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.
Files changed (45) hide show
  1. package/README.md +3 -3
  2. package/dist/backends/gimp/bridge/lib.py +1790 -26
  3. package/dist/backends/gimp/bridge/ops.py +3013 -168
  4. package/dist/bin/editmamei-core-darwin-arm64 +0 -0
  5. package/dist/bin/editmamei-core-darwin-x64 +0 -0
  6. package/dist/bin/editmamei-core-win-x64.exe +0 -0
  7. package/dist/cli/config.js +7 -0
  8. package/dist/core/server.js +58 -13
  9. package/dist/core/settings.js +7 -0
  10. package/dist/core/tool-activity.js +15 -1
  11. package/dist/core/tool-groups.js +9 -1
  12. package/dist/core/tool-tiers.js +9 -1
  13. package/dist/delivery/client.js +3 -0
  14. package/dist/modules/gimp/index.js +12 -2
  15. package/dist/skills/editmamei-skill.zip +0 -0
  16. package/dist/telemetry/sanitize.js +15 -1
  17. package/dist/tools/adjustment-tools.js +9 -1
  18. package/dist/tools/document-tools.js +118 -21
  19. package/dist/tools/filter-tools.js +2 -1
  20. package/dist/tools/gimp-adjustment-tools.js +7 -6
  21. package/dist/tools/gimp-checkpoint-tools.js +7 -1
  22. package/dist/tools/gimp-core-tools.js +10 -11
  23. package/dist/tools/gimp-document-tools.js +100 -18
  24. package/dist/tools/gimp-effect-tools.js +56 -10
  25. package/dist/tools/gimp-fill-tools.js +256 -0
  26. package/dist/tools/gimp-filter-tools.js +4 -1
  27. package/dist/tools/gimp-geometry-tools.js +2 -2
  28. package/dist/tools/gimp-inspect-tools.js +48 -10
  29. package/dist/tools/gimp-layer-tools.js +7 -24
  30. package/dist/tools/gimp-match-tools.js +133 -0
  31. package/dist/tools/gimp-result-text.js +104 -0
  32. package/dist/tools/gimp-retouch-tools.js +238 -0
  33. package/dist/tools/gimp-selection-tools.js +374 -0
  34. package/dist/tools/gimp-shared.js +12 -0
  35. package/dist/tools/gimp-text-tools.js +241 -0
  36. package/dist/tools/gimp-transform-layer-tools.js +215 -0
  37. package/dist/tools/gimp-verify-tools.js +13 -15
  38. package/dist/tools/image-placement-tools.js +6 -1
  39. package/dist/tools/sky-tools.js +2 -1
  40. package/dist/utils/gimp-path.js +18 -22
  41. package/dist/utils/local-path.js +99 -0
  42. package/dist/utils/operation-timeouts.js +12 -2
  43. package/dist/version.js +1 -1
  44. package/package.json +5 -5
  45. package/dist/tools/gimp-mask-tools.js +0 -102
@@ -55,7 +55,7 @@ const adjustSchema = {
55
55
  },
56
56
  mask: {
57
57
  type: 'string',
58
- description: 'Name of a mask channel from gimp_create_mask, confining a NEW filter to it. Only valid ' +
58
+ description: 'Name of a saved selection channel, confining a NEW filter to it. Only valid ' +
59
59
  "when creating (no filter_id) — a filter's mask is fixed at creation. ORDER MATTERS: " +
60
60
  'straighten / flip / resize the canvas first (gimp_transform_canvas, gimp_resize_image), ' +
61
61
  'then crop, THEN add masked adjustments — rotate, flip, and resize all refuse outright ' +
@@ -278,9 +278,10 @@ const adjustSchema = {
278
278
  maximum: 100,
279
279
  description: 'vibrance only. THE vibrance control — a vibrance adjustment must set this field, not ' +
280
280
  '`saturation` (a separate, plain multiplier for this type that normally stays at its ' +
281
- 'default). Positive values boost muted/less-saturated colours more than already-saturated ' +
282
- 'ones (skin tones move less than a bright sky); negative desaturates the same way in ' +
283
- 'reverse. Default (when creating): 0.',
281
+ "default). The scale follows Photoshop's Vibrance: +10 to +30 is a natural boost, +50 a " +
282
+ "strong one; negative values desaturate. Unlike Photoshop's, it lifts already-saturated " +
283
+ 'colors (a blue sky, a bright shirt) about as much as muted ones, so check those areas ' +
284
+ 'with a histogram above about +50. Default (when creating): 0.',
284
285
  },
285
286
  amount: {
286
287
  type: 'number',
@@ -348,11 +349,11 @@ export function createGimpAdjustmentTools(gimp) {
348
349
  'separate filters for red and blue rather than one filter for both; points are [input, ' +
349
350
  'output] 0-255 pairs including the endpoints. hue_saturation and color_balance likewise ' +
350
351
  'carry ONE `range` per filter. gaussian_blur: radius in pixels at full resolution; ' +
351
- 'combine with a mask from gimp_create_mask for soft-focus or background-blur effects. A ' +
352
+ 'combine with a saved-selection mask for soft-focus or background-blur effects. A ' +
352
353
  "re-edit (filter_id) MERGES: any field you omit keeps the filter's existing value, so " +
353
354
  "re-editing {contrast: 50} on a brightness_contrast filter doesn't reset brightness. Only " +
354
355
  'filters Editmamei created can be re-edited; one added in the GIMP GUI is refused (delete ' +
355
- 'and re-create it). `mask` (a channel name from gimp_create_mask) confines a NEW filter ' +
356
+ 'and re-create it). `mask` (a saved selection channel name) confines a NEW filter ' +
356
357
  '— fixed at creation, cannot change on a re-edit. ORDER MATTERS: straighten / flip / ' +
357
358
  'resize the canvas first, then crop, THEN add any masked adjustment — rotate, flip, and ' +
358
359
  'resize all refuse outright once a masked adjustment filter exists, or any filter not ' +
@@ -6,6 +6,7 @@ import { validateArgs } from '../utils/validate.js';
6
6
  import { toolGimpErrorResult, unknownDiscriminator } from '../utils/tool-helpers.js';
7
7
  import { GimpError } from '../backends/gimp/errors.js';
8
8
  import { GIMP_IMAGE_PROP } from './gimp-shared.js';
9
+ import { capLines, quoted } from './gimp-result-text.js';
9
10
  export const MAX_CHECKPOINTS_PER_IMAGE = 5;
10
11
  export const MAX_CHECKPOINTS_TOTAL = 20;
11
12
  const checkpointSchema = {
@@ -303,7 +304,12 @@ async function checkpointList(gimp, args, store) {
303
304
  content: [
304
305
  {
305
306
  type: 'text',
306
- text: `${checkpoints.length} checkpoint(s)${scopedNote}.${goneNote}`,
307
+ text: `${checkpoints.length} checkpoint(s)${scopedNote}.${goneNote}` +
308
+ capLines(checkpoints.map((c) => `- ${quoted(c.checkpoint_id)}: image ${c.image}` +
309
+ `${c.open ? '' : ' (gone)'}${c.pending ? ', still being created' : ''}, ` +
310
+ `${c.created_at}, ${c.bytes.toLocaleString('en-US')} bytes`))
311
+ .map((line) => `\n${line}`)
312
+ .join(''),
307
313
  },
308
314
  ],
309
315
  structuredContent: { checkpoints },
@@ -90,9 +90,9 @@ in the real GIMP GUI afterward to see it directly.
90
90
  stacking a second one for the same correction (see "Curve laws"
91
91
  below).
92
92
  6. **Finish** — \`gimp_save_xcf\` keeps every filter live and
93
- re-editable; \`gimp_export\` writes a flattened deliverable. Do both
94
- when the user wants to keep working on it later AND ship a JPEG/PNG
95
- now.
93
+ re-editable; \`gimp_export\` writes a flattened deliverable, or a
94
+ layered .psd for finishing in Photoshop. Do both when the user wants
95
+ to keep working on it later AND ship a JPEG/PNG now.
96
96
 
97
97
  ## Curve laws (gimp_add_adjustment type=curves)
98
98
 
@@ -145,13 +145,12 @@ workflow avoids.
145
145
 
146
146
  ## Masks
147
147
 
148
- \`gimp_create_mask\` builds a geometric mask (rectangle / ellipse /
149
- gradient) into a NAMED channel. Pass that name as \`mask\` when you
150
- CREATE a filter with \`gimp_add_adjustment\` or \`gimp_add_effect\` to
151
- confine it. A filter's
152
- mask is fixed at creation — re-editing it by \`filter_id\` cannot
153
- change which mask it uses; delete and re-create it with a new mask
154
- instead.
148
+ A selection can be saved into a NAMED channel. Pass the name of a
149
+ channel saved in the image as \`mask\` when you CREATE a filter
150
+ with \`gimp_add_adjustment\` or \`gimp_add_effect\` to confine it.
151
+ A filter's mask is fixed at creation — re-editing it by
152
+ \`filter_id\` cannot change which mask it uses; delete and
153
+ re-create it with a new mask instead.
155
154
 
156
155
  ## Order matters: geometry before masked adjustments
157
156
 
@@ -169,7 +168,7 @@ every geometry op.
169
168
  ## Layers and composites
170
169
 
171
170
  A filter applies to ONE layer. \`gimp_layer\` creates, groups, reorders,
172
- moves and restyles layers and merges or flattens them;
171
+ and restyles layers and merges or flattens them;
173
172
  \`gimp_place_image\` adds another file as a new layer;
174
173
  \`gimp_create_document\` starts a blank canvas. Address layers by the
175
174
  \`layer_id\` these tools and \`gimp_inspect\` report — names can repeat.
@@ -40,7 +40,10 @@ async function gimpOpenDocument(gimp, rawArgs) {
40
40
  type: 'text',
41
41
  text: `Opened ${basename(filePath)} as image ${result.image} ` +
42
42
  `(${result.width}x${result.height}, ${result.base_type}, ${result.precision}). ` +
43
- `${result.layers.length} layer(s): ${result.layers.join(', ') || '(none)'}.`,
43
+ `${result.layers.length} layer(s): ${result.layers.join(', ') || '(none)'}.` +
44
+ (result.orientation_applied !== undefined
45
+ ? ` Rotated upright for EXIF orientation ${result.orientation_applied}.`
46
+ : ''),
44
47
  },
45
48
  ],
46
49
  structuredContent: result,
@@ -63,7 +66,7 @@ const saveXcfSchema = {
63
66
  type: 'string',
64
67
  description: 'Absolute path ending in .xcf. Saves the LIVE GIMP document — every filter stays in ' +
65
68
  "place and re-editable afterward (matches ps_save_psd's framing for .psd). For a " +
66
- 'flattened deliverable, use gimp_export instead.',
69
+ 'deliverable (flattened, or a layered .psd), use gimp_export instead.',
67
70
  },
68
71
  overwrite: {
69
72
  type: 'boolean',
@@ -78,7 +81,7 @@ async function gimpSaveXcf(gimp, rawArgs) {
78
81
  const args = validateArgs(saveXcfSchema, rawArgs);
79
82
  const filePath = requireAbsoluteGimpPath('file_path', args.file_path);
80
83
  if (extname(filePath).toLowerCase() !== '.xcf') {
81
- throw new GimpError('invalid_argument', `"file_path" must end in .xcf, got "${filePath}" — use gimp_export for a flattened deliverable.`);
84
+ throw new GimpError('invalid_argument', `"file_path" must end in .xcf, got "${filePath}" — use gimp_export for a deliverable.`);
82
85
  }
83
86
  refuseExistingGimpFile(filePath, args.overwrite, existsSync);
84
87
  const result = await gimp.call('export', {
@@ -99,16 +102,22 @@ async function gimpSaveXcf(gimp, rawArgs) {
99
102
  return toolGimpErrorResult('Error saving GIMP document', error);
100
103
  }
101
104
  }
102
- const EXPORT_FORMAT_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.webp', '.tif', '.tiff'];
105
+ const FLAT_EXPORT_FORMAT_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.webp', '.tif', '.tiff'];
106
+ const LAYERED_EXPORT_FORMAT_EXTENSIONS = ['.psd'];
107
+ const EXPORT_FORMAT_EXTENSIONS = [
108
+ ...FLAT_EXPORT_FORMAT_EXTENSIONS,
109
+ ...LAYERED_EXPORT_FORMAT_EXTENSIONS,
110
+ ];
103
111
  const exportSchema = {
104
112
  type: 'object',
105
113
  properties: {
106
114
  image: GIMP_IMAGE_PROP,
107
115
  file_path: {
108
116
  type: 'string',
109
- description: 'Absolute path. The extension picks the format: jpg/jpeg, png, webp, tif/tiff. Refuses ' +
110
- ".xcf outright — that is gimp_save_xcf's job. Metadata (EXIF/XMP/IPTC/GPS, thumbnail) is " +
111
- 'ALWAYS stripped on export, unconditionally — never carried over from the source.',
117
+ description: 'Absolute path. The extension picks the format: jpg/jpeg, png, webp, tif/tiff (flattened), ' +
118
+ "or psd (layered). Refuses .xcf outright — that is gimp_save_xcf's job. Metadata " +
119
+ '(EXIF/XMP/IPTC/GPS, thumbnail) is ALWAYS stripped on export, unconditionally — never ' +
120
+ 'carried over from the source.',
112
121
  },
113
122
  overwrite: {
114
123
  type: 'boolean',
@@ -148,23 +157,59 @@ const exportSchema = {
148
157
  type: 'integer',
149
158
  enum: [8, 16],
150
159
  default: 8,
151
- description: 'PNG/TIFF only: output bit depth. JPEG/WebP are always 8-bit.',
160
+ description: 'PNG/TIFF/PSD only: output bit depth. JPEG/WebP are always 8-bit.',
161
+ },
162
+ keep_alpha: {
163
+ type: 'boolean',
164
+ default: false,
165
+ description: 'PNG/WebP only: keep transparency. The visible layers are merged onto a transparent ' +
166
+ 'canvas, so areas no layer covers (a cut-out, say) stay transparent. Default false ' +
167
+ 'flattens onto white. Refused for JPEG and TIFF.',
152
168
  },
153
169
  },
154
170
  required: ['image', 'file_path'],
155
171
  };
172
+ const LAYER_NAMES_SHOWN = 10;
173
+ function layerNames(names) {
174
+ const shown = names.slice(0, LAYER_NAMES_SHOWN).map((n) => `"${n}"`);
175
+ const more = names.length - shown.length;
176
+ return more > 0 ? `${shown.join(', ')} and ${more} more` : shown.join(', ');
177
+ }
178
+ function layeredExportText(result) {
179
+ const parts = [
180
+ `Exported ${result.bytes.toLocaleString()} bytes to ${result.path} as a layered PSD with ` +
181
+ `${result.layers} layers. Live filters were baked into their layers' pixels.`,
182
+ ];
183
+ if (result.merged_groups.length > 0) {
184
+ parts.push(`Groups merged into one layer each, because PSD cannot store a filter on a group: ` +
185
+ `${layerNames(result.merged_groups)}.`);
186
+ }
187
+ if (result.rasterized_text.length > 0) {
188
+ parts.push(`Text layers written as pixels, not editable text: ${layerNames(result.rasterized_text)}.`);
189
+ }
190
+ if (result.may_blend_differently.length > 0) {
191
+ parts.push('Photoshop blends partly transparent layers and blend modes in gamma space, so these ' +
192
+ `may look slightly different there: ${layerNames(result.may_blend_differently)}.`);
193
+ }
194
+ parts.push('Metadata (EXIF/XMP/IPTC/GPS, thumbnail) was stripped.');
195
+ return parts.join(' ');
196
+ }
156
197
  async function gimpExport(gimp, rawArgs) {
157
198
  try {
158
199
  const args = validateArgs(exportSchema, rawArgs);
159
200
  const filePath = requireAbsoluteGimpPath('file_path', args.file_path);
160
201
  const ext = extname(filePath).toLowerCase();
161
202
  if (ext === '.xcf') {
162
- throw new GimpError('invalid_argument', '"file_path" must not be a .xcf file — gimp_export writes a flattened deliverable; use ' +
203
+ throw new GimpError('invalid_argument', '"file_path" must not be a .xcf file — gimp_export writes a deliverable; use ' +
163
204
  'gimp_save_xcf to save the live, re-editable document.');
164
205
  }
165
206
  if (!EXPORT_FORMAT_EXTENSIONS.includes(ext)) {
166
207
  throw new GimpError('invalid_argument', `"file_path" must end in ${EXPORT_FORMAT_EXTENSIONS.join(', ')}, got "${ext || '(none)'}".`);
167
208
  }
209
+ const keepAlpha = args.keep_alpha === true;
210
+ if (keepAlpha && ext !== '.png' && ext !== '.webp') {
211
+ throw new GimpError('invalid_argument', `"keep_alpha" only works with a .png or .webp file_path, got "${ext}".`);
212
+ }
168
213
  refuseExistingGimpFile(filePath, args.overwrite, existsSync);
169
214
  const bridgeArgs = { image: args.image, path: filePath };
170
215
  if (ext === '.jpg' || ext === '.jpeg') {
@@ -182,8 +227,20 @@ async function gimpExport(gimp, rawArgs) {
182
227
  bridgeArgs.compression = args.tiff_compression;
183
228
  bridgeArgs.bit_depth = args.bit_depth;
184
229
  }
185
- const result = await gimp.call('export', bridgeArgs);
230
+ else if (ext === '.psd') {
231
+ bridgeArgs.bit_depth = args.bit_depth;
232
+ }
233
+ if (keepAlpha)
234
+ bridgeArgs.keep_alpha = true;
186
235
  const format = ext.replace('.', '');
236
+ if (ext === '.psd') {
237
+ const result = await gimp.call('export', bridgeArgs);
238
+ return {
239
+ content: [{ type: 'text', text: layeredExportText(result) }],
240
+ structuredContent: { ...result, format },
241
+ };
242
+ }
243
+ const result = await gimp.call('export', bridgeArgs);
187
244
  return {
188
245
  content: [
189
246
  {
@@ -240,7 +297,7 @@ export function createGimpDocumentTools(gimp) {
240
297
  tool: {
241
298
  name: 'gimp_close_document',
242
299
  description: 'Headless GIMP: close an image WITHOUT saving. Save first with gimp_save_xcf (keeps filters live) or ' +
243
- 'gimp_export (flattened deliverable) if the work should be kept.',
300
+ 'gimp_export (deliverable) if the work should be kept.',
244
301
  inputSchema: closeSchema,
245
302
  outputSchema: { type: 'object', properties: { closed: { type: 'number' } } },
246
303
  annotations: {
@@ -271,7 +328,8 @@ export function createGimpDocumentTools(gimp) {
271
328
  'to overwrite an existing file unless overwrite: true. Save early: on a very large ' +
272
329
  'document a save can take tens of seconds, and if any call times out the GIMP session ' +
273
330
  'restarts and every unsaved open image and filter is lost — save before long operations, ' +
274
- 'not after. For a flattened, shippable copy with metadata stripped, use gimp_export instead.',
331
+ 'not after. For a shippable copy with metadata stripped (flattened, or a layered .psd), ' +
332
+ 'use gimp_export instead.',
275
333
  inputSchema: saveXcfSchema,
276
334
  outputSchema: {
277
335
  type: 'object',
@@ -290,12 +348,20 @@ export function createGimpDocumentTools(gimp) {
290
348
  {
291
349
  tool: {
292
350
  name: 'gimp_export',
293
- description: 'Headless GIMP: write a FLATTENED deliverable to disk — every live filter is baked in, ' +
294
- 'and ALL metadata (EXIF/XMP/IPTC/GPS, thumbnail) is stripped unconditionally, never ' +
295
- 'carried over from the source. Format is picked from the file_path extension (jpg/jpeg, ' +
296
- 'png, webp, tif/tiff); any other extension is refused, and .xcf is refused outright — ' +
297
- 'use gimp_save_xcf for the live, re-editable document instead. Refuses to overwrite an ' +
298
- 'existing file unless overwrite: true (with it, the existing file is replaced).',
351
+ description: 'Headless GIMP: write a deliverable to disk — every live filter is baked in, and ALL ' +
352
+ 'metadata (EXIF/XMP/IPTC/GPS, thumbnail) is stripped unconditionally, never carried ' +
353
+ 'over from the source. Format is picked from the file_path extension: jpg/jpeg, png, ' +
354
+ 'webp and tif/tiff write a FLATTENED image; psd writes a LAYERED file for finishing in ' +
355
+ 'Photoshop or another editor. In a PSD, layers keep their masks, opacity, blend modes, ' +
356
+ 'visibility and groups; filters are baked into each layer; a group with its own filter ' +
357
+ 'is merged into one layer; text layers become pixels. Photoshop blends partly ' +
358
+ 'transparent layers and blend modes in gamma space, so those can look slightly ' +
359
+ 'different there — the result lists them. Any other extension is refused, and .xcf is ' +
360
+ 'refused outright — use gimp_save_xcf for the live, re-editable document instead. ' +
361
+ 'Refuses to overwrite an existing file unless overwrite: true (with it, the existing ' +
362
+ 'file is replaced). A flattened image is composited onto white by default; keep_alpha: ' +
363
+ 'true (png or webp only) keeps transparency, so a cut-out exports with a transparent ' +
364
+ 'background.',
299
365
  inputSchema: exportSchema,
300
366
  outputSchema: {
301
367
  type: 'object',
@@ -303,6 +369,22 @@ export function createGimpDocumentTools(gimp) {
303
369
  path: { type: 'string' },
304
370
  bytes: { type: 'number' },
305
371
  format: { type: ['string', 'null'] },
372
+ layers: { type: 'number', description: 'PSD only: layers written, groups included.' },
373
+ merged_groups: {
374
+ type: 'array',
375
+ items: { type: 'string' },
376
+ description: 'PSD only: groups merged into one layer to keep their own filter.',
377
+ },
378
+ rasterized_text: {
379
+ type: 'array',
380
+ items: { type: 'string' },
381
+ description: 'PSD only: text layers written as pixels.',
382
+ },
383
+ may_blend_differently: {
384
+ type: 'array',
385
+ items: { type: 'string' },
386
+ description: 'PSD only: visible layers below 100% opacity or with a non-normal blend mode.',
387
+ },
306
388
  },
307
389
  },
308
390
  annotations: {
@@ -8,6 +8,9 @@ const EFFECT_TYPES = [
8
8
  'lens_blur',
9
9
  'add_noise',
10
10
  'drop_shadow',
11
+ 'outline',
12
+ 'outer_glow',
13
+ 'surface_blur',
11
14
  ];
12
15
  const effectSchema = {
13
16
  type: 'object',
@@ -41,7 +44,7 @@ const effectSchema = {
41
44
  layer_id: GIMP_LAYER_ID_PROP,
42
45
  mask: {
43
46
  type: 'string',
44
- description: 'Name of a mask channel from gimp_create_mask, confining a NEW filter to it. Only valid ' +
47
+ description: 'Name of a saved selection channel, confining a NEW filter to it. Only valid ' +
45
48
  "when creating (no filter_id) — a filter's mask is fixed at creation. ORDER MATTERS: " +
46
49
  'straighten / flip / resize the canvas first (gimp_transform_canvas, gimp_resize_image), ' +
47
50
  'then crop, THEN add masked effects — rotate, flip, and resize all refuse outright once ' +
@@ -67,8 +70,11 @@ const effectSchema = {
67
70
  '(default 25 when creating) — capped much lower than the other two effects here (measured ' +
68
71
  'live: expensive at full resolution). drop_shadow: blur radius in pixels at full ' +
69
72
  'resolution, 0..1500 (default 10 when creating), clipped to the layer bounds rather than ' +
70
- 'growing it. Both lens_blur and drop_shadow are SPATIAL (radius-scaled on the proxy ' +
71
- 'preview, see gimp_overview). The bridge enforces the precise range for whichever ' +
73
+ 'growing it. outer_glow: glow blur in pixels, 1..250 (default 20 when creating). ' +
74
+ 'surface_blur: smoothing radius in pixels, 1..6 (default 4 when creating) — capped low ' +
75
+ 'because its cost grows with the square of the radius (a 24 MP export takes ~17 s at 6). ' +
76
+ 'lens_blur, drop_shadow, outer_glow and surface_blur are SPATIAL (radius-scaled on the ' +
77
+ 'proxy preview, see gimp_overview). The bridge enforces the precise range for whichever ' +
72
78
  '`type` you gave.',
73
79
  },
74
80
  softness: {
@@ -189,8 +195,38 @@ const effectSchema = {
189
195
  type: 'number',
190
196
  minimum: 0,
191
197
  maximum: 1,
192
- description: 'drop_shadow only. Default (when creating): 0.5. The shadow color is black and not ' +
193
- 'configurable yet.',
198
+ description: 'drop_shadow: default (when creating) 0.5. The shadow color is black and not ' +
199
+ 'configurable yet. outer_glow: default 0.75 (its color is the `color` field).',
200
+ },
201
+ width: {
202
+ type: 'number',
203
+ minimum: 1,
204
+ maximum: 50,
205
+ description: 'outline only. Stroke thickness in pixels at full resolution, 1..50. Default (when ' +
206
+ "creating): 4. SPATIAL. The stroke grows outward from the layer's alpha edge (round " +
207
+ "corners) and the layer's own pixels are untouched.",
208
+ },
209
+ spread: {
210
+ type: 'number',
211
+ minimum: 0,
212
+ maximum: 50,
213
+ description: "outer_glow only. Pixels the glow's solid core grows past the alpha edge before it " +
214
+ 'blurs, 0..50. Default (when creating): 4. SPATIAL.',
215
+ },
216
+ color: {
217
+ type: 'string',
218
+ pattern: '^#[0-9a-fA-F]{6}$',
219
+ description: 'outline and outer_glow only. "#rrggbb" hex. Default (when creating): "#000000" for ' +
220
+ 'outline, "#ffffff" for outer_glow.',
221
+ },
222
+ edge_preservation: {
223
+ type: 'number',
224
+ minimum: 0,
225
+ maximum: 1,
226
+ description: 'surface_blur only. How much edge contrast survives, 0..1: LOWER smooths harder (near 0 ' +
227
+ 'starts to soften the edges too), HIGHER leaves more detail and noise. Default (when ' +
228
+ 'creating): 0.08, which flattens fine noise and skin texture while keeping hard edges ' +
229
+ 'sharp.',
194
230
  },
195
231
  },
196
232
  required: ['image', 'type'],
@@ -222,7 +258,7 @@ export function createGimpEffectTools(gimp) {
222
258
  name: 'gimp_add_effect',
223
259
  description: 'Headless GIMP: append (or, with filter_id, re-edit in place) one of a small ' +
224
260
  'allow-listed set of GEGL effect filters — vignette, black_white, motion_blur, ' +
225
- 'lens_blur, add_noise, drop_shadow — the same non-destructive, re-editable, maskable ' +
261
+ 'lens_blur, add_noise, drop_shadow, outline, outer_glow, surface_blur — the same non-destructive, re-editable, maskable ' +
226
262
  "filter mechanism gimp_add_adjustment uses, sharing the SAME stack: gimp_filter's " +
227
263
  "op=list/set_visibility/delete manage this tool's filters exactly as it manages " +
228
264
  "gimp_add_adjustment's. `type` picks the effect; a field another effect uses is " +
@@ -230,16 +266,26 @@ export function createGimpEffectTools(gimp) {
230
266
  'or `layer`, else the selected or top layer), not to the flattened image. A re-edit ' +
231
267
  "(filter_id) MERGES: any field you omit keeps the filter's existing value. Only " +
232
268
  'filters Editmamei created can be re-edited; one added in the GIMP GUI is refused ' +
233
- '(delete and re-create it). `mask` (a channel name from gimp_create_mask) confines a ' +
269
+ '(delete and re-create it). `mask` (a saved selection channel name) confines a ' +
234
270
  'NEW filter — fixed at creation, cannot change on a re-edit. ORDER MATTERS: straighten ' +
235
271
  '/ flip / resize the canvas first, then crop, THEN add any masked effect — rotate, ' +
236
272
  'flip, and resize all refuse outright once a masked filter exists, or any filter not ' +
237
273
  'created by Editmamei. vignette, motion_blur, and drop_shadow stay locked to the ' +
238
274
  'content through flip, resize, and an exact 90/180/270-degree rotate (their center/' +
239
275
  'angle/offset fields are updated automatically, and gimp_filter op=list reflects the ' +
240
- 'new values afterward) — rotating by any OTHER angle is refused while one of these ' +
241
- 'three is present, rather than letting it drift out of alignment. lens_blur is a ' +
242
- 'uniform soft-focus blur, not a depth-of-field falloff. For a plain blur with no bokeh ' +
276
+ 'new values afterward) — rotating by any OTHER angle is refused while a vignette, a ' +
277
+ 'motion_blur, or a drop_shadow with a non-zero offset is present, rather than letting ' +
278
+ 'it drift out of alignment. outline, outer_glow, surface_blur, lens_blur and a ' +
279
+ 'zero-offset drop_shadow have no direction, so they survive any rotation. Resizing ' +
280
+ 'scales their pixel sizes with the image (never below the 1 px minimum of outline ' +
281
+ 'width, outer_glow radius and surface_blur radius). lens_blur is a ' +
282
+ 'uniform soft-focus blur, not a depth-of-field falloff. outline puts a solid stroke ' +
283
+ "around a layer's transparent edge (meme text, sticker cut-outs); outer_glow adds a " +
284
+ 'soft glow around it (glowing text); surface_blur smooths skin and other texture ' +
285
+ 'while keeping hard edges. outline and outer_glow need a layer with transparency, ' +
286
+ 'and attach to a text layer without rasterizing it: the text stays editable and the ' +
287
+ 'effect follows each edit. For a plain ' +
288
+ 'blur with no bokeh ' +
243
289
  'highlight boost, use gimp_add_adjustment type=gaussian_blur instead. Check the stack ' +
244
290
  'afterward with gimp_filter (op=list).',
245
291
  inputSchema: effectSchema,