editmamei 1.7.1 → 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 +3 -3
- 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/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/modules/gimp/index.js +8 -2
- package/dist/skills/editmamei-skill.zip +0 -0
- 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/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,181 @@
|
|
|
1
|
+
import { validateArgs } from '../utils/validate.js';
|
|
2
|
+
import { toolGimpErrorResult } from '../utils/tool-helpers.js';
|
|
3
|
+
import { GIMP_IMAGE_PROP, GIMP_LAYER_ID_PROP, GIMP_LAYER_PROP, pickSchemaDeclaredKeys, } from './gimp-shared.js';
|
|
4
|
+
const TEXT_OPS = ['create', 'set_content', 'set_font', 'set_color', 'set_alignment'];
|
|
5
|
+
const ALIGNMENTS = [
|
|
6
|
+
'LEFT',
|
|
7
|
+
'CENTER',
|
|
8
|
+
'RIGHT',
|
|
9
|
+
'FULLYJUSTIFIED',
|
|
10
|
+
'LEFTJUSTIFIED',
|
|
11
|
+
'CENTERJUSTIFIED',
|
|
12
|
+
'RIGHTJUSTIFIED',
|
|
13
|
+
];
|
|
14
|
+
const textSchema = {
|
|
15
|
+
type: 'object',
|
|
16
|
+
properties: {
|
|
17
|
+
image: GIMP_IMAGE_PROP,
|
|
18
|
+
op: {
|
|
19
|
+
type: 'string',
|
|
20
|
+
enum: [...TEXT_OPS],
|
|
21
|
+
description: 'create: a new text layer at the top of the stack with `text` at `x`/`y` (default ' +
|
|
22
|
+
'100,100) and `font_size` (default 24), optionally with font_name, red/green/blue and ' +
|
|
23
|
+
'alignment. set_content: replace the text with `text`. set_font: set `font_name` ' +
|
|
24
|
+
'(optionally `font_size`). set_color: set red/green/blue. set_alignment: set ' +
|
|
25
|
+
'`alignment`. The set_* ops act on the text layer named by `layer_id` or `layer`.',
|
|
26
|
+
},
|
|
27
|
+
layer: {
|
|
28
|
+
...GIMP_LAYER_PROP,
|
|
29
|
+
description: 'set_*: name of the text layer to change. Ignored when layer_id is given. Not used by ' +
|
|
30
|
+
'create.',
|
|
31
|
+
},
|
|
32
|
+
layer_id: {
|
|
33
|
+
...GIMP_LAYER_ID_PROP,
|
|
34
|
+
description: 'set_*: id of the text layer to change (create reports it; gimp_inspect flags text ' +
|
|
35
|
+
'layers with is_text_layer). Takes priority over `layer`. Not used by create.',
|
|
36
|
+
},
|
|
37
|
+
text: {
|
|
38
|
+
type: 'string',
|
|
39
|
+
description: 'create: initial text. set_content: the new text, replacing the existing text. At most ' +
|
|
40
|
+
'2000 characters. For a line break, put a newline character in the string (not a backslash ' +
|
|
41
|
+
'followed by n).',
|
|
42
|
+
},
|
|
43
|
+
x: {
|
|
44
|
+
type: 'integer',
|
|
45
|
+
default: 100,
|
|
46
|
+
description: "create only: the layer's left edge in document pixels.",
|
|
47
|
+
},
|
|
48
|
+
y: {
|
|
49
|
+
type: 'integer',
|
|
50
|
+
default: 100,
|
|
51
|
+
description: "create only: the layer's top edge in document pixels.",
|
|
52
|
+
},
|
|
53
|
+
font_size: {
|
|
54
|
+
type: 'number',
|
|
55
|
+
minimum: 1,
|
|
56
|
+
maximum: 1296,
|
|
57
|
+
description: 'Font size in points, 1 to 1296, converted to pixels with the image resolution. create: ' +
|
|
58
|
+
'initial size (default 24). set_font: new size (omit to keep the current size).',
|
|
59
|
+
},
|
|
60
|
+
font_name: {
|
|
61
|
+
type: 'string',
|
|
62
|
+
description: 'Font family ("Inter", "Open Sans") or full name ("Inter Bold"), case-insensitive. ' +
|
|
63
|
+
'create: omit for a default sans. A name that matches nothing is refused with the ' +
|
|
64
|
+
'closest installed names; gimp_inspect what=fonts lists them all.',
|
|
65
|
+
},
|
|
66
|
+
red: {
|
|
67
|
+
type: 'integer',
|
|
68
|
+
minimum: 0,
|
|
69
|
+
maximum: 255,
|
|
70
|
+
description: 'Text colour red, 0-255. red, green and blue go together.',
|
|
71
|
+
},
|
|
72
|
+
green: {
|
|
73
|
+
type: 'integer',
|
|
74
|
+
minimum: 0,
|
|
75
|
+
maximum: 255,
|
|
76
|
+
description: 'Text colour green, 0-255.',
|
|
77
|
+
},
|
|
78
|
+
blue: {
|
|
79
|
+
type: 'integer',
|
|
80
|
+
minimum: 0,
|
|
81
|
+
maximum: 255,
|
|
82
|
+
description: 'Text colour blue, 0-255.',
|
|
83
|
+
},
|
|
84
|
+
alignment: {
|
|
85
|
+
type: 'string',
|
|
86
|
+
enum: [...ALIGNMENTS],
|
|
87
|
+
description: 'Text alignment. GIMP supports LEFT, CENTER, RIGHT and FULLYJUSTIFIED; the other ' +
|
|
88
|
+
'values ps_text accepts (LEFTJUSTIFIED, CENTERJUSTIFIED, RIGHTJUSTIFIED) are refused.',
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
required: ['image', 'op'],
|
|
92
|
+
};
|
|
93
|
+
async function gimpText(gimp, rawArgs) {
|
|
94
|
+
try {
|
|
95
|
+
const args = validateArgs(textSchema, rawArgs);
|
|
96
|
+
const result = await gimp.call('text', pickSchemaDeclaredKeys(textSchema, args));
|
|
97
|
+
const b = result.bounds;
|
|
98
|
+
const sizing = result.font_size_px !== undefined && result.ppi
|
|
99
|
+
? ` = ${result.font_size_px} px at ${result.ppi} ppi`
|
|
100
|
+
: '';
|
|
101
|
+
return {
|
|
102
|
+
content: [
|
|
103
|
+
{
|
|
104
|
+
type: 'text',
|
|
105
|
+
text: `Text layer "${result.name}" (id ${result.layer_id}): ${result.font ?? 'unknown font'} ` +
|
|
106
|
+
`${result.font_size} pt${sizing}, ${b.width}x${b.height} px at (${b.x}, ${b.y}).`,
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
structuredContent: result,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
return toolGimpErrorResult('Error editing GIMP text layer', error);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
export function createGimpTextTools(gimp) {
|
|
117
|
+
return [
|
|
118
|
+
{
|
|
119
|
+
tool: {
|
|
120
|
+
name: 'gimp_text',
|
|
121
|
+
description: 'Headless GIMP: create a live, editable text layer or restyle one, chosen with `op` ' +
|
|
122
|
+
'(create, set_content, set_font, set_color, set_alignment) — the same ops and ' +
|
|
123
|
+
'parameters as ps_text. create adds a layer at `x`/`y` with `text`, `font_size` in ' +
|
|
124
|
+
'points, and optional font_name, red/green/blue and alignment; it reports layer_id, ' +
|
|
125
|
+
'name, bounds, the image ppi and the font size in pixels. The set_* ops change the text layer named by `layer_id` or `layer` ' +
|
|
126
|
+
'(a non-text layer is refused). Fonts accept a family ("Inter") or full name ("Inter ' +
|
|
127
|
+
'Bold"), case-insensitive; a miss is refused with the closest installed names (list ' +
|
|
128
|
+
'them with gimp_inspect what=fonts). Text is capped at 2000 characters, and a layer ' +
|
|
129
|
+
'that would render past the size limit is refused and leaves the document unchanged. ' +
|
|
130
|
+
'GIMP supports LEFT, CENTER, RIGHT and FULLYJUSTIFIED alignment only. There is no ' +
|
|
131
|
+
'undo in this session: gimp_checkpoint or gimp_save_xcf first when in doubt.',
|
|
132
|
+
inputSchema: textSchema,
|
|
133
|
+
outputSchema: {
|
|
134
|
+
type: 'object',
|
|
135
|
+
properties: {
|
|
136
|
+
layer_id: { type: 'number' },
|
|
137
|
+
name: { type: 'string' },
|
|
138
|
+
text: { type: 'string', description: 'Capped at 200 characters for reporting.' },
|
|
139
|
+
text_length: { type: 'integer' },
|
|
140
|
+
text_truncated: { type: 'boolean' },
|
|
141
|
+
has_markup: { type: 'boolean' },
|
|
142
|
+
font: { type: ['string', 'null'] },
|
|
143
|
+
font_size: { type: 'number', description: 'Points.' },
|
|
144
|
+
font_size_px: {
|
|
145
|
+
type: 'number',
|
|
146
|
+
description: 'The font size in pixels at the image resolution (the height of a line).',
|
|
147
|
+
},
|
|
148
|
+
ppi: { type: ['number', 'null'], description: "The image's resolution." },
|
|
149
|
+
color: {
|
|
150
|
+
type: 'object',
|
|
151
|
+
properties: {
|
|
152
|
+
red: { type: 'number' },
|
|
153
|
+
green: { type: 'number' },
|
|
154
|
+
blue: { type: 'number' },
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
alignment: { type: ['string', 'null'] },
|
|
158
|
+
bounds: {
|
|
159
|
+
type: 'object',
|
|
160
|
+
properties: {
|
|
161
|
+
x: { type: ['number', 'null'] },
|
|
162
|
+
y: { type: ['number', 'null'] },
|
|
163
|
+
width: { type: 'number' },
|
|
164
|
+
height: { type: 'number' },
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
annotations: {
|
|
170
|
+
title: 'GIMP Text Layer',
|
|
171
|
+
readOnlyHint: false,
|
|
172
|
+
destructiveHint: false,
|
|
173
|
+
idempotentHint: false,
|
|
174
|
+
openWorldHint: true,
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
handler: async (args) => gimpText(gimp, args),
|
|
178
|
+
},
|
|
179
|
+
];
|
|
180
|
+
}
|
|
181
|
+
export const TEXT_SCHEMA_FOR_TESTS = textSchema;
|
|
@@ -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/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"
|