editmamei 0.20.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.
- package/NOTICES.md +17 -1
- package/README.md +8 -7
- package/dist/api/extendscript/_helpers.js +1 -1
- package/dist/bin/editmamei-core-darwin-arm64 +0 -0
- package/dist/bin/editmamei-core-darwin-x64 +0 -0
- package/dist/bin/editmamei-core-win-x64.exe +0 -0
- package/dist/cli/config.js +3 -3
- package/dist/cli/install.js +2 -2
- package/dist/core/server.js +7 -7
- package/dist/core/settings.js +2 -2
- package/dist/core/tool-groups.js +91 -82
- package/dist/core/tool-tiers.js +91 -82
- package/dist/detection/landmark-detection-client.js +8 -0
- package/dist/detection/mesh-eye-correction.js +126 -0
- package/dist/detection/sam-segmenter.js +83 -0
- package/dist/index.js +2 -0
- package/dist/license/entitlement.js +52 -0
- package/dist/modules/ce/index.js +4 -4
- package/dist/perception/grounding-anchors.js +37 -0
- package/dist/perception/grounding-corners.js +93 -0
- package/dist/perception/grounding-edge-trace.js +90 -0
- package/dist/perception/grounding-extrema.js +41 -0
- package/dist/perception/grounding-gate.js +154 -0
- package/dist/perception/grounding-geometry.js +132 -0
- package/dist/perception/grounding-grid.js +34 -0
- package/dist/perception/grounding-landmarks.js +40 -0
- package/dist/perception/grounding-locate.js +341 -0
- package/dist/perception/grounding-resolver.js +202 -0
- package/dist/perception/grounding-review-crop.js +85 -0
- package/dist/perception/grounding-warp.js +182 -0
- package/dist/perception/sky-mask-transfer.js +50 -18
- package/dist/skills/editmamei-skill.zip +0 -0
- package/dist/tools/adjustment-tools.js +3 -3
- package/dist/tools/brush-tools.js +111 -23
- package/dist/tools/channel-compose-tools.js +241 -0
- package/dist/tools/detection-tools.js +1 -1
- package/dist/tools/diagnostics-tools.js +1 -1
- package/dist/tools/document-tools.js +6 -6
- package/dist/tools/filter-tools.js +476 -19
- package/dist/tools/group-tools.js +10 -10
- package/dist/tools/guide-tools.js +4 -4
- package/dist/tools/history-tools.js +4 -4
- package/dist/tools/image-placement-tools.js +1 -1
- package/dist/tools/image-tools.js +62 -19
- package/dist/tools/inspect-tools.js +2 -2
- package/dist/tools/layer-ordering-tools.js +1 -1
- package/dist/tools/layer-properties-tools.js +42 -23
- package/dist/tools/layer-tools.js +10 -10
- package/dist/tools/layer-transform-tools.js +80 -41
- package/dist/tools/overview-tools.js +42 -42
- package/dist/tools/path-tools.js +53 -10
- package/dist/tools/portrait-tools.js +3 -3
- package/dist/tools/preview-tools.js +7 -82
- package/dist/tools/retouch-tools.js +93 -25
- package/dist/tools/scene-tools.js +13 -13
- package/dist/tools/selection-tools.js +319 -40
- package/dist/tools/shape-tools.js +216 -0
- package/dist/tools/text-tools.js +1 -1
- package/dist/tools/transform-canvas-tools.js +2 -2
- package/dist/tools/vector-mask-tools.js +23 -8
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/dist/preview/coordinate-grid.js +0 -250
- package/dist/tools/object-tools.js +0 -193
- 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
|
|
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
|
-
//
|
|
35
|
-
|
|
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
|
|
52
|
-
orig.activeChannels = [
|
|
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
|
|
58
|
-
orig.selection.load(
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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-
|
|
68
|
-
: await TempDir.create('editmamei-
|
|
98
|
+
? await TempDir.createWithRoot(userOwnedTempRoot(), 'editmamei-mask-')
|
|
99
|
+
: await TempDir.create('editmamei-mask-');
|
|
69
100
|
try {
|
|
70
|
-
const maskPath = dir.path('
|
|
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: '
|
|
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
|
|
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: '
|
|
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', '
|
|
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: '
|
|
111
|
-
description: 'Paint along a
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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?})
|
|
169
|
-
`before stroking. (Healing brush and clone stamp
|
|
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:
|
|
291
|
+
structuredContent: structured,
|
|
204
292
|
};
|
|
205
293
|
}
|
|
206
294
|
catch (error) {
|