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.
Binary file
Binary file
@@ -66,6 +66,14 @@ export const FIRST_RUN_DISCLOSURE = 'First run: Editmamei collects content-free
66
66
  '`editmamei config set telemetry.usage false` (or edit ~/.editmamei/settings.json). ' +
67
67
  'Opt in to sanitized diagnostics: `editmamei config set telemetry.diagnostics true`.';
68
68
  export const BACKGROUND_VERSION_PROBE_TIMEOUT_MS = 3_000;
69
+ const PING_ROUND_TRIP_CLASSES = new Set([
70
+ 'timeout',
71
+ 'ps_modal_blocking',
72
+ 'ps_not_running',
73
+ 'ps_general_error',
74
+ 'file_io',
75
+ ]);
76
+ const PHOTOSHOP_PATH_DOCS_URL = 'https://github.com/editmamei/editmamei/blob/main/docs/installation.md#optional-pin-a-specific-photoshop-install';
69
77
  export class EditmameiServer {
70
78
  server;
71
79
  logger;
@@ -83,6 +91,7 @@ export class EditmameiServer {
83
91
  }
84
92
  psVersion = null;
85
93
  lastPingReachedPs = null;
94
+ lastPingFailure = null;
86
95
  lastTelemetryCallKeyHash = null;
87
96
  lastPsLocale = null;
88
97
  lastDocDepth = null;
@@ -169,8 +178,13 @@ export class EditmameiServer {
169
178
  });
170
179
  const telemetrySuccess = entry.tool === 'ps_ping' && this.lastPingReachedPs === false ? false : entry.success;
171
180
  const isPingDowngrade = entry.tool === 'ps_ping' && !telemetrySuccess && entry.error === undefined;
181
+ const pingFailure = isPingDowngrade ? this.lastPingFailure : null;
172
182
  const errorClass = classifyError(entry.error) ??
173
- (telemetrySuccess ? null : isPingDowngrade ? 'ps_not_running' : NO_ERROR_TEXT_CLASS);
183
+ (telemetrySuccess
184
+ ? null
185
+ : isPingDowngrade
186
+ ? (pingFailure?.errorClass ?? 'ps_not_running')
187
+ : NO_ERROR_TEXT_CLASS);
174
188
  const telemetryCallKeyHash = hashRetryKey(entry.tool, entry.args);
175
189
  const isTelemetryRetry = telemetryCallKeyHash !== null && telemetryCallKeyHash === this.lastTelemetryCallKeyHash;
176
190
  if (telemetryCallKeyHash !== null)
@@ -189,7 +203,7 @@ export class EditmameiServer {
189
203
  error_class: errorClass ?? NO_ERROR_TEXT_CLASS,
190
204
  error_message: entry.error ||
191
205
  (isPingDowngrade
192
- ? 'ps_ping did not reach Photoshop'
206
+ ? (pingFailure?.message ?? 'ps_ping did not reach Photoshop')
193
207
  : 'tool reported failure with no message'),
194
208
  ...(this.lastPsLocale !== null ? { ps_locale: this.lastPsLocale } : {}),
195
209
  ...(this.lastDocDepth !== null ? { doc_depth: this.lastDocDepth } : {}),
@@ -493,8 +507,15 @@ export class EditmameiServer {
493
507
  }
494
508
  })();
495
509
  }
510
+ markPingUnreachable(errorClass, message) {
511
+ this.lastPingReachedPs = false;
512
+ this.lastPingFailure = { errorClass, message };
513
+ this.lastDocDepth = null;
514
+ this.lastDocMode = null;
515
+ }
496
516
  async pingPhotoshop() {
497
517
  this.lastPingReachedPs = null;
518
+ this.lastPingFailure = null;
498
519
  this.refreshLicenseOnPing();
499
520
  if (this.updateCheck)
500
521
  await this.updateCheck;
@@ -515,17 +536,41 @@ export class EditmameiServer {
515
536
  const degraded = [];
516
537
  let versionIsLive = false;
517
538
  let pingStateObserved = false;
539
+ let detectError = null;
518
540
  try {
519
541
  const detected = await connection.getVersion();
520
542
  if (detected)
521
543
  version = detected;
522
544
  }
523
- catch {
545
+ catch (err) {
546
+ detectError = err instanceof Error ? err.message : String(err);
547
+ }
548
+ if (connection.getPhotoshopInfo() === null && unsupportedHostReason() === null) {
549
+ this.markPingUnreachable('ps_not_detected', detectError ?? 'No Photoshop install was detected');
550
+ return {
551
+ content: [
552
+ {
553
+ type: 'text',
554
+ text: withAdvisory('Editmamei could not find a Photoshop install on this computer. Photoshop must be ' +
555
+ 'installed on the same computer as Editmamei. ' +
556
+ (process.env.PHOTOSHOP_PATH?.trim()
557
+ ? 'PHOTOSHOP_PATH is set, but does not point at a Photoshop install: check it '
558
+ : 'If it is installed in a custom location, set PHOTOSHOP_PATH to its full path ') +
559
+ "in your MCP client's Editmamei config (see " +
560
+ PHOTOSHOP_PATH_DOCS_URL +
561
+ '), restart the client, then call ps_ping again.' +
562
+ update.note),
563
+ },
564
+ ],
565
+ structuredContent: {
566
+ connected: false,
567
+ update_available: this.updateInfo,
568
+ notify_user: update.notify,
569
+ },
570
+ };
524
571
  }
525
572
  if (connection.getPhotoshopInfo() !== null && !(await connection.isCurrentlyRunning())) {
526
- this.lastPingReachedPs = false;
527
- this.lastDocDepth = null;
528
- this.lastDocMode = null;
573
+ this.markPingUnreachable('ps_not_running', 'Photoshop is installed but no Photoshop process is running');
529
574
  return {
530
575
  content: [
531
576
  {
@@ -550,9 +595,7 @@ export class EditmameiServer {
550
595
  const alive = await connection.ping().catch(() => false);
551
596
  if (!alive) {
552
597
  const reason = unsupportedHostReason();
553
- this.lastPingReachedPs = false;
554
- this.lastDocDepth = null;
555
- this.lastDocMode = null;
598
+ this.markPingUnreachable('ps_not_running', reason ?? 'pingState snippet build failed and the fallback ping did not reach Photoshop');
556
599
  return {
557
600
  content: [
558
601
  {
@@ -575,10 +618,12 @@ export class EditmameiServer {
575
618
  state = (await runScript(connection, pingStateSnippet));
576
619
  }
577
620
  catch (err) {
578
- this.logger.warn(`pingState snippet failed: ${err instanceof Error ? err.message : String(err)}`);
579
- this.lastPingReachedPs = false;
580
- this.lastDocDepth = null;
581
- this.lastDocMode = null;
621
+ const message = err instanceof Error ? err.message : String(err);
622
+ this.logger.warn(`pingState snippet failed: ${message}`);
623
+ const errorClass = classifyError(message);
624
+ this.markPingUnreachable(errorClass !== null && PING_ROUND_TRIP_CLASSES.has(errorClass)
625
+ ? errorClass
626
+ : 'ps_not_running', message);
582
627
  return {
583
628
  content: [
584
629
  {
@@ -129,6 +129,8 @@ export const MUTATING_TOOLS = new Set([
129
129
  'gimp_layer_mask',
130
130
  'gimp_transform_layer',
131
131
  'gimp_match_layer',
132
+ 'gimp_retouch',
133
+ 'gimp_fill',
132
134
  ]);
133
135
  export const RAW_DEVELOP_TOOL = 'ps_develop_raw';
134
136
  export const CAMERA_RAW_TOOL = 'ps_apply_camera_raw';
@@ -201,6 +201,8 @@ export const TOOL_GROUPS = {
201
201
  gimp_layer_mask: 'masks',
202
202
  gimp_get_selection_preview: 'verify',
203
203
  gimp_match_layer: 'layers',
204
+ gimp_retouch: 'layers',
205
+ gimp_fill: 'layers',
204
206
  };
205
207
  export function groupOf(toolName) {
206
208
  const group = TOOL_GROUPS[toolName];
@@ -115,10 +115,12 @@ export const TOOL_TIERS = {
115
115
  gimp_text: 'community',
116
116
  gimp_convert_image_mode: 'community',
117
117
  gimp_select: 'community',
118
- gimp_modify_selection: 'dev',
118
+ gimp_modify_selection: 'community',
119
119
  gimp_layer_mask: 'community',
120
120
  gimp_get_selection_preview: 'community',
121
121
  gimp_match_layer: 'dev',
122
+ gimp_retouch: 'dev',
123
+ gimp_fill: 'dev',
122
124
  };
123
125
  export function tierOf(toolName) {
124
126
  const tier = TOOL_TIERS[toolName];
@@ -157,6 +157,9 @@ export class DeliveryClient {
157
157
  if (status === 404) {
158
158
  return new DeliveryError('Requested module/version was not found.', 404, 'not_found');
159
159
  }
160
+ if (status === 451) {
161
+ return new DeliveryError("Editmamei Pro downloads aren't available in your region.", 451, 'unavailable_in_region');
162
+ }
160
163
  return new DeliveryError(`Delivery request failed (HTTP ${status}).`, status, 'server', retryAfterMs);
161
164
  }
162
165
  }
@@ -16,6 +16,8 @@ import { createGimpComposeTools } from '../../tools/gimp-compose-tools.js';
16
16
  import { createGimpTextTools } from '../../tools/gimp-text-tools.js';
17
17
  import { createGimpSelectionTools } from '../../tools/gimp-selection-tools.js';
18
18
  import { createGimpMatchTools } from '../../tools/gimp-match-tools.js';
19
+ import { createGimpRetouchTools } from '../../tools/gimp-retouch-tools.js';
20
+ import { createGimpFillTools } from '../../tools/gimp-fill-tools.js';
19
21
  export const gimpFactories = [
20
22
  createGimpCoreTools,
21
23
  createGimpDocumentTools,
@@ -32,6 +34,8 @@ export const gimpFactories = [
32
34
  createGimpTextTools,
33
35
  createGimpSelectionTools,
34
36
  createGimpMatchTools,
37
+ createGimpRetouchTools,
38
+ createGimpFillTools,
35
39
  ];
36
40
  export const gimpModule = {
37
41
  manifest: { id: 'gimp', name: 'Editmamei GIMP Tools', abi: KERNEL_ABI },
Binary file
@@ -1,6 +1,7 @@
1
+ import { statSync } from 'node:fs';
1
2
  import { runScript } from '../utils/run-script.js';
2
3
  import { validateArgs } from '../utils/validate.js';
3
- import { OPEN_DOCUMENT_TIMEOUT_MS, OPEN_DOCUMENT_REPROBE_TIMEOUT_MS, } from '../utils/operation-timeouts.js';
4
+ import { OPEN_DOCUMENT_TIMEOUT_MS, OPEN_DOCUMENT_REPROBE_TIMEOUT_MS, SAVE_PSD_REPROBE_TIMEOUT_MS, getToolTimeoutMs, } from '../utils/operation-timeouts.js';
4
5
  import { toolErrorResult, runSnippetTool } from '../utils/tool-helpers.js';
5
6
  import { checkOutputPath, recordOutputPath, requireLocalPath } from '../utils/local-path.js';
6
7
  import { purgeSceneChannels } from '../perception/region-precompute.js';
@@ -349,6 +350,15 @@ export function createDocumentTools(connection, snippetClient) {
349
350
  saved_to: { type: 'string' },
350
351
  document_name: { type: 'string' },
351
352
  layers: { type: 'number' },
353
+ reprobed: {
354
+ type: 'boolean',
355
+ description: 'Present when the save outran its time budget and a follow-up check confirmed the file was written. document_name, layers and context are absent then.',
356
+ },
357
+ bytes: {
358
+ type: 'number',
359
+ description: 'Size of the written file. Present only with reprobed.',
360
+ },
361
+ scene_channels_purged: { type: 'number' },
352
362
  context: { type: 'object' },
353
363
  },
354
364
  required: ['success'],
@@ -601,6 +611,40 @@ async function openDocumentPipeline(connection, snippetClient, rawArgs) {
601
611
  return toolErrorResult('Error opening document', error);
602
612
  }
603
613
  }
614
+ async function reprobeSavedPsd(connection, outputPath, startedAt) {
615
+ try {
616
+ await runScript(connection, 'return { ready: true };', SAVE_PSD_REPROBE_TIMEOUT_MS);
617
+ }
618
+ catch {
619
+ return null;
620
+ }
621
+ let size;
622
+ let modifiedMs;
623
+ try {
624
+ const stats = statSync(outputPath);
625
+ if (!stats.isFile())
626
+ return null;
627
+ size = stats.size;
628
+ modifiedMs = stats.mtimeMs;
629
+ }
630
+ catch {
631
+ return null;
632
+ }
633
+ if (size === 0 || modifiedMs < startedAt - 2_000)
634
+ return null;
635
+ const payload = { success: true, saved_to: outputPath, bytes: size, reprobed: true };
636
+ return {
637
+ content: [
638
+ {
639
+ type: 'text',
640
+ text: `PSD saved (the save ran past the ${getToolTimeoutMs('ps_save_psd')}ms budget and was reported as a ` +
641
+ `timeout, but a post-timeout check confirmed the file was written):\n` +
642
+ JSON.stringify(payload, null, 2),
643
+ },
644
+ ],
645
+ structuredContent: payload,
646
+ };
647
+ }
604
648
  async function savePsdPipeline(connection, snippetClient, rawArgs) {
605
649
  let args;
606
650
  let outputPath;
@@ -624,19 +668,31 @@ async function savePsdPipeline(connection, snippetClient, rawArgs) {
624
668
  `${err instanceof Error ? err.message : String(err)}`);
625
669
  }
626
670
  }
627
- const res = await runSnippetTool({
628
- connection,
629
- snippetClient,
630
- rawArgs,
631
- schema: savePsdSchema,
632
- snippet: 'savePsdAsCopy',
633
- errorPrefix: 'Error saving PSD',
634
- params: (args) => ({
671
+ const startedAt = Date.now();
672
+ let res;
673
+ try {
674
+ const script = await snippetClient.build('savePsdAsCopy', {
635
675
  outputPath,
636
676
  maximizeCompat: args.maximize_compatibility,
637
- }),
638
- successText: (result) => `PSD saved:\n${JSON.stringify(result, null, 2)}`,
639
- });
677
+ });
678
+ const result = await runScript(connection, script, getToolTimeoutMs('ps_save_psd'));
679
+ res = {
680
+ content: [{ type: 'text', text: `PSD saved:\n${JSON.stringify(result, null, 2)}` }],
681
+ structuredContent: result,
682
+ };
683
+ }
684
+ catch (error) {
685
+ const message = error instanceof Error ? error.message : String(error);
686
+ if (isScriptTimeoutError(message)) {
687
+ res =
688
+ (await reprobeSavedPsd(connection, outputPath, startedAt)) ??
689
+ toolErrorResult('Error saving PSD', new Error(`${message} Photoshop may still be writing the file: check whether ${outputPath} ` +
690
+ 'was just modified before saving again.'));
691
+ }
692
+ else {
693
+ res = toolErrorResult('Error saving PSD', error);
694
+ }
695
+ }
640
696
  if (!res.isError)
641
697
  recordOutputPath(outputPath);
642
698
  if (!res.isError && typeof res.structuredContent === 'object' && res.structuredContent !== null) {
@@ -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',
@@ -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
 
@@ -66,7 +66,7 @@ const saveXcfSchema = {
66
66
  type: 'string',
67
67
  description: 'Absolute path ending in .xcf. Saves the LIVE GIMP document — every filter stays in ' +
68
68
  "place and re-editable afterward (matches ps_save_psd's framing for .psd). For a " +
69
- 'flattened deliverable, use gimp_export instead.',
69
+ 'deliverable (flattened, or a layered .psd), use gimp_export instead.',
70
70
  },
71
71
  overwrite: {
72
72
  type: 'boolean',
@@ -81,7 +81,7 @@ async function gimpSaveXcf(gimp, rawArgs) {
81
81
  const args = validateArgs(saveXcfSchema, rawArgs);
82
82
  const filePath = requireAbsoluteGimpPath('file_path', args.file_path);
83
83
  if (extname(filePath).toLowerCase() !== '.xcf') {
84
- 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.`);
85
85
  }
86
86
  refuseExistingGimpFile(filePath, args.overwrite, existsSync);
87
87
  const result = await gimp.call('export', {
@@ -102,16 +102,22 @@ async function gimpSaveXcf(gimp, rawArgs) {
102
102
  return toolGimpErrorResult('Error saving GIMP document', error);
103
103
  }
104
104
  }
105
- 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
+ ];
106
111
  const exportSchema = {
107
112
  type: 'object',
108
113
  properties: {
109
114
  image: GIMP_IMAGE_PROP,
110
115
  file_path: {
111
116
  type: 'string',
112
- description: 'Absolute path. The extension picks the format: jpg/jpeg, png, webp, tif/tiff. Refuses ' +
113
- ".xcf outright — that is gimp_save_xcf's job. Metadata (EXIF/XMP/IPTC/GPS, thumbnail) is " +
114
- '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.',
115
121
  },
116
122
  overwrite: {
117
123
  type: 'boolean',
@@ -151,23 +157,59 @@ const exportSchema = {
151
157
  type: 'integer',
152
158
  enum: [8, 16],
153
159
  default: 8,
154
- 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.',
155
168
  },
156
169
  },
157
170
  required: ['image', 'file_path'],
158
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
+ }
159
197
  async function gimpExport(gimp, rawArgs) {
160
198
  try {
161
199
  const args = validateArgs(exportSchema, rawArgs);
162
200
  const filePath = requireAbsoluteGimpPath('file_path', args.file_path);
163
201
  const ext = extname(filePath).toLowerCase();
164
202
  if (ext === '.xcf') {
165
- 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 ' +
166
204
  'gimp_save_xcf to save the live, re-editable document.');
167
205
  }
168
206
  if (!EXPORT_FORMAT_EXTENSIONS.includes(ext)) {
169
207
  throw new GimpError('invalid_argument', `"file_path" must end in ${EXPORT_FORMAT_EXTENSIONS.join(', ')}, got "${ext || '(none)'}".`);
170
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
+ }
171
213
  refuseExistingGimpFile(filePath, args.overwrite, existsSync);
172
214
  const bridgeArgs = { image: args.image, path: filePath };
173
215
  if (ext === '.jpg' || ext === '.jpeg') {
@@ -185,8 +227,20 @@ async function gimpExport(gimp, rawArgs) {
185
227
  bridgeArgs.compression = args.tiff_compression;
186
228
  bridgeArgs.bit_depth = args.bit_depth;
187
229
  }
188
- 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;
189
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);
190
244
  return {
191
245
  content: [
192
246
  {
@@ -243,7 +297,7 @@ export function createGimpDocumentTools(gimp) {
243
297
  tool: {
244
298
  name: 'gimp_close_document',
245
299
  description: 'Headless GIMP: close an image WITHOUT saving. Save first with gimp_save_xcf (keeps filters live) or ' +
246
- 'gimp_export (flattened deliverable) if the work should be kept.',
300
+ 'gimp_export (deliverable) if the work should be kept.',
247
301
  inputSchema: closeSchema,
248
302
  outputSchema: { type: 'object', properties: { closed: { type: 'number' } } },
249
303
  annotations: {
@@ -274,7 +328,8 @@ export function createGimpDocumentTools(gimp) {
274
328
  'to overwrite an existing file unless overwrite: true. Save early: on a very large ' +
275
329
  'document a save can take tens of seconds, and if any call times out the GIMP session ' +
276
330
  'restarts and every unsaved open image and filter is lost — save before long operations, ' +
277
- '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.',
278
333
  inputSchema: saveXcfSchema,
279
334
  outputSchema: {
280
335
  type: 'object',
@@ -293,12 +348,20 @@ export function createGimpDocumentTools(gimp) {
293
348
  {
294
349
  tool: {
295
350
  name: 'gimp_export',
296
- description: 'Headless GIMP: write a FLATTENED deliverable to disk — every live filter is baked in, ' +
297
- 'and ALL metadata (EXIF/XMP/IPTC/GPS, thumbnail) is stripped unconditionally, never ' +
298
- 'carried over from the source. Format is picked from the file_path extension (jpg/jpeg, ' +
299
- 'png, webp, tif/tiff); any other extension is refused, and .xcf is refused outright — ' +
300
- 'use gimp_save_xcf for the live, re-editable document instead. Refuses to overwrite an ' +
301
- '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.',
302
365
  inputSchema: exportSchema,
303
366
  outputSchema: {
304
367
  type: 'object',
@@ -306,6 +369,22 @@ export function createGimpDocumentTools(gimp) {
306
369
  path: { type: 'string' },
307
370
  bytes: { type: 'number' },
308
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
+ },
309
388
  },
310
389
  },
311
390
  annotations: {