editmamei 1.8.0 → 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.
@@ -0,0 +1,238 @@
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 RETOUCH_MAX_POINTS = 500;
4
+ export const RETOUCH_MAX_SPOTS = 50;
5
+ export const RETOUCH_MAX_BRUSH = 1000;
6
+ export const RETOUCH_MAX_SPOT_RADIUS = 500;
7
+ export const RETOUCH_MAX_STROKE_WORK = 2000000;
8
+ export const RETOUCH_MAX_SPOT_WORK = 200000;
9
+ const METHODS = ['heal', 'clone', 'spot_heal'];
10
+ const TARGETS = ['retouch_layer', 'in_place'];
11
+ const retouchSchema = {
12
+ type: 'object',
13
+ properties: {
14
+ image: GIMP_IMAGE_PROP,
15
+ layer: {
16
+ ...GIMP_LAYER_PROP,
17
+ description: 'Name of the pixel layer to retouch. Defaults to the selected layer, or the topmost layer ' +
18
+ 'if none is selected.',
19
+ },
20
+ layer_id: GIMP_LAYER_ID_PROP,
21
+ method: {
22
+ type: 'string',
23
+ enum: METHODS,
24
+ description: "'heal': paint a stroke that blends the sampled texture into its surroundings (blemishes, " +
25
+ "dust, scratches). 'clone': paint a stroke that copies the sampled pixels exactly. " +
26
+ "'spot_heal': round spots, each healed from an automatically chosen nearby source.",
27
+ },
28
+ points: {
29
+ type: 'array',
30
+ minItems: 1,
31
+ maxItems: RETOUCH_MAX_POINTS,
32
+ items: { type: 'array', minItems: 2, maxItems: 2, items: { type: 'number' } },
33
+ description: 'heal / clone: the stroke as [[x, y], ...] in image pixels (1-500 points), every one ' +
34
+ 'inside the layer.',
35
+ },
36
+ source: {
37
+ type: 'object',
38
+ properties: { x: { type: 'number' }, y: { type: 'number' } },
39
+ required: ['x', 'y'],
40
+ description: 'heal / clone: the image-pixel point to sample for points[0]; the rest of the stroke ' +
41
+ 'samples at the same offset. Give this OR source_offset.',
42
+ },
43
+ source_offset: {
44
+ type: 'object',
45
+ properties: { dx: { type: 'number' }, dy: { type: 'number' } },
46
+ required: ['dx', 'dy'],
47
+ description: 'heal / clone: sample this many pixels from each stroke point (source = point + offset). ' +
48
+ 'Give this OR source.',
49
+ },
50
+ brush_size: {
51
+ type: 'number',
52
+ minimum: 1,
53
+ maximum: RETOUCH_MAX_BRUSH,
54
+ default: 20,
55
+ description: 'heal / clone: brush diameter in pixels. spot_heal sizes its own brush.',
56
+ },
57
+ hardness: {
58
+ type: 'number',
59
+ minimum: 0,
60
+ maximum: 1,
61
+ description: 'heal / clone: brush edge hardness, 0 soft to 1 hard. Default 0.5 for heal, 0.8 for ' +
62
+ 'clone. Not allowed with spot_heal.',
63
+ },
64
+ source_layer_id: {
65
+ ...GIMP_LAYER_ID_PROP,
66
+ description: 'clone only: sample from this pixel layer instead of the one being painted. The source ' +
67
+ 'point plus the stroke must lie inside it.',
68
+ },
69
+ spots: {
70
+ type: 'array',
71
+ minItems: 1,
72
+ maxItems: RETOUCH_MAX_SPOTS,
73
+ items: {
74
+ type: 'object',
75
+ properties: { x: { type: 'number' }, y: { type: 'number' }, radius: { type: 'number' } },
76
+ required: ['x', 'y', 'radius'],
77
+ },
78
+ description: 'spot_heal: [{x, y, radius}] in image pixels (1-50 spots, radius 1-500). The source for ' +
79
+ 'each is picked from 8 directions at about 2.2x and 3.5x the radius, by how closely its ' +
80
+ 'brightness and texture match the ring around the spot. Every source is chosen before ' +
81
+ 'anything is painted, so a spot with no usable source fails the whole call unchanged.',
82
+ },
83
+ target: {
84
+ type: 'string',
85
+ enum: TARGETS,
86
+ default: 'retouch_layer',
87
+ description: "'retouch_layer': paint on a 'Retouch' copy directly above the layer (made on the first " +
88
+ 'call, reused while it stays directly above the layer it was made from); needs a Normal ' +
89
+ "mode, 100% opacity layer without a layer mask. 'in_place': paint the layer itself.",
90
+ },
91
+ },
92
+ required: ['image', 'method'],
93
+ };
94
+ const bad = (message) => {
95
+ throw new GimpError('invalid_argument', message);
96
+ };
97
+ function isNum(v) {
98
+ return typeof v === 'number' && Number.isFinite(v);
99
+ }
100
+ function checkXY(name, v, keys) {
101
+ const o = v;
102
+ if (!o || typeof o !== 'object' || !isNum(o[keys[0]]) || !isNum(o[keys[1]])) {
103
+ bad(`${name} must be {${keys[0]}, ${keys[1]}} with finite numbers`);
104
+ }
105
+ }
106
+ export function validateRetouchArgs(args) {
107
+ const method = args['method'];
108
+ if (method === 'spot_heal') {
109
+ const spots = args['spots'];
110
+ if (!Array.isArray(spots) || spots.length < 1 || spots.length > RETOUCH_MAX_SPOTS) {
111
+ bad(`spots must be a list of 1..${RETOUCH_MAX_SPOTS} spots for method spot_heal`);
112
+ }
113
+ spots.forEach((s, i) => {
114
+ checkXY(`spots[${i}]`, s, ['x', 'y']);
115
+ const r = s['radius'];
116
+ if (!isNum(r) || r < 1 || r > RETOUCH_MAX_SPOT_RADIUS) {
117
+ bad(`spots[${i}].radius must be within 1..${RETOUCH_MAX_SPOT_RADIUS}`);
118
+ }
119
+ });
120
+ for (const k of ['points', 'source', 'source_offset', 'source_layer_id', 'hardness']) {
121
+ if (args[k] !== undefined)
122
+ bad(`${k} does not apply to method spot_heal`);
123
+ }
124
+ const spotWork = spots.reduce((a, sp) => a + sp.radius ** 2, 0);
125
+ if (spotWork > RETOUCH_MAX_SPOT_WORK) {
126
+ bad('these spots are too large to heal in one call: split them into several calls or use ' +
127
+ `smaller radii (the sum of radius squared is ${spotWork}, the limit is ${RETOUCH_MAX_SPOT_WORK})`);
128
+ }
129
+ return;
130
+ }
131
+ const points = args['points'];
132
+ if (!Array.isArray(points) || points.length < 1 || points.length > RETOUCH_MAX_POINTS) {
133
+ bad(`points must be a list of 1..${RETOUCH_MAX_POINTS} [x, y] points for method ${String(method)}`);
134
+ }
135
+ points.forEach((p, i) => {
136
+ if (!Array.isArray(p) || p.length !== 2 || !isNum(p[0]) || !isNum(p[1])) {
137
+ bad(`points[${i}] must be [x, y] with finite numbers`);
138
+ }
139
+ });
140
+ const hasSource = args['source'] !== undefined;
141
+ const hasOffset = args['source_offset'] !== undefined;
142
+ if (hasSource === hasOffset)
143
+ bad('give exactly one of source {x, y} or source_offset {dx, dy}');
144
+ if (hasSource)
145
+ checkXY('source', args['source'], ['x', 'y']);
146
+ else
147
+ checkXY('source_offset', args['source_offset'], ['dx', 'dy']);
148
+ if (args['spots'] !== undefined)
149
+ bad(`spots does not apply to method ${String(method)}`);
150
+ const brush = args['brush_size'] ?? 20;
151
+ const pts = points;
152
+ let length = 0;
153
+ for (let i = 1; i < pts.length; i++) {
154
+ length += Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]);
155
+ }
156
+ const strokeWork = (length + brush) * brush;
157
+ if (strokeWork > RETOUCH_MAX_STROKE_WORK) {
158
+ bad('this stroke is too long for its brush size to paint in one call: split the stroke into ' +
159
+ 'several calls or use a smaller brush ' +
160
+ `(about ${Math.round(strokeWork)} px of path x brush, the limit is ${RETOUCH_MAX_STROKE_WORK})`);
161
+ }
162
+ if (method === 'heal' && args['source_layer_id'] !== undefined) {
163
+ bad('source_layer_id applies to method clone only');
164
+ }
165
+ }
166
+ export function createGimpRetouchTools(gimp) {
167
+ return [
168
+ {
169
+ tool: {
170
+ name: 'gimp_retouch',
171
+ description: 'Headless GIMP: heal or clone pixels with a round brush. method heal paints a stroke ' +
172
+ 'that samples texture from elsewhere and blends it into its surroundings, for blemishes, ' +
173
+ 'dust and scratches; clone paints a stroke that copies the sampled pixels exactly; ' +
174
+ "spot_heal takes round spots {x, y, radius} and picks each one's source automatically, " +
175
+ 'from the nearby area whose brightness and texture best match the ring around the spot. ' +
176
+ 'Everything is in image pixels. By default (target retouch_layer) the paint lands on a ' +
177
+ '"Retouch" copy of the layer directly above it, shown only where you painted, so the ' +
178
+ 'original and the picture everywhere else stay untouched; later calls reuse that copy ' +
179
+ 'while it stays directly above the layer it was made from. target in_place paints the ' +
180
+ 'layer itself, and is required for a layer that is below 100% opacity, not in Normal ' +
181
+ 'mode, or has a layer mask; a layer with semi-transparent pixels also shows its original ' +
182
+ 'through the copy, so use in_place there. The current selection is kept and does not clip the ' +
183
+ 'retouch. Refuses layer groups, text layers, locked or hidden layers, indexed images, ' +
184
+ 'and any point or spot outside the layer. A call has a work limit (a long stroke with a ' +
185
+ 'big brush, or many large spots): split it into several calls or use a smaller brush. ' +
186
+ 'Do NOT use for removing whole objects; a stroke or spot only blends in texture ' +
187
+ 'at the size of the brush. Returns layer_id, layer_name, created_layer, painted_bbox ' +
188
+ "[x, y, w, h] and, for spot_heal, each spot's chosen source offset and score (lower is " +
189
+ 'a closer match).',
190
+ inputSchema: retouchSchema,
191
+ outputSchema: {
192
+ type: 'object',
193
+ properties: {
194
+ layer_id: { type: 'number' },
195
+ layer_name: { type: 'string' },
196
+ created_layer: { type: 'boolean' },
197
+ method: { type: 'string' },
198
+ target: { type: 'string' },
199
+ painted_bbox: { type: 'array' },
200
+ spots: { type: 'array' },
201
+ brush_size: { type: 'number' },
202
+ hardness: { type: 'number' },
203
+ },
204
+ },
205
+ annotations: {
206
+ title: 'Retouch GIMP Layer',
207
+ readOnlyHint: false,
208
+ destructiveHint: true,
209
+ idempotentHint: false,
210
+ openWorldHint: true,
211
+ },
212
+ },
213
+ handler: async (args) => runGimpTool({
214
+ gimp,
215
+ rawArgs: args,
216
+ schema: retouchSchema,
217
+ op: 'retouch',
218
+ errorPrefix: 'Error retouching GIMP layer',
219
+ params: (validated) => {
220
+ validateRetouchArgs(validated);
221
+ return pickSchemaDeclaredKeys(retouchSchema, validated);
222
+ },
223
+ successText: (result) => {
224
+ const r = result;
225
+ const [x, y, w, h] = r.painted_bbox;
226
+ const what = r.spots
227
+ ? `${r.spots.length} spot(s) healed`
228
+ : r.method === 'clone'
229
+ ? 'cloned'
230
+ : 'healed';
231
+ return (`Retouch: ${what} on "${r.layer_name}" (layer ${r.layer_id}` +
232
+ `${r.created_layer ? ', new Retouch layer' : ''}), painted area ${w}x${h} at ${x},${y}.`);
233
+ },
234
+ }),
235
+ },
236
+ ];
237
+ }
238
+ export const RETOUCH_SCHEMA_FOR_TESTS = retouchSchema;
@@ -1,7 +1,14 @@
1
1
  import { validateArgs } from '../utils/validate.js';
2
2
  import { toolGimpErrorResult } from '../utils/tool-helpers.js';
3
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'];
4
+ const TEXT_OPS = [
5
+ 'create',
6
+ 'set_content',
7
+ 'set_font',
8
+ 'set_color',
9
+ 'set_alignment',
10
+ 'set_box',
11
+ ];
5
12
  const ALIGNMENTS = [
6
13
  'LEFT',
7
14
  'CENTER',
@@ -22,7 +29,8 @@ const textSchema = {
22
29
  '100,100) and `font_size` (default 24), optionally with font_name, red/green/blue and ' +
23
30
  'alignment. set_content: replace the text with `text`. set_font: set `font_name` ' +
24
31
  '(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`.',
32
+ '`alignment`. set_box: put the layer in a fixed `box` (re-wrapping its text). The ' +
33
+ 'set_* ops act on the text layer named by `layer_id` or `layer`.',
26
34
  },
27
35
  layer: {
28
36
  ...GIMP_LAYER_PROP,
@@ -85,7 +93,36 @@ const textSchema = {
85
93
  type: 'string',
86
94
  enum: [...ALIGNMENTS],
87
95
  description: 'Text alignment. GIMP supports LEFT, CENTER, RIGHT and FULLYJUSTIFIED; the other ' +
88
- 'values ps_text accepts (LEFTJUSTIFIED, CENTERJUSTIFIED, RIGHTJUSTIFIED) are refused.',
96
+ 'values ps_text accepts (LEFTJUSTIFIED, CENTERJUSTIFIED, RIGHTJUSTIFIED) are refused. ' +
97
+ 'In a box, alignment applies within the box width.',
98
+ },
99
+ box: {
100
+ type: 'object',
101
+ properties: {
102
+ width: { type: 'integer', minimum: 1, maximum: 30000 },
103
+ height: { type: 'integer', minimum: 1, maximum: 30000 },
104
+ },
105
+ required: ['width'],
106
+ description: 'create, set_box: a fixed text box {width, height?} in image pixels (each side 1 to ' +
107
+ '30000, area within the text-box cap: 64 MP at 8-bit, 32 MP at 16-bit, 16 MP at 32-bit). The text wraps at `width` (newlines in the ' +
108
+ 'text are kept) and is placed at the top of the box; GIMP has no vertical alignment, so ' +
109
+ 'a taller box leaves the space below. Omit `height` and the layer grows to the wrapped ' +
110
+ "text's height. The box survives later set_content / set_font / set_alignment edits. " +
111
+ 'Emoji and some scripts depend on installed fonts and may render as empty boxes.',
112
+ },
113
+ fit: {
114
+ type: 'boolean',
115
+ description: 'create, set_box: with a `box` that has both width and height (at most 16 MP), shrink the ' +
116
+ 'text to approximately the largest size (at most font_size, or the current size for ' +
117
+ 'set_box) at which the wrapped text fits inside the box, searched in at most 12 trial ' +
118
+ 'renders. A word wider than the box counts as not fitting. The result ' +
119
+ 'reports `fitted`: false when even min_size overflows (the text is left at min_size).',
120
+ },
121
+ min_size: {
122
+ type: 'number',
123
+ minimum: 1,
124
+ maximum: 10000,
125
+ description: 'fit only: the smallest font size to try, in pixels (default 8).',
89
126
  },
90
127
  },
91
128
  required: ['image', 'op'],
@@ -103,7 +140,12 @@ async function gimpText(gimp, rawArgs) {
103
140
  {
104
141
  type: 'text',
105
142
  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}).`,
143
+ `${result.font_size} pt${sizing}, ${b.width}x${b.height} px at (${b.x}, ${b.y}).` +
144
+ (result.fitted === undefined
145
+ ? ''
146
+ : result.fitted
147
+ ? ' Fitted to the box.'
148
+ : ' Does NOT fit: even min_size overflows the box, so the text is left at min_size and is clipped.'),
107
149
  },
108
150
  ],
109
151
  structuredContent: result,
@@ -119,15 +161,19 @@ export function createGimpTextTools(gimp) {
119
161
  tool: {
120
162
  name: 'gimp_text',
121
163
  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 ' +
164
+ '(create, set_content, set_font, set_color, set_alignment, set_box) — the same ops and ' +
165
+ 'parameters as ps_text, plus a fixed `box` with wrapping and `fit`. create adds a layer at `x`/`y` with `text`, `font_size` in ' +
124
166
  'points, and optional font_name, red/green/blue and alignment; it reports layer_id, ' +
125
167
  '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
168
  '(a non-text layer is refused). Fonts accept a family ("Inter") or full name ("Inter ' +
127
169
  'Bold"), case-insensitive; a miss is refused with the closest installed names (list ' +
128
170
  'them with gimp_inspect what=fonts). Text is capped at 2000 characters, and a layer ' +
129
171
  '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 ' +
172
+ 'GIMP supports LEFT, CENTER, RIGHT and FULLYJUSTIFIED alignment only. `box` {width, ' +
173
+ 'height?} (create, set_box) fixes the text box so text wraps at `width`; `fit` ' +
174
+ 'shrinks the size to approximately the largest size at which the wrapped text fits a box with both sides; the result then ' +
175
+ 'carries `box` and, for fit, `fitted` and `fit_probes`. Text sits at the top of the ' +
176
+ 'box (no vertical alignment). There is no ' +
131
177
  'undo in this session: gimp_checkpoint or gimp_save_xcf first when in doubt.',
132
178
  inputSchema: textSchema,
133
179
  outputSchema: {
@@ -164,6 +210,20 @@ export function createGimpTextTools(gimp) {
164
210
  height: { type: 'number' },
165
211
  },
166
212
  },
213
+ box: {
214
+ type: 'object',
215
+ description: 'create / set_box with a box: the resulting box in pixels.',
216
+ properties: { width: { type: 'number' }, height: { type: 'number' } },
217
+ },
218
+ fitted: {
219
+ type: 'boolean',
220
+ description: 'fit only: false when even min_size overflows the box.',
221
+ },
222
+ fit_probes: {
223
+ type: 'integer',
224
+ description: 'fit only: trial renders used (at most 12).',
225
+ },
226
+ fit_seconds: { type: 'number', description: 'fit only: time the search took.' },
167
227
  },
168
228
  },
169
229
  annotations: {
@@ -5,6 +5,7 @@ import { validateArgs } from '../utils/validate.js';
5
5
  import { toolGimpErrorResult, unknownDiscriminator } from '../utils/tool-helpers.js';
6
6
  import { Logger } from '../utils/logger.js';
7
7
  import { GIMP_IMAGE_PROP, GIMP_REGION_PROP, GIMP_MAX_PX_PROP, pickSchemaDeclaredKeys, readRender, defaultPreviewsAllowed, } from './gimp-shared.js';
8
+ import { regionStatsLines } from './gimp-result-text.js';
8
9
  const logger = new Logger('GimpVerifyTools');
9
10
  const CHANNELS = ['luminance', 'red', 'green', 'blue'];
10
11
  export const HISTOGRAM_BIN_COUNT = 256;
@@ -104,6 +105,13 @@ const histogramSchema = {
104
105
  ' Computed on the proxy when it maps to a big-enough proxy area, else auto-falls back to a ' +
105
106
  'full-resolution crop; exact:true always forces the full-resolution crop.',
106
107
  },
108
+ mask: {
109
+ type: 'string',
110
+ description: 'The name of a selection channel: measure only the pixels it selects at ' +
111
+ '50% or more (a hard cut, no partial credit for soft edges), e.g. the mean lightness or ' +
112
+ 'color of a selected object. Combines with region and exact; pixels in the result is ' +
113
+ 'the number of pixels counted.',
114
+ },
107
115
  },
108
116
  required: ['image'],
109
117
  };
@@ -121,7 +129,7 @@ async function gimpGetHistogram(gimp, rawArgs) {
121
129
  content: [
122
130
  {
123
131
  type: 'text',
124
- text: `Histogram (${result.exact ? 'exact' : 'proxy'}, ${result.pixels.toLocaleString()} px): ${summary}${unmirroredNote}`,
132
+ text: `Histogram (${result.exact ? 'exact' : 'proxy'}${result.mask ? `, mask ${result.mask}` : ''}, ${result.pixels.toLocaleString()} px): ${summary}${unmirroredNote}`,
125
133
  },
126
134
  ],
127
135
  structuredContent: result,
@@ -185,7 +193,8 @@ async function gimpCompare(gimp, rawArgs, previewsAllowedFn) {
185
193
  content: [
186
194
  {
187
195
  type: 'text',
188
- text: 'Compared region_a vs region_b (full-resolution, exact).',
196
+ text: 'Compared region_a (A) vs region_b (B), full-resolution, exact. Values 0-255.\n' +
197
+ regionStatsLines(result.region_a, result.region_b).join('\n'),
189
198
  },
190
199
  ],
191
200
  structuredContent: result,
@@ -4,6 +4,8 @@ export const DEFAULT_SCRIPT_TIMEOUT_MS = 30_000;
4
4
  export const SCRIPT_TIMEOUT_FLOOR_MS = 5_000;
5
5
  export const OPEN_DOCUMENT_TIMEOUT_MS = 120_000;
6
6
  export const OPEN_DOCUMENT_REPROBE_TIMEOUT_MS = 10_000;
7
+ export const SAVE_PSD_TIMEOUT_MS = 120_000;
8
+ export const SAVE_PSD_REPROBE_TIMEOUT_MS = 30_000;
7
9
  export const CAMERA_RAW_FILTER_TIMEOUT_MS = 120_000;
8
10
  export const SELECT_SUBJECT_TIMEOUT_MS = 120_000;
9
11
  export const SELECT_SKY_TIMEOUT_MS = 120_000;
@@ -17,12 +19,12 @@ export const TOOL_TIMEOUT_BUDGETS_MS = {
17
19
  ps_select_subject: SELECT_SUBJECT_TIMEOUT_MS,
18
20
  ps_select_sky: SELECT_SKY_TIMEOUT_MS,
19
21
  ps_replace_sky: SKY_REPLACEMENT_TIMEOUT_MS,
22
+ ps_save_psd: SAVE_PSD_TIMEOUT_MS,
20
23
  ps_ping: 30_000,
21
24
  ps_read_scene: 164_000,
22
25
  ps_selection_channel: 123_000,
23
26
  ps_select: 54_000,
24
27
  ps_modify_selection: 37_000,
25
- ps_save_psd: 32_000,
26
28
  ps_filter: 31_000,
27
29
  ps_get_histogram: 25_000,
28
30
  ps_create_document: 20_000,
@@ -101,8 +103,10 @@ export const TOOL_TIMEOUT_BUDGETS_MS = {
101
103
  gimp_modify_selection: 90_000,
102
104
  gimp_layer_mask: 15_000,
103
105
  gimp_get_selection_preview: 30_000,
104
- gimp_transform_layer: 30_000,
106
+ gimp_transform_layer: 90_000,
105
107
  gimp_match_layer: 70_000,
108
+ gimp_retouch: 240_000,
109
+ gimp_fill: 60_000,
106
110
  };
107
111
  export const TOOLS_WITHOUT_A_BUDGET = {
108
112
  ps_report_problem: 'n=3 measured calls',
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '1.8.0';
1
+ export const VERSION = '1.9.0';
package/package.json CHANGED
@@ -1,19 +1,19 @@
1
1
  {
2
2
  "name": "editmamei",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "Photoshop MCP server: natural-language AI photo editing in desktop Photoshop (Community Edition)",
5
5
  "mcpName": "io.github.editmamei/editmamei",
6
6
  "editmamei": {
7
7
  "fixesByVersion": {
8
+ "1.9.0": [
9
+ "ps_save_psd"
10
+ ],
8
11
  "1.4.0": [
9
12
  "ps_read_scene",
10
13
  "ps_selection_channel",
11
14
  "ps_select",
12
15
  "ps_modify_selection"
13
16
  ],
14
- "1.1.0": [
15
- "ps_create_clipping_mask"
16
- ],
17
17
  "1.2.0": [
18
18
  "ps_delete_layer",
19
19
  "ps_select_layer"