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.
- package/README.md +2 -2
- package/dist/backends/gimp/bridge/lib.py +957 -22
- package/dist/backends/gimp/bridge/ops.py +1074 -62
- 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/core/server.js +58 -13
- package/dist/core/tool-activity.js +2 -0
- package/dist/core/tool-groups.js +2 -0
- package/dist/core/tool-tiers.js +3 -1
- package/dist/delivery/client.js +3 -0
- package/dist/modules/gimp/index.js +4 -0
- package/dist/skills/editmamei-skill.zip +0 -0
- package/dist/tools/document-tools.js +68 -12
- package/dist/tools/gimp-adjustment-tools.js +4 -3
- package/dist/tools/gimp-checkpoint-tools.js +7 -1
- package/dist/tools/gimp-core-tools.js +3 -3
- package/dist/tools/gimp-document-tools.js +96 -17
- package/dist/tools/gimp-effect-tools.js +54 -8
- package/dist/tools/gimp-fill-tools.js +256 -0
- package/dist/tools/gimp-filter-tools.js +4 -1
- package/dist/tools/gimp-inspect-tools.js +13 -5
- package/dist/tools/gimp-layer-tools.js +2 -0
- package/dist/tools/gimp-result-text.js +104 -0
- package/dist/tools/gimp-retouch-tools.js +238 -0
- package/dist/tools/gimp-text-tools.js +67 -7
- package/dist/tools/gimp-verify-tools.js +11 -2
- package/dist/utils/operation-timeouts.js +6 -2
- package/dist/version.js +1 -1
- package/package.json +4 -4
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/dist/core/server.js
CHANGED
|
@@ -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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
579
|
-
this.
|
|
580
|
-
|
|
581
|
-
this.
|
|
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';
|
package/dist/core/tool-groups.js
CHANGED
|
@@ -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];
|
package/dist/core/tool-tiers.js
CHANGED
|
@@ -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: '
|
|
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];
|
package/dist/delivery/client.js
CHANGED
|
@@ -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
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
'
|
|
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
|
|
94
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
113
|
-
".xcf outright — that is gimp_save_xcf's job. Metadata
|
|
114
|
-
'ALWAYS stripped on export, unconditionally — never
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
|
297
|
-
'
|
|
298
|
-
'
|
|
299
|
-
'
|
|
300
|
-
'
|
|
301
|
-
'
|
|
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: {
|