editmamei 1.6.0 → 1.7.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.
@@ -0,0 +1,267 @@
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 EFFECT_TYPES = [
5
+ 'vignette',
6
+ 'black_white',
7
+ 'motion_blur',
8
+ 'lens_blur',
9
+ 'add_noise',
10
+ 'drop_shadow',
11
+ ];
12
+ const effectSchema = {
13
+ type: 'object',
14
+ properties: {
15
+ image: GIMP_IMAGE_PROP,
16
+ type: {
17
+ type: 'string',
18
+ enum: [...EFFECT_TYPES],
19
+ description: 'Which effect to create/re-edit — see the per-field descriptions below for which ' +
20
+ 'fields apply to which effect.',
21
+ },
22
+ filter_id: {
23
+ type: 'integer',
24
+ description: "Re-edit this existing filter IN PLACE instead of adding a new one (must match `type`'s " +
25
+ 'operation). Any per-effect field you omit keeps the value it already had — this is a ' +
26
+ 'MERGE, not a reset. The values gimp_filter op=list reports for it are in these same ' +
27
+ 'field names and units, so they can be passed straight back. The mask is fixed at ' +
28
+ 'creation and cannot change on a re-edit. Only filters Editmamei created can be ' +
29
+ 're-edited (gimp_filter op=list shows source: "editmamei"); re-editing any other filter ' +
30
+ '(for example one added in the GIMP GUI) is refused, because its current values cannot ' +
31
+ 'be read back exactly — delete it and re-create it instead. A re-edit keeps the filter ' +
32
+ 'on its own layer; `layer` and `layer_id` are ignored.',
33
+ },
34
+ layer: {
35
+ ...GIMP_LAYER_PROP,
36
+ description: GIMP_LAYER_PROP.description +
37
+ ' The filter applies to that ONE layer, not to the flattened image, so on a ' +
38
+ 'multi-layer document name the layer you mean. A layer inside a layer group can be ' +
39
+ 'named directly.',
40
+ },
41
+ layer_id: GIMP_LAYER_ID_PROP,
42
+ mask: {
43
+ type: 'string',
44
+ description: 'Name of a mask channel from gimp_create_mask, confining a NEW filter to it. Only valid ' +
45
+ "when creating (no filter_id) — a filter's mask is fixed at creation. ORDER MATTERS: " +
46
+ 'straighten / flip / resize the canvas first (gimp_transform_canvas, gimp_resize_image), ' +
47
+ 'then crop, THEN add masked effects — rotate, flip, and resize all refuse outright once ' +
48
+ 'any masked filter exists on the image, or any filter not created by Editmamei, since ' +
49
+ 'they cannot keep a mask aligned through those transforms. Cropping afterward is always ' +
50
+ 'safe.',
51
+ },
52
+ name: {
53
+ type: 'string',
54
+ description: 'Label for a new filter, e.g. "Corner darken". Made unique automatically.',
55
+ },
56
+ radius: {
57
+ type: 'number',
58
+ minimum: 0,
59
+ maximum: 1500,
60
+ description: 'vignette: size of the untouched centre, 0..3, relative to the LAYER (not absolute ' +
61
+ 'pixels — the same value looks visually equivalent at any resolution, and consistent ' +
62
+ 'with center_x/center_y below). SMALLER is STRONGER. Default (when creating): 2.0, a ' +
63
+ 'subtle vignette (corners about a quarter darker, centre untouched). At the default ' +
64
+ 'softness and gamma, 1.5 is strong (corners more than half darker) and 1.2 or less ' +
65
+ 'turns the corners black. ' +
66
+ 'lens_blur: blur radius in pixels at full resolution, 0..150 ' +
67
+ '(default 25 when creating) — capped much lower than the other two effects here (measured ' +
68
+ 'live: expensive at full resolution). drop_shadow: blur radius in pixels at full ' +
69
+ 'resolution, 0..1500 (default 10 when creating), clipped to the layer bounds rather than ' +
70
+ 'growing it. Both lens_blur and drop_shadow are SPATIAL (radius-scaled on the proxy ' +
71
+ 'preview, see gimp_overview). The bridge enforces the precise range for whichever ' +
72
+ '`type` you gave.',
73
+ },
74
+ softness: {
75
+ type: 'number',
76
+ minimum: 0,
77
+ maximum: 1,
78
+ description: 'vignette only. How gradual the falloff is; 0 is a hard edge. Default (when creating): 1.0.',
79
+ },
80
+ gamma: {
81
+ type: 'number',
82
+ minimum: 0.1,
83
+ maximum: 10,
84
+ description: 'vignette only. Falloff curve shape: lower spreads the darkening further in toward the ' +
85
+ 'centre and deepens it; higher confines it to the corners and weakens it. Default (when ' +
86
+ 'creating): 2.0.',
87
+ },
88
+ center_x: {
89
+ type: 'number',
90
+ minimum: 0,
91
+ maximum: 1,
92
+ description: "vignette only. Vignette center, as a fraction of the LAYER's own width (not the whole " +
93
+ 'canvas — 0 = left edge, 1 = right edge). Default (when creating): 0.5. Cropping ' +
94
+ 're-centres the vignette on the new, smaller frame (the same fraction now means a ' +
95
+ 'different point) — the intended behaviour, like a post-crop vignette in Lightroom, not ' +
96
+ 'something to correct for.',
97
+ },
98
+ center_y: {
99
+ type: 'number',
100
+ minimum: 0,
101
+ maximum: 1,
102
+ description: "vignette only. Vignette center, as a fraction of the LAYER's own height (not the whole " +
103
+ 'canvas — 0 = top edge, 1 = bottom edge). Default (when creating): 0.5. The vignette ' +
104
+ 'color is black and not configurable yet.',
105
+ },
106
+ red_weight: {
107
+ type: 'number',
108
+ minimum: -5,
109
+ maximum: 5,
110
+ description: 'black_white only. How much the red channel contributes to the resulting gray. Default ' +
111
+ '(when creating): 0.333.',
112
+ },
113
+ green_weight: {
114
+ type: 'number',
115
+ minimum: -5,
116
+ maximum: 5,
117
+ description: 'black_white only. Default (when creating): 0.333.',
118
+ },
119
+ blue_weight: {
120
+ type: 'number',
121
+ minimum: -5,
122
+ maximum: 5,
123
+ description: 'black_white only. Default (when creating): 0.333.',
124
+ },
125
+ preserve_luminosity: {
126
+ type: 'boolean',
127
+ description: 'black_white only. Default (when creating): false.',
128
+ },
129
+ length: {
130
+ type: 'number',
131
+ minimum: 0,
132
+ maximum: 1000,
133
+ description: 'motion_blur only. Blur length in pixels at full resolution, 0..1000. Default (when ' +
134
+ 'creating): 10. SPATIAL (scaled on the proxy preview, see gimp_overview).',
135
+ },
136
+ angle: {
137
+ type: 'number',
138
+ minimum: -180,
139
+ maximum: 180,
140
+ description: 'motion_blur only. Direction of the streak, in degrees: 0 is horizontal, positive angles ' +
141
+ 'rotate clockwise (90 is vertical). Default (when creating): 0.',
142
+ },
143
+ highlight_factor: {
144
+ type: 'number',
145
+ minimum: 0,
146
+ maximum: 1,
147
+ description: 'lens_blur only. Boosts bright out-of-focus highlights (bokeh) above this threshold. ' +
148
+ 'Default (when creating): 0 (no boost). lens_blur applies uniformly across the whole ' +
149
+ 'layer (a soft-focus effect), not a depth-of-field falloff — mask it (see `mask`) to ' +
150
+ 'confine it to part of the image instead.',
151
+ },
152
+ noise_amount: {
153
+ type: 'number',
154
+ minimum: 0,
155
+ maximum: 1,
156
+ description: 'add_noise only. THE amount control — sets red/green/blue uniformly (grayscale-leaning ' +
157
+ 'noise, not a color-tinted one). Default (when creating): 0.2.',
158
+ },
159
+ alpha: {
160
+ type: 'number',
161
+ minimum: 0,
162
+ maximum: 1,
163
+ description: 'add_noise only. Noise added to the alpha channel too. Default (when creating): 0.',
164
+ },
165
+ seed: {
166
+ type: 'integer',
167
+ minimum: 0,
168
+ maximum: 4294967295,
169
+ description: 'add_noise only. RNG seed — the same seed reproduces the identical noise pattern (useful ' +
170
+ 'for an exact re-edit round trip). Default (when creating): 0.',
171
+ },
172
+ offset_x: {
173
+ type: 'number',
174
+ minimum: -500,
175
+ maximum: 500,
176
+ description: 'drop_shadow only. Horizontal shadow offset in pixels at full resolution. Default (when ' +
177
+ 'creating): 20. SPATIAL (scaled on the proxy preview, see gimp_overview). Only meaningful ' +
178
+ 'on a layer with an alpha channel — a shadow is cast from what is transparent around the ' +
179
+ 'opaque content.',
180
+ },
181
+ offset_y: {
182
+ type: 'number',
183
+ minimum: -500,
184
+ maximum: 500,
185
+ description: 'drop_shadow only. Vertical shadow offset in pixels at full resolution. Default (when ' +
186
+ 'creating): 20. SPATIAL (scaled on the proxy preview, see gimp_overview).',
187
+ },
188
+ opacity: {
189
+ type: 'number',
190
+ minimum: 0,
191
+ maximum: 1,
192
+ description: 'drop_shadow only. Default (when creating): 0.5. The shadow color is black and not ' +
193
+ 'configurable yet.',
194
+ },
195
+ },
196
+ required: ['image', 'type'],
197
+ };
198
+ async function gimpAddEffect(gimp, rawArgs) {
199
+ try {
200
+ const args = validateArgs(effectSchema, rawArgs);
201
+ const result = await gimp.call('effect', pickSchemaDeclaredKeys(effectSchema, args));
202
+ return {
203
+ content: [
204
+ {
205
+ type: 'text',
206
+ text: `${rawArgs.filter_id !== undefined ? 'Updated' : 'Added'} ` +
207
+ `${result.type} filter "${result.name}" (id ${result.filter_id})` +
208
+ (result.mask ? ` confined to mask "${result.mask}".` : '.'),
209
+ },
210
+ ],
211
+ structuredContent: result,
212
+ };
213
+ }
214
+ catch (error) {
215
+ return toolGimpErrorResult('Error applying GIMP effect filter', error);
216
+ }
217
+ }
218
+ export function createGimpEffectTools(gimp) {
219
+ return [
220
+ {
221
+ tool: {
222
+ name: 'gimp_add_effect',
223
+ description: 'Headless GIMP: append (or, with filter_id, re-edit in place) one of a small ' +
224
+ 'allow-listed set of GEGL effect filters — vignette, black_white, motion_blur, ' +
225
+ 'lens_blur, add_noise, drop_shadow — the same non-destructive, re-editable, maskable ' +
226
+ "filter mechanism gimp_add_adjustment uses, sharing the SAME stack: gimp_filter's " +
227
+ "op=list/set_visibility/delete manage this tool's filters exactly as it manages " +
228
+ "gimp_add_adjustment's. `type` picks the effect; a field another effect uses is " +
229
+ "refused, naming this type's own fields. A filter applies to ONE layer (`layer_id` " +
230
+ 'or `layer`, else the selected or top layer), not to the flattened image. A re-edit ' +
231
+ "(filter_id) MERGES: any field you omit keeps the filter's existing value. Only " +
232
+ 'filters Editmamei created can be re-edited; one added in the GIMP GUI is refused ' +
233
+ '(delete and re-create it). `mask` (a channel name from gimp_create_mask) confines a ' +
234
+ 'NEW filter — fixed at creation, cannot change on a re-edit. ORDER MATTERS: straighten ' +
235
+ '/ flip / resize the canvas first, then crop, THEN add any masked effect — rotate, ' +
236
+ 'flip, and resize all refuse outright once a masked filter exists, or any filter not ' +
237
+ 'created by Editmamei. vignette, motion_blur, and drop_shadow stay locked to the ' +
238
+ 'content through flip, resize, and an exact 90/180/270-degree rotate (their center/' +
239
+ 'angle/offset fields are updated automatically, and gimp_filter op=list reflects the ' +
240
+ 'new values afterward) — rotating by any OTHER angle is refused while one of these ' +
241
+ 'three is present, rather than letting it drift out of alignment. lens_blur is a ' +
242
+ 'uniform soft-focus blur, not a depth-of-field falloff. For a plain blur with no bokeh ' +
243
+ 'highlight boost, use gimp_add_adjustment type=gaussian_blur instead. Check the stack ' +
244
+ 'afterward with gimp_filter (op=list).',
245
+ inputSchema: effectSchema,
246
+ outputSchema: {
247
+ type: 'object',
248
+ properties: {
249
+ filter_id: { type: 'number' },
250
+ name: { type: 'string' },
251
+ type: { type: 'string' },
252
+ mask: { type: ['string', 'null'] },
253
+ },
254
+ },
255
+ annotations: {
256
+ title: 'Add/Update GIMP Effect Filter',
257
+ readOnlyHint: false,
258
+ destructiveHint: false,
259
+ idempotentHint: false,
260
+ openWorldHint: true,
261
+ },
262
+ },
263
+ handler: async (args) => gimpAddEffect(gimp, args),
264
+ },
265
+ ];
266
+ }
267
+ export const EFFECT_SCHEMA_FOR_TESTS = effectSchema;
@@ -9,7 +9,8 @@ const filterSchema = {
9
9
  type: 'string',
10
10
  enum: ['list', 'set_visibility', 'delete'],
11
11
  description: "'list' returns every filter on the image (including filters on layers inside groups), " +
12
- 'top of stack first, with id/name/operation/visible/mask/source and params. source ' +
12
+ 'top of stack first, with layer/layer_id (which layer the filter lives on, and that ' +
13
+ "layer's id), id/name/operation/visible/mask/source and params. source " +
13
14
  '"editmamei" = exact ledger record, with params in gimp_add_adjustment\'s own field names ' +
14
15
  'and units, so they can be passed straight back on a re-edit. source "readback" = a filter ' +
15
16
  "Editmamei did not create; its params are GIMP's raw GEGL property names and units (not " +
@@ -38,6 +38,16 @@ const resizeSchema = {
38
38
  },
39
39
  required: ['image'],
40
40
  };
41
+ const EFFECT_UPDATE_FAILURES_PROP = {
42
+ type: 'array',
43
+ items: { type: 'string' },
44
+ description: 'Names of effect filters (position/direction/size-dependent ones tracked through geometry ' +
45
+ 'changes — a center, an angle, an offset, or a size-dependent blur radius under resize) ' +
46
+ 'whose own params could not be updated for this transform (a live update failure) -- they ' +
47
+ "were restored to their OLD values in gimp_filter's own ledger (guaranteed), and the bridge " +
48
+ 'also tried to restore those same OLD values to the live render (best effort — it can, ' +
49
+ 'rarely, also fail). Omitted when every filter updated cleanly.',
50
+ };
41
51
  const transformCanvasSchema = {
42
52
  type: 'object',
43
53
  properties: {
@@ -65,6 +75,17 @@ const transformCanvasSchema = {
65
75
  },
66
76
  required: ['image', 'op'],
67
77
  };
78
+ function effectUpdateFailuresNote(failures) {
79
+ if (!failures || failures.length === 0)
80
+ return '';
81
+ const plural = failures.length > 1;
82
+ const it = plural ? 'them' : 'it';
83
+ return (` WARNING: ${failures.join(', ')} could not be updated for this transform and ` +
84
+ `${plural ? 'were' : 'was'} left at ${plural ? 'their' : 'its'} old position/angle/offset/size ` +
85
+ `instead -- delete ${it} (gimp_filter op=delete) and re-create ${it} if ${plural ? 'they need' : 'it needs'} ` +
86
+ `to track the content (re-applying THIS SAME transform again would move ${it} a second time, ` +
87
+ `misaligning ${it} the other way).`);
88
+ }
68
89
  async function gimpTransformCanvas(gimp, rawArgs) {
69
90
  try {
70
91
  const args = validateArgs(transformCanvasSchema, rawArgs);
@@ -73,9 +94,10 @@ async function gimpTransformCanvas(gimp, rawArgs) {
73
94
  return unknownDiscriminator('op', op, ['rotate', 'flip']);
74
95
  }
75
96
  const result = await gimp.call(op, pickSchemaDeclaredKeys(transformCanvasSchema, args));
76
- const text = op === 'rotate'
97
+ const text = (op === 'rotate'
77
98
  ? `Rotated ${result.degrees}°. New canvas ${result.width}x${result.height}.`
78
- : `Flipped ${args.orientation}. Canvas ${result.width}x${result.height}.`;
99
+ : `Flipped ${args.orientation}. Canvas ${result.width}x${result.height}.`) +
100
+ effectUpdateFailuresNote(result.effect_update_failures);
79
101
  return {
80
102
  content: [{ type: 'text', text }],
81
103
  structuredContent: result,
@@ -92,7 +114,7 @@ export function createGimpGeometryTools(gimp) {
92
114
  name: 'gimp_crop_document',
93
115
  description: 'Headless GIMP: crop the canvas to an explicit rectangle (left, top, width, height in ' +
94
116
  'document pixels) — not a scale (that is gimp_resize_image). IRREVERSIBLE in this ' +
95
- 'session: there is no undo, so gimp_save_xcf first when in doubt. Every live filter, ' +
117
+ 'session: there is no undo, so gimp_checkpoint (or gimp_save_xcf) first when in doubt. Every live filter, ' +
96
118
  'mask channel, and the filter ledger survive a crop — verified live; it is the one ' +
97
119
  'geometry op that never refuses for masked-filter safety.',
98
120
  inputSchema: cropSchema,
@@ -126,16 +148,20 @@ export function createGimpGeometryTools(gimp) {
126
148
  description: 'Headless GIMP: scale the whole image up or down — not a crop (that is ' +
127
149
  'gimp_crop_document). Give width+height to stretch to an exact box, one alone to keep ' +
128
150
  'aspect, or long_edge to scale by the longer side. IRREVERSIBLE in this session: there ' +
129
- 'is no undo, so gimp_save_xcf first when in doubt. REFUSES outright when the image has a ' +
130
- 'masked adjustment filter, or any filter not created by Editmamei (for example one added ' +
131
- 'in the GIMP GUI) — resize before adding any masked adjustment, not after. A large target (the ' +
151
+ 'is no undo, so gimp_checkpoint (or gimp_save_xcf) first when in doubt. REFUSES outright when the image has a ' +
152
+ 'masked filter, or any filter not created by Editmamei (for example one added ' +
153
+ 'in the GIMP GUI) — resize before adding any masked filter, not after. A large target (the ' +
132
154
  'bridge allows up to 250 megapixels / 30000px per side) can take tens of seconds; if it ' +
133
155
  'times out, the GIMP session restarts and any unsaved work — filters, masks, and any ' +
134
- 'other open image — is lost, so save (gimp_save_xcf) before an aggressive resize.',
156
+ 'other open image — is lost, so make a checkpoint (gimp_checkpoint) before an aggressive resize.',
135
157
  inputSchema: resizeSchema,
136
158
  outputSchema: {
137
159
  type: 'object',
138
- properties: { width: { type: 'number' }, height: { type: 'number' } },
160
+ properties: {
161
+ width: { type: 'number' },
162
+ height: { type: 'number' },
163
+ effect_update_failures: EFFECT_UPDATE_FAILURES_PROP,
164
+ },
139
165
  },
140
166
  annotations: {
141
167
  title: 'Resize GIMP Image',
@@ -153,7 +179,8 @@ export function createGimpGeometryTools(gimp) {
153
179
  errorPrefix: 'Error resizing GIMP image',
154
180
  successText: (result) => {
155
181
  const r = result;
156
- return `Resized to ${r.width}x${r.height}.`;
182
+ return (`Resized to ${r.width}x${r.height}.` +
183
+ effectUpdateFailuresNote(r.effect_update_failures));
157
184
  },
158
185
  }),
159
186
  },
@@ -162,12 +189,16 @@ export function createGimpGeometryTools(gimp) {
162
189
  name: 'gimp_transform_canvas',
163
190
  description: "Headless GIMP: op 'rotate' (arbitrary `degrees` — straighten — plus `expand`) | 'flip' " +
164
191
  '(`orientation` horizontal/vertical). IRREVERSIBLE in this session: there is no undo, so ' +
165
- 'gimp_save_xcf first when in doubt. REFUSES outright when the image has a masked ' +
166
- 'adjustment filter, or any filter not created by Editmamei (for example one added in the ' +
167
- 'GIMP GUI) — straighten/flip before adding any masked adjustment, not after (crop is the ' +
168
- 'one geometry op that is always safe afterward). Rotating a very large image can take tens of seconds; ' +
192
+ 'gimp_checkpoint (or gimp_save_xcf) first when in doubt. REFUSES outright when the image has a masked ' +
193
+ 'filter, or any filter not created by Editmamei (for example one added in the ' +
194
+ 'GIMP GUI) — straighten/flip before adding any masked filter, not after (crop is the ' +
195
+ 'one geometry op that is always safe afterward). A rotate to anything other than an ' +
196
+ 'exact 0/90/180/270 degrees is also refused while a position/direction-dependent ' +
197
+ 'filter is present, since only a right angle keeps such a filter locked to the content ' +
198
+ '— rotate at a right angle instead, or delete it (gimp_filter op=delete) and re-add it ' +
199
+ 'afterwards. Rotating a very large image can take tens of seconds; ' +
169
200
  'if it times out, the GIMP session restarts and any unsaved work — filters, masks, and ' +
170
- 'any other open image — is lost, so save (gimp_save_xcf) before rotating a large canvas.',
201
+ 'any other open image — is lost, so make a checkpoint (gimp_checkpoint) before rotating a large canvas.',
171
202
  inputSchema: transformCanvasSchema,
172
203
  outputSchema: {
173
204
  type: 'object',
@@ -175,6 +206,7 @@ export function createGimpGeometryTools(gimp) {
175
206
  width: { type: 'number' },
176
207
  height: { type: 'number' },
177
208
  degrees: { type: 'number' },
209
+ effect_update_failures: EFFECT_UPDATE_FAILURES_PROP,
178
210
  },
179
211
  },
180
212
  annotations: {
@@ -1,37 +1,88 @@
1
1
  import { validateArgs } from '../utils/validate.js';
2
2
  import { toolGimpErrorResult, unknownDiscriminator } from '../utils/tool-helpers.js';
3
+ import { pickSchemaDeclaredKeys } from './gimp-shared.js';
4
+ const INSPECT_WHATS = ['documents', 'document', 'layers', 'channels', 'filter'];
3
5
  const inspectSchema = {
4
6
  type: 'object',
5
7
  properties: {
6
8
  what: {
7
9
  type: 'string',
8
- enum: ['documents'],
10
+ enum: [...INSPECT_WHATS],
9
11
  description: "What to inspect. 'documents' lists every image currently open in this headless session " +
10
- '(id only — the bridge has no per-image describe-by-id op yet, so no name/size/layer data ' +
11
- "is available here; gimp_open_document's own return value is the place to get that, at " +
12
- 'open time).',
12
+ "(id only). 'document' describes ONE open image by id: dims, base_type, precision, " +
13
+ 'resolution, its full layer tree, and its channels by id/name only (no coverage). ' +
14
+ "'layers' returns just that image's layer tree; 'channels' returns its named channels " +
15
+ "WITH coverage (selected_pixels/fraction) — the cost 'document' skips — reading each " +
16
+ "channel's full pixel buffer in turn; on a document with many named channels this can " +
17
+ 'stop early, reporting `truncated: true` and `channels_skipped` (how many were never ' +
18
+ "read). 'filter' returns one filter by `filter_id`, in the same shape gimp_filter " +
19
+ '(op=list) reports it in. Layer tree nodes are addressed by `layer_id` (canonical — GIMP ' +
20
+ "allows duplicate layer names) and flag `is_text_layer`. 'document'/'layers' cap the tree " +
21
+ 'at 2000 nodes total and report `truncated: true` if the cap was hit, alongside ' +
22
+ '`top_level_count` and `total_nodes`; if it times out the GIMP session restarts and ' +
23
+ 'unsaved work is lost, so save (gimp_save_xcf) first.',
24
+ },
25
+ image: {
26
+ type: 'integer',
27
+ description: 'Image id, as returned by gimp_open_document. Required for every `what` except ' +
28
+ "'documents'.",
29
+ },
30
+ filter_id: {
31
+ type: 'integer',
32
+ description: "Required for what='filter'. The filter id, as returned by gimp_add_adjustment " +
33
+ 'or listed by gimp_filter (op=list).',
13
34
  },
14
35
  },
15
36
  required: ['what'],
16
37
  };
38
+ function describeSummary(what, args, result) {
39
+ const truncatedSuffix = result.truncated ? ' (truncated at the node cap)' : '';
40
+ if (what === 'filter') {
41
+ return `Filter ${result.filter_id} ("${result.name}"): ${result.operation}.`;
42
+ }
43
+ if (what === 'layers') {
44
+ const layers = result.layers ?? [];
45
+ const topLevelCount = result.top_level_count ?? layers.length;
46
+ return `${topLevelCount} top-level layer(s) on image ${args.image}${truncatedSuffix}.`;
47
+ }
48
+ if (what === 'channels') {
49
+ const channels = result.channels ?? [];
50
+ const skipped = result.channels_skipped;
51
+ const stoppedEarlySuffix = result.truncated && skipped ? ` (stopped early, ${skipped} channel(s) skipped)` : '';
52
+ return `${channels.length} channel(s) on image ${args.image}${stoppedEarlySuffix}.`;
53
+ }
54
+ const layers = result.layers ?? [];
55
+ const channels = result.channels ?? [];
56
+ const topLevelCount = result.top_level_count ?? layers.length;
57
+ return (`Document ${result.image}: ${result.width}x${result.height} ${result.base_type} ` +
58
+ `${result.precision}, ${topLevelCount} top-level layer(s)${truncatedSuffix}, ` +
59
+ `${channels.length} channel(s).`);
60
+ }
17
61
  async function gimpInspect(gimp, rawArgs) {
18
62
  try {
19
63
  const args = validateArgs(inspectSchema, rawArgs);
20
64
  const what = args.what;
21
- if (what !== 'documents') {
22
- return unknownDiscriminator('what', what, ['documents']);
65
+ if (what === 'documents') {
66
+ const result = await gimp.call('ping', {});
67
+ const documents = (result.images ?? []).map((image) => ({ image }));
68
+ return {
69
+ content: [
70
+ {
71
+ type: 'text',
72
+ text: `${documents.length} image(s) open: ${documents.map((d) => d.image).join(', ') || '(none)'}.`,
73
+ },
74
+ ],
75
+ structuredContent: { what, documents },
76
+ };
23
77
  }
24
- const result = await gimp.call('ping', {});
25
- const documents = (result.images ?? []).map((image) => ({ image }));
26
- return {
27
- content: [
28
- {
29
- type: 'text',
30
- text: `${documents.length} image(s) open: ${documents.map((d) => d.image).join(', ') || '(none)'}.`,
31
- },
32
- ],
33
- structuredContent: { what, documents },
34
- };
78
+ if (what === 'document' || what === 'layers' || what === 'channels' || what === 'filter') {
79
+ const result = await gimp.call('describe', pickSchemaDeclaredKeys(inspectSchema, args));
80
+ return {
81
+ content: [{ type: 'text', text: describeSummary(what, args, result) }],
82
+ structuredContent: { what, ...result },
83
+ };
84
+ }
85
+ return unknownDiscriminator('what', what, INSPECT_WHATS);
35
86
  }
36
87
  catch (error) {
37
88
  return toolGimpErrorResult('Error inspecting GIMP state', error);
@@ -42,10 +93,18 @@ export function createGimpInspectTools(gimp) {
42
93
  {
43
94
  tool: {
44
95
  name: 'gimp_inspect',
45
- description: "Headless GIMP: read-only state reader. Today's only target: what='documents' lists every image " +
46
- 'currently open in this headless GIMP session by id (no name/size — track those from ' +
47
- "gimp_open_document's own return value). Fewer targets than ps_inspect: the GIMP bridge " +
48
- 'has no describe-by-id op for an already-open image yet.',
96
+ description: "Headless GIMP: read-only state reader. `what`: 'documents' lists every image " +
97
+ "currently open in this headless GIMP session by id. 'document' describes ONE open " +
98
+ 'image (dims, base_type, precision, resolution, full layer tree, channels by id/name ' +
99
+ "only — no coverage); 'layers' returns just the tree; 'channels' returns the channels " +
100
+ "WITH coverage (selected_pixels/fraction), the cost 'document' skips, and can stop " +
101
+ 'early on a document with many named channels (`truncated: true`, `channels_skipped`). ' +
102
+ "'filter' describes one filter by filter_id, in the same shape gimp_filter (op=list) " +
103
+ 'reports it in. Layer tree nodes are addressed by `layer_id` (canonical — GIMP allows ' +
104
+ "duplicate layer names) and flag `is_text_layer`. 'document'/'layers' cap the tree at " +
105
+ '2000 nodes total and report `truncated: true` if the cap was hit, alongside ' +
106
+ '`top_level_count` and `total_nodes`; if it times out the GIMP session restarts and ' +
107
+ 'unsaved work is lost, so save (gimp_save_xcf) first.',
49
108
  inputSchema: inspectSchema,
50
109
  outputSchema: {
51
110
  type: 'object',
@@ -55,6 +114,34 @@ export function createGimpInspectTools(gimp) {
55
114
  type: 'array',
56
115
  items: { type: 'object', properties: { image: { type: 'number' } } },
57
116
  },
117
+ image: { type: 'number' },
118
+ width: { type: 'number' },
119
+ height: { type: 'number' },
120
+ base_type: { type: 'string' },
121
+ precision: { type: 'string' },
122
+ resolution: {
123
+ type: 'object',
124
+ properties: {
125
+ x: { type: ['number', 'null'] },
126
+ y: { type: ['number', 'null'] },
127
+ },
128
+ },
129
+ layers: { type: 'array', items: { type: 'object' } },
130
+ truncated: { type: 'boolean' },
131
+ top_level_count: { type: 'number' },
132
+ total_nodes: { type: 'number' },
133
+ channels: { type: 'array', items: { type: 'object' } },
134
+ channels_skipped: { type: 'number' },
135
+ filter_id: { type: 'number' },
136
+ layer: { type: 'string' },
137
+ layer_id: { type: 'number' },
138
+ name: { type: 'string' },
139
+ operation: { type: 'string' },
140
+ type: { type: ['string', 'null'] },
141
+ visible: { type: 'boolean' },
142
+ source: { type: 'string' },
143
+ mask: { type: ['string', 'null'] },
144
+ params: { type: 'object' },
58
145
  },
59
146
  },
60
147
  annotations: {