editmamei 1.7.1 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/backends/gimp/bridge/lib.py +1790 -26
- package/dist/backends/gimp/bridge/ops.py +3013 -168
- package/dist/bin/editmamei-core-darwin-arm64 +0 -0
- package/dist/bin/editmamei-core-darwin-x64 +0 -0
- package/dist/bin/editmamei-core-win-x64.exe +0 -0
- package/dist/cli/config.js +7 -0
- package/dist/core/server.js +58 -13
- package/dist/core/settings.js +7 -0
- package/dist/core/tool-activity.js +15 -1
- package/dist/core/tool-groups.js +9 -1
- package/dist/core/tool-tiers.js +9 -1
- package/dist/delivery/client.js +3 -0
- package/dist/modules/gimp/index.js +12 -2
- package/dist/skills/editmamei-skill.zip +0 -0
- package/dist/telemetry/sanitize.js +15 -1
- package/dist/tools/adjustment-tools.js +9 -1
- package/dist/tools/document-tools.js +118 -21
- package/dist/tools/filter-tools.js +2 -1
- package/dist/tools/gimp-adjustment-tools.js +7 -6
- package/dist/tools/gimp-checkpoint-tools.js +7 -1
- package/dist/tools/gimp-core-tools.js +10 -11
- package/dist/tools/gimp-document-tools.js +100 -18
- package/dist/tools/gimp-effect-tools.js +56 -10
- package/dist/tools/gimp-fill-tools.js +256 -0
- package/dist/tools/gimp-filter-tools.js +4 -1
- package/dist/tools/gimp-geometry-tools.js +2 -2
- package/dist/tools/gimp-inspect-tools.js +48 -10
- package/dist/tools/gimp-layer-tools.js +7 -24
- package/dist/tools/gimp-match-tools.js +133 -0
- package/dist/tools/gimp-result-text.js +104 -0
- package/dist/tools/gimp-retouch-tools.js +238 -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 +241 -0
- package/dist/tools/gimp-transform-layer-tools.js +215 -0
- package/dist/tools/gimp-verify-tools.js +13 -15
- package/dist/tools/image-placement-tools.js +6 -1
- package/dist/tools/sky-tools.js +2 -1
- package/dist/utils/gimp-path.js +18 -22
- package/dist/utils/local-path.js +99 -0
- package/dist/utils/operation-timeouts.js +12 -2
- package/dist/version.js +1 -1
- package/package.json +5 -5
- package/dist/tools/gimp-mask-tools.js +0 -102
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { GimpError } from '../backends/gimp/errors.js';
|
|
2
|
+
import { GIMP_IMAGE_PROP, GIMP_LAYER_ID_PROP, GIMP_LAYER_PROP, pickSchemaDeclaredKeys, runGimpTool, } from './gimp-shared.js';
|
|
3
|
+
export const FILL_MIN_STOPS = 2;
|
|
4
|
+
export const FILL_MAX_STOPS = 8;
|
|
5
|
+
const OPS = ['solid', 'linear_gradient', 'radial_gradient'];
|
|
6
|
+
export const FILL_MODE_NAMES = [
|
|
7
|
+
'normal',
|
|
8
|
+
'multiply',
|
|
9
|
+
'screen',
|
|
10
|
+
'overlay',
|
|
11
|
+
'soft_light',
|
|
12
|
+
'hard_light',
|
|
13
|
+
'darken',
|
|
14
|
+
'lighten',
|
|
15
|
+
'difference',
|
|
16
|
+
'exclusion',
|
|
17
|
+
'addition',
|
|
18
|
+
'subtract',
|
|
19
|
+
'divide',
|
|
20
|
+
'dodge',
|
|
21
|
+
'burn',
|
|
22
|
+
'hue',
|
|
23
|
+
'saturation',
|
|
24
|
+
'color',
|
|
25
|
+
'luminosity',
|
|
26
|
+
'grain_extract',
|
|
27
|
+
'grain_merge',
|
|
28
|
+
'lch_color',
|
|
29
|
+
];
|
|
30
|
+
export const FILL_MAX_COORD_PX = 300_000;
|
|
31
|
+
export const FILL_MAX_STRING_LEN = 255;
|
|
32
|
+
const MIN_AXIS_PX = 1;
|
|
33
|
+
const HEX = /^#[0-9a-fA-F]{6}$/;
|
|
34
|
+
const POINT_PROP = {
|
|
35
|
+
type: 'object',
|
|
36
|
+
properties: {
|
|
37
|
+
x: { type: 'number', minimum: -FILL_MAX_COORD_PX, maximum: FILL_MAX_COORD_PX },
|
|
38
|
+
y: { type: 'number', minimum: -FILL_MAX_COORD_PX, maximum: FILL_MAX_COORD_PX },
|
|
39
|
+
},
|
|
40
|
+
required: ['x', 'y'],
|
|
41
|
+
};
|
|
42
|
+
const fillSchema = {
|
|
43
|
+
type: 'object',
|
|
44
|
+
properties: {
|
|
45
|
+
image: GIMP_IMAGE_PROP,
|
|
46
|
+
op: {
|
|
47
|
+
type: 'string',
|
|
48
|
+
enum: OPS,
|
|
49
|
+
description: "'solid': one flat color. 'linear_gradient': a ramp from start to end. " +
|
|
50
|
+
"'radial_gradient': a ramp from a center out to a radius.",
|
|
51
|
+
},
|
|
52
|
+
layer: {
|
|
53
|
+
...GIMP_LAYER_PROP,
|
|
54
|
+
description: 'Name of the layer the new fill layer goes directly above (inside its group). Defaults ' +
|
|
55
|
+
'to the selected layer, or the topmost layer if none is selected.',
|
|
56
|
+
},
|
|
57
|
+
layer_id: GIMP_LAYER_ID_PROP,
|
|
58
|
+
name: {
|
|
59
|
+
type: 'string',
|
|
60
|
+
description: "New layer's name. Default 'Fill' for solid, 'Gradient' for a gradient.",
|
|
61
|
+
},
|
|
62
|
+
color: {
|
|
63
|
+
type: 'string',
|
|
64
|
+
pattern: '^#[0-9a-fA-F]{6}$',
|
|
65
|
+
description: 'solid only: the color as #rrggbb.',
|
|
66
|
+
},
|
|
67
|
+
stops: {
|
|
68
|
+
type: 'array',
|
|
69
|
+
minItems: FILL_MIN_STOPS,
|
|
70
|
+
maxItems: FILL_MAX_STOPS,
|
|
71
|
+
items: {
|
|
72
|
+
type: 'object',
|
|
73
|
+
properties: {
|
|
74
|
+
offset: { type: 'number', minimum: 0, maximum: 1 },
|
|
75
|
+
color: { type: 'string', pattern: '^#[0-9a-fA-F]{6}$' },
|
|
76
|
+
opacity: { type: 'number', minimum: 0, maximum: 100, default: 100 },
|
|
77
|
+
},
|
|
78
|
+
required: ['offset', 'color'],
|
|
79
|
+
},
|
|
80
|
+
description: 'Gradient only: 2-8 color stops {offset 0..1, color #rrggbb, opacity 0..100}, sorted by ' +
|
|
81
|
+
'offset. Colors blend in sRGB (a black to white stop pair reads 128 at the midpoint). ' +
|
|
82
|
+
'Before the first stop and after the last, the end colors hold.',
|
|
83
|
+
},
|
|
84
|
+
start: {
|
|
85
|
+
...POINT_PROP,
|
|
86
|
+
description: 'linear_gradient: where offset 0 sits, in image pixels. Default top center of the canvas.',
|
|
87
|
+
},
|
|
88
|
+
end: {
|
|
89
|
+
...POINT_PROP,
|
|
90
|
+
description: 'linear_gradient: where offset 1 sits, in image pixels. Default bottom center of the canvas.',
|
|
91
|
+
},
|
|
92
|
+
center: {
|
|
93
|
+
...POINT_PROP,
|
|
94
|
+
description: 'radial_gradient: where offset 0 sits, in image pixels. Default canvas center.',
|
|
95
|
+
},
|
|
96
|
+
radius: {
|
|
97
|
+
type: 'number',
|
|
98
|
+
minimum: 1,
|
|
99
|
+
maximum: FILL_MAX_COORD_PX,
|
|
100
|
+
description: 'radial_gradient: the distance in pixels at which offset 1 is reached. Default half the ' +
|
|
101
|
+
'canvas diagonal.',
|
|
102
|
+
},
|
|
103
|
+
mode: {
|
|
104
|
+
type: 'string',
|
|
105
|
+
enum: FILL_MODE_NAMES,
|
|
106
|
+
default: 'normal',
|
|
107
|
+
description: "The new layer's blend mode: gimp_layer's list plus 'lch_color'. 'color' is HSL Color; " +
|
|
108
|
+
"'lch_color' (LCh Color) keeps the lightness underneath exactly and swaps only hue and " +
|
|
109
|
+
'chroma, the better choice for recoloring.',
|
|
110
|
+
},
|
|
111
|
+
opacity: {
|
|
112
|
+
type: 'number',
|
|
113
|
+
minimum: 0,
|
|
114
|
+
maximum: 100,
|
|
115
|
+
default: 100,
|
|
116
|
+
description: "The new layer's opacity, 0-100.",
|
|
117
|
+
},
|
|
118
|
+
mask: {
|
|
119
|
+
type: 'string',
|
|
120
|
+
description: "The name of a selection channel: becomes the new layer's layer mask, white " +
|
|
121
|
+
'where the channel is selected, so the fill shows only there.',
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
required: ['image', 'op'],
|
|
125
|
+
};
|
|
126
|
+
const bad = (message) => {
|
|
127
|
+
throw new GimpError('invalid_argument', message);
|
|
128
|
+
};
|
|
129
|
+
function isNum(v) {
|
|
130
|
+
return typeof v === 'number' && Number.isFinite(v);
|
|
131
|
+
}
|
|
132
|
+
function checkPoint(name, v) {
|
|
133
|
+
const o = v;
|
|
134
|
+
if (!o || typeof o !== 'object' || !isNum(o['x']) || !isNum(o['y'])) {
|
|
135
|
+
return bad(`${name} must be {x, y} with finite numbers`);
|
|
136
|
+
}
|
|
137
|
+
if (Math.abs(o['x']) > FILL_MAX_COORD_PX || Math.abs(o['y']) > FILL_MAX_COORD_PX) {
|
|
138
|
+
bad(`${name} must be within +/-${FILL_MAX_COORD_PX} px`);
|
|
139
|
+
}
|
|
140
|
+
return { x: o['x'], y: o['y'] };
|
|
141
|
+
}
|
|
142
|
+
export function validateFillArgs(args) {
|
|
143
|
+
const op = args['op'];
|
|
144
|
+
for (const k of ['name', 'mask']) {
|
|
145
|
+
const v = args[k];
|
|
146
|
+
if (typeof v === 'string' && v.length > FILL_MAX_STRING_LEN) {
|
|
147
|
+
bad(`${k} must be at most ${FILL_MAX_STRING_LEN} characters`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const only = (keys, why) => {
|
|
151
|
+
for (const k of keys)
|
|
152
|
+
if (args[k] !== undefined)
|
|
153
|
+
bad(`${k} does not apply to ${why}`);
|
|
154
|
+
};
|
|
155
|
+
if (op === 'solid') {
|
|
156
|
+
if (typeof args['color'] !== 'string' || !HEX.test(args['color'])) {
|
|
157
|
+
bad('color must be a color like #rrggbb for op solid');
|
|
158
|
+
}
|
|
159
|
+
only(['stops', 'start', 'end', 'center', 'radius'], 'op solid');
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
only(['color'], 'a gradient (give stops)');
|
|
163
|
+
const stops = args['stops'];
|
|
164
|
+
if (!Array.isArray(stops) || stops.length < FILL_MIN_STOPS || stops.length > FILL_MAX_STOPS) {
|
|
165
|
+
bad(`stops must be a list of ${FILL_MIN_STOPS}..${FILL_MAX_STOPS} stops for op ${String(op)}`);
|
|
166
|
+
}
|
|
167
|
+
let previous = -1;
|
|
168
|
+
stops.forEach((s, i) => {
|
|
169
|
+
const o = s;
|
|
170
|
+
if (!o || typeof o !== 'object' || !isNum(o['offset']) || typeof o['color'] !== 'string') {
|
|
171
|
+
return bad(`stops[${i}] must be {offset, color, opacity?}`);
|
|
172
|
+
}
|
|
173
|
+
if (!HEX.test(o['color']))
|
|
174
|
+
bad(`stops[${i}].color must be a color like #rrggbb`);
|
|
175
|
+
if (o['offset'] < 0 || o['offset'] > 1)
|
|
176
|
+
bad(`stops[${i}].offset must be within 0..1`);
|
|
177
|
+
const opacity = o['opacity'];
|
|
178
|
+
if (opacity !== undefined && (!isNum(opacity) || opacity < 0 || opacity > 100)) {
|
|
179
|
+
bad(`stops[${i}].opacity must be within 0..100`);
|
|
180
|
+
}
|
|
181
|
+
if (o['offset'] < previous)
|
|
182
|
+
bad(`stops must be sorted by offset: stops[${i}] is out of order`);
|
|
183
|
+
previous = o['offset'];
|
|
184
|
+
});
|
|
185
|
+
if (op === 'linear_gradient') {
|
|
186
|
+
only(['center', 'radius'], 'op linear_gradient');
|
|
187
|
+
const start = args['start'] === undefined ? undefined : checkPoint('start', args['start']);
|
|
188
|
+
const end = args['end'] === undefined ? undefined : checkPoint('end', args['end']);
|
|
189
|
+
if (start && end && Math.hypot(end.x - start.x, end.y - start.y) < MIN_AXIS_PX) {
|
|
190
|
+
bad(`start and end must be at least ${MIN_AXIS_PX} pixel apart`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
only(['start', 'end'], 'op radial_gradient');
|
|
195
|
+
if (args['center'] !== undefined)
|
|
196
|
+
checkPoint('center', args['center']);
|
|
197
|
+
const radius = args['radius'];
|
|
198
|
+
if (radius !== undefined && (!isNum(radius) || radius < 1 || radius > FILL_MAX_COORD_PX)) {
|
|
199
|
+
bad(`radius must be within 1..${FILL_MAX_COORD_PX} pixels`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
export function createGimpFillTools(gimp) {
|
|
204
|
+
return [
|
|
205
|
+
{
|
|
206
|
+
tool: {
|
|
207
|
+
name: 'gimp_fill',
|
|
208
|
+
description: 'Headless GIMP: add a NEW layer, canvas-sized, filled with a solid color (op solid), a ' +
|
|
209
|
+
'linear gradient (start to end) or a radial gradient (center and radius) through 2-8 ' +
|
|
210
|
+
'color stops. The layer goes directly above the target layer (inside its group), with ' +
|
|
211
|
+
'the blend mode and opacity you give; mode lch_color tints without touching lightness, ' +
|
|
212
|
+
'the usual choice for recoloring. Pass mask (a channel name) to confine the fill to ' +
|
|
213
|
+
'that channel: it becomes the layer mask. Gradients blend in sRGB, are not dithered, ' +
|
|
214
|
+
'and use image pixels for every point. The current selection is kept and does not clip ' +
|
|
215
|
+
'the fill. Do NOT use for painting strokes or touching up pixels; use gimp_retouch ' +
|
|
216
|
+
'for that. Returns layer_id, layer_name, op, mode, opacity and masked.',
|
|
217
|
+
inputSchema: fillSchema,
|
|
218
|
+
outputSchema: {
|
|
219
|
+
type: 'object',
|
|
220
|
+
properties: {
|
|
221
|
+
layer_id: { type: 'number' },
|
|
222
|
+
layer_name: { type: 'string' },
|
|
223
|
+
op: { type: 'string' },
|
|
224
|
+
mode: { type: 'string' },
|
|
225
|
+
opacity: { type: 'number' },
|
|
226
|
+
masked: { type: 'boolean' },
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
annotations: {
|
|
230
|
+
title: 'Fill GIMP Layer',
|
|
231
|
+
readOnlyHint: false,
|
|
232
|
+
destructiveHint: false,
|
|
233
|
+
idempotentHint: false,
|
|
234
|
+
openWorldHint: true,
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
handler: async (args) => runGimpTool({
|
|
238
|
+
gimp,
|
|
239
|
+
rawArgs: args,
|
|
240
|
+
schema: fillSchema,
|
|
241
|
+
op: 'fill',
|
|
242
|
+
errorPrefix: 'Error filling GIMP layer',
|
|
243
|
+
params: (validated) => {
|
|
244
|
+
validateFillArgs(validated);
|
|
245
|
+
return pickSchemaDeclaredKeys(fillSchema, validated);
|
|
246
|
+
},
|
|
247
|
+
successText: (result) => {
|
|
248
|
+
const r = result;
|
|
249
|
+
return (`Fill: ${r.op.replace('_', ' ')} on new layer "${r.layer_name}" (layer ${r.layer_id}), ` +
|
|
250
|
+
`${r.mode} at ${r.opacity}%${r.masked ? ', masked' : ''}.`);
|
|
251
|
+
},
|
|
252
|
+
}),
|
|
253
|
+
},
|
|
254
|
+
];
|
|
255
|
+
}
|
|
256
|
+
export const FILL_SCHEMA_FOR_TESTS = fillSchema;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { validateArgs } from '../utils/validate.js';
|
|
2
2
|
import { toolGimpErrorResult, unknownDiscriminator } from '../utils/tool-helpers.js';
|
|
3
3
|
import { GIMP_IMAGE_PROP, pickSchemaDeclaredKeys } from './gimp-shared.js';
|
|
4
|
+
import { capLines, filterLine } from './gimp-result-text.js';
|
|
4
5
|
const filterSchema = {
|
|
5
6
|
type: 'object',
|
|
6
7
|
properties: {
|
|
@@ -41,7 +42,9 @@ async function gimpFilter(gimp, rawArgs) {
|
|
|
41
42
|
let text;
|
|
42
43
|
if (op === 'list') {
|
|
43
44
|
const filters = result.filters ?? [];
|
|
44
|
-
text = `${filters.length} filter(s) on image ${args.image}.`;
|
|
45
|
+
text = `${filters.length} filter(s) on image ${args.image}, top of stack first.`;
|
|
46
|
+
if (filters.length > 0)
|
|
47
|
+
text += '\n' + capLines(filters.map(filterLine)).join('\n');
|
|
45
48
|
}
|
|
46
49
|
else if (op === 'set_visibility') {
|
|
47
50
|
text = `Filter ${result.filter_id} visibility set to ${result.visible}.`;
|
|
@@ -38,7 +38,7 @@ const resizeSchema = {
|
|
|
38
38
|
},
|
|
39
39
|
required: ['image'],
|
|
40
40
|
};
|
|
41
|
-
const EFFECT_UPDATE_FAILURES_PROP = {
|
|
41
|
+
export const EFFECT_UPDATE_FAILURES_PROP = {
|
|
42
42
|
type: 'array',
|
|
43
43
|
items: { type: 'string' },
|
|
44
44
|
description: 'Names of effect filters (position/direction/size-dependent ones tracked through geometry ' +
|
|
@@ -75,7 +75,7 @@ const transformCanvasSchema = {
|
|
|
75
75
|
},
|
|
76
76
|
required: ['image', 'op'],
|
|
77
77
|
};
|
|
78
|
-
function effectUpdateFailuresNote(failures) {
|
|
78
|
+
export function effectUpdateFailuresNote(failures) {
|
|
79
79
|
if (!failures || failures.length === 0)
|
|
80
80
|
return '';
|
|
81
81
|
const plural = failures.length > 1;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { validateArgs } from '../utils/validate.js';
|
|
2
2
|
import { toolGimpErrorResult, unknownDiscriminator } from '../utils/tool-helpers.js';
|
|
3
3
|
import { pickSchemaDeclaredKeys } from './gimp-shared.js';
|
|
4
|
-
|
|
4
|
+
import { capLines, channelsText, filterLine, layerTreeLines } from './gimp-result-text.js';
|
|
5
|
+
const INSPECT_WHATS = ['documents', 'document', 'layers', 'channels', 'filter', 'fonts'];
|
|
5
6
|
const inspectSchema = {
|
|
6
7
|
type: 'object',
|
|
7
8
|
properties: {
|
|
@@ -20,12 +21,19 @@ const inspectSchema = {
|
|
|
20
21
|
"allows duplicate layer names) and flag `is_text_layer`. 'document'/'layers' cap the tree " +
|
|
21
22
|
'at 2000 nodes total and report `truncated: true` if the cap was hit, alongside ' +
|
|
22
23
|
'`top_level_count` and `total_nodes`; if it times out the GIMP session restarts and ' +
|
|
23
|
-
|
|
24
|
+
"unsaved work is lost, so save (gimp_save_xcf) first. A text layer's node also reports " +
|
|
25
|
+
"its text, font, font_size (points), color and alignment. 'fonts' lists the installed " +
|
|
26
|
+
'font names (no image needed), optionally narrowed by `filter`.',
|
|
24
27
|
},
|
|
25
28
|
image: {
|
|
26
29
|
type: 'integer',
|
|
27
30
|
description: 'Image id, as returned by gimp_open_document. Required for every `what` except ' +
|
|
28
|
-
"'documents'.",
|
|
31
|
+
"'documents' and 'fonts'.",
|
|
32
|
+
},
|
|
33
|
+
filter: {
|
|
34
|
+
type: 'string',
|
|
35
|
+
description: "what='fonts' only: case-insensitive substring to narrow the font names by. The list is " +
|
|
36
|
+
'sorted and capped at 200 names; `total` counts every match.',
|
|
29
37
|
},
|
|
30
38
|
filter_id: {
|
|
31
39
|
type: 'integer',
|
|
@@ -35,28 +43,35 @@ const inspectSchema = {
|
|
|
35
43
|
},
|
|
36
44
|
required: ['what'],
|
|
37
45
|
};
|
|
46
|
+
function withLayerTree(summary, layers) {
|
|
47
|
+
const lines = capLines(layerTreeLines(layers));
|
|
48
|
+
return lines.length > 0 ? `${summary}\n${lines.join('\n')}` : summary;
|
|
49
|
+
}
|
|
38
50
|
function describeSummary(what, args, result) {
|
|
39
51
|
const truncatedSuffix = result.truncated ? ' (truncated at the node cap)' : '';
|
|
40
52
|
if (what === 'filter') {
|
|
41
|
-
return
|
|
53
|
+
return filterLine(result);
|
|
42
54
|
}
|
|
43
55
|
if (what === 'layers') {
|
|
44
56
|
const layers = result.layers ?? [];
|
|
45
57
|
const topLevelCount = result.top_level_count ?? layers.length;
|
|
46
|
-
return `${topLevelCount} top-level layer(s) on image ${args.image}${truncatedSuffix}
|
|
58
|
+
return withLayerTree(`${topLevelCount} top-level layer(s) on image ${args.image}${truncatedSuffix}, top of stack first.`, layers);
|
|
47
59
|
}
|
|
48
60
|
if (what === 'channels') {
|
|
49
61
|
const channels = result.channels ?? [];
|
|
50
62
|
const skipped = result.channels_skipped;
|
|
51
63
|
const stoppedEarlySuffix = result.truncated && skipped ? ` (stopped early, ${skipped} channel(s) skipped)` : '';
|
|
52
|
-
return `${channels.length} channel(s) on image ${args.image}${stoppedEarlySuffix}
|
|
64
|
+
return (`${channels.length} channel(s) on image ${args.image}${stoppedEarlySuffix}` +
|
|
65
|
+
(channels.length > 0 ? `: ${channelsText(channels)}.` : '.'));
|
|
53
66
|
}
|
|
54
67
|
const layers = result.layers ?? [];
|
|
55
68
|
const channels = result.channels ?? [];
|
|
56
69
|
const topLevelCount = result.top_level_count ?? layers.length;
|
|
57
|
-
return (`Document ${result.image}: ${result.width}x${result.height} ${result.base_type} ` +
|
|
70
|
+
return withLayerTree(`Document ${result.image}: ${result.width}x${result.height} ${result.base_type} ` +
|
|
58
71
|
`${result.precision}, ${topLevelCount} top-level layer(s)${truncatedSuffix}, ` +
|
|
59
|
-
`${channels.length} channel(s)
|
|
72
|
+
`${channels.length} channel(s)` +
|
|
73
|
+
(channels.length > 0 ? `: ${channelsText(channels)}.` : '.') +
|
|
74
|
+
(layers.length > 0 ? ' Layers, top of stack first:' : ''), layers);
|
|
60
75
|
}
|
|
61
76
|
async function gimpInspect(gimp, rawArgs) {
|
|
62
77
|
try {
|
|
@@ -75,6 +90,22 @@ async function gimpInspect(gimp, rawArgs) {
|
|
|
75
90
|
structuredContent: { what, documents },
|
|
76
91
|
};
|
|
77
92
|
}
|
|
93
|
+
if (what === 'fonts') {
|
|
94
|
+
const result = await gimp.call('fonts', pickSchemaDeclaredKeys(inspectSchema, args));
|
|
95
|
+
const shown = result.fonts.length;
|
|
96
|
+
return {
|
|
97
|
+
content: [
|
|
98
|
+
{
|
|
99
|
+
type: 'text',
|
|
100
|
+
text: `${result.total} font(s) installed` +
|
|
101
|
+
(args.filter ? ` matching "${String(args.filter).slice(0, 100)}"` : '') +
|
|
102
|
+
(shown < result.total ? `, showing the first ${shown}` : '') +
|
|
103
|
+
`: ${result.fonts.join(', ') || '(none)'}.`,
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
structuredContent: { what, ...result },
|
|
107
|
+
};
|
|
108
|
+
}
|
|
78
109
|
if (what === 'document' || what === 'layers' || what === 'channels' || what === 'filter') {
|
|
79
110
|
const result = await gimp.call('describe', pickSchemaDeclaredKeys(inspectSchema, args));
|
|
80
111
|
return {
|
|
@@ -100,8 +131,11 @@ export function createGimpInspectTools(gimp) {
|
|
|
100
131
|
"WITH coverage (selected_pixels/fraction), the cost 'document' skips, and can stop " +
|
|
101
132
|
'early on a document with many named channels (`truncated: true`, `channels_skipped`). ' +
|
|
102
133
|
"'filter' describes one filter by filter_id, in the same shape gimp_filter (op=list) " +
|
|
103
|
-
|
|
104
|
-
|
|
134
|
+
"reports it in. 'fonts' lists the installed font names (no image needed), sorted, " +
|
|
135
|
+
'optionally narrowed by `filter` (substring), capped at 200 with a `total`. Layer tree ' +
|
|
136
|
+
'nodes are addressed by `layer_id` (canonical — GIMP allows duplicate layer names) and ' +
|
|
137
|
+
"flag `is_text_layer`; a text layer's node also reports its text, font, font_size " +
|
|
138
|
+
"(points), color and alignment. 'document'/'layers' cap the tree at " +
|
|
105
139
|
'2000 nodes total and report `truncated: true` if the cap was hit, alongside ' +
|
|
106
140
|
'`top_level_count` and `total_nodes`; if it times out the GIMP session restarts and ' +
|
|
107
141
|
'unsaved work is lost, so save (gimp_save_xcf) first.',
|
|
@@ -110,6 +144,10 @@ export function createGimpInspectTools(gimp) {
|
|
|
110
144
|
type: 'object',
|
|
111
145
|
properties: {
|
|
112
146
|
what: { type: 'string' },
|
|
147
|
+
fonts: { type: 'array', items: { type: 'string' } },
|
|
148
|
+
total: { type: 'number' },
|
|
149
|
+
returned: { type: 'number' },
|
|
150
|
+
default: { type: ['string', 'null'] },
|
|
113
151
|
documents: {
|
|
114
152
|
type: 'array',
|
|
115
153
|
items: { type: 'object', properties: { image: { type: 'number' } } },
|
|
@@ -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. '
|
|
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 ' +
|
|
@@ -132,6 +128,8 @@ const layerSchema = {
|
|
|
132
128
|
'saturation',
|
|
133
129
|
'color',
|
|
134
130
|
'luminosity',
|
|
131
|
+
'grain_extract',
|
|
132
|
+
'grain_merge',
|
|
135
133
|
],
|
|
136
134
|
description: 'set only. A fixed blend-mode list, not a raw GEGL/GIMP mode name.',
|
|
137
135
|
},
|
|
@@ -139,14 +137,6 @@ const layerSchema = {
|
|
|
139
137
|
type: 'boolean',
|
|
140
138
|
description: 'set only.',
|
|
141
139
|
},
|
|
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
140
|
discard_hidden: {
|
|
151
141
|
type: 'boolean',
|
|
152
142
|
default: false,
|
|
@@ -169,8 +159,6 @@ function layerSuccessText(op, result, args) {
|
|
|
169
159
|
return `Duplicated as layer ${result.layer_id} ("${result.name}").`;
|
|
170
160
|
case 'select':
|
|
171
161
|
return `Selected layer ${result.layer_id} ("${result.name}").`;
|
|
172
|
-
case 'move':
|
|
173
|
-
return `Moved layer ${result.layer_id} to (${result.x}, ${result.y}).`;
|
|
174
162
|
case 'reorder':
|
|
175
163
|
return (`Reordered layer ${result.layer_id}` +
|
|
176
164
|
(result.parent_group != null
|
|
@@ -277,7 +265,7 @@ export function createGimpLayerTools(gimp) {
|
|
|
277
265
|
tool: {
|
|
278
266
|
name: 'gimp_layer',
|
|
279
267
|
description: 'Headless GIMP: layer management. op: create | create_group | delete | duplicate | ' +
|
|
280
|
-
'select | set |
|
|
268
|
+
'select | set | reorder | merge_down | flatten (a group is created, addressed, ' +
|
|
281
269
|
'and reordered the same as any other layer — there is no separate group tool). ' +
|
|
282
270
|
'Addressed by layer_id (canonical — an id from gimp_inspect or a prior gimp_layer/' +
|
|
283
271
|
'gimp_bake result) or layer (name; GIMP allows duplicate names, so layer_id is the only ' +
|
|
@@ -286,10 +274,7 @@ export function createGimpLayerTools(gimp) {
|
|
|
286
274
|
'first when in doubt. duplicate REFUSES outright when the layer (or, for a group, any ' +
|
|
287
275
|
"descendant) carries an Editmamei filter, since the copy's filter would collide with " +
|
|
288
276
|
"the original's own ledger record; bake it first (gimp_bake) or delete it, then " +
|
|
289
|
-
'duplicate.
|
|
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 ' +
|
|
277
|
+
'duplicate. merge_down REFUSES outright when the SOURCE layer ' +
|
|
293
278
|
'itself is hidden (GIMP cannot merge a hidden layer down); it and ' +
|
|
294
279
|
'flatten both bake every live filter they touch into pixels first (masked filters ' +
|
|
295
280
|
'included) and rasterize any VISIBLE text layer in the process (rasterized_text); ' +
|
|
@@ -308,8 +293,6 @@ export function createGimpLayerTools(gimp) {
|
|
|
308
293
|
opacity: { type: 'number' },
|
|
309
294
|
mode: { type: 'string' },
|
|
310
295
|
visible: { type: 'boolean' },
|
|
311
|
-
x: { type: ['number', 'null'] },
|
|
312
|
-
y: { type: ['number', 'null'] },
|
|
313
296
|
parent_group: { type: ['number', 'null'] },
|
|
314
297
|
rasterized_text: { type: 'boolean' },
|
|
315
298
|
has_alpha: { type: 'boolean' },
|
|
@@ -340,9 +323,9 @@ export function createGimpLayerTools(gimp) {
|
|
|
340
323
|
'filters; a text layer stays a text layer (baking never rasterizes one). A masked ' +
|
|
341
324
|
"filter's confinement survives the bake exactly. Defaults to the selected layer, or the " +
|
|
342
325
|
'topmost layer if none is selected, the same as any other gimp_layer addressing. Baking ' +
|
|
343
|
-
'clears any masked-filter refusal (
|
|
326
|
+
'clears any masked-filter refusal (gimp_canvas, gimp_resize_image, ' +
|
|
344
327
|
'gimp_transform_canvas) that filter would otherwise trigger — it is the sanctioned way ' +
|
|
345
|
-
'to make a masked adjustment safe to
|
|
328
|
+
'to make a masked adjustment safe to resize, rotate, or flip around. all: true ' +
|
|
346
329
|
'bakes every ordinary layer with a live filter at once; a group layer is always skipped, ' +
|
|
347
330
|
'even one that carries its own filter (GIMP cannot merge filters on a group item) — ' +
|
|
348
331
|
'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;
|