editmamei 1.7.0 → 1.8.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/README.md +5 -4
- package/dist/backends/gimp/bridge/lib.py +833 -4
- package/dist/backends/gimp/bridge/ops.py +1943 -110
- 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 +7 -0
- package/dist/cli/help.js +2 -2
- package/dist/cli/install.js +2 -2
- package/dist/core/settings.js +7 -0
- package/dist/core/tool-activity.js +13 -1
- package/dist/core/tool-groups.js +7 -1
- package/dist/core/tool-tiers.js +7 -1
- package/dist/install-channel.js +2 -0
- package/dist/modules/gimp/index.js +8 -2
- package/dist/skills/editmamei-skill.zip +0 -0
- package/dist/telemetry/client.js +23 -16
- package/dist/telemetry/outbox.js +248 -28
- package/dist/telemetry/sanitize.js +15 -1
- package/dist/tools/adjustment-tools.js +9 -1
- package/dist/tools/document-tools.js +50 -9
- package/dist/tools/filter-tools.js +2 -1
- package/dist/tools/gimp-adjustment-tools.js +3 -3
- package/dist/tools/gimp-core-tools.js +7 -8
- package/dist/tools/gimp-document-tools.js +4 -1
- package/dist/tools/gimp-effect-tools.js +2 -2
- package/dist/tools/gimp-geometry-tools.js +2 -2
- package/dist/tools/gimp-inspect-tools.js +35 -5
- package/dist/tools/gimp-layer-tools.js +5 -24
- package/dist/tools/gimp-match-tools.js +133 -0
- package/dist/tools/gimp-selection-tools.js +374 -0
- package/dist/tools/gimp-shared.js +12 -0
- package/dist/tools/gimp-text-tools.js +181 -0
- package/dist/tools/gimp-transform-layer-tools.js +215 -0
- package/dist/tools/gimp-verify-tools.js +2 -13
- package/dist/tools/image-placement-tools.js +6 -1
- package/dist/tools/sky-tools.js +2 -1
- package/dist/update/check.js +2 -0
- package/dist/utils/gimp-path.js +18 -22
- package/dist/utils/local-path.js +99 -0
- package/dist/utils/operation-timeouts.js +7 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
- package/dist/tools/gimp-mask-tools.js +0 -102
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { validateArgs } from '../utils/validate.js';
|
|
2
|
+
import { toolGimpErrorResult, unknownDiscriminator } from '../utils/tool-helpers.js';
|
|
3
|
+
import { GIMP_IMAGE_PROP, GIMP_LAYER_PROP, GIMP_LAYER_ID_PROP, pickSchemaDeclaredKeys, } from './gimp-shared.js';
|
|
4
|
+
import { EFFECT_UPDATE_FAILURES_PROP, effectUpdateFailuresNote } from './gimp-geometry-tools.js';
|
|
5
|
+
const TRANSFORM_LAYER_OPS = ['fit', 'scale', 'move', 'rotate', 'flip', 'skew', 'free'];
|
|
6
|
+
const xyProp = (what) => ({
|
|
7
|
+
type: 'object',
|
|
8
|
+
description: `A document-pixel point: {x, y}. ${what}`,
|
|
9
|
+
properties: { x: { type: 'number' }, y: { type: 'number' } },
|
|
10
|
+
required: ['x', 'y'],
|
|
11
|
+
});
|
|
12
|
+
const transformLayerSchema = {
|
|
13
|
+
type: 'object',
|
|
14
|
+
properties: {
|
|
15
|
+
image: GIMP_IMAGE_PROP,
|
|
16
|
+
layer: {
|
|
17
|
+
...GIMP_LAYER_PROP,
|
|
18
|
+
description: (GIMP_LAYER_PROP.description ?? '') + ' Ignored when layer_id is given.',
|
|
19
|
+
},
|
|
20
|
+
layer_id: GIMP_LAYER_ID_PROP,
|
|
21
|
+
op: {
|
|
22
|
+
type: 'string',
|
|
23
|
+
enum: [...TRANSFORM_LAYER_OPS],
|
|
24
|
+
description: "'fit' scales the layer to fit (letterbox) or fill (cover) the canvas, preserving " +
|
|
25
|
+
"aspect, and centers it (mode fit|fill, idempotent). 'scale' is uniform (scale_percent) " +
|
|
26
|
+
'or non-uniform (scale_x_percent/scale_y_percent) — multiplicative, anchored at the ' +
|
|
27
|
+
"layer's own center; a NON-uniform scale is refused while an unmasked position/" +
|
|
28
|
+
'direction-dependent effect filter (vignette/motion_blur/drop_shadow) is present. ' +
|
|
29
|
+
"'move' translates — pass exactly ONE of delta (relative), absolute (top-left target), " +
|
|
30
|
+
'or center_on (center target), each a {x, y} object (flat delta_x/absolute_x fields are ' +
|
|
31
|
+
"not accepted); mixing modes is refused. 'rotate' rotates by relative degrees (positive " +
|
|
32
|
+
"clockwise) around the layer's own center; the layer GROWS to fit the rotated content " +
|
|
33
|
+
'— nothing is clipped; an arbitrary (non-90°-multiple) angle is refused while such an ' +
|
|
34
|
+
"effect is present. 'flip' mirrors horizontal|vertical in place (axis). 'skew' slants " +
|
|
35
|
+
"the layer (skew_h_degrees/skew_v_degrees) around its own center. 'free' is a numeric " +
|
|
36
|
+
'free transform in one matrix: scale_x_percent/scale_y_percent + degrees + offset_x/' +
|
|
37
|
+
'offset_y. skew and free are always refused while such an effect is present (no angle ' +
|
|
38
|
+
'is safe for either). A text layer stays a text layer through every op, never ' +
|
|
39
|
+
'rasterized.',
|
|
40
|
+
},
|
|
41
|
+
mode: {
|
|
42
|
+
type: 'string',
|
|
43
|
+
enum: ['fit', 'fill'],
|
|
44
|
+
description: "fit only. 'fit' letterboxes inside the canvas (shorter edge touches); 'fill' covers it " +
|
|
45
|
+
'(longer edge touches, overflow is left outside the canvas, not cropped). Defaults to ' +
|
|
46
|
+
"'fit'.",
|
|
47
|
+
},
|
|
48
|
+
scale_percent: {
|
|
49
|
+
type: 'number',
|
|
50
|
+
minimum: 1,
|
|
51
|
+
maximum: 10000,
|
|
52
|
+
description: 'scale only. Uniform scale percentage (100 = unchanged). 1..10000.',
|
|
53
|
+
},
|
|
54
|
+
scale_x_percent: {
|
|
55
|
+
type: 'number',
|
|
56
|
+
minimum: 1,
|
|
57
|
+
maximum: 10000,
|
|
58
|
+
description: 'scale/free: horizontal scale percentage (100 = unchanged), 1..10000. scale: pair with ' +
|
|
59
|
+
'scale_y_percent for non-uniform stretch/squash; the other axis defaults to ' +
|
|
60
|
+
'scale_percent, or 100 if that is absent too.',
|
|
61
|
+
},
|
|
62
|
+
scale_y_percent: {
|
|
63
|
+
type: 'number',
|
|
64
|
+
minimum: 1,
|
|
65
|
+
maximum: 10000,
|
|
66
|
+
description: 'scale/free: vertical scale percentage. See scale_x_percent.',
|
|
67
|
+
},
|
|
68
|
+
delta: xyProp("move, RELATIVE mode: offset from the layer's current top-left. Mutually exclusive with absolute/center_on."),
|
|
69
|
+
absolute: xyProp("move, ABSOLUTE mode: target for the layer's top-left corner. Mutually exclusive with delta/center_on."),
|
|
70
|
+
center_on: xyProp("move, CENTER mode: target for the layer's center point. Mutually exclusive with delta/absolute."),
|
|
71
|
+
degrees: {
|
|
72
|
+
type: 'number',
|
|
73
|
+
description: "rotate: relative rotation angle, positive clockwise, around the layer's own center. " +
|
|
74
|
+
'free: relative rotation angle (default 0), applied in the same combined matrix as the ' +
|
|
75
|
+
'scale and offset.',
|
|
76
|
+
},
|
|
77
|
+
axis: {
|
|
78
|
+
type: 'string',
|
|
79
|
+
enum: ['horizontal', 'vertical'],
|
|
80
|
+
description: "flip only. 'horizontal' mirrors left-right; 'vertical' mirrors top-bottom.",
|
|
81
|
+
},
|
|
82
|
+
skew_h_degrees: {
|
|
83
|
+
type: 'number',
|
|
84
|
+
description: 'skew only. Horizontal slant angle in degrees — positive slants the top edge right. At ' +
|
|
85
|
+
'least one of skew_h_degrees/skew_v_degrees is required.',
|
|
86
|
+
},
|
|
87
|
+
skew_v_degrees: {
|
|
88
|
+
type: 'number',
|
|
89
|
+
description: 'skew only. Vertical slant angle in degrees — positive slants the left edge down.',
|
|
90
|
+
},
|
|
91
|
+
offset_x: {
|
|
92
|
+
type: 'number',
|
|
93
|
+
minimum: -100000,
|
|
94
|
+
maximum: 100000,
|
|
95
|
+
description: 'free only. Horizontal translation in document pixels, applied last. Defaults to 0.',
|
|
96
|
+
},
|
|
97
|
+
offset_y: {
|
|
98
|
+
type: 'number',
|
|
99
|
+
minimum: -100000,
|
|
100
|
+
maximum: 100000,
|
|
101
|
+
description: 'free only. Vertical translation in document pixels, applied last. Defaults to 0.',
|
|
102
|
+
},
|
|
103
|
+
interpolation: {
|
|
104
|
+
type: 'string',
|
|
105
|
+
enum: ['none', 'linear', 'cubic', 'nohalo', 'lohalo'],
|
|
106
|
+
default: 'cubic',
|
|
107
|
+
description: 'Resampling filter for this call, every op. Always set explicitly here — never carried ' +
|
|
108
|
+
'over from a previous gimp_transform_layer call.',
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
required: ['image', 'op'],
|
|
112
|
+
};
|
|
113
|
+
function boundsText(result) {
|
|
114
|
+
const b = result.bounds;
|
|
115
|
+
return b ? `bounds now (${b.x}, ${b.y}) ${b.width}x${b.height}` : '';
|
|
116
|
+
}
|
|
117
|
+
function alphaNote(result) {
|
|
118
|
+
return result.alpha_added ? ' (alpha channel added)' : '';
|
|
119
|
+
}
|
|
120
|
+
function effectNote(result) {
|
|
121
|
+
return effectUpdateFailuresNote(result.effect_update_failures);
|
|
122
|
+
}
|
|
123
|
+
function transformLayerSuccessText(op, result, args) {
|
|
124
|
+
switch (op) {
|
|
125
|
+
case 'fit':
|
|
126
|
+
return (`Layer ${result.mode === 'fill' ? 'filled' : 'fitted'} to the canvas and centered ` +
|
|
127
|
+
`(${result.scale_percent}%), ${boundsText(result)}${alphaNote(result)}.` +
|
|
128
|
+
effectNote(result));
|
|
129
|
+
case 'scale':
|
|
130
|
+
return (`Layer scaled to ${result.scale_x_percent}% x ${result.scale_y_percent}%, ` +
|
|
131
|
+
`${boundsText(result)}${alphaNote(result)}.${effectNote(result)}`);
|
|
132
|
+
case 'move':
|
|
133
|
+
return `Layer moved, ${boundsText(result)}${alphaNote(result)}.`;
|
|
134
|
+
case 'rotate':
|
|
135
|
+
return (`Layer rotated ${result.degrees}°, ${boundsText(result)}${alphaNote(result)}.` +
|
|
136
|
+
effectNote(result));
|
|
137
|
+
case 'flip':
|
|
138
|
+
return (`Layer flipped ${args.axis}, ${boundsText(result)}${alphaNote(result)}.` +
|
|
139
|
+
effectNote(result));
|
|
140
|
+
case 'skew':
|
|
141
|
+
return (`Layer skewed (h=${result.skew_h_degrees ?? 0}°, v=` +
|
|
142
|
+
`${result.skew_v_degrees ?? 0}°), ${boundsText(result)}${alphaNote(result)}.`);
|
|
143
|
+
default:
|
|
144
|
+
return `Layer free-transformed, ${boundsText(result)}${alphaNote(result)}.`;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
async function gimpTransformLayer(gimp, rawArgs) {
|
|
148
|
+
try {
|
|
149
|
+
const args = validateArgs(transformLayerSchema, rawArgs);
|
|
150
|
+
const op = args.op;
|
|
151
|
+
if (!TRANSFORM_LAYER_OPS.includes(op)) {
|
|
152
|
+
return unknownDiscriminator('op', op, TRANSFORM_LAYER_OPS);
|
|
153
|
+
}
|
|
154
|
+
const result = await gimp.call('transform_layer', pickSchemaDeclaredKeys(transformLayerSchema, args));
|
|
155
|
+
return {
|
|
156
|
+
content: [{ type: 'text', text: transformLayerSuccessText(op, result, args) }],
|
|
157
|
+
structuredContent: result,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
return toolGimpErrorResult('Error transforming GIMP layer', error);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
export function createGimpTransformLayerTools(gimp) {
|
|
165
|
+
return [
|
|
166
|
+
{
|
|
167
|
+
tool: {
|
|
168
|
+
name: 'gimp_transform_layer',
|
|
169
|
+
description: 'Headless GIMP: transform the active layer (layer/layer_id) — op selects ' +
|
|
170
|
+
'fit|scale|move|rotate|flip|skew|free, each documented on op. Rotate/skew/free grow ' +
|
|
171
|
+
'the layer so nothing clips; a layer with no alpha gets one (alpha_added). REFUSES ' +
|
|
172
|
+
"when the layer, or a group target's descendants, carries a masked filter, or an " +
|
|
173
|
+
'unmasked effect this transform cannot keep locked to the content.',
|
|
174
|
+
inputSchema: transformLayerSchema,
|
|
175
|
+
outputSchema: {
|
|
176
|
+
type: 'object',
|
|
177
|
+
properties: {
|
|
178
|
+
layer_id: { type: 'number' },
|
|
179
|
+
bounds: {
|
|
180
|
+
type: 'object',
|
|
181
|
+
properties: {
|
|
182
|
+
x: { type: ['number', 'null'] },
|
|
183
|
+
y: { type: ['number', 'null'] },
|
|
184
|
+
width: { type: 'number' },
|
|
185
|
+
height: { type: 'number' },
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
alpha_added: { type: 'boolean' },
|
|
189
|
+
interpolation: { type: 'string' },
|
|
190
|
+
text_layer: { type: 'boolean' },
|
|
191
|
+
effect_update_failures: EFFECT_UPDATE_FAILURES_PROP,
|
|
192
|
+
mode: { type: 'string' },
|
|
193
|
+
scale_percent: { type: 'number' },
|
|
194
|
+
scale_x_percent: { type: 'number' },
|
|
195
|
+
scale_y_percent: { type: 'number' },
|
|
196
|
+
degrees: { type: 'number' },
|
|
197
|
+
axis: { type: 'string' },
|
|
198
|
+
skew_h_degrees: { type: 'number' },
|
|
199
|
+
skew_v_degrees: { type: 'number' },
|
|
200
|
+
offset_x: { type: 'number' },
|
|
201
|
+
offset_y: { type: 'number' },
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
annotations: {
|
|
205
|
+
title: 'Transform GIMP Layer',
|
|
206
|
+
readOnlyHint: false,
|
|
207
|
+
destructiveHint: true,
|
|
208
|
+
idempotentHint: false,
|
|
209
|
+
openWorldHint: true,
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
handler: async (args) => gimpTransformLayer(gimp, args),
|
|
213
|
+
},
|
|
214
|
+
];
|
|
215
|
+
}
|
|
@@ -1,24 +1,13 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import {
|
|
2
|
+
import { rm } from 'node:fs/promises';
|
|
3
3
|
import { basename } from 'node:path';
|
|
4
4
|
import { validateArgs } from '../utils/validate.js';
|
|
5
5
|
import { toolGimpErrorResult, unknownDiscriminator } from '../utils/tool-helpers.js';
|
|
6
|
-
import { loadSettings } from '../core/settings.js';
|
|
7
6
|
import { Logger } from '../utils/logger.js';
|
|
8
|
-
import {
|
|
9
|
-
import { GIMP_IMAGE_PROP, GIMP_REGION_PROP, GIMP_MAX_PX_PROP, pickSchemaDeclaredKeys, } from './gimp-shared.js';
|
|
7
|
+
import { GIMP_IMAGE_PROP, GIMP_REGION_PROP, GIMP_MAX_PX_PROP, pickSchemaDeclaredKeys, readRender, defaultPreviewsAllowed, } from './gimp-shared.js';
|
|
10
8
|
const logger = new Logger('GimpVerifyTools');
|
|
11
9
|
const CHANNELS = ['luminance', 'red', 'green', 'blue'];
|
|
12
|
-
async function readRender(path) {
|
|
13
|
-
try {
|
|
14
|
-
return await readFile(path);
|
|
15
|
-
}
|
|
16
|
-
catch {
|
|
17
|
-
throw new GimpError('gimp_op_failed', 'GIMP reported success but the render was not written');
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
10
|
export const HISTOGRAM_BIN_COUNT = 256;
|
|
21
|
-
const defaultPreviewsAllowed = () => loadSettings().settings.privacy.send_previews_to_llm;
|
|
22
11
|
const previewSchema = {
|
|
23
12
|
type: 'object',
|
|
24
13
|
properties: {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { runSnippetTool } from '../utils/tool-helpers.js';
|
|
2
|
+
import { requireLocalPath } from '../utils/local-path.js';
|
|
2
3
|
const PS_COORD_MAX_PX = 300_000;
|
|
3
4
|
const placeImageSchema = {
|
|
4
5
|
type: 'object',
|
|
@@ -72,7 +73,11 @@ async function placeImage(connection, snippetClient, rawArgs) {
|
|
|
72
73
|
snippet: 'placeImage',
|
|
73
74
|
errorPrefix: 'Error placing image',
|
|
74
75
|
params: (args) => {
|
|
75
|
-
const params = {
|
|
76
|
+
const params = {
|
|
77
|
+
filePath: requireLocalPath('file_path', args.file_path),
|
|
78
|
+
x: args.x,
|
|
79
|
+
y: args.y,
|
|
80
|
+
};
|
|
76
81
|
if (args.width_percent !== undefined)
|
|
77
82
|
params.widthPercent = args.width_percent;
|
|
78
83
|
if (args.height_percent !== undefined)
|
package/dist/tools/sky-tools.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { runSnippetTool } from '../utils/tool-helpers.js';
|
|
2
|
+
import { requireLocalPath } from '../utils/local-path.js';
|
|
2
3
|
import { SKY_REPLACEMENT_TIMEOUT_MS } from '../utils/operation-timeouts.js';
|
|
3
4
|
const PLACEHOLDER_SKY_ID = '00000000-0000-0000-0000-000000000000';
|
|
4
5
|
const replaceSkyInputSchema = {
|
|
@@ -75,7 +76,7 @@ async function replaceSky(connection, snippetClient, rawArgs) {
|
|
|
75
76
|
errorPrefix: 'Error running Sky Replacement',
|
|
76
77
|
timeoutMs: SKY_REPLACEMENT_TIMEOUT_MS,
|
|
77
78
|
params: (args) => ({
|
|
78
|
-
skyPath: args.sky_file,
|
|
79
|
+
skyPath: requireLocalPath('sky_file', args.sky_file),
|
|
79
80
|
skyName: args.sky_name ?? 'Custom Sky',
|
|
80
81
|
skyId: PLACEHOLDER_SKY_ID,
|
|
81
82
|
shiftEdge: args.shift_edge ?? 0,
|
package/dist/update/check.js
CHANGED
|
@@ -99,6 +99,8 @@ export function updateMessage(channel, latest) {
|
|
|
99
99
|
switch (channel) {
|
|
100
100
|
case 'mcpb':
|
|
101
101
|
return `Download editmamei.mcpb (v${latest}) from ${RELEASES_URL} and reinstall the Claude Desktop extension.`;
|
|
102
|
+
case 'plugin':
|
|
103
|
+
return `Editmamei v${latest} is available. Update the Editmamei plugin in Claude, then restart.`;
|
|
102
104
|
case 'dev':
|
|
103
105
|
return `You're on a local dev build — pull the latest source and rebuild.`;
|
|
104
106
|
case 'npx':
|
package/dist/utils/gimp-path.js
CHANGED
|
@@ -1,27 +1,23 @@
|
|
|
1
1
|
import { win32 as pathWin32, posix as pathPosix } from 'node:path';
|
|
2
2
|
import { GimpError } from '../backends/gimp/errors.js';
|
|
3
|
-
|
|
4
|
-
const WIN32_DRIVE_ROOTED_RE = /^[A-Za-z]:[\\/]/;
|
|
3
|
+
import { localPathProblem } from './local-path.js';
|
|
5
4
|
export function requireAbsoluteGimpPath(field, value, platform = process.platform) {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
switch (localPathProblem(value, platform)) {
|
|
6
|
+
case undefined:
|
|
7
|
+
return value;
|
|
8
|
+
case 'not_string':
|
|
9
|
+
throw new GimpError('invalid_argument', `"${field}" is required and must be a non-empty string`);
|
|
10
|
+
case 'unc_or_device':
|
|
11
|
+
throw new GimpError('invalid_argument', `"${field}" must be a plain local path — UNC shares and \\\\?\\ / \\\\.\\ device paths are ` +
|
|
12
|
+
`refused, got "${value}". Pass a path on a local drive, e.g. C:/Users/you/photo.jpg.`);
|
|
13
|
+
case 'relative':
|
|
14
|
+
throw new GimpError('invalid_argument', `"${field}" must be an absolute path, got "${value}" — pass a full filesystem path, not one relative to a working directory the GIMP session doesn't share.`);
|
|
15
|
+
case 'no_drive':
|
|
16
|
+
throw new GimpError('invalid_argument', `"${field}" must include a drive letter, got "${value}" — a path rooted at "\\" alone is ` +
|
|
17
|
+
`ambiguous about which drive it resolves on. Pass e.g. C:/Users/you/photo.jpg.`);
|
|
18
|
+
case 'unstable': {
|
|
19
|
+
const resolved = (platform === 'win32' ? pathWin32 : pathPosix).resolve(value);
|
|
20
|
+
throw new GimpError('invalid_argument', `"${field}" does not resolve to a stable absolute path ("${value}" -> "${resolved}") — pass a plain, fully-qualified path.`);
|
|
21
|
+
}
|
|
9
22
|
}
|
|
10
|
-
if (UNC_OR_DEVICE_PATH_RE.test(value)) {
|
|
11
|
-
throw new GimpError('invalid_argument', `"${field}" must be a plain local path — UNC shares and \\\\?\\ / \\\\.\\ device paths are ` +
|
|
12
|
-
`refused, got "${value}". Pass a path on a local drive, e.g. C:/Users/you/photo.jpg.`);
|
|
13
|
-
}
|
|
14
|
-
if (!pathImpl.isAbsolute(value)) {
|
|
15
|
-
throw new GimpError('invalid_argument', `"${field}" must be an absolute path, got "${value}" — pass a full filesystem path, not one relative to a working directory the GIMP session doesn't share.`);
|
|
16
|
-
}
|
|
17
|
-
if (platform === 'win32' && !WIN32_DRIVE_ROOTED_RE.test(value)) {
|
|
18
|
-
throw new GimpError('invalid_argument', `"${field}" must include a drive letter, got "${value}" — a path rooted at "\\" alone is ` +
|
|
19
|
-
`ambiguous about which drive it resolves on. Pass e.g. C:/Users/you/photo.jpg.`);
|
|
20
|
-
}
|
|
21
|
-
const normalizeRoot = (root) => root.replace(/\//g, '\\').toLowerCase();
|
|
22
|
-
const resolved = pathImpl.resolve(value);
|
|
23
|
-
if (normalizeRoot(pathImpl.parse(resolved).root) !== normalizeRoot(pathImpl.parse(value).root)) {
|
|
24
|
-
throw new GimpError('invalid_argument', `"${field}" does not resolve to a stable absolute path ("${value}" -> "${resolved}") — pass a plain, fully-qualified path.`);
|
|
25
|
-
}
|
|
26
|
-
return value;
|
|
27
23
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { win32 as pathWin32, posix as pathPosix } from 'node:path';
|
|
4
|
+
const UNC_OR_DEVICE_PATH_RE = /^[\\/]{2}/;
|
|
5
|
+
const WIN32_DRIVE_ROOTED_RE = /^[A-Za-z]:[\\/]/;
|
|
6
|
+
const HOME_RELATIVE_RE = /^~[\\/]/;
|
|
7
|
+
export function localPathProblem(value, platform = process.platform) {
|
|
8
|
+
const pathImpl = platform === 'win32' ? pathWin32 : pathPosix;
|
|
9
|
+
if (typeof value !== 'string' || value.length === 0)
|
|
10
|
+
return 'not_string';
|
|
11
|
+
if (UNC_OR_DEVICE_PATH_RE.test(value))
|
|
12
|
+
return 'unc_or_device';
|
|
13
|
+
if (!pathImpl.isAbsolute(value))
|
|
14
|
+
return 'relative';
|
|
15
|
+
if (platform === 'win32' && !WIN32_DRIVE_ROOTED_RE.test(value))
|
|
16
|
+
return 'no_drive';
|
|
17
|
+
const normalizeRoot = (root) => root.replace(/\//g, '\\').toLowerCase();
|
|
18
|
+
const resolved = pathImpl.resolve(value);
|
|
19
|
+
if (normalizeRoot(pathImpl.parse(resolved).root) !== normalizeRoot(pathImpl.parse(value).root))
|
|
20
|
+
return 'unstable';
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
export class PathArgumentError extends Error {
|
|
24
|
+
constructor(message) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = 'PathArgumentError';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
let platformOverride;
|
|
30
|
+
export function setPathPlatformForTests(platform) {
|
|
31
|
+
platformOverride = platform;
|
|
32
|
+
}
|
|
33
|
+
export function requireLocalPath(field, value, opts = {}) {
|
|
34
|
+
const platform = opts.platform ?? platformOverride ?? process.platform;
|
|
35
|
+
const path = typeof value === 'string' && HOME_RELATIVE_RE.test(value)
|
|
36
|
+
? (platform === 'win32' ? pathWin32 : pathPosix).join(opts.home ?? homedir(), value.slice(2))
|
|
37
|
+
: value;
|
|
38
|
+
switch (localPathProblem(path, platform)) {
|
|
39
|
+
case undefined:
|
|
40
|
+
return refuseExtendScriptAmbiguity(field, path, platform);
|
|
41
|
+
case 'not_string':
|
|
42
|
+
throw new PathArgumentError(`"${field}" is required and must be a non-empty string.`);
|
|
43
|
+
case 'unc_or_device':
|
|
44
|
+
throw new PathArgumentError(`"${field}" must be a file on this computer's own drives: network shares (\\\\server\\share), ` +
|
|
45
|
+
`WebDAV addresses and \\\\?\\ or \\\\.\\ device paths are refused, got "${String(value)}". ` +
|
|
46
|
+
'Copy the file to a local folder first.');
|
|
47
|
+
case 'relative':
|
|
48
|
+
throw new PathArgumentError(`"${field}" must be an absolute path, got "${String(value)}". Pass a full path, e.g. ` +
|
|
49
|
+
`${platform === 'win32' ? 'C:/Users/you/Pictures/photo.jpg' : '/Users/you/Pictures/photo.jpg'}.`);
|
|
50
|
+
case 'no_drive':
|
|
51
|
+
throw new PathArgumentError(`"${field}" must start with a drive letter, got "${String(value)}". Pass e.g. C:/Users/you/Pictures/photo.jpg.`);
|
|
52
|
+
case 'unstable':
|
|
53
|
+
throw new PathArgumentError(`"${field}" does not resolve to a stable absolute path ("${String(value)}"). Pass a plain, full path.`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const PERCENT_ESCAPE_RE = /%[0-9A-Fa-f]{2}/;
|
|
57
|
+
const WIN32_DEVICE_NAME_RE = /^(con|prn|aux|nul|conin\$|conout\$|com[0-9¹²³]|lpt[0-9¹²³])\s*(\..*)?$/i;
|
|
58
|
+
function refuseExtendScriptAmbiguity(field, path, platform) {
|
|
59
|
+
if (PERCENT_ESCAPE_RE.test(path)) {
|
|
60
|
+
throw new PathArgumentError(`"${field}" must not contain a percent-escape such as %20 or %5F, got "${path}". Write the characters themselves.`);
|
|
61
|
+
}
|
|
62
|
+
if (platform === 'win32') {
|
|
63
|
+
if (path.indexOf(':', 2) !== -1) {
|
|
64
|
+
throw new PathArgumentError(`"${field}" must not contain ":" after the drive letter, got "${path}".`);
|
|
65
|
+
}
|
|
66
|
+
if (WIN32_DEVICE_NAME_RE.test(pathWin32.basename(path))) {
|
|
67
|
+
throw new PathArgumentError(`"${field}" names a Windows device (${pathWin32.basename(path)}), not a file.`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return path;
|
|
71
|
+
}
|
|
72
|
+
export function looksLikePath(value) {
|
|
73
|
+
return /[\\/]/.test(value) || /^[A-Za-z]:/.test(value) || HOME_RELATIVE_RE.test(value);
|
|
74
|
+
}
|
|
75
|
+
const written = new Set();
|
|
76
|
+
function outputKey(path, platform) {
|
|
77
|
+
const resolved = (platform === 'win32' ? pathWin32 : pathPosix).resolve(path);
|
|
78
|
+
return platform === 'win32' || platform === 'darwin' ? resolved.toLowerCase() : resolved;
|
|
79
|
+
}
|
|
80
|
+
export function checkOutputPath(field, value, opts) {
|
|
81
|
+
const platform = opts.platform ?? platformOverride ?? process.platform;
|
|
82
|
+
const path = requireLocalPath(field, value, opts);
|
|
83
|
+
const lower = path.toLowerCase();
|
|
84
|
+
if (!opts.extensions.some((ext) => lower.endsWith(ext))) {
|
|
85
|
+
throw new PathArgumentError(`"${field}" must end in ${opts.extensions.join(' or ')}, got "${path}".`);
|
|
86
|
+
}
|
|
87
|
+
const exists = opts.exists ?? existsSync;
|
|
88
|
+
if (opts.overwrite !== true && exists(path) && !written.has(outputKey(path, platform))) {
|
|
89
|
+
throw new PathArgumentError(`"${field}": a file already exists at "${path}" and was not written in this session. Choose a new ` +
|
|
90
|
+
'name, or pass overwrite: true only if the user asked to replace that file.');
|
|
91
|
+
}
|
|
92
|
+
return path;
|
|
93
|
+
}
|
|
94
|
+
export function recordOutputPath(path, platform = platformOverride ?? process.platform) {
|
|
95
|
+
written.add(outputKey(path, platform));
|
|
96
|
+
}
|
|
97
|
+
export function resetOutputPathsForTests() {
|
|
98
|
+
written.clear();
|
|
99
|
+
}
|
|
@@ -86,7 +86,6 @@ export const TOOL_TIMEOUT_BUDGETS_MS = {
|
|
|
86
86
|
gimp_crop_document: 20_000,
|
|
87
87
|
gimp_resize_image: 45_000,
|
|
88
88
|
gimp_transform_canvas: 60_000,
|
|
89
|
-
gimp_create_mask: 20_000,
|
|
90
89
|
gimp_get_preview: 30_000,
|
|
91
90
|
gimp_get_histogram: 90_000,
|
|
92
91
|
gimp_compare: 45_000,
|
|
@@ -97,6 +96,13 @@ export const TOOL_TIMEOUT_BUDGETS_MS = {
|
|
|
97
96
|
gimp_place_image: 30_000,
|
|
98
97
|
gimp_canvas: 30_000,
|
|
99
98
|
gimp_convert_image_mode: 30_000,
|
|
99
|
+
gimp_text: 30_000,
|
|
100
|
+
gimp_select: 20_000,
|
|
101
|
+
gimp_modify_selection: 90_000,
|
|
102
|
+
gimp_layer_mask: 15_000,
|
|
103
|
+
gimp_get_selection_preview: 30_000,
|
|
104
|
+
gimp_transform_layer: 30_000,
|
|
105
|
+
gimp_match_layer: 70_000,
|
|
100
106
|
};
|
|
101
107
|
export const TOOLS_WITHOUT_A_BUDGET = {
|
|
102
108
|
ps_report_problem: 'n=3 measured calls',
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = '1.
|
|
1
|
+
export const VERSION = '1.8.0';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "editmamei",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "Photoshop MCP server: natural-language AI photo editing in desktop Photoshop (Community Edition)",
|
|
5
5
|
"mcpName": "io.github.editmamei/editmamei",
|
|
6
6
|
"editmamei": {
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
"node": ">=22.0.0"
|
|
69
69
|
},
|
|
70
70
|
"dependencies": {
|
|
71
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
71
|
+
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
72
72
|
"adm-zip": "^0.6.1",
|
|
73
73
|
"jpeg-js": "^0.4.4",
|
|
74
74
|
"onnxruntime-web": "1.30.0"
|
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
import { GIMP_IMAGE_PROP, runGimpTool } from './gimp-shared.js';
|
|
2
|
-
const createMaskSchema = {
|
|
3
|
-
type: 'object',
|
|
4
|
-
properties: {
|
|
5
|
-
image: GIMP_IMAGE_PROP,
|
|
6
|
-
type: {
|
|
7
|
-
type: 'string',
|
|
8
|
-
enum: ['rectangle', 'ellipse', 'gradient_linear', 'gradient_radial'],
|
|
9
|
-
description: 'rectangle/ellipse: geometric selection (needs x, y, width, height). gradient_linear: a ' +
|
|
10
|
-
'ramp from (x1,y1) to (x2,y2), default the full image width. gradient_radial: a ramp from ' +
|
|
11
|
-
'full effect at the center (cx,cy) fading to none at radius, default centered at the ' +
|
|
12
|
-
"image's own center with radius = half the shorter side.",
|
|
13
|
-
},
|
|
14
|
-
name: {
|
|
15
|
-
type: 'string',
|
|
16
|
-
default: 'Mask',
|
|
17
|
-
description: 'Channel name — replaces any existing channel of the same name (refused if that name is ' +
|
|
18
|
-
"already used as an existing filter's mask). Pass this name to gimp_add_adjustment's " +
|
|
19
|
-
'`mask` field to confine a new filter to it.',
|
|
20
|
-
},
|
|
21
|
-
invert: {
|
|
22
|
-
type: 'boolean',
|
|
23
|
-
default: false,
|
|
24
|
-
description: 'rectangle/ellipse: invert the selection. gradient_linear/gradient_radial: swap which end ' +
|
|
25
|
-
'is black vs white.',
|
|
26
|
-
},
|
|
27
|
-
feather_px: {
|
|
28
|
-
type: 'number',
|
|
29
|
-
minimum: 0,
|
|
30
|
-
maximum: 1000,
|
|
31
|
-
default: 0,
|
|
32
|
-
description: 'rectangle/ellipse only. Feather radius in pixels. No effect on a gradient (already continuous).',
|
|
33
|
-
},
|
|
34
|
-
x: { type: 'number', description: 'rectangle/ellipse: left edge, document pixels.' },
|
|
35
|
-
y: { type: 'number', description: 'rectangle/ellipse: top edge, document pixels.' },
|
|
36
|
-
width: { type: 'number', description: 'rectangle/ellipse: width, document pixels.' },
|
|
37
|
-
height: { type: 'number', description: 'rectangle/ellipse: height, document pixels.' },
|
|
38
|
-
x1: { type: 'number', description: 'gradient_linear: start point x. Default 0.' },
|
|
39
|
-
y1: { type: 'number', description: 'gradient_linear: start point y. Default 0.' },
|
|
40
|
-
x2: { type: 'number', description: "gradient_linear: end point x. Default the image's width." },
|
|
41
|
-
y2: { type: 'number', description: 'gradient_linear: end point y. Default 0.' },
|
|
42
|
-
cx: {
|
|
43
|
-
type: 'number',
|
|
44
|
-
description: "gradient_radial: center x. Default the image's own center.",
|
|
45
|
-
},
|
|
46
|
-
cy: {
|
|
47
|
-
type: 'number',
|
|
48
|
-
description: "gradient_radial: center y. Default the image's own center.",
|
|
49
|
-
},
|
|
50
|
-
radius: {
|
|
51
|
-
type: 'number',
|
|
52
|
-
description: 'gradient_radial: radius, document pixels. Default half the shorter image side.',
|
|
53
|
-
},
|
|
54
|
-
},
|
|
55
|
-
required: ['image', 'type'],
|
|
56
|
-
};
|
|
57
|
-
export function createGimpMaskTools(gimp) {
|
|
58
|
-
return [
|
|
59
|
-
{
|
|
60
|
-
tool: {
|
|
61
|
-
name: 'gimp_create_mask',
|
|
62
|
-
description: 'Headless GIMP: build a geometric mask (rectangle / ellipse / linear or radial gradient) ' +
|
|
63
|
-
'into a NAMED channel. The active selection is CLEARED when this call returns — it does ' +
|
|
64
|
-
'NOT leave the mask selected the way a Photoshop selection would. Pass the returned ' +
|
|
65
|
-
"`channel` name to gimp_add_adjustment's `mask` field WHEN CREATING a new filter — that " +
|
|
66
|
-
"is the ONLY way to apply it — a filter's mask is fixed at creation and cannot be " +
|
|
67
|
-
'changed on a re-edit. Order matters: straighten / flip / resize the canvas FIRST (see ' +
|
|
68
|
-
'gimp_transform_canvas, gimp_resize_image), then crop, then create masks and masked ' +
|
|
69
|
-
'adjustments — rotate, flip, and resize all refuse outright once ANY masked adjustment ' +
|
|
70
|
-
'filter exists on the image, or any filter not created by Editmamei (for example one ' +
|
|
71
|
-
'added in the GIMP GUI).',
|
|
72
|
-
inputSchema: createMaskSchema,
|
|
73
|
-
outputSchema: {
|
|
74
|
-
type: 'object',
|
|
75
|
-
properties: {
|
|
76
|
-
channel: { type: 'string' },
|
|
77
|
-
selected_pixels: { type: 'number' },
|
|
78
|
-
fraction: { type: 'number' },
|
|
79
|
-
},
|
|
80
|
-
},
|
|
81
|
-
annotations: {
|
|
82
|
-
title: 'Create GIMP Mask',
|
|
83
|
-
readOnlyHint: false,
|
|
84
|
-
destructiveHint: true,
|
|
85
|
-
idempotentHint: false,
|
|
86
|
-
openWorldHint: true,
|
|
87
|
-
},
|
|
88
|
-
},
|
|
89
|
-
handler: async (args) => runGimpTool({
|
|
90
|
-
gimp,
|
|
91
|
-
rawArgs: args,
|
|
92
|
-
schema: createMaskSchema,
|
|
93
|
-
op: 'create_mask',
|
|
94
|
-
errorPrefix: 'Error creating GIMP mask',
|
|
95
|
-
successText: (result) => {
|
|
96
|
-
const r = result;
|
|
97
|
-
return `Created mask channel "${r.channel}" — ${(r.fraction * 100).toFixed(1)}% of pixels selected.`;
|
|
98
|
-
},
|
|
99
|
-
}),
|
|
100
|
-
},
|
|
101
|
-
];
|
|
102
|
-
}
|