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.
Files changed (38) hide show
  1. package/README.md +3 -3
  2. package/dist/backends/gimp/bridge/lib.py +833 -4
  3. package/dist/backends/gimp/bridge/ops.py +1943 -110
  4. package/dist/bin/editmamei-core-darwin-arm64 +0 -0
  5. package/dist/bin/editmamei-core-darwin-x64 +0 -0
  6. package/dist/bin/editmamei-core-win-x64.exe +0 -0
  7. package/dist/cli/config.js +7 -0
  8. package/dist/core/settings.js +7 -0
  9. package/dist/core/tool-activity.js +13 -1
  10. package/dist/core/tool-groups.js +7 -1
  11. package/dist/core/tool-tiers.js +7 -1
  12. package/dist/modules/gimp/index.js +8 -2
  13. package/dist/skills/editmamei-skill.zip +0 -0
  14. package/dist/telemetry/sanitize.js +15 -1
  15. package/dist/tools/adjustment-tools.js +9 -1
  16. package/dist/tools/document-tools.js +50 -9
  17. package/dist/tools/filter-tools.js +2 -1
  18. package/dist/tools/gimp-adjustment-tools.js +3 -3
  19. package/dist/tools/gimp-core-tools.js +7 -8
  20. package/dist/tools/gimp-document-tools.js +4 -1
  21. package/dist/tools/gimp-effect-tools.js +2 -2
  22. package/dist/tools/gimp-geometry-tools.js +2 -2
  23. package/dist/tools/gimp-inspect-tools.js +35 -5
  24. package/dist/tools/gimp-layer-tools.js +5 -24
  25. package/dist/tools/gimp-match-tools.js +133 -0
  26. package/dist/tools/gimp-selection-tools.js +374 -0
  27. package/dist/tools/gimp-shared.js +12 -0
  28. package/dist/tools/gimp-text-tools.js +181 -0
  29. package/dist/tools/gimp-transform-layer-tools.js +215 -0
  30. package/dist/tools/gimp-verify-tools.js +2 -13
  31. package/dist/tools/image-placement-tools.js +6 -1
  32. package/dist/tools/sky-tools.js +2 -1
  33. package/dist/utils/gimp-path.js +18 -22
  34. package/dist/utils/local-path.js +99 -0
  35. package/dist/utils/operation-timeouts.js +7 -1
  36. package/dist/version.js +1 -1
  37. package/package.json +2 -2
  38. package/dist/tools/gimp-mask-tools.js +0 -102
@@ -8,7 +8,6 @@ const LAYER_OPS = [
8
8
  'duplicate',
9
9
  'select',
10
10
  'set',
11
- 'move',
12
11
  'reorder',
13
12
  'merge_down',
14
13
  'flatten',
@@ -31,10 +30,7 @@ const layerSchema = {
31
30
  'target for gimp_add_adjustment and friends when no layer is named) and is the one sub-op ' +
32
31
  "that changes nothing else. 'set' changes opacity, mode, visible, and/or name (at least " +
33
32
  'one required, name may not be empty) — mode is a blend mode from a fixed list, not a raw ' +
34
- "GEGL/GIMP name. 'move' repositions a layer to an ABSOLUTE x/y (not a delta), bounded to " +
35
- 'stay near the canvas — REFUSED when the layer, or a group containing it, carries a ' +
36
- "masked or unverifiable adjustment filter, since a filter's mask does not travel with " +
37
- "content that moves beneath it. 'reorder' " +
33
+ "GEGL/GIMP name. 'reorder' " +
38
34
  "changes a layer's stack position and/or moves it into a group (parent_group) or out to " +
39
35
  'top-level (to_top_level: true) — neither given keeps the layer in its current group and ' +
40
36
  'only changes `position`; REFUSED if parent_group would nest a group inside itself or one ' +
@@ -139,14 +135,6 @@ const layerSchema = {
139
135
  type: 'boolean',
140
136
  description: 'set only.',
141
137
  },
142
- x: {
143
- type: 'integer',
144
- description: "move only. The layer's new ABSOLUTE horizontal offset in document pixels (not a delta).",
145
- },
146
- y: {
147
- type: 'integer',
148
- description: "move only. The layer's new ABSOLUTE vertical offset in document pixels (not a delta).",
149
- },
150
138
  discard_hidden: {
151
139
  type: 'boolean',
152
140
  default: false,
@@ -169,8 +157,6 @@ function layerSuccessText(op, result, args) {
169
157
  return `Duplicated as layer ${result.layer_id} ("${result.name}").`;
170
158
  case 'select':
171
159
  return `Selected layer ${result.layer_id} ("${result.name}").`;
172
- case 'move':
173
- return `Moved layer ${result.layer_id} to (${result.x}, ${result.y}).`;
174
160
  case 'reorder':
175
161
  return (`Reordered layer ${result.layer_id}` +
176
162
  (result.parent_group != null
@@ -277,7 +263,7 @@ export function createGimpLayerTools(gimp) {
277
263
  tool: {
278
264
  name: 'gimp_layer',
279
265
  description: 'Headless GIMP: layer management. op: create | create_group | delete | duplicate | ' +
280
- 'select | set | move | reorder | merge_down | flatten (a group is created, addressed, ' +
266
+ 'select | set | reorder | merge_down | flatten (a group is created, addressed, ' +
281
267
  'and reordered the same as any other layer — there is no separate group tool). ' +
282
268
  'Addressed by layer_id (canonical — an id from gimp_inspect or a prior gimp_layer/' +
283
269
  'gimp_bake result) or layer (name; GIMP allows duplicate names, so layer_id is the only ' +
@@ -286,10 +272,7 @@ export function createGimpLayerTools(gimp) {
286
272
  'first when in doubt. duplicate REFUSES outright when the layer (or, for a group, any ' +
287
273
  "descendant) carries an Editmamei filter, since the copy's filter would collide with " +
288
274
  "the original's own ledger record; bake it first (gimp_bake) or delete it, then " +
289
- 'duplicate. move takes an ABSOLUTE x/y (not a delta) and REFUSES when the layer, or ' +
290
- 'a group containing it, carries a masked or unverifiable adjustment filter — a ' +
291
- "filter's mask does not travel with content that moves beneath it, the same physics " +
292
- 'gimp_transform_canvas refuses on. merge_down REFUSES outright when the SOURCE layer ' +
275
+ 'duplicate. merge_down REFUSES outright when the SOURCE layer ' +
293
276
  'itself is hidden (GIMP cannot merge a hidden layer down); it and ' +
294
277
  'flatten both bake every live filter they touch into pixels first (masked filters ' +
295
278
  'included) and rasterize any VISIBLE text layer in the process (rasterized_text); ' +
@@ -308,8 +291,6 @@ export function createGimpLayerTools(gimp) {
308
291
  opacity: { type: 'number' },
309
292
  mode: { type: 'string' },
310
293
  visible: { type: 'boolean' },
311
- x: { type: ['number', 'null'] },
312
- y: { type: ['number', 'null'] },
313
294
  parent_group: { type: ['number', 'null'] },
314
295
  rasterized_text: { type: 'boolean' },
315
296
  has_alpha: { type: 'boolean' },
@@ -340,9 +321,9 @@ export function createGimpLayerTools(gimp) {
340
321
  'filters; a text layer stays a text layer (baking never rasterizes one). A masked ' +
341
322
  "filter's confinement survives the bake exactly. Defaults to the selected layer, or the " +
342
323
  'topmost layer if none is selected, the same as any other gimp_layer addressing. Baking ' +
343
- 'clears any masked-filter refusal (gimp_layer op=move, gimp_canvas, gimp_resize_image, ' +
324
+ 'clears any masked-filter refusal (gimp_canvas, gimp_resize_image, ' +
344
325
  'gimp_transform_canvas) that filter would otherwise trigger — it is the sanctioned way ' +
345
- 'to make a masked adjustment safe to move, resize, rotate, or flip around. all: true ' +
326
+ 'to make a masked adjustment safe to resize, rotate, or flip around. all: true ' +
346
327
  'bakes every ordinary layer with a live filter at once; a group layer is always skipped, ' +
347
328
  'even one that carries its own filter (GIMP cannot merge filters on a group item) — ' +
348
329
  'reported under skipped_groups_with_filters rather than silently ignored. Targeting a ' +
@@ -0,0 +1,133 @@
1
+ import { GIMP_IMAGE_PROP, GIMP_LAYER_ID_PROP, GIMP_LAYER_PROP, runGimpTool, } from './gimp-shared.js';
2
+ const matchSchema = {
3
+ type: 'object',
4
+ properties: {
5
+ image: GIMP_IMAGE_PROP,
6
+ layer: {
7
+ ...GIMP_LAYER_PROP,
8
+ description: 'Name of the pasted layer to match. Defaults to the selected layer, or the topmost layer ' +
9
+ 'if none is selected. A layer inside a layer group can be named directly.',
10
+ },
11
+ layer_id: GIMP_LAYER_ID_PROP,
12
+ match: {
13
+ type: 'string',
14
+ enum: ['both', 'color', 'tone'],
15
+ default: 'both',
16
+ description: "'color' and 'both' write one curves filter each for red, green and blue (matching each " +
17
+ "channel's mean and spread also matches overall tone, so 'both' adds no separate value " +
18
+ "curve). 'tone' writes ONE value curve only, which moves brightness and contrast without " +
19
+ 'shifting the colour balance.',
20
+ },
21
+ strength: {
22
+ type: 'number',
23
+ minimum: 0,
24
+ maximum: 100,
25
+ default: 70,
26
+ description: 'How far to move toward the reference, 0-100. 0 changes nothing (identity curves); 100 ' +
27
+ 'matches the reference mean and spread (contrast gain is limited to 0.5-2x).',
28
+ },
29
+ reference: {
30
+ type: 'string',
31
+ enum: ['surround', 'below'],
32
+ default: 'surround',
33
+ description: "'surround': a ring of the composite beneath, just outside the layer's visible edge " +
34
+ "(the light the subject sits in). 'below': the whole composite beneath the layer's box.",
35
+ },
36
+ surround_px: {
37
+ type: 'integer',
38
+ minimum: 1,
39
+ maximum: 400,
40
+ description: "reference 'surround' only. Ring width in pixels. Default about 12% of the layer's " +
41
+ 'longer side, clamped to 16-400.',
42
+ },
43
+ edge_contract_px: {
44
+ type: 'integer',
45
+ minimum: 0,
46
+ maximum: 20,
47
+ default: 0,
48
+ description: "Pull the layer's visible edge in by this many pixels, through its layer mask (one is " +
49
+ 'made from its alpha if it has none) to hide a cut-out fringe. 0 leaves the edge alone.',
50
+ },
51
+ edge_feather_px: {
52
+ type: 'integer',
53
+ minimum: 0,
54
+ maximum: 50,
55
+ default: 0,
56
+ description: "Soften the layer's visible edge by this many pixels through its layer mask, after any " +
57
+ "contraction. 0 leaves the edge alone. The layer's own alpha is never changed.",
58
+ },
59
+ },
60
+ required: ['image'],
61
+ };
62
+ export function createGimpMatchTools(gimp) {
63
+ return [
64
+ {
65
+ tool: {
66
+ name: 'gimp_match_layer',
67
+ description: 'Headless GIMP: make a pasted or composited layer sit in its scene by pulling its colour ' +
68
+ "and tone toward the surrounding pixels. Measures the layer's visible pixels (alpha and " +
69
+ 'layer mask) against a reference region of everything below it, then adds LIVE curves ' +
70
+ 'filters named "Match red/green/blue" (match: both or color) or "Match value" (match: ' +
71
+ "tone) that move the layer's per-channel mean and spread toward the reference by " +
72
+ '`strength`. The filters are ordinary ones: list, hide, re-edit or delete them with ' +
73
+ 'gimp_filter and gimp_add_adjustment (filter_id); nothing bakes until gimp_export. ' +
74
+ 'Calling it again REPLACES the previous match filters on that layer instead of stacking ' +
75
+ 'them. The layer is measured as it renders with its other filters, so run it after the ' +
76
+ 'layer is placed, cut out and transformed. Optional edge_contract_px / edge_feather_px ' +
77
+ 'refine the layer mask to hide a cut-out fringe. Refuses a layer group, a text layer ' +
78
+ '(bake it first), a locked layer, a layer with no visible layers beneath it, and a ' +
79
+ 'region with fewer than 500 pixels. The result reports the filters made, each ' +
80
+ "channel's before/after mean and std (0-255; `after` is the map's expected value) and " +
81
+ 'the pixel counts measured.',
82
+ inputSchema: matchSchema,
83
+ outputSchema: {
84
+ type: 'object',
85
+ properties: {
86
+ layer: { type: 'string' },
87
+ layer_id: { type: 'number' },
88
+ match: { type: 'string' },
89
+ reference: { type: 'string' },
90
+ strength: { type: 'number' },
91
+ surround_px: { type: ['number', 'null'] },
92
+ filters: { type: 'array' },
93
+ replaced_filter_ids: { type: 'array' },
94
+ measured: { type: 'object' },
95
+ channels: { type: 'object' },
96
+ edge: { type: ['object', 'null'] },
97
+ },
98
+ },
99
+ annotations: {
100
+ title: 'Match GIMP Layer To Scene',
101
+ readOnlyHint: false,
102
+ destructiveHint: false,
103
+ idempotentHint: false,
104
+ openWorldHint: true,
105
+ },
106
+ },
107
+ handler: async (args) => runGimpTool({
108
+ gimp,
109
+ rawArgs: args,
110
+ schema: matchSchema,
111
+ op: 'match_layer',
112
+ errorPrefix: 'Error matching GIMP layer',
113
+ successText: (result) => {
114
+ const r = result;
115
+ const moved = Object.entries(r.channels)
116
+ .map(([channel, c]) => `${channel} ${c.before.mean.toFixed(0)} -> ${c.after.mean.toFixed(0)} ` +
117
+ `(reference ${c.reference.mean.toFixed(0)})`)
118
+ .join(', ');
119
+ return (`Matched "${r.layer}" to its ${r.reference} at ${r.strength}% (${r.match}): ` +
120
+ `${r.filters.length} curves filter(s)` +
121
+ (r.replaced_filter_ids.length
122
+ ? `, replacing ${r.replaced_filter_ids.length} earlier`
123
+ : '') +
124
+ `. Means ${moved}.` +
125
+ (r.edge
126
+ ? ` Edge: contracted ${r.edge.contract_px} px, feathered ${r.edge.feather_px} px via the layer mask.`
127
+ : ''));
128
+ },
129
+ }),
130
+ },
131
+ ];
132
+ }
133
+ export const MATCH_SCHEMA_FOR_TESTS = matchSchema;
@@ -0,0 +1,374 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { rm } from 'node:fs/promises';
3
+ import { validateArgs } from '../utils/validate.js';
4
+ import { toolGimpErrorResult } from '../utils/tool-helpers.js';
5
+ import { GIMP_IMAGE_PROP, GIMP_LAYER_ID_PROP, GIMP_LAYER_PROP, GIMP_MAX_PX_PROP, readRender, defaultPreviewsAllowed, pickSchemaDeclaredKeys, runGimpTool, } from './gimp-shared.js';
6
+ const maskResultSchema = {
7
+ type: 'object',
8
+ properties: {
9
+ channel: { type: 'string' },
10
+ selected_pixels: { type: 'number' },
11
+ fraction: { type: 'number' },
12
+ },
13
+ };
14
+ const maskText = (result) => {
15
+ const r = result;
16
+ return `Channel "${r.channel}" — ${(r.fraction * 100).toFixed(1)}% of pixels selected.`;
17
+ };
18
+ const layerMaskResultSchema = {
19
+ type: 'object',
20
+ properties: {
21
+ layer: { type: 'string' },
22
+ layer_id: { type: 'integer' },
23
+ op: { type: 'string' },
24
+ has_mask: { type: 'boolean' },
25
+ },
26
+ };
27
+ const selectionPreviewResultSchema = {
28
+ type: 'object',
29
+ properties: {
30
+ width: { type: 'number' },
31
+ height: { type: 'number' },
32
+ },
33
+ };
34
+ const selectSchema = {
35
+ type: 'object',
36
+ properties: {
37
+ image: GIMP_IMAGE_PROP,
38
+ mode: {
39
+ type: 'string',
40
+ enum: [
41
+ 'all',
42
+ 'rectangle',
43
+ 'ellipse',
44
+ 'polygon',
45
+ 'color_range',
46
+ 'magic_wand',
47
+ 'alpha',
48
+ 'channel',
49
+ 'gradient_linear',
50
+ 'gradient_radial',
51
+ ],
52
+ description: 'rectangle/ellipse: x, y, width, height. polygon: points (>=3 [x,y] pairs). color_range: ' +
53
+ 'every pixel near `color` (hex) or the colour at a sample point x,y, anywhere in the ' +
54
+ "image. magic_wand: the contiguous region around x,y. alpha: a layer's opaque pixels. " +
55
+ 'channel: copy another saved selection (`source`). gradient_linear: a ramp from (x1,y1) ' +
56
+ 'to (x2,y2), default the full image width. gradient_radial: a ramp from full effect at ' +
57
+ "the center (cx,cy) fading to none at radius, default centered at the image's own center " +
58
+ 'with radius = half the shorter side.',
59
+ },
60
+ name: {
61
+ type: 'string',
62
+ default: 'Selection',
63
+ minLength: 1,
64
+ description: 'Channel to save the selection in (replaced).',
65
+ },
66
+ combine: {
67
+ type: 'string',
68
+ enum: ['replace', 'add', 'subtract', 'intersect'],
69
+ default: 'replace',
70
+ description: 'Combine with the existing saved selection of the same `name` (like shift/alt in a ' +
71
+ 'GUI). `invert`/`feather_px` apply to the NEW shape alone, before it is combined.',
72
+ },
73
+ x: {
74
+ type: 'number',
75
+ description: "rectangle/ellipse/magic_wand, or color_range's sample point: left edge or point x, in " +
76
+ 'image pixels from the canvas origin, also when `layer` names a layer that sits at an ' +
77
+ "offset (never counted from that layer's own corner).",
78
+ },
79
+ y: {
80
+ type: 'number',
81
+ description: "rectangle/ellipse/magic_wand, or color_range's sample point: top edge or point y, in " +
82
+ 'image pixels from the canvas origin, also when `layer` names a layer that sits at an ' +
83
+ "offset (never counted from that layer's own corner).",
84
+ },
85
+ width: { type: 'number', description: 'rectangle/ellipse: width, document pixels.' },
86
+ height: { type: 'number', description: 'rectangle/ellipse: height, document pixels.' },
87
+ points: {
88
+ type: 'array',
89
+ minItems: 3,
90
+ maxItems: 10_000,
91
+ items: { type: 'array', minItems: 2, maxItems: 2, items: { type: 'number' } },
92
+ description: 'polygon: 3-10,000 [x, y] pairs, document pixels.',
93
+ },
94
+ color: {
95
+ type: 'string',
96
+ pattern: '^#[0-9a-fA-F]{6}$',
97
+ description: 'color_range: a hex colour like "#c0392b".',
98
+ },
99
+ threshold: {
100
+ type: 'number',
101
+ minimum: 0,
102
+ maximum: 255,
103
+ default: 15,
104
+ description: 'color_range/magic_wand tolerance, 0-255.',
105
+ },
106
+ sample_merged: {
107
+ type: 'boolean',
108
+ default: true,
109
+ description: 'color_range/magic_wand: sample the visible composite. false samples the one named/' +
110
+ "selected layer's own raw pixels instead.",
111
+ },
112
+ source: {
113
+ type: 'string',
114
+ minLength: 1,
115
+ description: 'mode=channel: the saved selection to copy.',
116
+ },
117
+ layer: {
118
+ ...GIMP_LAYER_PROP,
119
+ description: GIMP_LAYER_PROP.description +
120
+ ' alpha: which layer. color_range/magic_wand: only matters when sample_merged:false ' +
121
+ '(otherwise the visible composite is sampled, and which layer this names makes no ' +
122
+ 'difference to the result).',
123
+ },
124
+ layer_id: {
125
+ ...GIMP_LAYER_ID_PROP,
126
+ description: GIMP_LAYER_ID_PROP.description +
127
+ ' alpha: which layer. color_range/magic_wand: only matters when sample_merged:false.',
128
+ },
129
+ invert: {
130
+ type: 'boolean',
131
+ default: false,
132
+ description: 'Applied to the NEW shape alone, before any `combine`. rectangle/ellipse/polygon/' +
133
+ 'color_range/magic_wand/alpha/channel: invert the selection. gradient_linear/' +
134
+ 'gradient_radial: swap which end is black vs white.',
135
+ },
136
+ feather_px: {
137
+ type: 'number',
138
+ minimum: 0,
139
+ maximum: 1000,
140
+ default: 0,
141
+ description: 'Feather radius in document pixels, applied to the NEW shape alone before any `combine`. ' +
142
+ 'Refused (nonzero) on gradient_linear/gradient_radial -- already a continuous ramp, ' +
143
+ 'nothing to feather.',
144
+ },
145
+ x1: {
146
+ type: 'number',
147
+ description: 'gradient_linear: start point x, document pixels. Default 0.',
148
+ },
149
+ y1: {
150
+ type: 'number',
151
+ description: 'gradient_linear: start point y, document pixels. Default 0.',
152
+ },
153
+ x2: {
154
+ type: 'number',
155
+ description: "gradient_linear: end point x, document pixels. Default the image's width.",
156
+ },
157
+ y2: {
158
+ type: 'number',
159
+ description: 'gradient_linear: end point y, document pixels. Default 0.',
160
+ },
161
+ cx: {
162
+ type: 'number',
163
+ description: "gradient_radial: center x, document pixels. Default the image's own center.",
164
+ },
165
+ cy: {
166
+ type: 'number',
167
+ description: "gradient_radial: center y, document pixels. Default the image's own center.",
168
+ },
169
+ radius: {
170
+ type: 'number',
171
+ description: 'gradient_radial: radius, document pixels. Default half the shorter image side.',
172
+ },
173
+ },
174
+ required: ['image', 'mode'],
175
+ };
176
+ const modifySelectionSchema = {
177
+ type: 'object',
178
+ properties: {
179
+ image: GIMP_IMAGE_PROP,
180
+ channel: { type: 'string', minLength: 1, description: 'The saved selection to modify.' },
181
+ op: {
182
+ type: 'string',
183
+ enum: ['expand', 'contract', 'border', 'feather', 'smooth', 'invert', 'harden'],
184
+ description: 'expand/contract/border/feather require `px` (> 0). harden: threshold a soft mask at ' +
185
+ '50%. smooth: round off jagged edges (`px` optional, floors at 1px).',
186
+ },
187
+ px: {
188
+ type: 'number',
189
+ minimum: 0,
190
+ maximum: 1000,
191
+ description: 'Radius in document pixels, rounded to the nearest whole pixel. Required (> 0) for ' +
192
+ 'expand/contract/border/feather; optional for smooth; unused otherwise. expand/contract/' +
193
+ 'border cap at 150 on an ordinary document, LOWER on a very large one (morphological ' +
194
+ "ops whose cost scales with both the radius and the document's own megapixels — the " +
195
+ 'error names the actual cap for that document when a value is refused); feather has no ' +
196
+ 'such cap up to 1000 (a GEGL blur, flat cost regardless of radius).',
197
+ },
198
+ output: {
199
+ type: 'string',
200
+ minLength: 1,
201
+ description: 'Write to this saved selection instead of modifying `channel` in place.',
202
+ },
203
+ },
204
+ required: ['image', 'channel', 'op'],
205
+ };
206
+ const layerMaskSchema = {
207
+ type: 'object',
208
+ properties: {
209
+ image: GIMP_IMAGE_PROP,
210
+ op: {
211
+ type: 'string',
212
+ enum: ['create', 'delete', 'apply', 'invert'],
213
+ description: "create replaces any existing mask. apply bakes the mask into the layer's alpha.",
214
+ },
215
+ source: {
216
+ type: 'string',
217
+ enum: ['channel', 'white', 'black', 'alpha', 'grayscale'],
218
+ default: 'channel',
219
+ description: 'create: where the mask comes from.',
220
+ },
221
+ channel: {
222
+ type: 'string',
223
+ minLength: 1,
224
+ description: 'source=channel: the saved selection to use.',
225
+ },
226
+ invert: { type: 'boolean', default: false, description: 'create: invert the new mask.' },
227
+ layer: GIMP_LAYER_PROP,
228
+ layer_id: GIMP_LAYER_ID_PROP,
229
+ },
230
+ required: ['image', 'op'],
231
+ };
232
+ const selectionPreviewSchema = {
233
+ type: 'object',
234
+ properties: {
235
+ image: GIMP_IMAGE_PROP,
236
+ channel: { type: 'string', minLength: 1, description: 'The saved selection to show.' },
237
+ style: {
238
+ type: 'string',
239
+ enum: ['overlay', 'mask'],
240
+ default: 'overlay',
241
+ description: 'overlay: the image with red over what is NOT selected. mask: white = selected.',
242
+ },
243
+ max_px: GIMP_MAX_PX_PROP,
244
+ },
245
+ required: ['image', 'channel'],
246
+ };
247
+ async function selectionPreview(gimp, rawArgs, previewsAllowedFn) {
248
+ try {
249
+ const args = validateArgs(selectionPreviewSchema, rawArgs);
250
+ await gimp.prepare();
251
+ const out = gimp.tempPath(`selection-preview-${randomUUID()}.jpg`);
252
+ try {
253
+ const bridgeArgs = pickSchemaDeclaredKeys(selectionPreviewSchema, args);
254
+ const r = await gimp.call('mask_preview', { ...bridgeArgs, out_path: out });
255
+ const allowed = previewsAllowedFn();
256
+ const content = [];
257
+ if (allowed) {
258
+ const bytes = await readRender(out);
259
+ content.push({
260
+ type: 'image',
261
+ data: bytes.toString('base64'),
262
+ mimeType: 'image/jpeg',
263
+ });
264
+ }
265
+ const unmirroredNote = r.unmirrored_filters && r.unmirrored_filters.length > 0
266
+ ? ` WARNING: ${r.unmirrored_filters.join(', ')} could not be rendered on this proxy and ` +
267
+ `${r.unmirrored_filters.length === 1 ? 'is' : 'are'} missing from it.`
268
+ : '';
269
+ content.push({
270
+ type: 'text',
271
+ text: `Selection "${String(args.channel)}" ${r.width}x${r.height}.` +
272
+ (allowed
273
+ ? ''
274
+ : ' privacy.send_previews_to_llm is false — image not returned to the model.') +
275
+ unmirroredNote,
276
+ });
277
+ return { content, structuredContent: { width: r.width, height: r.height } };
278
+ }
279
+ finally {
280
+ await rm(out, { force: true }).catch(() => undefined);
281
+ }
282
+ }
283
+ catch (error) {
284
+ return toolGimpErrorResult('Error previewing GIMP selection', error);
285
+ }
286
+ }
287
+ const annotations = (title, readOnly = false) => ({
288
+ title,
289
+ readOnlyHint: readOnly,
290
+ destructiveHint: !readOnly,
291
+ idempotentHint: false,
292
+ openWorldHint: true,
293
+ });
294
+ export function createGimpSelectionTools(gimp, deps = {}) {
295
+ const previewsAllowedFn = deps.previewsAllowed ?? defaultPreviewsAllowed;
296
+ return [
297
+ {
298
+ tool: {
299
+ name: 'gimp_select',
300
+ description: 'Headless GIMP: make a selection, saved as a NAMED channel (no live selection ' +
301
+ 'persists between calls): rectangle/ellipse/polygon/colour range/magic wand/layer ' +
302
+ 'alpha/gradient/another channel. Combine add/subtract/intersect by reusing a name. ' +
303
+ "Use the channel as gimp_add_adjustment's " +
304
+ '`mask` when creating a filter, or gimp_layer_mask for a real layer mask. Make ' +
305
+ 'selections after straighten/resize/crop.',
306
+ inputSchema: selectSchema,
307
+ outputSchema: maskResultSchema,
308
+ annotations: annotations('Select (GIMP)'),
309
+ },
310
+ handler: (args) => runGimpTool({
311
+ gimp,
312
+ rawArgs: args,
313
+ schema: selectSchema,
314
+ op: 'select',
315
+ errorPrefix: 'Error selecting',
316
+ successText: maskText,
317
+ }),
318
+ },
319
+ {
320
+ tool: {
321
+ name: 'gimp_modify_selection',
322
+ description: 'Headless GIMP: expand, contract, border, feather, smooth, invert or harden a saved ' +
323
+ 'selection. Refused if the target channel already confines a filter.',
324
+ inputSchema: modifySelectionSchema,
325
+ outputSchema: maskResultSchema,
326
+ annotations: annotations('Modify Selection (GIMP)'),
327
+ },
328
+ handler: (args) => runGimpTool({
329
+ gimp,
330
+ rawArgs: args,
331
+ schema: modifySelectionSchema,
332
+ op: 'modify_mask',
333
+ errorPrefix: 'Error modifying selection',
334
+ successText: maskText,
335
+ }),
336
+ },
337
+ {
338
+ tool: {
339
+ name: 'gimp_layer_mask',
340
+ description: 'Headless GIMP: a real layer mask (moves with the layer). create from a saved ' +
341
+ 'selection (e.g. from gimp_select) or white/black/alpha/grayscale; delete; apply (bake ' +
342
+ 'into alpha — a cut-out); invert. For a cut-out composite: place the photo as a ' +
343
+ 'layer, select on that layer, then create its mask from the channel.',
344
+ inputSchema: layerMaskSchema,
345
+ outputSchema: layerMaskResultSchema,
346
+ annotations: annotations('Layer Mask (GIMP)'),
347
+ },
348
+ handler: (args) => runGimpTool({
349
+ gimp,
350
+ rawArgs: args,
351
+ schema: layerMaskSchema,
352
+ op: 'layer_mask',
353
+ errorPrefix: 'Error on layer mask',
354
+ successText: (r) => {
355
+ const x = r;
356
+ return `Layer "${x.layer}": ${x.op} done (${x.has_mask ? 'has' : 'no'} mask).`;
357
+ },
358
+ }),
359
+ },
360
+ {
361
+ tool: {
362
+ name: 'gimp_get_selection_preview',
363
+ description: 'Headless GIMP: see a saved selection — overlay (red over what is NOT selected, Quick ' +
364
+ 'Mask style) or the mask itself. Always check a selection before using it. Respects ' +
365
+ 'privacy.send_previews_to_llm: when disabled, no image bytes are returned, only the ' +
366
+ 'dimensions.',
367
+ inputSchema: selectionPreviewSchema,
368
+ outputSchema: selectionPreviewResultSchema,
369
+ annotations: annotations('Selection Preview (GIMP)', true),
370
+ },
371
+ handler: (args) => selectionPreview(gimp, args, previewsAllowedFn),
372
+ },
373
+ ];
374
+ }
@@ -1,3 +1,6 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { loadSettings } from '../core/settings.js';
3
+ import { GimpError } from '../backends/gimp/errors.js';
1
4
  import { validateArgs } from '../utils/validate.js';
2
5
  import { toolGimpErrorResult } from '../utils/tool-helpers.js';
3
6
  export const GIMP_IMAGE_PROP = {
@@ -42,6 +45,15 @@ export function pickSchemaDeclaredKeys(schema, args) {
42
45
  }
43
46
  return out;
44
47
  }
48
+ export async function readRender(path) {
49
+ try {
50
+ return await readFile(path);
51
+ }
52
+ catch {
53
+ throw new GimpError('gimp_op_failed', 'GIMP reported success but the render was not written');
54
+ }
55
+ }
56
+ export const defaultPreviewsAllowed = () => loadSettings().settings.privacy.send_previews_to_llm;
45
57
  export async function runGimpTool(spec) {
46
58
  try {
47
59
  const args = validateArgs(spec.schema, spec.rawArgs);