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,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);
|
|
@@ -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;
|