editmamei 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/api/extendscript/_helpers.js +9 -3
- package/dist/api/photoshop-api.js +16 -1
- 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 +83 -15
- package/dist/core/tool-groups.js +4 -9
- package/dist/core/tool-tiers.js +3 -8
- package/dist/modules/ce/index.js +2 -0
- package/dist/skills/editmamei-skill.zip +0 -0
- package/dist/tools/filter-tools.js +0 -14
- package/dist/tools/group-tools.js +1 -119
- package/dist/tools/layer-tools.js +1 -24
- package/dist/tools/selection-tools.js +72 -1
- package/dist/tools/sky-tools.js +126 -0
- package/dist/tools/text-tools.js +0 -69
- package/dist/update/check.js +65 -6
- package/dist/update/session-fixes.js +33 -0
- package/dist/utils/jsx.js +2 -1
- package/dist/utils/operation-timeouts.js +2 -0
- package/dist/utils/session-log.js +4 -0
- package/dist/version.js +1 -1
- package/package.json +12 -1
package/README.md
CHANGED
|
@@ -75,7 +75,7 @@ These are the choices that shape the surface, and the reason an AI assistant can
|
|
|
75
75
|
|
|
76
76
|
## Tool surface
|
|
77
77
|
|
|
78
|
-
**
|
|
78
|
+
**79 tools across 16 capability groups** (59 Community, 20 Pro). Every tool is namespaced `ps_*` and discoverable at runtime via `tools/list`. Community tools ship in both editions; Pro tools unlock with a license.
|
|
79
79
|
|
|
80
80
|
| Group | Edition | Tools |
|
|
81
81
|
| --- | --- | --- |
|
|
@@ -94,11 +94,11 @@ These are the choices that shape the surface, and the reason an AI assistant can
|
|
|
94
94
|
| **AI selection** | Community | `select_subject` · `select_sky` |
|
|
95
95
|
| **AI selection** | Pro | `select_subject_instance` · `select_object` |
|
|
96
96
|
| **Filters** | Pro | `apply_camera_raw` |
|
|
97
|
-
| **Layers (warp)** | Pro | `warp_layer`
|
|
97
|
+
| **Layers (warp)** | Pro | `warp_layer` |
|
|
98
98
|
| **Perception** | Pro | `edit_object` · `add_text_to_object` · `resolve_placement` |
|
|
99
99
|
| **Face mesh** | Pro | `detect_landmarks` · `select_face_feature` |
|
|
100
100
|
| **Templates** | Pro | `template_create_evidence` · `template_save` · `template_list` · `template_apply` · `template_verify` · `template_recall` · `template_delete` |
|
|
101
|
-
| **Automation** | Pro | `list_actions` · `play_action` · `execute_script` |
|
|
101
|
+
| **Automation** | Pro | `list_actions` · `play_action` · `execute_script` · `batch` |
|
|
102
102
|
|
|
103
103
|
## Editions
|
|
104
104
|
|
|
@@ -27,12 +27,13 @@ function __notFoundMessage(label, requested, groupsOnly) {
|
|
|
27
27
|
var out = '';
|
|
28
28
|
for (var c = 0; c < s.length; c++) {
|
|
29
29
|
var code = s.charCodeAt(c);
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
} else {
|
|
30
|
+
// Control characters and backslash only — see the doc comment above.
|
|
31
|
+
if (code < 32 || code === 127 || code === 92) {
|
|
33
32
|
var hex = code.toString(16);
|
|
34
33
|
while (hex.length < 4) hex = '0' + hex;
|
|
35
34
|
out += '\\\\u' + hex;
|
|
35
|
+
} else {
|
|
36
|
+
out += s.charAt(c);
|
|
36
37
|
}
|
|
37
38
|
}
|
|
38
39
|
return out;
|
|
@@ -47,6 +48,11 @@ function __notFoundMessage(label, requested, groupsOnly) {
|
|
|
47
48
|
nm = nm.substring(0, 40);
|
|
48
49
|
var cut = nm.lastIndexOf('\\\\u');
|
|
49
50
|
if (cut > 34) nm = nm.substring(0, cut);
|
|
51
|
+
// Non-ASCII is no longer escaped, so the cut counts raw UTF-16 units and
|
|
52
|
+
// can land between the halves of a surrogate pair. Drop a trailing high
|
|
53
|
+
// surrogate rather than emit a lone one.
|
|
54
|
+
var lastCode = nm.length > 0 ? nm.charCodeAt(nm.length - 1) : 0;
|
|
55
|
+
if (lastCode >= 0xD800 && lastCode <= 0xDBFF) nm = nm.substring(0, nm.length - 1);
|
|
50
56
|
nm = nm + '...';
|
|
51
57
|
}
|
|
52
58
|
kept.push(nm);
|
|
@@ -55,6 +55,21 @@ class ExtendScriptPhotoshopAPI {
|
|
|
55
55
|
// numbers, booleans, null/undefined, arrays, and plain objects. Cycles
|
|
56
56
|
// and very deep nesting are capped via __depth to avoid stack overflow
|
|
57
57
|
// on pathological inputs.
|
|
58
|
+
//
|
|
59
|
+
// Everything outside printable ASCII is escaped as a JSON u-escape, not
|
|
60
|
+
// just the control range. The Windows cscript stdout transport is
|
|
61
|
+
// codepage-bound and flattens raw non-ASCII to '?' (measured live,
|
|
62
|
+
// PS 27.2.0), so a layer Photoshop named itself in a non-English UI —
|
|
63
|
+
// 'Farbfuellung 1' spelled with the u-umlaut — used to reach the caller as
|
|
64
|
+
// 'Farbf?llung 1'. Naming that layer back at us then missed, because '?'
|
|
65
|
+
// is not what the layer is called. The escape is valid JSON, survives the
|
|
66
|
+
// transport, and JSON.parse restores the exact character, so the round trip
|
|
67
|
+
// is lossless rather than merely legible. Every outcome the script itself
|
|
68
|
+
// produces routes through here, errors included, so this covers messages as
|
|
69
|
+
// well as values. It does NOT cover the Windows shim's own COM failures
|
|
70
|
+
// (windows-runner.ts echoes Err.Description straight to stdout), nor raw
|
|
71
|
+
// non-ASCII sitting in a snippet's own source text — that arrives by a
|
|
72
|
+
// different path and is not addressed here.
|
|
58
73
|
function __mcpJsonEncode(v, __depth) {
|
|
59
74
|
if (__depth === undefined) __depth = 0;
|
|
60
75
|
if (__depth > 32) return '"<<truncated:max-depth>>"';
|
|
@@ -72,7 +87,7 @@ class ExtendScriptPhotoshopAPI {
|
|
|
72
87
|
else if (c === '\\t') s += '\\\\t';
|
|
73
88
|
else if (c === '\\b') s += '\\\\b';
|
|
74
89
|
else if (c === '\\f') s += '\\\\f';
|
|
75
|
-
else if (cc < 32) {
|
|
90
|
+
else if (cc < 32 || cc > 126) {
|
|
76
91
|
var hex = cc.toString(16);
|
|
77
92
|
while (hex.length < 4) hex = '0' + hex;
|
|
78
93
|
s += '\\\\u' + hex;
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/dist/core/server.js
CHANGED
|
@@ -13,6 +13,7 @@ import { loadSettings, applyTelemetryEnvOverrides, applyUpdateCheckEnvOverride,
|
|
|
13
13
|
import { TelemetryClient } from '../telemetry/client.js';
|
|
14
14
|
import { resolveInstallChannel } from '../install-channel.js';
|
|
15
15
|
import { checkForUpdate, shouldCheckForUpdate } from '../update/check.js';
|
|
16
|
+
import { previousSessionFailureCounts, relevantFixes } from '../update/session-fixes.js';
|
|
16
17
|
import { join } from 'node:path';
|
|
17
18
|
import { GoSnippetClient, coreBinaryName } from '../api/snippet-client.js';
|
|
18
19
|
import { isProEntitled } from '../license/entitlement.js';
|
|
@@ -63,6 +64,8 @@ export class EditmameiServer {
|
|
|
63
64
|
lastPingReachedPs = null;
|
|
64
65
|
liveVersionResolutionAttempted = false;
|
|
65
66
|
updateInfo = null;
|
|
67
|
+
updateCheck = null;
|
|
68
|
+
updateNoticeShown = false;
|
|
66
69
|
snippetClient = new GoSnippetClient();
|
|
67
70
|
refreshLicenseOnPing = createPingLicenseRefresher();
|
|
68
71
|
constructor() {
|
|
@@ -82,7 +85,7 @@ export class EditmameiServer {
|
|
|
82
85
|
if (created)
|
|
83
86
|
this.logger.info(FIRST_RUN_DISCLOSURE);
|
|
84
87
|
if (shouldCheckForUpdate(effectiveSettings.update_check)) {
|
|
85
|
-
|
|
88
|
+
this.updateCheck = checkForUpdate().then((info) => {
|
|
86
89
|
this.updateInfo = info;
|
|
87
90
|
});
|
|
88
91
|
}
|
|
@@ -102,18 +105,23 @@ export class EditmameiServer {
|
|
|
102
105
|
...(entry.error ? { error: entry.error } : {}),
|
|
103
106
|
}, entry.result);
|
|
104
107
|
const telemetrySuccess = entry.tool === 'ps_ping' && this.lastPingReachedPs === false ? false : entry.success;
|
|
105
|
-
const
|
|
108
|
+
const isPingDowngrade = entry.tool === 'ps_ping' && !telemetrySuccess && entry.error === undefined;
|
|
109
|
+
const errorClass = classifyError(entry.error) ??
|
|
110
|
+
(telemetrySuccess ? null : isPingDowngrade ? 'ps_not_running' : 'other');
|
|
106
111
|
this.telemetry.recordCall({
|
|
107
112
|
tool: entry.tool,
|
|
108
113
|
success: telemetrySuccess,
|
|
109
114
|
duration_ms: entry.duration_ms,
|
|
110
115
|
error_class: errorClass,
|
|
111
116
|
});
|
|
112
|
-
if (!
|
|
117
|
+
if (!telemetrySuccess) {
|
|
113
118
|
this.telemetry.recordDiagnostic({
|
|
114
119
|
tool: entry.tool,
|
|
115
120
|
error_class: errorClass ?? 'other',
|
|
116
|
-
error_message: entry.error
|
|
121
|
+
error_message: entry.error ||
|
|
122
|
+
(isPingDowngrade
|
|
123
|
+
? 'ps_ping did not reach Photoshop'
|
|
124
|
+
: 'tool reported failure with no message'),
|
|
117
125
|
});
|
|
118
126
|
}
|
|
119
127
|
},
|
|
@@ -155,14 +163,19 @@ export class EditmameiServer {
|
|
|
155
163
|
},
|
|
156
164
|
update_available: {
|
|
157
165
|
type: ['object', 'null'],
|
|
158
|
-
description: 'Set when a newer Editmamei version is published, else null
|
|
166
|
+
description: 'Set when a newer Editmamei version is published, else null: { current, latest, channel (npm/mcpb/dev), how_to_update, fixed_tools (tools whose recorded failures the newer version fixes) }. The relay instruction rides the ping TEXT on the first ping — see notify_user. Anonymous npm-registry check at boot; opt out with `editmamei config set update_check false`.',
|
|
159
167
|
properties: {
|
|
160
168
|
current: { type: 'string' },
|
|
161
169
|
latest: { type: 'string' },
|
|
162
170
|
channel: { type: 'string' },
|
|
163
171
|
how_to_update: { type: 'string' },
|
|
172
|
+
fixed_tools: { type: 'array', items: { type: 'string' } },
|
|
164
173
|
},
|
|
165
174
|
},
|
|
175
|
+
notify_user: {
|
|
176
|
+
type: 'boolean',
|
|
177
|
+
description: 'True on the one ping whose text carries the update notice — relay that notice to the user before continuing. False on later pings and when no update is available.',
|
|
178
|
+
},
|
|
166
179
|
},
|
|
167
180
|
},
|
|
168
181
|
annotations: {
|
|
@@ -346,6 +359,9 @@ export class EditmameiServer {
|
|
|
346
359
|
async pingPhotoshop() {
|
|
347
360
|
this.lastPingReachedPs = null;
|
|
348
361
|
this.refreshLicenseOnPing();
|
|
362
|
+
if (this.updateCheck)
|
|
363
|
+
await this.updateCheck;
|
|
364
|
+
const update = await this.buildUpdateNotice();
|
|
349
365
|
const connection = this.session.getConnection();
|
|
350
366
|
let version = 'Unknown';
|
|
351
367
|
let actionSetsCount = 0;
|
|
@@ -359,6 +375,23 @@ export class EditmameiServer {
|
|
|
359
375
|
}
|
|
360
376
|
catch {
|
|
361
377
|
}
|
|
378
|
+
if (connection.getPhotoshopInfo() !== null && !(await connection.isCurrentlyRunning())) {
|
|
379
|
+
this.lastPingReachedPs = false;
|
|
380
|
+
return {
|
|
381
|
+
content: [
|
|
382
|
+
{
|
|
383
|
+
type: 'text',
|
|
384
|
+
text: 'Photoshop does not appear to be running. Start Photoshop, then call ps_ping again.' +
|
|
385
|
+
update.note,
|
|
386
|
+
},
|
|
387
|
+
],
|
|
388
|
+
structuredContent: {
|
|
389
|
+
connected: false,
|
|
390
|
+
update_available: this.updateInfo,
|
|
391
|
+
notify_user: update.notify,
|
|
392
|
+
},
|
|
393
|
+
};
|
|
394
|
+
}
|
|
362
395
|
let pingStateSnippet = null;
|
|
363
396
|
try {
|
|
364
397
|
pingStateSnippet = await this.snippetClient.build('pingState');
|
|
@@ -373,10 +406,14 @@ export class EditmameiServer {
|
|
|
373
406
|
content: [
|
|
374
407
|
{
|
|
375
408
|
type: 'text',
|
|
376
|
-
text: (reason ?? 'Photoshop did not respond') +
|
|
409
|
+
text: (reason ?? 'Photoshop did not respond') + update.note,
|
|
377
410
|
},
|
|
378
411
|
],
|
|
379
|
-
structuredContent: {
|
|
412
|
+
structuredContent: {
|
|
413
|
+
connected: false,
|
|
414
|
+
update_available: this.updateInfo,
|
|
415
|
+
notify_user: update.notify,
|
|
416
|
+
},
|
|
380
417
|
};
|
|
381
418
|
}
|
|
382
419
|
degraded.push('pingState');
|
|
@@ -390,10 +427,12 @@ export class EditmameiServer {
|
|
|
390
427
|
this.logger.warn(`pingState snippet failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
391
428
|
this.lastPingReachedPs = false;
|
|
392
429
|
return {
|
|
393
|
-
content: [
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
430
|
+
content: [{ type: 'text', text: 'Photoshop did not respond' + update.note }],
|
|
431
|
+
structuredContent: {
|
|
432
|
+
connected: false,
|
|
433
|
+
update_available: this.updateInfo,
|
|
434
|
+
notify_user: update.notify,
|
|
435
|
+
},
|
|
397
436
|
};
|
|
398
437
|
}
|
|
399
438
|
if (state.version) {
|
|
@@ -431,7 +470,7 @@ export class EditmameiServer {
|
|
|
431
470
|
`${actionSetsCount} custom action set(s), ${userTemplates} saved template(s), ` +
|
|
432
471
|
`${openDocuments.length} open document(s)${openDocuments.length ? ': ' + openDocuments.join(', ') : ''}` +
|
|
433
472
|
`${degradedNote}.` +
|
|
434
|
-
|
|
473
|
+
update.note,
|
|
435
474
|
},
|
|
436
475
|
],
|
|
437
476
|
structuredContent: {
|
|
@@ -442,14 +481,43 @@ export class EditmameiServer {
|
|
|
442
481
|
open_documents: openDocuments,
|
|
443
482
|
degraded,
|
|
444
483
|
update_available: this.updateInfo,
|
|
484
|
+
notify_user: update.notify,
|
|
445
485
|
},
|
|
446
486
|
};
|
|
447
487
|
}
|
|
448
|
-
|
|
488
|
+
async buildUpdateNotice() {
|
|
449
489
|
const u = this.updateInfo;
|
|
450
490
|
if (!u)
|
|
451
|
-
return '';
|
|
452
|
-
|
|
491
|
+
return { note: '', notify: false };
|
|
492
|
+
if (this.updateNoticeShown) {
|
|
493
|
+
return {
|
|
494
|
+
note: ` Update available: v${u.current} → v${u.latest}. ${u.how_to_update}`,
|
|
495
|
+
notify: false,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
this.updateNoticeShown = true;
|
|
499
|
+
let fixNote = '';
|
|
500
|
+
if (u.fixed_tools.length > 0) {
|
|
501
|
+
try {
|
|
502
|
+
const counts = await previousSessionFailureCounts(this.session.getSessionId(), {
|
|
503
|
+
dir: this.sessionLog.directory,
|
|
504
|
+
});
|
|
505
|
+
const hits = relevantFixes(counts, u.fixed_tools);
|
|
506
|
+
if (hits.length > 0) {
|
|
507
|
+
const named = hits
|
|
508
|
+
.map((h) => `${h.tool} (failed ${h.failures} time${h.failures === 1 ? '' : 's'} last session)`)
|
|
509
|
+
.join(', ');
|
|
510
|
+
fixNote = ` v${u.latest} fixes known failures in ${named}.`;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
catch {
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
return {
|
|
517
|
+
note: ` IMPORTANT — tell the user before continuing: Editmamei v${u.latest} is available` +
|
|
518
|
+
` (this install runs v${u.current}).${fixNote} To update: ${u.how_to_update}`,
|
|
519
|
+
notify: true,
|
|
520
|
+
};
|
|
453
521
|
}
|
|
454
522
|
async start() {
|
|
455
523
|
await this.loadModules();
|
package/dist/core/tool-groups.js
CHANGED
|
@@ -77,7 +77,7 @@ export const GROUPS = {
|
|
|
77
77
|
automation: {
|
|
78
78
|
id: 'automation',
|
|
79
79
|
label: 'Automation (Pro)',
|
|
80
|
-
purpose: '
|
|
80
|
+
purpose: 'Batch files, play recorded Actions, and the ExtendScript escape hatch (Pro).',
|
|
81
81
|
},
|
|
82
82
|
};
|
|
83
83
|
export const TOOL_GROUPS = {
|
|
@@ -111,10 +111,11 @@ export const TOOL_GROUPS = {
|
|
|
111
111
|
ps_select_sky: 'select_ai',
|
|
112
112
|
ps_select_subject_instance: 'select_ai',
|
|
113
113
|
ps_select_object: 'select_ai',
|
|
114
|
+
ps_select_focus_area: 'select_ai',
|
|
115
|
+
ps_replace_sky: 'select_ai',
|
|
114
116
|
ps_add_adjustment_layer: 'adjust',
|
|
115
117
|
ps_apply_adjustment: 'adjust',
|
|
116
118
|
ps_filter: 'filter',
|
|
117
|
-
ps_apply_filter: 'filter',
|
|
118
119
|
ps_apply_camera_raw: 'filter',
|
|
119
120
|
ps_retouch: 'retouch',
|
|
120
121
|
ps_apply_brush_stroke: 'retouch',
|
|
@@ -139,11 +140,6 @@ export const TOOL_GROUPS = {
|
|
|
139
140
|
ps_warp_layer_region: 'layers',
|
|
140
141
|
ps_warp_layer_to: 'layers',
|
|
141
142
|
ps_group: 'layers',
|
|
142
|
-
ps_create_group: 'layers',
|
|
143
|
-
ps_move_layer_to_group: 'layers',
|
|
144
|
-
ps_set_group_blend_mode: 'layers',
|
|
145
|
-
ps_ungroup: 'layers',
|
|
146
|
-
ps_delete_group: 'layers',
|
|
147
143
|
ps_shape: 'layers',
|
|
148
144
|
ps_layer_mask: 'masks',
|
|
149
145
|
ps_clipping_mask: 'masks',
|
|
@@ -152,8 +148,6 @@ export const TOOL_GROUPS = {
|
|
|
152
148
|
ps_apply_image: 'masks',
|
|
153
149
|
ps_calculations: 'masks',
|
|
154
150
|
ps_text: 'type',
|
|
155
|
-
ps_create_text_layer: 'type',
|
|
156
|
-
ps_set_text: 'type',
|
|
157
151
|
ps_detect: 'perception',
|
|
158
152
|
ps_read_scene: 'perception',
|
|
159
153
|
ps_select_by_reference: 'perception',
|
|
@@ -174,6 +168,7 @@ export const TOOL_GROUPS = {
|
|
|
174
168
|
ps_list_actions: 'automation',
|
|
175
169
|
ps_play_action: 'automation',
|
|
176
170
|
ps_execute_script: 'automation',
|
|
171
|
+
ps_batch: 'automation',
|
|
177
172
|
};
|
|
178
173
|
export function groupOf(toolName) {
|
|
179
174
|
const group = TOOL_GROUPS[toolName];
|
package/dist/core/tool-tiers.js
CHANGED
|
@@ -5,6 +5,7 @@ export const TOOL_TIERS = {
|
|
|
5
5
|
ps_list_actions: 'pro',
|
|
6
6
|
ps_play_action: 'pro',
|
|
7
7
|
ps_execute_script: 'pro',
|
|
8
|
+
ps_batch: 'pro',
|
|
8
9
|
ps_add_adjustment_layer: 'community',
|
|
9
10
|
ps_apply_adjustment: 'community',
|
|
10
11
|
ps_create_document: 'community',
|
|
@@ -13,13 +14,7 @@ export const TOOL_TIERS = {
|
|
|
13
14
|
ps_save_psd: 'community',
|
|
14
15
|
ps_export: 'community',
|
|
15
16
|
ps_filter: 'community',
|
|
16
|
-
ps_apply_filter: 'community',
|
|
17
17
|
ps_group: 'community',
|
|
18
|
-
ps_create_group: 'community',
|
|
19
|
-
ps_move_layer_to_group: 'community',
|
|
20
|
-
ps_set_group_blend_mode: 'community',
|
|
21
|
-
ps_ungroup: 'community',
|
|
22
|
-
ps_delete_group: 'community',
|
|
23
18
|
ps_clipping_mask: 'community',
|
|
24
19
|
ps_undo: 'community',
|
|
25
20
|
ps_redo: 'community',
|
|
@@ -38,7 +33,6 @@ export const TOOL_TIERS = {
|
|
|
38
33
|
ps_add_layer_style: 'community',
|
|
39
34
|
ps_create_layer: 'community',
|
|
40
35
|
ps_delete_layer: 'community',
|
|
41
|
-
ps_create_text_layer: 'community',
|
|
42
36
|
ps_fill_layer: 'community',
|
|
43
37
|
ps_add_fill_layer: 'community',
|
|
44
38
|
ps_select_layer: 'community',
|
|
@@ -73,6 +67,8 @@ export const TOOL_TIERS = {
|
|
|
73
67
|
ps_select_sky: 'community',
|
|
74
68
|
ps_select_subject_instance: 'pro',
|
|
75
69
|
ps_select_object: 'pro',
|
|
70
|
+
ps_select_focus_area: 'dev',
|
|
71
|
+
ps_replace_sky: 'dev',
|
|
76
72
|
ps_modify_selection: 'community',
|
|
77
73
|
ps_get_selection_preview: 'community',
|
|
78
74
|
ps_selection_channel: 'community',
|
|
@@ -91,7 +87,6 @@ export const TOOL_TIERS = {
|
|
|
91
87
|
ps_template_recall: 'pro',
|
|
92
88
|
ps_template_delete: 'pro',
|
|
93
89
|
ps_text: 'community',
|
|
94
|
-
ps_set_text: 'community',
|
|
95
90
|
};
|
|
96
91
|
export function tierOf(toolName) {
|
|
97
92
|
const tier = TOOL_TIERS[toolName];
|
package/dist/modules/ce/index.js
CHANGED
|
@@ -29,6 +29,7 @@ import { createPathTools } from '../../tools/path-tools.js';
|
|
|
29
29
|
import { createVectorMaskTools } from '../../tools/vector-mask-tools.js';
|
|
30
30
|
import { createChannelComposeTools } from '../../tools/channel-compose-tools.js';
|
|
31
31
|
import { createShapeTools } from '../../tools/shape-tools.js';
|
|
32
|
+
import { createSkyTools } from '../../tools/sky-tools.js';
|
|
32
33
|
const ceFactories = [
|
|
33
34
|
createDocumentTools,
|
|
34
35
|
createLayerTools,
|
|
@@ -57,6 +58,7 @@ const ceFactories = [
|
|
|
57
58
|
createVectorMaskTools,
|
|
58
59
|
createChannelComposeTools,
|
|
59
60
|
createShapeTools,
|
|
61
|
+
createSkyTools,
|
|
60
62
|
];
|
|
61
63
|
export const ceModule = {
|
|
62
64
|
manifest: { id: 'ce', name: 'Editmamei Community Tools', abi: KERNEL_ABI },
|
|
Binary file
|
|
@@ -1010,20 +1010,6 @@ export function createFilterTools(connection, snippetClient, client = new OnnxLa
|
|
|
1010
1010
|
},
|
|
1011
1011
|
handler: async (args) => runFilterTool(connection, snippetClient, client, args),
|
|
1012
1012
|
},
|
|
1013
|
-
{
|
|
1014
|
-
tool: {
|
|
1015
|
-
name: 'ps_apply_filter',
|
|
1016
|
-
description: `DEPRECATED — use ps_filter instead (kept for one release for backward compatibility, identical behaviour). ${FILTER_DESCRIPTION}`,
|
|
1017
|
-
inputSchema: FILTER_INPUT_SCHEMA,
|
|
1018
|
-
outputSchema: FILTER_OUTPUT_SCHEMA,
|
|
1019
|
-
annotations: {
|
|
1020
|
-
title: 'Apply Filter',
|
|
1021
|
-
destructiveHint: true,
|
|
1022
|
-
idempotentHint: false,
|
|
1023
|
-
},
|
|
1024
|
-
},
|
|
1025
|
-
handler: async (args) => runFilterTool(connection, snippetClient, client, args),
|
|
1026
|
-
},
|
|
1027
1013
|
];
|
|
1028
1014
|
}
|
|
1029
1015
|
async function runFilterTool(connection, snippetClient, detClient, rawArgs) {
|
|
@@ -41,7 +41,7 @@ const moveLayerToGroupSchema = {
|
|
|
41
41
|
properties: {
|
|
42
42
|
layer_name: {
|
|
43
43
|
type: 'string',
|
|
44
|
-
description: 'Name of the layer to move (recursive search).',
|
|
44
|
+
description: 'Name of the layer to move (recursive search). A layer is preferred over a group of the same name; a group is moved only when no layer matches, which is how one group is nested inside another.',
|
|
45
45
|
},
|
|
46
46
|
group_name: {
|
|
47
47
|
type: 'string',
|
|
@@ -187,102 +187,6 @@ export function createGroupTools(connection, snippetClient) {
|
|
|
187
187
|
},
|
|
188
188
|
handler: async (args) => groupDispatch(connection, snippetClient, args),
|
|
189
189
|
},
|
|
190
|
-
{
|
|
191
|
-
tool: {
|
|
192
|
-
name: 'ps_create_group',
|
|
193
|
-
description: `DEPRECATED — use ps_group(op=create) instead (kept for one release for backward compatibility, identical behaviour). Create a new layer group (LayerSet) above the active layer with the given name — hoisted out of the active layer's group by default even though Photoshop's own Mk-layerSection placement rule would otherwise nest it INSIDE that group (pass into_active_group:true to keep that native nesting; this is why groups created one after another land as siblings rather than nested, so bottom-to-top group creation is safe). Optionally moves existing layers into it in one step. Non-destructive. Foundational for structured non-destructive editing — e.g. group all adjustment layers into an "edits" group so you can A/B toggle the whole stack via group visibility.`,
|
|
194
|
-
inputSchema: createGroupSchema,
|
|
195
|
-
outputSchema: {
|
|
196
|
-
type: 'object',
|
|
197
|
-
properties: {
|
|
198
|
-
created: { type: 'boolean' },
|
|
199
|
-
groupName: { type: 'string' },
|
|
200
|
-
moved_count: { type: 'number' },
|
|
201
|
-
not_found: { type: 'array', items: { type: 'string' } },
|
|
202
|
-
hoisted: {
|
|
203
|
-
type: 'boolean',
|
|
204
|
-
description: 'True when the new group had to be moved back out of the previously-active group to honor into_active_group:false (the default). False when it landed correctly on its own, or when the move-back itself failed — check the layer tree if this matters and hoisted is false.',
|
|
205
|
-
},
|
|
206
|
-
parent_path: {
|
|
207
|
-
type: ['array', 'null'],
|
|
208
|
-
items: { type: 'string' },
|
|
209
|
-
description: 'The containing-group name chain (outermost first), empty array at the document root.',
|
|
210
|
-
},
|
|
211
|
-
context: { type: 'object' },
|
|
212
|
-
},
|
|
213
|
-
},
|
|
214
|
-
annotations: {
|
|
215
|
-
title: 'Create Layer Group',
|
|
216
|
-
idempotentHint: false,
|
|
217
|
-
},
|
|
218
|
-
},
|
|
219
|
-
handler: async (args) => createGroup(connection, snippetClient, args),
|
|
220
|
-
},
|
|
221
|
-
{
|
|
222
|
-
tool: {
|
|
223
|
-
name: 'ps_move_layer_to_group',
|
|
224
|
-
description: 'DEPRECATED — use ps_group(op=add_layer) instead (kept for one release for backward compatibility, identical behaviour). Move a named layer into a named group. The layer is placed at the top of the group stack. Both layer_name and group_name are looked up recursively. Throws if either is not found, or if the layer IS the group itself.',
|
|
225
|
-
inputSchema: moveLayerToGroupSchema,
|
|
226
|
-
outputSchema: {
|
|
227
|
-
type: 'object',
|
|
228
|
-
properties: {
|
|
229
|
-
moved: { type: 'boolean' },
|
|
230
|
-
layerName: { type: 'string' },
|
|
231
|
-
groupName: { type: 'string' },
|
|
232
|
-
context: { type: 'object' },
|
|
233
|
-
},
|
|
234
|
-
},
|
|
235
|
-
annotations: {
|
|
236
|
-
title: 'Move Layer Into Group',
|
|
237
|
-
idempotentHint: false,
|
|
238
|
-
},
|
|
239
|
-
},
|
|
240
|
-
handler: async (args) => moveLayerToGroup(connection, snippetClient, args),
|
|
241
|
-
},
|
|
242
|
-
{
|
|
243
|
-
tool: {
|
|
244
|
-
name: 'ps_set_group_blend_mode',
|
|
245
|
-
description: "DEPRECATED — use ps_group(op=set_blend_mode) instead (kept for one release for backward compatibility, identical behaviour). Set a group's blend mode. The default for new groups is PASSTHROUGH (adjustments inside affect layers below the group). Change to NORMAL when treating the group as a single composite — required for masking a stack of adjustments with one mask.",
|
|
246
|
-
inputSchema: setGroupBlendModeSchema,
|
|
247
|
-
outputSchema: {
|
|
248
|
-
type: 'object',
|
|
249
|
-
properties: {
|
|
250
|
-
set: { type: 'boolean' },
|
|
251
|
-
groupName: { type: 'string' },
|
|
252
|
-
blendMode: { type: 'string' },
|
|
253
|
-
context: { type: 'object' },
|
|
254
|
-
},
|
|
255
|
-
},
|
|
256
|
-
annotations: {
|
|
257
|
-
title: 'Set Group Blend Mode',
|
|
258
|
-
idempotentHint: true,
|
|
259
|
-
},
|
|
260
|
-
},
|
|
261
|
-
handler: async (args) => setGroupBlendMode(connection, snippetClient, args),
|
|
262
|
-
},
|
|
263
|
-
{
|
|
264
|
-
tool: {
|
|
265
|
-
name: 'ps_ungroup',
|
|
266
|
-
description: 'DEPRECATED — use ps_group(op=ungroup) instead (kept for one release for backward compatibility, identical behaviour). DESTRUCTIVE structural change: Dissolve a group, leaving its contents at the parent level in their existing stack order. The group itself is removed. Requires confirm:true. Recoverable only via Edit > Undo.',
|
|
267
|
-
inputSchema: ungroupSchema,
|
|
268
|
-
outputSchema: {
|
|
269
|
-
type: 'object',
|
|
270
|
-
properties: {
|
|
271
|
-
ungrouped: { type: 'boolean' },
|
|
272
|
-
groupName: { type: 'string' },
|
|
273
|
-
children_promoted: { type: 'number' },
|
|
274
|
-
child_names: { type: 'array', items: { type: 'string' } },
|
|
275
|
-
context: { type: 'object' },
|
|
276
|
-
},
|
|
277
|
-
},
|
|
278
|
-
annotations: {
|
|
279
|
-
title: 'Ungroup Layer Set (destructive structural change)',
|
|
280
|
-
destructiveHint: true,
|
|
281
|
-
idempotentHint: false,
|
|
282
|
-
},
|
|
283
|
-
},
|
|
284
|
-
handler: async (args) => ungroup(connection, snippetClient, args),
|
|
285
|
-
},
|
|
286
190
|
{
|
|
287
191
|
tool: {
|
|
288
192
|
name: 'ps_clipping_mask',
|
|
@@ -305,28 +209,6 @@ export function createGroupTools(connection, snippetClient) {
|
|
|
305
209
|
},
|
|
306
210
|
handler: async (args) => clippingMask(connection, snippetClient, args),
|
|
307
211
|
},
|
|
308
|
-
{
|
|
309
|
-
tool: {
|
|
310
|
-
name: 'ps_delete_group',
|
|
311
|
-
description: 'DEPRECATED — use ps_group(op=delete) instead (kept for one release for backward compatibility, identical behaviour). DESTRUCTIVE: Delete a group AND ALL its contents (including nested groups and their layers). Requires confirm:true because the destructive scope is much larger than a single-layer delete. Recoverable only via Edit > Undo. To dissolve a group while keeping its contents, use ps_ungroup instead.',
|
|
312
|
-
inputSchema: deleteGroupSchema,
|
|
313
|
-
outputSchema: {
|
|
314
|
-
type: 'object',
|
|
315
|
-
properties: {
|
|
316
|
-
deleted: { type: 'boolean' },
|
|
317
|
-
groupName: { type: 'string' },
|
|
318
|
-
descendants_deleted: { type: 'number' },
|
|
319
|
-
context: { type: 'object' },
|
|
320
|
-
},
|
|
321
|
-
},
|
|
322
|
-
annotations: {
|
|
323
|
-
title: 'Delete Group (destructive, recursive)',
|
|
324
|
-
destructiveHint: true,
|
|
325
|
-
idempotentHint: false,
|
|
326
|
-
},
|
|
327
|
-
},
|
|
328
|
-
handler: async (args) => deleteGroup(connection, snippetClient, args),
|
|
329
|
-
},
|
|
330
212
|
];
|
|
331
213
|
}
|
|
332
214
|
async function clippingMask(connection, snippetClient, rawArgs) {
|
|
@@ -220,7 +220,7 @@ export function createLayerTools(connection, snippetClient) {
|
|
|
220
220
|
{
|
|
221
221
|
tool: {
|
|
222
222
|
name: 'ps_delete_layer',
|
|
223
|
-
description: 'DESTRUCTIVE: Delete a layer. With no arg, deletes the currently active layer (backward-compatible). With `name`, recurses into groups and deletes the first
|
|
223
|
+
description: 'DESTRUCTIVE: Delete a layer. With no arg, deletes the currently active layer (backward-compatible). With `name`, recurses into groups and deletes the first LAYER matching that name — useful for cleanup workflows where the dead layer is not currently active. A name that matches a group is refused rather than deleted; use ps_group(op=delete) to delete a group and all its contents. Recoverable only via Edit > Undo.',
|
|
224
224
|
inputSchema: deleteLayerSchema,
|
|
225
225
|
outputSchema: {
|
|
226
226
|
type: 'object',
|
|
@@ -238,29 +238,6 @@ export function createLayerTools(connection, snippetClient) {
|
|
|
238
238
|
},
|
|
239
239
|
handler: async (args) => deleteLayer(connection, snippetClient, args),
|
|
240
240
|
},
|
|
241
|
-
{
|
|
242
|
-
tool: {
|
|
243
|
-
name: 'ps_create_text_layer',
|
|
244
|
-
description: 'DEPRECATED — use ps_text(op=create) instead (kept for one release for backward compatibility, identical behaviour). Create a new text layer with the given content, position, and font size. The text is editable (vector). Use ps_text (op=set_font / set_color / set_alignment) afterwards to style.',
|
|
245
|
-
inputSchema: createTextLayerSchema,
|
|
246
|
-
outputSchema: {
|
|
247
|
-
type: 'object',
|
|
248
|
-
properties: {
|
|
249
|
-
created: { type: 'boolean' },
|
|
250
|
-
layerName: { type: 'string' },
|
|
251
|
-
text: { type: 'string' },
|
|
252
|
-
position: { type: 'object' },
|
|
253
|
-
fontSize: { type: 'number' },
|
|
254
|
-
context: { type: 'object' },
|
|
255
|
-
},
|
|
256
|
-
},
|
|
257
|
-
annotations: {
|
|
258
|
-
title: 'Create Text Layer',
|
|
259
|
-
idempotentHint: false,
|
|
260
|
-
},
|
|
261
|
-
},
|
|
262
|
-
handler: async (args) => createTextLayer(connection, snippetClient, args),
|
|
263
|
-
},
|
|
264
241
|
{
|
|
265
242
|
tool: {
|
|
266
243
|
name: 'ps_fill_layer',
|
|
@@ -5,7 +5,7 @@ import { validateArgs } from '../utils/validate.js';
|
|
|
5
5
|
import { toolErrorResult, runSnippetTool, unknownDiscriminator } from '../utils/tool-helpers.js';
|
|
6
6
|
import { OnnxLandmarkDetectionClient } from '../detection/landmark-detection-client.js';
|
|
7
7
|
import { resolveExpectedPlacement, PLACEMENT_SCHEMA } from '../perception/grounding-locate.js';
|
|
8
|
-
import { SELECT_SUBJECT_TIMEOUT_MS, SELECT_SKY_TIMEOUT_MS } from '../utils/operation-timeouts.js';
|
|
8
|
+
import { SELECT_SUBJECT_TIMEOUT_MS, SELECT_SKY_TIMEOUT_MS, SELECT_FOCUS_AREA_TIMEOUT_MS, } from '../utils/operation-timeouts.js';
|
|
9
9
|
export const SELECTION_TYPE_ENUM = ['replace', 'add', 'subtract', 'intersect'];
|
|
10
10
|
export const selectionTypeFragment = {
|
|
11
11
|
type: 'string',
|
|
@@ -817,6 +817,42 @@ export function createSelectionTools(connection, snippetClient, client = new Onn
|
|
|
817
817
|
},
|
|
818
818
|
handler: async (args) => selectSky(connection, snippetClient, args),
|
|
819
819
|
},
|
|
820
|
+
{
|
|
821
|
+
tool: {
|
|
822
|
+
name: 'ps_select_focus_area',
|
|
823
|
+
description: "Run Photoshop's \"Focus Area\" — select what the lens rendered SHARP, by depth of field rather than by subject or colour. Use it when the thing you want is defined by focus and not by what it is: lifting a subject off a bokeh background, masking the in-focus plane of a macro shot, or grabbing a shallow-depth foreground that Select Subject splits badly. Takes NO coordinates. in_focus_radius widens (higher) or narrows (lower) what counts as sharp; soft_mask=true gives feathered edges instead of a hard boundary. Analyses the ACTIVE layer, so target the photographic pixels you mean. If the active layer is not a raster layer at all (adjustment, smart object, text, shape), detection is retargeted to the bottom layer and active_layer_temporarily_changed comes back true — check it, because the analysed layer was then NOT the one you selected. An EMPTY raster layer cannot be told apart by kind, so it is not retargeted; it surfaces instead as an error or as whole_canvas_selected. whole_canvas_selected and warning diagnose Focus Area's RAW detection, measured BEFORE any selection_type combine — a uniformly sharp image (or too high an in_focus_radius) trips them even when combining then folds the result down to something small. Check whole_canvas_selected first; selection_info separately reports the FINAL, post-combine selection and can disagree with it by design (e.g. selection_type='subtract' against an existing selection), so read selection_info for what actually got selected, not as a substitute for whole_canvas_selected.",
|
|
824
|
+
inputSchema: selectFocusAreaSchema,
|
|
825
|
+
outputSchema: {
|
|
826
|
+
type: 'object',
|
|
827
|
+
properties: {
|
|
828
|
+
selected: { type: 'boolean' },
|
|
829
|
+
method: { type: 'string' },
|
|
830
|
+
strategy_used: { type: 'string' },
|
|
831
|
+
in_focus_radius: { type: 'number' },
|
|
832
|
+
soft_mask: { type: 'boolean' },
|
|
833
|
+
active_layer_temporarily_changed: {
|
|
834
|
+
type: 'boolean',
|
|
835
|
+
description: 'True if the active layer was not an ordinary pixel layer and detection was temporarily retargeted to the bottom layer. The original active layer is restored before return.',
|
|
836
|
+
},
|
|
837
|
+
whole_canvas_selected: {
|
|
838
|
+
type: 'boolean',
|
|
839
|
+
description: "True when Focus Area's RAW detection covered essentially the entire canvas — usually a non-result (radius too high, or nothing photographic to analyse). Measured BEFORE combining with any prior selection, so it describes the detection step, not the final selection: selection_info reports the FINAL, post-combine result and the two can legitimately disagree, e.g. selection_type='subtract' against an existing selection can leave this true while selection_info.area_percent is well under 100.",
|
|
840
|
+
},
|
|
841
|
+
warning: {
|
|
842
|
+
type: ['string', 'null'],
|
|
843
|
+
description: 'Set when whole_canvas_selected is true. Also describes the RAW detection, not the final post-combine selection.',
|
|
844
|
+
},
|
|
845
|
+
selection_type: { type: 'string' },
|
|
846
|
+
selection_info: selectionInfoFragment,
|
|
847
|
+
},
|
|
848
|
+
},
|
|
849
|
+
annotations: {
|
|
850
|
+
title: 'Select Focus Area',
|
|
851
|
+
idempotentHint: true,
|
|
852
|
+
},
|
|
853
|
+
},
|
|
854
|
+
handler: async (args) => selectFocusArea(connection, snippetClient, args),
|
|
855
|
+
},
|
|
820
856
|
];
|
|
821
857
|
}
|
|
822
858
|
const selectSubjectSchema = {
|
|
@@ -841,6 +877,41 @@ const selectSkySchema = {
|
|
|
841
877
|
selection_type: selectionTypeFragment,
|
|
842
878
|
},
|
|
843
879
|
};
|
|
880
|
+
const selectFocusAreaSchema = {
|
|
881
|
+
type: 'object',
|
|
882
|
+
properties: {
|
|
883
|
+
in_focus_radius: {
|
|
884
|
+
type: 'number',
|
|
885
|
+
description: 'How much blur still counts as "in focus", in pixels. Higher pulls more of the soft transition zone into the selection; lower keeps only the crisply resolved plane. 4.07 is the Photoshop dialog default and a sane starting point. The useful band is narrow, and a radius well above the default selects the entire frame — so move in small steps and CHECK the returned area_percent and whole_canvas_selected: a selection covering essentially everything means the radius is too high and the result is worthless, even though the call reports success.',
|
|
886
|
+
default: 4.07,
|
|
887
|
+
minimum: 0.1,
|
|
888
|
+
maximum: 15,
|
|
889
|
+
},
|
|
890
|
+
soft_mask: {
|
|
891
|
+
type: 'boolean',
|
|
892
|
+
description: 'False (default) yields a hard-edged selection — every pixel fully in or fully out, which is what you want before ps_modify_selection feathering. True lets Photoshop feather the focus falloff itself, useful when the subject edge is genuinely gradual (hair, fur, motion).',
|
|
893
|
+
default: false,
|
|
894
|
+
},
|
|
895
|
+
selection_type: selectionTypeFragment,
|
|
896
|
+
},
|
|
897
|
+
};
|
|
898
|
+
async function selectFocusArea(connection, snippetClient, rawArgs) {
|
|
899
|
+
return runSnippetTool({
|
|
900
|
+
connection,
|
|
901
|
+
snippetClient,
|
|
902
|
+
rawArgs,
|
|
903
|
+
schema: selectFocusAreaSchema,
|
|
904
|
+
snippet: 'selectFocusArea',
|
|
905
|
+
errorPrefix: 'Error running Focus Area selection',
|
|
906
|
+
timeoutMs: SELECT_FOCUS_AREA_TIMEOUT_MS,
|
|
907
|
+
params: (args) => ({
|
|
908
|
+
inFocusRadius: args.in_focus_radius ?? 4.07,
|
|
909
|
+
softMask: args.soft_mask ?? false,
|
|
910
|
+
selectionType: normalizeSelectionType(args.selection_type),
|
|
911
|
+
}),
|
|
912
|
+
successText: (_result, args) => `Focus Area selection (${normalizeSelectionType(args.selection_type)}) complete`,
|
|
913
|
+
});
|
|
914
|
+
}
|
|
844
915
|
async function selectSubject(connection, snippetClient, rawArgs) {
|
|
845
916
|
return runSnippetTool({
|
|
846
917
|
connection,
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { runSnippetTool } from '../utils/tool-helpers.js';
|
|
2
|
+
import { SKY_REPLACEMENT_TIMEOUT_MS } from '../utils/operation-timeouts.js';
|
|
3
|
+
const PLACEHOLDER_SKY_ID = '00000000-0000-0000-0000-000000000000';
|
|
4
|
+
const replaceSkyInputSchema = {
|
|
5
|
+
type: 'object',
|
|
6
|
+
properties: {
|
|
7
|
+
sky_file: {
|
|
8
|
+
type: 'string',
|
|
9
|
+
description: 'ABSOLUTE path to the image to use as the sky. Any image Photoshop can open works — it does not need to be a registered Photoshop sky preset. The built-in skies live as .jpg files under the Sky_Presets folder inside your Photoshop settings directory, and their paths can be passed here directly.',
|
|
10
|
+
},
|
|
11
|
+
sky_name: {
|
|
12
|
+
type: 'string',
|
|
13
|
+
description: 'Label recorded on the operation. Cosmetic — it does not select the sky, sky_file does.',
|
|
14
|
+
default: 'Custom Sky',
|
|
15
|
+
},
|
|
16
|
+
shift_edge: {
|
|
17
|
+
type: 'number',
|
|
18
|
+
description: 'Moves the sky/foreground boundary inward (negative) or outward (positive). Use it when the horizon leaves a halo or eats into the foreground.',
|
|
19
|
+
default: 0,
|
|
20
|
+
minimum: -100,
|
|
21
|
+
maximum: 100,
|
|
22
|
+
},
|
|
23
|
+
border_smoothness: {
|
|
24
|
+
type: 'number',
|
|
25
|
+
description: 'Softens the boundary between sky and foreground. 0 is a hard cut.',
|
|
26
|
+
default: 50,
|
|
27
|
+
minimum: 0,
|
|
28
|
+
maximum: 100,
|
|
29
|
+
},
|
|
30
|
+
brightness: {
|
|
31
|
+
type: 'number',
|
|
32
|
+
description: 'Brightness of the replaced sky itself.',
|
|
33
|
+
default: 0,
|
|
34
|
+
minimum: -100,
|
|
35
|
+
maximum: 100,
|
|
36
|
+
},
|
|
37
|
+
temperature: {
|
|
38
|
+
type: 'number',
|
|
39
|
+
description: 'Warms (positive) or cools (negative) the replaced sky.',
|
|
40
|
+
default: 0,
|
|
41
|
+
minimum: -100,
|
|
42
|
+
maximum: 100,
|
|
43
|
+
},
|
|
44
|
+
harmonization_opacity: {
|
|
45
|
+
type: 'number',
|
|
46
|
+
description: 'How strongly the foreground is colour-graded to match the new sky. This is what sells the composite — 0 leaves the foreground untouched and usually reads as pasted-on.',
|
|
47
|
+
default: 35,
|
|
48
|
+
minimum: 0,
|
|
49
|
+
maximum: 100,
|
|
50
|
+
},
|
|
51
|
+
foreground_lighting_opacity: {
|
|
52
|
+
type: 'number',
|
|
53
|
+
description: 'Strength of the relighting applied to the foreground so it appears lit by the new sky.',
|
|
54
|
+
default: 78,
|
|
55
|
+
minimum: 0,
|
|
56
|
+
maximum: 100,
|
|
57
|
+
},
|
|
58
|
+
edge_lighting_opacity: {
|
|
59
|
+
type: 'number',
|
|
60
|
+
description: 'Strength of the light wrap along the foreground edge where it meets the sky.',
|
|
61
|
+
default: 70,
|
|
62
|
+
minimum: 0,
|
|
63
|
+
maximum: 100,
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
required: ['sky_file'],
|
|
67
|
+
};
|
|
68
|
+
async function replaceSky(connection, snippetClient, rawArgs) {
|
|
69
|
+
return runSnippetTool({
|
|
70
|
+
connection,
|
|
71
|
+
snippetClient,
|
|
72
|
+
rawArgs,
|
|
73
|
+
schema: replaceSkyInputSchema,
|
|
74
|
+
snippet: 'replaceSky',
|
|
75
|
+
errorPrefix: 'Error running Sky Replacement',
|
|
76
|
+
timeoutMs: SKY_REPLACEMENT_TIMEOUT_MS,
|
|
77
|
+
params: (args) => ({
|
|
78
|
+
skyPath: args.sky_file,
|
|
79
|
+
skyName: args.sky_name ?? 'Custom Sky',
|
|
80
|
+
skyId: PLACEHOLDER_SKY_ID,
|
|
81
|
+
shiftEdge: args.shift_edge ?? 0,
|
|
82
|
+
borderSmoothness: args.border_smoothness ?? 50,
|
|
83
|
+
brightness: args.brightness ?? 0,
|
|
84
|
+
temperature: args.temperature ?? 0,
|
|
85
|
+
harmonizationOpacity: args.harmonization_opacity ?? 35,
|
|
86
|
+
foregroundLightingOpacity: args.foreground_lighting_opacity ?? 78,
|
|
87
|
+
edgeLightingOpacity: args.edge_lighting_opacity ?? 70,
|
|
88
|
+
}),
|
|
89
|
+
successText: (_result, args) => `Sky replaced using ${String(args.sky_file)}`,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
export function createSkyTools(connection, snippetClient) {
|
|
93
|
+
return [
|
|
94
|
+
{
|
|
95
|
+
tool: {
|
|
96
|
+
name: 'ps_replace_sky',
|
|
97
|
+
description: 'Run Photoshop\'s Sky Replacement (Adobe Sensei): detect the sky, composite a replacement, and relight/colour-grade the foreground to match. Non-destructive — everything lands in a "Sky Replacement Group" of editable layers above the original, so the result can be tuned or deleted afterwards. sky_file takes an ABSOLUTE path to ANY image on disk, not only Photoshop\'s built-in presets. Reach for harmonization_opacity and foreground_lighting_opacity when the composite reads as pasted-on; those two carry most of the believability. Fails cleanly when the image has no detectable sky (indoor or closed compositions).',
|
|
98
|
+
inputSchema: replaceSkyInputSchema,
|
|
99
|
+
outputSchema: {
|
|
100
|
+
type: 'object',
|
|
101
|
+
properties: {
|
|
102
|
+
replaced: { type: 'boolean' },
|
|
103
|
+
strategy_used: { type: 'string' },
|
|
104
|
+
group_name: {
|
|
105
|
+
type: 'string',
|
|
106
|
+
description: 'Name of the layer group Photoshop created.',
|
|
107
|
+
},
|
|
108
|
+
group_layers: {
|
|
109
|
+
type: 'array',
|
|
110
|
+
items: { type: 'string' },
|
|
111
|
+
description: 'Layers inside the group, top to bottom — typically the sky layer, an edge-lighting group, a foreground-lighting layer and a foreground-colour curves layer.',
|
|
112
|
+
},
|
|
113
|
+
sky_file: { type: 'string' },
|
|
114
|
+
sky_name: { type: 'string' },
|
|
115
|
+
context: { type: 'object' },
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
annotations: {
|
|
119
|
+
title: 'Replace Sky (Sensei)',
|
|
120
|
+
idempotentHint: false,
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
handler: async (args) => replaceSky(connection, snippetClient, args),
|
|
124
|
+
},
|
|
125
|
+
];
|
|
126
|
+
}
|
package/dist/tools/text-tools.js
CHANGED
|
@@ -70,24 +70,6 @@ const updateTextContentSchema = {
|
|
|
70
70
|
},
|
|
71
71
|
required: ['text'],
|
|
72
72
|
};
|
|
73
|
-
const SET_TEXT_PROPERTIES = ['font', 'color', 'alignment', 'content'];
|
|
74
|
-
const SET_TEXT_INPUT_SCHEMA = {
|
|
75
|
-
type: 'object',
|
|
76
|
-
properties: {
|
|
77
|
-
property: {
|
|
78
|
-
type: 'string',
|
|
79
|
-
enum: [...SET_TEXT_PROPERTIES],
|
|
80
|
-
description: 'Which text attribute to set on the active text layer: ' +
|
|
81
|
-
'font(font_name, optional font_size); color(red, green, blue); ' +
|
|
82
|
-
'alignment(alignment); content(text).',
|
|
83
|
-
},
|
|
84
|
-
...setTextFontSchema.properties,
|
|
85
|
-
...setTextColorSchema.properties,
|
|
86
|
-
...setTextAlignmentSchema.properties,
|
|
87
|
-
...updateTextContentSchema.properties,
|
|
88
|
-
},
|
|
89
|
-
required: ['property'],
|
|
90
|
-
};
|
|
91
73
|
const TEXT_OPS = ['create', 'set_content', 'set_font', 'set_color', 'set_alignment'];
|
|
92
74
|
export const TEXT_OP_SCHEMAS = {
|
|
93
75
|
create: createTextLayerSchema,
|
|
@@ -161,33 +143,6 @@ export function createTextTools(connection, snippetClient) {
|
|
|
161
143
|
},
|
|
162
144
|
handler: async (args) => textDispatch(connection, snippetClient, args),
|
|
163
145
|
},
|
|
164
|
-
{
|
|
165
|
-
tool: {
|
|
166
|
-
name: 'ps_set_text',
|
|
167
|
-
description: 'DEPRECATED — use ps_text(op=set_font / set_color / set_alignment / set_content) instead (kept for one release for backward compatibility, identical behaviour). Set an attribute of the currently active text layer — font (family or PostScript name, optionally size), color (RGB), alignment, or text content — selected via `property`. Idempotent. Throws if the active layer is not a text layer. Font names accept either the PostScript name ("ArialMT") or family name ("Arial", resolved to its Regular/first variant); throws clearly if no installed font matches.',
|
|
168
|
-
inputSchema: SET_TEXT_INPUT_SCHEMA,
|
|
169
|
-
outputSchema: {
|
|
170
|
-
type: 'object',
|
|
171
|
-
properties: {
|
|
172
|
-
requested: { type: 'string' },
|
|
173
|
-
font: { type: 'string' },
|
|
174
|
-
size: { type: 'number' },
|
|
175
|
-
matched_by: {
|
|
176
|
-
type: 'string',
|
|
177
|
-
enum: ['postScriptName', 'family+regular', 'family', 'name'],
|
|
178
|
-
},
|
|
179
|
-
color: { type: 'string' },
|
|
180
|
-
alignment: { type: 'string' },
|
|
181
|
-
text: { type: 'string' },
|
|
182
|
-
},
|
|
183
|
-
},
|
|
184
|
-
annotations: {
|
|
185
|
-
title: 'Set Text',
|
|
186
|
-
idempotentHint: true,
|
|
187
|
-
},
|
|
188
|
-
},
|
|
189
|
-
handler: async (args) => setText(connection, snippetClient, args),
|
|
190
|
-
},
|
|
191
146
|
];
|
|
192
147
|
}
|
|
193
148
|
async function textDispatch(connection, snippetClient, rawArgs) {
|
|
@@ -208,30 +163,6 @@ async function textDispatch(connection, snippetClient, rawArgs) {
|
|
|
208
163
|
return unknownDiscriminator('text op', op, TEXT_OPS);
|
|
209
164
|
}
|
|
210
165
|
}
|
|
211
|
-
async function setText(connection, snippetClient, rawArgs) {
|
|
212
|
-
const property = rawArgs.property;
|
|
213
|
-
const { property: _omit, ...rest } = rawArgs;
|
|
214
|
-
switch (property) {
|
|
215
|
-
case 'font':
|
|
216
|
-
return setTextFont(connection, snippetClient, rest);
|
|
217
|
-
case 'color':
|
|
218
|
-
return setTextColor(connection, snippetClient, rest);
|
|
219
|
-
case 'alignment':
|
|
220
|
-
return setTextAlignment(connection, snippetClient, rest);
|
|
221
|
-
case 'content':
|
|
222
|
-
return updateTextContent(connection, snippetClient, rest);
|
|
223
|
-
default:
|
|
224
|
-
return {
|
|
225
|
-
content: [
|
|
226
|
-
{
|
|
227
|
-
type: 'text',
|
|
228
|
-
text: `Error: unknown text property "${String(property)}". Allowed: ${SET_TEXT_PROPERTIES.join(', ')}.`,
|
|
229
|
-
},
|
|
230
|
-
],
|
|
231
|
-
isError: true,
|
|
232
|
-
};
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
166
|
async function setTextFont(connection, snippetClient, rawArgs) {
|
|
236
167
|
return runSnippetTool({
|
|
237
168
|
connection,
|
package/dist/update/check.js
CHANGED
|
@@ -1,10 +1,33 @@
|
|
|
1
1
|
import { VERSION } from '../version.js';
|
|
2
2
|
import { resolveInstallChannel } from '../install-channel.js';
|
|
3
|
-
const
|
|
3
|
+
const DEFAULT_LATEST_MANIFEST_URL = 'https://registry.npmjs.org/editmamei/latest';
|
|
4
4
|
const RELEASES_URL = 'https://editmamei.com/download';
|
|
5
5
|
export function resolveUpdateCheckUrl(env = process.env) {
|
|
6
6
|
const override = env.EDITMAMEI_UPDATE_CHECK_URL;
|
|
7
|
-
return override && override.length > 0 ? override :
|
|
7
|
+
return override && override.length > 0 ? override : DEFAULT_LATEST_MANIFEST_URL;
|
|
8
|
+
}
|
|
9
|
+
const MAX_FIX_VERSIONS = 16;
|
|
10
|
+
const MAX_TOOLS_PER_VERSION = 16;
|
|
11
|
+
const MAX_TOOL_NAME_LENGTH = 64;
|
|
12
|
+
export function parseFixesByVersion(raw) {
|
|
13
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
|
|
14
|
+
return {};
|
|
15
|
+
const out = {};
|
|
16
|
+
let versions = 0;
|
|
17
|
+
for (const [version, tools] of Object.entries(raw)) {
|
|
18
|
+
if (versions >= MAX_FIX_VERSIONS)
|
|
19
|
+
break;
|
|
20
|
+
if (!parseSemver(version) || !Array.isArray(tools))
|
|
21
|
+
continue;
|
|
22
|
+
const names = tools
|
|
23
|
+
.filter((t) => typeof t === 'string' && t.length > 0 && t.length <= MAX_TOOL_NAME_LENGTH)
|
|
24
|
+
.slice(0, MAX_TOOLS_PER_VERSION);
|
|
25
|
+
if (names.length > 0) {
|
|
26
|
+
out[version] = names;
|
|
27
|
+
versions++;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
8
31
|
}
|
|
9
32
|
export function httpFetchLatest() {
|
|
10
33
|
return async (url, timeoutMs) => {
|
|
@@ -18,7 +41,12 @@ export function httpFetchLatest() {
|
|
|
18
41
|
if (!res.ok)
|
|
19
42
|
return null;
|
|
20
43
|
const data = (await res.json());
|
|
21
|
-
|
|
44
|
+
if (typeof data.version !== 'string')
|
|
45
|
+
return null;
|
|
46
|
+
return {
|
|
47
|
+
version: data.version,
|
|
48
|
+
fixesByVersion: parseFixesByVersion(data.editmamei?.fixesByVersion),
|
|
49
|
+
};
|
|
22
50
|
}
|
|
23
51
|
catch {
|
|
24
52
|
return null;
|
|
@@ -43,6 +71,30 @@ export function isNewer(latest, current) {
|
|
|
43
71
|
}
|
|
44
72
|
return false;
|
|
45
73
|
}
|
|
74
|
+
export function fixedToolsSince(fixesByVersion, current, latest) {
|
|
75
|
+
const versions = Object.keys(fixesByVersion)
|
|
76
|
+
.filter((v) => isNewer(v, current) && !isNewer(v, latest))
|
|
77
|
+
.sort((a, b) => {
|
|
78
|
+
const pa = parseSemver(a);
|
|
79
|
+
const pb = parseSemver(b);
|
|
80
|
+
for (let i = 0; i < 3; i++) {
|
|
81
|
+
if (pa[i] !== pb[i])
|
|
82
|
+
return pa[i] - pb[i];
|
|
83
|
+
}
|
|
84
|
+
return 0;
|
|
85
|
+
});
|
|
86
|
+
const seen = new Set();
|
|
87
|
+
const out = [];
|
|
88
|
+
for (const v of versions) {
|
|
89
|
+
for (const tool of fixesByVersion[v]) {
|
|
90
|
+
if (!seen.has(tool)) {
|
|
91
|
+
seen.add(tool);
|
|
92
|
+
out.push(tool);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
46
98
|
export function updateMessage(channel, latest) {
|
|
47
99
|
switch (channel) {
|
|
48
100
|
case 'mcpb':
|
|
@@ -64,12 +116,19 @@ export async function checkForUpdate(opts = {}) {
|
|
|
64
116
|
const current = opts.current ?? VERSION;
|
|
65
117
|
const channel = resolveInstallChannel(env);
|
|
66
118
|
const fetchLatest = opts.fetchLatest ?? httpFetchLatest();
|
|
67
|
-
const
|
|
68
|
-
if (!
|
|
119
|
+
const manifest = await fetchLatest(resolveUpdateCheckUrl(env), opts.timeoutMs ?? 4000);
|
|
120
|
+
if (!manifest || !parseSemver(manifest.version))
|
|
69
121
|
return null;
|
|
122
|
+
const latest = manifest.version;
|
|
70
123
|
if (!isNewer(latest, current))
|
|
71
124
|
return null;
|
|
72
|
-
return {
|
|
125
|
+
return {
|
|
126
|
+
current,
|
|
127
|
+
latest,
|
|
128
|
+
channel,
|
|
129
|
+
how_to_update: updateMessage(channel, latest),
|
|
130
|
+
fixed_tools: fixedToolsSince(manifest.fixesByVersion, current, latest),
|
|
131
|
+
};
|
|
73
132
|
}
|
|
74
133
|
catch {
|
|
75
134
|
return null;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { listRecentSessionIds, readSessionLog, } from '../utils/session-log-reader.js';
|
|
2
|
+
const RECENT_SESSION_SCAN_LIMIT = 5;
|
|
3
|
+
export async function previousSessionFailureCounts(currentSessionId, opts = {}) {
|
|
4
|
+
const ids = await listRecentSessionIds(RECENT_SESSION_SCAN_LIMIT, opts);
|
|
5
|
+
for (const id of ids) {
|
|
6
|
+
if (id === currentSessionId)
|
|
7
|
+
continue;
|
|
8
|
+
const entries = await readSessionLog(id, opts);
|
|
9
|
+
const counts = new Map();
|
|
10
|
+
let sawCall = false;
|
|
11
|
+
for (const entry of entries) {
|
|
12
|
+
if ('type' in entry && entry.type === 'meta')
|
|
13
|
+
continue;
|
|
14
|
+
const call = entry;
|
|
15
|
+
if (typeof call.tool !== 'string')
|
|
16
|
+
continue;
|
|
17
|
+
sawCall = true;
|
|
18
|
+
if (call.success === false) {
|
|
19
|
+
counts.set(call.tool, (counts.get(call.tool) ?? 0) + 1);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
if (sawCall)
|
|
23
|
+
return counts;
|
|
24
|
+
}
|
|
25
|
+
return new Map();
|
|
26
|
+
}
|
|
27
|
+
export function relevantFixes(failureCounts, fixedTools, cap = 3) {
|
|
28
|
+
return fixedTools
|
|
29
|
+
.filter((tool) => (failureCounts.get(tool) ?? 0) > 0)
|
|
30
|
+
.map((tool) => ({ tool, failures: failureCounts.get(tool) }))
|
|
31
|
+
.sort((a, b) => b.failures - a.failures)
|
|
32
|
+
.slice(0, cap);
|
|
33
|
+
}
|
package/dist/utils/jsx.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
const NON_ASCII = /[^ -~]/g;
|
|
1
2
|
export function jsLit(value) {
|
|
2
|
-
return JSON.stringify(String(value));
|
|
3
|
+
return JSON.stringify(String(value)).replace(NON_ASCII, (ch) => `\\u${ch.charCodeAt(0).toString(16).padStart(4, '0')}`);
|
|
3
4
|
}
|
|
4
5
|
export function jsNum(value, fallback) {
|
|
5
6
|
const n = typeof value === 'number' ? value : Number(value);
|
|
@@ -3,5 +3,7 @@ export const OPEN_DOCUMENT_REPROBE_TIMEOUT_MS = 10_000;
|
|
|
3
3
|
export const CAMERA_RAW_FILTER_TIMEOUT_MS = 120_000;
|
|
4
4
|
export const SELECT_SUBJECT_TIMEOUT_MS = 120_000;
|
|
5
5
|
export const SELECT_SKY_TIMEOUT_MS = 120_000;
|
|
6
|
+
export const SELECT_FOCUS_AREA_TIMEOUT_MS = 120_000;
|
|
7
|
+
export const SKY_REPLACEMENT_TIMEOUT_MS = 120_000;
|
|
6
8
|
export const ANNOTATED_PREVIEW_TIMEOUT_MS = 90_000;
|
|
7
9
|
export const SCENE_CHANNEL_TIMEOUT_MS = 120_000;
|
|
@@ -24,6 +24,7 @@ export function generateSessionId(now = new Date()) {
|
|
|
24
24
|
}
|
|
25
25
|
export const ERROR_CLASS_TABLE = [
|
|
26
26
|
{ errorClass: 'ps_empty_error', pattern: /returned an empty error|failed with no message/i },
|
|
27
|
+
{ errorClass: 'wrong_layer_kind', pattern: /is a group, not an art layer/i },
|
|
27
28
|
{
|
|
28
29
|
errorClass: 'layer_not_found',
|
|
29
30
|
pattern: /\blayer not found|no layer named|(?:layer_to_move|target_layer_name) not found|layer "[^"]*" not found/i,
|
|
@@ -210,6 +211,9 @@ export class SessionLog {
|
|
|
210
211
|
get path() {
|
|
211
212
|
return join(this.dir, `${this.sessionId}.ndjson`);
|
|
212
213
|
}
|
|
214
|
+
get directory() {
|
|
215
|
+
return this.dir;
|
|
216
|
+
}
|
|
213
217
|
setMcpClientGetter(fn) {
|
|
214
218
|
this.getMcpClientFn = fn;
|
|
215
219
|
}
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = '1.
|
|
1
|
+
export const VERSION = '1.2.0';
|
package/package.json
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "editmamei",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Photoshop MCP server: natural-language AI photo editing with your own Photoshop (Community Edition)",
|
|
5
5
|
"mcpName": "io.github.editmamei/editmamei",
|
|
6
|
+
"editmamei": {
|
|
7
|
+
"fixesByVersion": {
|
|
8
|
+
"1.1.0": [
|
|
9
|
+
"ps_create_clipping_mask"
|
|
10
|
+
],
|
|
11
|
+
"1.2.0": [
|
|
12
|
+
"ps_delete_layer",
|
|
13
|
+
"ps_select_layer"
|
|
14
|
+
]
|
|
15
|
+
}
|
|
16
|
+
},
|
|
6
17
|
"main": "dist/index.js",
|
|
7
18
|
"type": "module",
|
|
8
19
|
"bin": {
|