editmamei 1.5.1 → 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.
- package/README.md +22 -1
- package/dist/backends/detect-editors.js +79 -0
- package/dist/backends/gimp/backend.js +99 -0
- package/dist/backends/gimp/bridge/lib.py +1988 -0
- package/dist/backends/gimp/bridge/ops.py +3120 -0
- package/dist/backends/gimp/detect.js +86 -0
- package/dist/backends/gimp/errors.js +21 -0
- package/dist/backends/gimp/launcher.js +6 -0
- package/dist/backends/gimp/session.js +632 -0
- 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 +28 -0
- package/dist/core/preview-privacy.js +54 -0
- package/dist/core/server.js +98 -49
- package/dist/core/settings.js +25 -0
- package/dist/core/tool-activity.js +122 -0
- package/dist/core/tool-groups.js +24 -0
- package/dist/core/tool-tiers.js +24 -0
- package/dist/diagnostics/collect.js +4 -0
- package/dist/index.js +5 -1
- package/dist/kernel/kernel.js +3 -0
- package/dist/modules/gimp/index.js +43 -0
- package/dist/skills/editmamei-skill.zip +0 -0
- package/dist/telemetry/activity.js +1 -93
- package/dist/tools/adjustment-tools.js +2 -1
- package/dist/tools/gimp-adjustment-tools.js +385 -0
- package/dist/tools/gimp-checkpoint-tools.js +541 -0
- package/dist/tools/gimp-compose-tools.js +399 -0
- package/dist/tools/gimp-core-tools.js +302 -0
- package/dist/tools/gimp-document-tools.js +321 -0
- package/dist/tools/gimp-effect-tools.js +267 -0
- package/dist/tools/gimp-filter-tools.js +94 -0
- package/dist/tools/gimp-geometry-tools.js +223 -0
- package/dist/tools/gimp-inspect-tools.js +157 -0
- package/dist/tools/gimp-layer-tools.js +385 -0
- package/dist/tools/gimp-mask-tools.js +102 -0
- package/dist/tools/gimp-shared.js +58 -0
- package/dist/tools/gimp-verify-tools.js +409 -0
- package/dist/utils/gimp-path.js +27 -0
- package/dist/utils/operation-timeouts.js +24 -0
- package/dist/utils/session-log.js +19 -1
- package/dist/utils/tool-helpers.js +14 -0
- package/dist/utils/validate.js +11 -0
- package/dist/version.js +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import { basename } from 'node:path';
|
|
2
|
+
import { validateArgs } from '../utils/validate.js';
|
|
3
|
+
import { toolGimpErrorResult, requireAbsoluteGimpPath } from '../utils/tool-helpers.js';
|
|
4
|
+
import { GIMP_IMAGE_PROP, runGimpTool, pickSchemaDeclaredKeys } from './gimp-shared.js';
|
|
5
|
+
const createDocumentSchema = {
|
|
6
|
+
type: 'object',
|
|
7
|
+
properties: {
|
|
8
|
+
width: { type: 'integer', minimum: 1, description: 'Canvas width, in pixels.' },
|
|
9
|
+
height: { type: 'integer', minimum: 1, description: 'Canvas height, in pixels.' },
|
|
10
|
+
mode: {
|
|
11
|
+
type: 'string',
|
|
12
|
+
enum: ['rgb', 'grayscale'],
|
|
13
|
+
default: 'rgb',
|
|
14
|
+
description: "The new image's base color mode.",
|
|
15
|
+
},
|
|
16
|
+
fill: {
|
|
17
|
+
type: 'string',
|
|
18
|
+
enum: ['white', 'black', 'transparent'],
|
|
19
|
+
default: 'white',
|
|
20
|
+
description: "The single background layer's initial content.",
|
|
21
|
+
},
|
|
22
|
+
precision: {
|
|
23
|
+
type: 'string',
|
|
24
|
+
enum: ['16', '32'],
|
|
25
|
+
description: "Promote bit depth at creation, same as gimp_open_document's own `precision` — use this " +
|
|
26
|
+
'ahead of an aggressive tone move to avoid visible banding. Omit for ordinary 8-bit. The ' +
|
|
27
|
+
'megapixel cap this tool enforces scales down with bit depth (a 16-bit request costs 2x ' +
|
|
28
|
+
'the memory of the same pixel count at 8-bit, 32-bit float 4x): 250 MP at 8-bit, 125 MP ' +
|
|
29
|
+
'at 16-bit, 60 MP at 32-bit — the same per-side 30000px limit applies at every depth.',
|
|
30
|
+
},
|
|
31
|
+
name: {
|
|
32
|
+
type: 'string',
|
|
33
|
+
description: "The background layer's name. Defaults to 'Background'.",
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
required: ['width', 'height'],
|
|
37
|
+
};
|
|
38
|
+
async function gimpCreateDocument(gimp, rawArgs) {
|
|
39
|
+
try {
|
|
40
|
+
const args = validateArgs(createDocumentSchema, rawArgs);
|
|
41
|
+
const result = await gimp.call('create_document', pickSchemaDeclaredKeys(createDocumentSchema, args));
|
|
42
|
+
return {
|
|
43
|
+
content: [
|
|
44
|
+
{
|
|
45
|
+
type: 'text',
|
|
46
|
+
text: `Created a new ${result.width}x${result.height} document as image ${result.image} ` +
|
|
47
|
+
`(${result.base_type}, ${result.precision}). ` +
|
|
48
|
+
`${result.layers.length} layer(s): ${result.layers.join(', ') || '(none)'}.`,
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
structuredContent: result,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
return toolGimpErrorResult('Error creating GIMP document', error);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const placeImageSchema = {
|
|
59
|
+
type: 'object',
|
|
60
|
+
properties: {
|
|
61
|
+
image: GIMP_IMAGE_PROP,
|
|
62
|
+
file_path: {
|
|
63
|
+
type: 'string',
|
|
64
|
+
description: 'Absolute path to an image file to load as a new layer — same format support and raw-file ' +
|
|
65
|
+
"handling as gimp_open_document's own `file_path`. If the file has multiple layers (e.g. a " +
|
|
66
|
+
"`.xcf`), only ONE of them is placed — GIMP's own choice, not a flattened composite of the " +
|
|
67
|
+
'whole file — flatten or export a single layer first if the full composite is wanted. If ' +
|
|
68
|
+
'that one layer itself carries live, re-editable filters, they are baked into its pixels ' +
|
|
69
|
+
'before placing (reported as baked_filters) rather than placed live, so they can never ' +
|
|
70
|
+
'collide with a filter of the same name already on the target. A mismatch between the ' +
|
|
71
|
+
"file's own color mode (RGB/grayscale) and this image's is converted automatically, not " +
|
|
72
|
+
'refused.',
|
|
73
|
+
},
|
|
74
|
+
x: {
|
|
75
|
+
type: 'integer',
|
|
76
|
+
default: 0,
|
|
77
|
+
description: "The new layer's ABSOLUTE horizontal offset in document pixels (not a delta).",
|
|
78
|
+
},
|
|
79
|
+
y: {
|
|
80
|
+
type: 'integer',
|
|
81
|
+
default: 0,
|
|
82
|
+
description: "The new layer's ABSOLUTE vertical offset in document pixels (not a delta).",
|
|
83
|
+
},
|
|
84
|
+
width: {
|
|
85
|
+
type: 'integer',
|
|
86
|
+
minimum: 1,
|
|
87
|
+
description: 'Scale the placed layer to this width. With height, stretches to exactly that box. Alone, ' +
|
|
88
|
+
"height is derived to keep the source file's own aspect ratio. Omit both to place at the " +
|
|
89
|
+
"source file's natural size.",
|
|
90
|
+
},
|
|
91
|
+
height: {
|
|
92
|
+
type: 'integer',
|
|
93
|
+
minimum: 1,
|
|
94
|
+
description: 'Scale the placed layer to this height. See width.',
|
|
95
|
+
},
|
|
96
|
+
name: {
|
|
97
|
+
type: 'string',
|
|
98
|
+
description: "The new layer's name. Defaults to the source file's own base filename. Every case " +
|
|
99
|
+
'enforces a unique name, suffixing " 2", " 3", ... if the name is already taken.',
|
|
100
|
+
},
|
|
101
|
+
parent_group: {
|
|
102
|
+
type: 'integer',
|
|
103
|
+
description: 'The group layer_id to insert the new layer into (omit for top-level).',
|
|
104
|
+
},
|
|
105
|
+
position: {
|
|
106
|
+
type: 'integer',
|
|
107
|
+
minimum: 0,
|
|
108
|
+
default: 0,
|
|
109
|
+
description: "Index within the target parent's (or top-level) stack, 0 = topmost.",
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
required: ['image', 'file_path'],
|
|
113
|
+
};
|
|
114
|
+
async function gimpPlaceImage(gimp, rawArgs) {
|
|
115
|
+
try {
|
|
116
|
+
const args = validateArgs(placeImageSchema, rawArgs);
|
|
117
|
+
const filePath = requireAbsoluteGimpPath('file_path', args.file_path);
|
|
118
|
+
const bridgeArgs = pickSchemaDeclaredKeys(placeImageSchema, args);
|
|
119
|
+
delete bridgeArgs.file_path;
|
|
120
|
+
bridgeArgs.path = filePath;
|
|
121
|
+
const result = await gimp.call('place_image', bridgeArgs);
|
|
122
|
+
const bakedNote = result.baked_filters && result.baked_filters.length > 0
|
|
123
|
+
? ` Its own live filter(s) (${result.baked_filters.join(', ')}) were baked into its ` +
|
|
124
|
+
'pixels before placing.'
|
|
125
|
+
: '';
|
|
126
|
+
return {
|
|
127
|
+
content: [
|
|
128
|
+
{
|
|
129
|
+
type: 'text',
|
|
130
|
+
text: `Placed ${basename(filePath)} as layer ${result.layer_id} ("${result.name}") at ` +
|
|
131
|
+
`(${result.x}, ${result.y}), ${result.width}x${result.height}.${bakedNote}`,
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
structuredContent: result,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
return toolGimpErrorResult('Error placing GIMP image', error);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const CANVAS_ANCHORS = [
|
|
142
|
+
'top_left',
|
|
143
|
+
'top_center',
|
|
144
|
+
'top_right',
|
|
145
|
+
'middle_left',
|
|
146
|
+
'center',
|
|
147
|
+
'middle_right',
|
|
148
|
+
'bottom_left',
|
|
149
|
+
'bottom_center',
|
|
150
|
+
'bottom_right',
|
|
151
|
+
];
|
|
152
|
+
const canvasSchema = {
|
|
153
|
+
type: 'object',
|
|
154
|
+
properties: {
|
|
155
|
+
image: GIMP_IMAGE_PROP,
|
|
156
|
+
width: {
|
|
157
|
+
type: 'integer',
|
|
158
|
+
minimum: 1,
|
|
159
|
+
description: 'New canvas width, in pixels. Must be at least the current width, and at least one of ' +
|
|
160
|
+
'width/height must actually be LARGER than the current size (both equal to the current ' +
|
|
161
|
+
'size is refused — there is nothing to extend).',
|
|
162
|
+
},
|
|
163
|
+
height: {
|
|
164
|
+
type: 'integer',
|
|
165
|
+
minimum: 1,
|
|
166
|
+
description: 'New canvas height, in pixels. Must be at least the current height. See width.',
|
|
167
|
+
},
|
|
168
|
+
anchor: {
|
|
169
|
+
type: 'string',
|
|
170
|
+
enum: [...CANVAS_ANCHORS],
|
|
171
|
+
description: 'Where the existing content sits within the new, larger canvas — a 3x3 grid, same as ' +
|
|
172
|
+
"Photoshop's Canvas Size anchor picker. Defaults to 'center' when neither this nor " +
|
|
173
|
+
'offset_x/offset_y is given. Mutually exclusive with offset_x/offset_y.',
|
|
174
|
+
},
|
|
175
|
+
offset_x: {
|
|
176
|
+
type: 'integer',
|
|
177
|
+
description: "Explicit horizontal offset (document pixels) for the existing content's left edge in " +
|
|
178
|
+
'the new canvas, instead of an anchor — must be between 0 and (new width - current ' +
|
|
179
|
+
'width). Requires offset_y too; mutually exclusive with anchor.',
|
|
180
|
+
},
|
|
181
|
+
offset_y: {
|
|
182
|
+
type: 'integer',
|
|
183
|
+
description: "Explicit vertical offset for the existing content's top edge, instead of an anchor — " +
|
|
184
|
+
'must be between 0 and (new height - current height). Requires offset_x too; mutually ' +
|
|
185
|
+
'exclusive with anchor.',
|
|
186
|
+
},
|
|
187
|
+
fill: {
|
|
188
|
+
type: 'string',
|
|
189
|
+
pattern: '^(white|black|transparent|#[0-9a-fA-F]{6})$',
|
|
190
|
+
default: 'transparent',
|
|
191
|
+
description: "'white', 'black', a '#rrggbb' hex color, or 'transparent' (default — no fill layer is " +
|
|
192
|
+
'added at all; see the note on gimp_export/flatten below). Any other choice is painted onto a ' +
|
|
193
|
+
'new, full-canvas layer inserted at the BOTTOM of the whole stack — a solid backdrop ' +
|
|
194
|
+
'behind every existing layer, not just the newly added area, so it also shows through any ' +
|
|
195
|
+
'pre-existing transparency in those layers. On a GRAYSCALE image a hex color renders as ' +
|
|
196
|
+
'its LUMINANCE (a weighted gray, not one channel picked out). transparent: genuinely ' +
|
|
197
|
+
'transparent only in a live, layered document (e.g. saved via gimp_save_xcf) — ' +
|
|
198
|
+
'gimp_export and gimp_layer op=flatten both flatten first, which fills any transparent ' +
|
|
199
|
+
"area with GIMP's ambient background color (white, unchanged by this tool) and drops " +
|
|
200
|
+
'alpha, not with transparency. A non-transparent fill on an INDEXED image is REFUSED ' +
|
|
201
|
+
'outright, before the canvas resizes at all — indexed has no backdrop layer type to add.',
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
required: ['image', 'width', 'height'],
|
|
205
|
+
};
|
|
206
|
+
async function gimpCanvas(gimp, rawArgs) {
|
|
207
|
+
return runGimpTool({
|
|
208
|
+
gimp,
|
|
209
|
+
rawArgs,
|
|
210
|
+
schema: canvasSchema,
|
|
211
|
+
op: 'canvas',
|
|
212
|
+
errorPrefix: 'Error extending GIMP canvas',
|
|
213
|
+
successText: (result) => {
|
|
214
|
+
const r = result;
|
|
215
|
+
return `Extended canvas to ${r.width}x${r.height} (existing content offset by ${r.offset_x}, ${r.offset_y}).`;
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
const convertImageModeSchema = {
|
|
220
|
+
type: 'object',
|
|
221
|
+
properties: {
|
|
222
|
+
image: GIMP_IMAGE_PROP,
|
|
223
|
+
mode: {
|
|
224
|
+
type: 'string',
|
|
225
|
+
enum: ['rgb', 'grayscale'],
|
|
226
|
+
description: 'Target base color mode. An indexed source image is refused outright.',
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
required: ['image', 'mode'],
|
|
230
|
+
};
|
|
231
|
+
async function gimpConvertImageMode(gimp, rawArgs) {
|
|
232
|
+
return runGimpTool({
|
|
233
|
+
gimp,
|
|
234
|
+
rawArgs,
|
|
235
|
+
schema: convertImageModeSchema,
|
|
236
|
+
op: 'convert_image_mode',
|
|
237
|
+
errorPrefix: 'Error converting GIMP image mode',
|
|
238
|
+
successText: (result, args) => {
|
|
239
|
+
const r = result;
|
|
240
|
+
return r.converted
|
|
241
|
+
? `Converted image ${args.image} to ${r.mode}.`
|
|
242
|
+
: `Image ${args.image} is already ${r.mode}; nothing to convert.`;
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
export function createGimpComposeTools(gimp) {
|
|
247
|
+
return [
|
|
248
|
+
{
|
|
249
|
+
tool: {
|
|
250
|
+
name: 'gimp_create_document',
|
|
251
|
+
description: 'Headless GIMP: create a new, empty image from scratch — not from a file (that is ' +
|
|
252
|
+
'gimp_open_document). width/height, mode (rgb/grayscale, default rgb), fill for the ' +
|
|
253
|
+
'single background layer (white/black/transparent, default white), optional precision ' +
|
|
254
|
+
"(16/32-bit, same promotion gimp_open_document's own `precision` does) and name. " +
|
|
255
|
+
'Returns the exact same shape gimp_open_document does (image id, dimensions, base type, ' +
|
|
256
|
+
'precision, layer list), so every other gimp_* tool treats it identically to an opened file.',
|
|
257
|
+
inputSchema: createDocumentSchema,
|
|
258
|
+
outputSchema: {
|
|
259
|
+
type: 'object',
|
|
260
|
+
properties: {
|
|
261
|
+
image: { type: 'number' },
|
|
262
|
+
width: { type: 'number' },
|
|
263
|
+
height: { type: 'number' },
|
|
264
|
+
base_type: { type: 'string' },
|
|
265
|
+
precision: { type: 'string' },
|
|
266
|
+
layers: { type: 'array', items: { type: 'string' } },
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
annotations: {
|
|
270
|
+
title: 'Create GIMP Document',
|
|
271
|
+
readOnlyHint: false,
|
|
272
|
+
destructiveHint: false,
|
|
273
|
+
idempotentHint: false,
|
|
274
|
+
openWorldHint: true,
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
handler: async (args) => gimpCreateDocument(gimp, args),
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
tool: {
|
|
281
|
+
name: 'gimp_place_image',
|
|
282
|
+
description: 'Headless GIMP: load another image file as a new layer in an already-open document — ' +
|
|
283
|
+
'compositing, not opening a second document. Absolute file_path, same format support ' +
|
|
284
|
+
'and raw-file handling as gimp_open_document. If the source has multiple layers (e.g. a ' +
|
|
285
|
+
"`.xcf`), only ONE of them is placed — GIMP's own choice, not a flattened composite of " +
|
|
286
|
+
"the whole file. A source whose color mode (RGB/grayscale) differs from this image's " +
|
|
287
|
+
'own is converted automatically on load, never refused. x/y place the layer at an ' +
|
|
288
|
+
'ABSOLUTE document-pixel offset (not a delta), default (0, 0); width/height optionally ' +
|
|
289
|
+
"scale it first (one alone keeps the source's own aspect ratio). name/parent_group/" +
|
|
290
|
+
"position work exactly like gimp_layer op=create. A source layer's OWN live filters " +
|
|
291
|
+
'(e.g. from a filtered `.xcf`) are baked into its pixels before placing, never placed ' +
|
|
292
|
+
"live (reported as baked_filters). Nothing from the source file's own metadata " +
|
|
293
|
+
'(EXIF/XMP) attaches to this image, and no path — just the placed layer — appears in ' +
|
|
294
|
+
'the result; there is no undo in this session: gimp_checkpoint or gimp_save_xcf first ' +
|
|
295
|
+
'when in doubt.',
|
|
296
|
+
inputSchema: placeImageSchema,
|
|
297
|
+
outputSchema: {
|
|
298
|
+
type: 'object',
|
|
299
|
+
properties: {
|
|
300
|
+
layer_id: { type: 'number' },
|
|
301
|
+
name: { type: 'string' },
|
|
302
|
+
width: { type: 'number' },
|
|
303
|
+
height: { type: 'number' },
|
|
304
|
+
x: { type: ['number', 'null'] },
|
|
305
|
+
y: { type: ['number', 'null'] },
|
|
306
|
+
parent_group: { type: ['number', 'null'] },
|
|
307
|
+
baked_filters: {
|
|
308
|
+
type: 'array',
|
|
309
|
+
items: { type: 'string' },
|
|
310
|
+
description: "Names of the placed layer's own live filters that were baked into its pixels " +
|
|
311
|
+
'before placing. Omitted when the source layer had none.',
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
annotations: {
|
|
316
|
+
title: 'Place Image Into GIMP Document',
|
|
317
|
+
readOnlyHint: false,
|
|
318
|
+
destructiveHint: false,
|
|
319
|
+
idempotentHint: false,
|
|
320
|
+
openWorldHint: true,
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
handler: async (args) => gimpPlaceImage(gimp, args),
|
|
324
|
+
},
|
|
325
|
+
{
|
|
326
|
+
tool: {
|
|
327
|
+
name: 'gimp_canvas',
|
|
328
|
+
description: 'Headless GIMP: extend the canvas — grow it and reposition the existing content within ' +
|
|
329
|
+
'the larger frame, never shrink (use gimp_crop_document for that; REFUSED outright when ' +
|
|
330
|
+
'either dimension would be smaller than the current one, and REFUSED when neither ' +
|
|
331
|
+
'dimension actually grows). Choose where the existing content lands with `anchor` (a ' +
|
|
332
|
+
"3x3 grid, default 'center') or explicit offset_x/offset_y; give one or the other, not " +
|
|
333
|
+
'both. `fill` sets a solid backdrop layer under the WHOLE canvas — white, black, a ' +
|
|
334
|
+
"'#rrggbb' hex color (rendered as luminance on a grayscale image), or transparent " +
|
|
335
|
+
'(default, adds no backdrop layer at all — see its own field description for what that ' +
|
|
336
|
+
'means at export/flatten time). IRREVERSIBLE in this session: there is no undo, so ' +
|
|
337
|
+
'gimp_checkpoint or gimp_save_xcf first when in doubt. REFUSES outright when the image ' +
|
|
338
|
+
'has a masked filter, or any filter not created by Editmamei (for example one added in ' +
|
|
339
|
+
'the GIMP GUI) AND the existing content actually moves (offset_x/offset_y not both 0 — ' +
|
|
340
|
+
'for example a top-left anchor never moves anything) — extend the canvas before adding ' +
|
|
341
|
+
"any masked filter, not after: a filter's mask does not travel with the layer it " +
|
|
342
|
+
'confines when the canvas repositions it, the same physics gimp_resize_image and ' +
|
|
343
|
+
'gimp_transform_canvas already refuse on. An unmasked effect filter (vignette and ' +
|
|
344
|
+
"friends) is unaffected — its own params key off the LAYER's own unchanged extent, " +
|
|
345
|
+
'never the canvas.',
|
|
346
|
+
inputSchema: canvasSchema,
|
|
347
|
+
outputSchema: {
|
|
348
|
+
type: 'object',
|
|
349
|
+
properties: {
|
|
350
|
+
width: { type: 'number' },
|
|
351
|
+
height: { type: 'number' },
|
|
352
|
+
offset_x: { type: 'number' },
|
|
353
|
+
offset_y: { type: 'number' },
|
|
354
|
+
},
|
|
355
|
+
},
|
|
356
|
+
annotations: {
|
|
357
|
+
title: 'Extend GIMP Canvas',
|
|
358
|
+
readOnlyHint: false,
|
|
359
|
+
destructiveHint: true,
|
|
360
|
+
idempotentHint: false,
|
|
361
|
+
openWorldHint: true,
|
|
362
|
+
},
|
|
363
|
+
},
|
|
364
|
+
handler: async (args) => gimpCanvas(gimp, args),
|
|
365
|
+
},
|
|
366
|
+
{
|
|
367
|
+
tool: {
|
|
368
|
+
name: 'gimp_convert_image_mode',
|
|
369
|
+
description: "Headless GIMP: convert the image's base color mode, rgb <-> grayscale — an indexed " +
|
|
370
|
+
'image is refused outright as a source. Converting to grayscale permanently discards ' +
|
|
371
|
+
'color; converting an already-grayscale image to rgb does not restore it. A no-op when ' +
|
|
372
|
+
'the image is already the requested mode (reported as converted: false, nothing else ' +
|
|
373
|
+
'changes — checked BEFORE the live-filter refusal below, so a no-op call never fails ' +
|
|
374
|
+
'just because a filter happens to be present). Otherwise REFUSES outright while the ' +
|
|
375
|
+
'image has ANY live filter — a color-dependent filter (curves on the red channel, ' +
|
|
376
|
+
'hue_saturation, a vignette color, ...) could change meaning across the mode change. ' +
|
|
377
|
+
'Bake it first (gimp_bake) or delete it (gimp_filter op=delete), then convert. ' +
|
|
378
|
+
'IRREVERSIBLE in this session: there is no undo, so gimp_checkpoint or gimp_save_xcf ' +
|
|
379
|
+
'first when in doubt.',
|
|
380
|
+
inputSchema: convertImageModeSchema,
|
|
381
|
+
outputSchema: {
|
|
382
|
+
type: 'object',
|
|
383
|
+
properties: {
|
|
384
|
+
mode: { type: 'string' },
|
|
385
|
+
converted: { type: 'boolean' },
|
|
386
|
+
},
|
|
387
|
+
},
|
|
388
|
+
annotations: {
|
|
389
|
+
title: 'Convert GIMP Image Mode',
|
|
390
|
+
readOnlyHint: false,
|
|
391
|
+
destructiveHint: true,
|
|
392
|
+
idempotentHint: true,
|
|
393
|
+
openWorldHint: true,
|
|
394
|
+
},
|
|
395
|
+
},
|
|
396
|
+
handler: async (args) => gimpConvertImageMode(gimp, args),
|
|
397
|
+
},
|
|
398
|
+
];
|
|
399
|
+
}
|