editmamei 1.2.0 → 1.2.1
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/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/cli/help.js +1 -0
- package/dist/core/server.js +9 -3
- package/dist/core/tool-groups.js +1 -0
- package/dist/core/tool-registry.js +3 -0
- package/dist/core/tool-tiers.js +1 -0
- package/dist/delivery/provision.js +2 -2
- package/dist/kernel/module-lifecycle.js +30 -4
- package/dist/perception/region-precompute.js +39 -0
- package/dist/skills/editmamei-skill.zip +0 -0
- package/dist/tools/document-tools.js +143 -2
- package/dist/tools/scene-tools.js +45 -21
- package/dist/utils/session-log.js +13 -0
- package/dist/version.js +1 -1
- package/package.json +4 -1
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/dist/cli/help.js
CHANGED
package/dist/core/server.js
CHANGED
|
@@ -222,6 +222,7 @@ export class EditmameiServer {
|
|
|
222
222
|
toolRegistry: this.toolRegistry,
|
|
223
223
|
logger: this.logger,
|
|
224
224
|
assertToolsClassified: () => this.assertToolsClassified(),
|
|
225
|
+
classifyTool: (name) => this.classifyTool(name),
|
|
225
226
|
});
|
|
226
227
|
const proModule = this.moduleLifecycle.resolveProModule();
|
|
227
228
|
this.kernel = new Kernel({
|
|
@@ -252,10 +253,13 @@ export class EditmameiServer {
|
|
|
252
253
|
async ensureEntitledModuleFresh(delivery = {}) {
|
|
253
254
|
return this.moduleLifecycle.ensureEntitledModuleFresh(delivery);
|
|
254
255
|
}
|
|
256
|
+
classifyTool(name) {
|
|
257
|
+
tierOf(name);
|
|
258
|
+
groupOf(name);
|
|
259
|
+
}
|
|
255
260
|
assertToolsClassified() {
|
|
256
261
|
for (const tool of this.toolRegistry.list()) {
|
|
257
|
-
|
|
258
|
-
groupOf(tool.name);
|
|
262
|
+
this.classifyTool(tool.name);
|
|
259
263
|
}
|
|
260
264
|
}
|
|
261
265
|
listCapabilities() {
|
|
@@ -515,7 +519,9 @@ export class EditmameiServer {
|
|
|
515
519
|
}
|
|
516
520
|
return {
|
|
517
521
|
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}
|
|
522
|
+
` (this install runs v${u.current}).${fixNote} To update: ${u.how_to_update}` +
|
|
523
|
+
` Not urgent: they can also read and subscribe to release notes at` +
|
|
524
|
+
` https://editmamei.com/blog?src=update_notice`,
|
|
519
525
|
notify: true,
|
|
520
526
|
};
|
|
521
527
|
}
|
package/dist/core/tool-groups.js
CHANGED
|
@@ -93,6 +93,7 @@ export const TOOL_GROUPS = {
|
|
|
93
93
|
ps_compare_regions: 'verify',
|
|
94
94
|
ps_get_layer_bounds_diff: 'verify',
|
|
95
95
|
ps_get_selection_preview: 'verify',
|
|
96
|
+
ps_document: 'document',
|
|
96
97
|
ps_create_document: 'document',
|
|
97
98
|
ps_open_document: 'document',
|
|
98
99
|
ps_close_document: 'document',
|
package/dist/core/tool-tiers.js
CHANGED
|
@@ -5,7 +5,7 @@ import { installModule, readInstalledModule } from './store.js';
|
|
|
5
5
|
import { Logger } from '../utils/logger.js';
|
|
6
6
|
const logger = new Logger('Modules');
|
|
7
7
|
const SKU_RE = /^[a-z0-9-]{2,32}$/;
|
|
8
|
-
const VERSION_RE = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/;
|
|
8
|
+
export const VERSION_RE = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/;
|
|
9
9
|
const MAX_ARTIFACT_BYTES = 64 * 1024 * 1024;
|
|
10
10
|
export async function provisionModules(key, opts = {}) {
|
|
11
11
|
const result = {
|
|
@@ -137,7 +137,7 @@ export async function provisionModules(key, opts = {}) {
|
|
|
137
137
|
function errMsg(e) {
|
|
138
138
|
return e instanceof Error ? e.message : String(e);
|
|
139
139
|
}
|
|
140
|
-
function compareVersions(a, b) {
|
|
140
|
+
export function compareVersions(a, b) {
|
|
141
141
|
const [aCore, aPre] = a.split('-', 2);
|
|
142
142
|
const [bCore, bPre] = b.split('-', 2);
|
|
143
143
|
const ap = aCore.split('.').map(Number);
|
|
@@ -5,9 +5,10 @@ import { EDITION } from '../edition.js';
|
|
|
5
5
|
import { resolveProBinaryPath } from '../api/snippet-client.js';
|
|
6
6
|
import { isProEntitled } from '../license/entitlement.js';
|
|
7
7
|
import { loadVerifiedModule, readInstalledModule, installedPath, PRO_SKU, } from '../delivery/store.js';
|
|
8
|
-
import { provisionModules } from '../delivery/provision.js';
|
|
8
|
+
import { provisionModules, compareVersions, VERSION_RE, } from '../delivery/provision.js';
|
|
9
9
|
import { readLicense } from '../license/store.js';
|
|
10
10
|
import { HOST_MIN_ABI } from './host-api.js';
|
|
11
|
+
import { VERSION } from '../version.js';
|
|
11
12
|
export function classifyModuleOutcome(inputs) {
|
|
12
13
|
if (inputs.proModuleLoaded && inputs.skipReason === null)
|
|
13
14
|
return 'loaded';
|
|
@@ -38,11 +39,12 @@ export class ModuleLifecycle {
|
|
|
38
39
|
if (isProEntitled()) {
|
|
39
40
|
const verified = loadVerifiedModule(PRO_SKU);
|
|
40
41
|
if (verified) {
|
|
41
|
-
const
|
|
42
|
+
const installed = readInstalledModule(PRO_SKU);
|
|
42
43
|
this._proModule = {
|
|
43
44
|
importer: () => import(pathToFileURL(verified.handlersPath).href),
|
|
44
45
|
binDir: verified.binDir,
|
|
45
|
-
abi,
|
|
46
|
+
abi: installed?.abi ?? null,
|
|
47
|
+
version: installed?.version ?? null,
|
|
46
48
|
};
|
|
47
49
|
return this._proModule;
|
|
48
50
|
}
|
|
@@ -61,6 +63,7 @@ export class ModuleLifecycle {
|
|
|
61
63
|
importer: () => import(inTreeProSpecifier),
|
|
62
64
|
binDir: dirname(resolveProBinaryPath()),
|
|
63
65
|
abi: null,
|
|
66
|
+
version: null,
|
|
64
67
|
};
|
|
65
68
|
return this._proModule;
|
|
66
69
|
}
|
|
@@ -79,10 +82,33 @@ export class ModuleLifecycle {
|
|
|
79
82
|
const snapshot = this.deps.toolRegistry.snapshot();
|
|
80
83
|
try {
|
|
81
84
|
await this.kernel.loadDownloaded(this._proModule.importer);
|
|
85
|
+
if (this._proModule.version !== null &&
|
|
86
|
+
VERSION_RE.test(this._proModule.version) &&
|
|
87
|
+
compareVersions(this._proModule.version, VERSION) > 0) {
|
|
88
|
+
const added = this.deps.toolRegistry
|
|
89
|
+
.list()
|
|
90
|
+
.map((tool) => tool.name)
|
|
91
|
+
.filter((name) => !snapshot.has(name));
|
|
92
|
+
for (const name of added) {
|
|
93
|
+
try {
|
|
94
|
+
this.deps.classifyTool(name);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
this.deps.toolRegistry.unregister(name);
|
|
98
|
+
this.deps.logger.warn(`Module tool '${name}' is not recognized by this host — skipping it; the ` +
|
|
99
|
+
`rest of the module is loaded. Update Editmamei to use it.`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
82
103
|
this.deps.assertToolsClassified();
|
|
83
104
|
}
|
|
84
105
|
catch (err) {
|
|
85
|
-
|
|
106
|
+
let changed = 0;
|
|
107
|
+
for (const tool of this.deps.toolRegistry.list()) {
|
|
108
|
+
const prior = snapshot.get(tool.name);
|
|
109
|
+
if (!prior || this.deps.toolRegistry.get(tool.name) !== prior)
|
|
110
|
+
changed++;
|
|
111
|
+
}
|
|
86
112
|
this.deps.toolRegistry.restore(snapshot);
|
|
87
113
|
this.deps.logger.warn(`Pro module could not be loaded on this host — booting Community and rolling back ` +
|
|
88
114
|
`${changed} module tool change(s); will re-provision in the background: ` +
|
|
@@ -10,11 +10,24 @@ let cachedMenu = [];
|
|
|
10
10
|
export function __resetPrecompute() {
|
|
11
11
|
lastPrecomputedKey = null;
|
|
12
12
|
cachedMenu = [];
|
|
13
|
+
channelsDocState = null;
|
|
13
14
|
}
|
|
14
15
|
export const CHANNEL_PREFIX = 'scene:';
|
|
15
16
|
export async function saveSelectionAsSceneChannel(connection, target) {
|
|
16
17
|
await runScript(connection, saveSelectionToNamedChannelScript(`${CHANNEL_PREFIX}${target}`), SCENE_CHANNEL_TIMEOUT_MS);
|
|
17
18
|
}
|
|
19
|
+
let channelsDocState = null;
|
|
20
|
+
export async function invalidateSceneChannelsIfStale(connection, cacheKey) {
|
|
21
|
+
if (channelsDocState === cacheKey)
|
|
22
|
+
return;
|
|
23
|
+
try {
|
|
24
|
+
await runScript(connection, deleteSceneChannelsScript(), SCENE_CHANNEL_TIMEOUT_MS);
|
|
25
|
+
channelsDocState = cacheKey;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
channelsDocState = null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
18
31
|
const PRECOMPUTE_TARGETS = [
|
|
19
32
|
'sky',
|
|
20
33
|
'ground',
|
|
@@ -24,6 +37,31 @@ const PRECOMPUTE_TARGETS = [
|
|
|
24
37
|
'subject',
|
|
25
38
|
'face',
|
|
26
39
|
];
|
|
40
|
+
export function candidateMenu(model) {
|
|
41
|
+
const advertise = (target) => ({
|
|
42
|
+
key: `${CHANNEL_PREFIX}${target}`,
|
|
43
|
+
target,
|
|
44
|
+
method: 'on_demand',
|
|
45
|
+
bounds: null,
|
|
46
|
+
on_demand: true,
|
|
47
|
+
});
|
|
48
|
+
const hasFace = model.faces.length > 0;
|
|
49
|
+
const hasSubject = model.subjects.length > 0;
|
|
50
|
+
const hasPerson = model.subjects.some((s) => s.label === 'person');
|
|
51
|
+
const menu = [
|
|
52
|
+
advertise('sky'),
|
|
53
|
+
advertise('ground'),
|
|
54
|
+
advertise('shadows'),
|
|
55
|
+
advertise('highlights'),
|
|
56
|
+
];
|
|
57
|
+
if (hasPerson || hasFace)
|
|
58
|
+
menu.push(advertise('skin'));
|
|
59
|
+
if (hasSubject)
|
|
60
|
+
menu.push(advertise('subject'));
|
|
61
|
+
if (hasFace)
|
|
62
|
+
menu.push(advertise('face'));
|
|
63
|
+
return menu;
|
|
64
|
+
}
|
|
27
65
|
function deleteSceneChannelsScript() {
|
|
28
66
|
return `
|
|
29
67
|
if (app.documents.length === 0) { throw new Error('No document is open in Photoshop'); }
|
|
@@ -145,6 +183,7 @@ export async function precomputeRegions(connection, snippet, model, composition,
|
|
|
145
183
|
const tally = { scripts: 0 };
|
|
146
184
|
const countedConnection = countingConnection(connection, tally);
|
|
147
185
|
await runScript(countedConnection, deleteSceneChannelsScript(), SCENE_CHANNEL_TIMEOUT_MS);
|
|
186
|
+
channelsDocState = model.provenance.cache_key;
|
|
148
187
|
const menu = [];
|
|
149
188
|
for (const target of PRECOMPUTE_TARGETS) {
|
|
150
189
|
try {
|
|
Binary file
|
|
@@ -36,6 +36,17 @@ const createDocumentSchema = {
|
|
|
36
36
|
},
|
|
37
37
|
required: ['width', 'height'],
|
|
38
38
|
};
|
|
39
|
+
const documentTargetProps = {
|
|
40
|
+
name: {
|
|
41
|
+
type: 'string',
|
|
42
|
+
description: "Target an open document by its exact Photoshop name, INCLUDING the extension as shown in the tab (e.g. 'portrait.jpg', not 'portrait'). If two open documents share a name the call fails rather than guessing — target by id instead.",
|
|
43
|
+
},
|
|
44
|
+
id: {
|
|
45
|
+
type: 'integer',
|
|
46
|
+
minimum: 1,
|
|
47
|
+
description: 'Target an open document by its Photoshop document id. Unambiguous — prefer this when names collide.',
|
|
48
|
+
},
|
|
49
|
+
};
|
|
39
50
|
const closeDocumentSchema = {
|
|
40
51
|
type: 'object',
|
|
41
52
|
properties: {
|
|
@@ -44,8 +55,22 @@ const closeDocumentSchema = {
|
|
|
44
55
|
description: 'Whether to save changes before closing',
|
|
45
56
|
default: false,
|
|
46
57
|
},
|
|
58
|
+
...documentTargetProps,
|
|
47
59
|
},
|
|
48
60
|
};
|
|
61
|
+
const DOCUMENT_OPS = ['list', 'activate'];
|
|
62
|
+
const documentSchema = {
|
|
63
|
+
type: 'object',
|
|
64
|
+
properties: {
|
|
65
|
+
op: {
|
|
66
|
+
type: 'string',
|
|
67
|
+
enum: [...DOCUMENT_OPS],
|
|
68
|
+
description: 'list: every open document (index, id, name, path, saved, active, dimensions) — safe to call when NOTHING is open, which is the point. activate: make one of them the active document, by name or id.',
|
|
69
|
+
},
|
|
70
|
+
...documentTargetProps,
|
|
71
|
+
},
|
|
72
|
+
required: ['op'],
|
|
73
|
+
};
|
|
49
74
|
const openDocumentSchema = {
|
|
50
75
|
type: 'object',
|
|
51
76
|
properties: {
|
|
@@ -180,10 +205,57 @@ export function createDocumentTools(connection, snippetClient) {
|
|
|
180
205
|
},
|
|
181
206
|
handler: async (args) => createDocument(connection, snippetClient, args),
|
|
182
207
|
},
|
|
208
|
+
{
|
|
209
|
+
tool: {
|
|
210
|
+
name: 'ps_document',
|
|
211
|
+
description: "See and steer WHICH documents are open, without touching their content. op=list answers 'what is open, which one is active, and does it have unsaved changes' — and it is the one document tool that works when nothing is open at all, so it is the recovery read after a 'No document is open' failure. op=activate switches the active document by name or id, which is how you fix having edited the wrong one. Read-only with respect to pixels; use ps_open_document to load a file and ps_close_document to close one.",
|
|
212
|
+
inputSchema: documentSchema,
|
|
213
|
+
outputSchema: {
|
|
214
|
+
type: 'object',
|
|
215
|
+
properties: {
|
|
216
|
+
op: { type: 'string' },
|
|
217
|
+
count: { type: 'number' },
|
|
218
|
+
documents: {
|
|
219
|
+
type: 'array',
|
|
220
|
+
items: {
|
|
221
|
+
type: 'object',
|
|
222
|
+
properties: {
|
|
223
|
+
index: { type: 'number' },
|
|
224
|
+
id: { type: 'number' },
|
|
225
|
+
name: { type: 'string' },
|
|
226
|
+
path: {
|
|
227
|
+
type: ['string', 'null'],
|
|
228
|
+
description: 'Absolute path, or null for a document never saved to disk.',
|
|
229
|
+
},
|
|
230
|
+
saved: {
|
|
231
|
+
type: ['boolean', 'null'],
|
|
232
|
+
description: 'False when the document has unsaved changes. Null when Photoshop would not report it.',
|
|
233
|
+
},
|
|
234
|
+
active: { type: 'boolean' },
|
|
235
|
+
width_px: { type: ['number', 'null'] },
|
|
236
|
+
height_px: { type: ['number', 'null'] },
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
activated: { type: 'boolean' },
|
|
241
|
+
id: { type: 'number' },
|
|
242
|
+
name: { type: 'string' },
|
|
243
|
+
context: { type: 'object' },
|
|
244
|
+
},
|
|
245
|
+
required: ['op'],
|
|
246
|
+
},
|
|
247
|
+
annotations: {
|
|
248
|
+
title: 'List / Activate Documents',
|
|
249
|
+
readOnlyHint: true,
|
|
250
|
+
idempotentHint: true,
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
handler: async (args) => documentOp(connection, snippetClient, args),
|
|
254
|
+
},
|
|
183
255
|
{
|
|
184
256
|
tool: {
|
|
185
257
|
name: 'ps_close_document',
|
|
186
|
-
description: 'Close the active
|
|
258
|
+
description: 'Close a Photoshop document — the active one by default, or a specific one by name or id. Destructive if save=false and the document has unsaved changes. If two open documents share the requested name the call fails rather than guessing. Returns the closed document name plus a fresh context block (which document, if any, is active afterwards).',
|
|
187
259
|
inputSchema: closeDocumentSchema,
|
|
188
260
|
outputSchema: {
|
|
189
261
|
type: 'object',
|
|
@@ -340,11 +412,80 @@ async function createDocument(connection, snippetClient, rawArgs) {
|
|
|
340
412
|
successText: (_result, args) => `Document created: ${args.width}x${args.height}px at ${args.resolution}dpi (${args.color_mode})`,
|
|
341
413
|
});
|
|
342
414
|
}
|
|
415
|
+
function documentTargetArgs(args) {
|
|
416
|
+
const out = {};
|
|
417
|
+
if (typeof args.name === 'string' && args.name !== '')
|
|
418
|
+
out.name = args.name;
|
|
419
|
+
if (typeof args.id === 'number')
|
|
420
|
+
out.id = args.id;
|
|
421
|
+
return out;
|
|
422
|
+
}
|
|
423
|
+
function emptySelectorError(args) {
|
|
424
|
+
if (typeof args.name === 'string' && args.name === '') {
|
|
425
|
+
return 'name was an empty string. Pass a real document name, or omit name entirely to act on the active document.';
|
|
426
|
+
}
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
async function documentOp(connection, snippetClient, rawArgs) {
|
|
430
|
+
let errorPrefix = 'Error reading documents';
|
|
431
|
+
try {
|
|
432
|
+
const args = validateArgs(documentSchema, rawArgs);
|
|
433
|
+
const op = args.op;
|
|
434
|
+
const target = documentTargetArgs(args);
|
|
435
|
+
if (op === 'activate') {
|
|
436
|
+
errorPrefix = 'Error activating document';
|
|
437
|
+
const empty = emptySelectorError(args);
|
|
438
|
+
if (empty !== null)
|
|
439
|
+
return toolErrorResult(errorPrefix, new Error(empty));
|
|
440
|
+
if (Object.keys(target).length === 0) {
|
|
441
|
+
return toolErrorResult(errorPrefix, new Error('op=activate needs a name or an id. Call op=list to see what is open.'));
|
|
442
|
+
}
|
|
443
|
+
const script = await snippetClient.build('activateDocument', target);
|
|
444
|
+
const result = (await runScript(connection, script));
|
|
445
|
+
return {
|
|
446
|
+
content: [{ type: 'text', text: `Activated "${result.name}" (id ${result.id}).` }],
|
|
447
|
+
structuredContent: {
|
|
448
|
+
op,
|
|
449
|
+
activated: true,
|
|
450
|
+
id: result.id,
|
|
451
|
+
name: result.name,
|
|
452
|
+
context: result.context,
|
|
453
|
+
},
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
const script = await snippetClient.build('listDocuments', {});
|
|
457
|
+
const result = (await runScript(connection, script));
|
|
458
|
+
const docs = result.documents ?? [];
|
|
459
|
+
const summary = docs.length
|
|
460
|
+
? `${docs.length} open document(s): ${docs
|
|
461
|
+
.map((d) => `${d.name} (id ${d.id}${d.active ? ', ACTIVE' : ''}${d.saved === false ? ', unsaved changes' : ''})`)
|
|
462
|
+
.join('; ')}.`
|
|
463
|
+
: 'No documents are open in Photoshop. Open one with ps_open_document, or create one with ps_create_document.';
|
|
464
|
+
return {
|
|
465
|
+
content: [{ type: 'text', text: summary }],
|
|
466
|
+
structuredContent: {
|
|
467
|
+
op,
|
|
468
|
+
count: docs.length,
|
|
469
|
+
documents: docs,
|
|
470
|
+
context: result.context,
|
|
471
|
+
},
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
catch (error) {
|
|
475
|
+
return toolErrorResult(errorPrefix, error);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
343
478
|
async function closeDocument(connection, snippetClient, rawArgs) {
|
|
344
479
|
try {
|
|
345
480
|
const args = validateArgs(closeDocumentSchema, rawArgs);
|
|
346
481
|
const save = args.save;
|
|
347
|
-
const
|
|
482
|
+
const empty = emptySelectorError(args);
|
|
483
|
+
if (empty !== null)
|
|
484
|
+
return toolErrorResult('Error closing document', new Error(empty));
|
|
485
|
+
const script = await snippetClient.build('closeDocument', {
|
|
486
|
+
save,
|
|
487
|
+
...documentTargetArgs(args),
|
|
488
|
+
});
|
|
348
489
|
const result = (await runScript(connection, script));
|
|
349
490
|
return {
|
|
350
491
|
content: [
|
|
@@ -4,7 +4,7 @@ import { OnnxDetectionClient } from '../detection/detection-client.js';
|
|
|
4
4
|
import { ANNOTATED_PREVIEW_JPEG_QUALITY } from '../utils/jpeg-quality.js';
|
|
5
5
|
import { buildSceneModel } from '../perception/scene-model.js';
|
|
6
6
|
import { resolveSelection, SELECT_REFERENCE_TARGETS, } from '../perception/select-recipes.js';
|
|
7
|
-
import { precomputeRegions, loadPrecomputedRegion, saveSelectionAsSceneChannel, CHANNEL_PREFIX, } from '../perception/region-precompute.js';
|
|
7
|
+
import { precomputeRegions, candidateMenu, invalidateSceneChannelsIfStale, loadPrecomputedRegion, saveSelectionAsSceneChannel, CHANNEL_PREFIX, } from '../perception/region-precompute.js';
|
|
8
8
|
import { toolErrorResult } from '../utils/tool-helpers.js';
|
|
9
9
|
import { Logger } from '../utils/logger.js';
|
|
10
10
|
import { isProEntitled } from '../license/entitlement.js';
|
|
@@ -54,7 +54,6 @@ function faceMenuFor(model, hasPro) {
|
|
|
54
54
|
key: `${CHANNEL_PREFIX}${target}`,
|
|
55
55
|
target,
|
|
56
56
|
method: 'face_mesh',
|
|
57
|
-
confidence: 1,
|
|
58
57
|
bounds: null,
|
|
59
58
|
on_demand: true,
|
|
60
59
|
}));
|
|
@@ -82,8 +81,8 @@ const sceneSchema = {
|
|
|
82
81
|
},
|
|
83
82
|
save_regions: {
|
|
84
83
|
type: 'boolean',
|
|
85
|
-
default:
|
|
86
|
-
description: '
|
|
84
|
+
default: false,
|
|
85
|
+
description: 'EAGERLY derive every region (sky/ground/shadows/highlights/skin/subject/face) up front and SAVE each confident one as a managed `scene:*` alpha channel, so the returned menu carries a verified method + confidence for each. Costs one derive per target — measured at ~21s on a 4898x3265 layered document, against a 30s script timeout — so it is OFF by default. Leave it off unless you specifically need every region scored in one call: the default advertises the same menu as `on_demand` entries and ps_select_by_reference derives whichever region you actually ask for (then saves its channel, so repeats of THAT region are instant). The `scene:` channel-name prefix is RESERVED: channels matching it are treated as derived and are deleted on the next scene read and on ps_save_psd, so do not give a channel you want to keep a `scene:`-prefixed name.',
|
|
87
86
|
},
|
|
88
87
|
composition_context: {
|
|
89
88
|
type: 'object',
|
|
@@ -143,7 +142,7 @@ async function scene(connection, snippet, client, rawArgs, proRefine, hasPro = f
|
|
|
143
142
|
const args = validateArgs(sceneSchema, rawArgs);
|
|
144
143
|
const annotate = args.annotate ?? true;
|
|
145
144
|
const refresh = args.refresh ?? false;
|
|
146
|
-
const saveRegions = args.save_regions ??
|
|
145
|
+
const saveRegions = args.save_regions ?? false;
|
|
147
146
|
const built = await buildSceneModel(connection, snippet, client, {
|
|
148
147
|
useCache: !refresh,
|
|
149
148
|
maxDimension: args.max_dimension,
|
|
@@ -165,6 +164,14 @@ async function scene(connection, snippet, client, rawArgs, proRefine, hasPro = f
|
|
|
165
164
|
precomputeOk = false;
|
|
166
165
|
}
|
|
167
166
|
}
|
|
167
|
+
else {
|
|
168
|
+
try {
|
|
169
|
+
await invalidateSceneChannelsIfStale(connection, model.provenance.cache_key);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
}
|
|
173
|
+
regions = [...candidateMenu(model), ...faceMenuFor(model, hasPro)];
|
|
174
|
+
}
|
|
168
175
|
const content = [];
|
|
169
176
|
if (annotate && built.decoded) {
|
|
170
177
|
try {
|
|
@@ -181,19 +188,25 @@ async function scene(connection, snippet, client, rawArgs, proRefine, hasPro = f
|
|
|
181
188
|
catch {
|
|
182
189
|
}
|
|
183
190
|
}
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
191
|
+
const named = (r) => `${r.target}${r.label ? `:${r.label}` : ''}`;
|
|
192
|
+
const menuText = !regions.length
|
|
193
|
+
? saveRegions
|
|
194
|
+
? ' No confident named regions detected here.'
|
|
195
|
+
: ''
|
|
196
|
+
: saveRegions
|
|
187
197
|
? ` Confident regions (select by name): ${regions
|
|
188
|
-
.map((r) => `${r
|
|
198
|
+
.map((r) => `${named(r)}${r.confidence === undefined ? '' : ` ${r.confidence.toFixed(2)}`}`)
|
|
189
199
|
.join(', ')}.`
|
|
190
|
-
:
|
|
200
|
+
:
|
|
201
|
+
` Selectable by name (each resolved when you ask for it, not yet scored): ${regions
|
|
202
|
+
.map(named)
|
|
203
|
+
.join(', ')}.`;
|
|
191
204
|
content.push({ type: 'text', text: summarizeScene(model) + menuText });
|
|
192
205
|
return {
|
|
193
206
|
content,
|
|
194
207
|
structuredContent: {
|
|
195
208
|
...model,
|
|
196
|
-
regions: reconcileRegions(model, regions, saveRegions
|
|
209
|
+
regions: reconcileRegions(model, regions, saveRegions ? (precomputeOk ? 'resolved' : 'unresolved') : 'candidate'),
|
|
197
210
|
region_menu: regions,
|
|
198
211
|
},
|
|
199
212
|
};
|
|
@@ -202,15 +215,23 @@ async function scene(connection, snippet, client, rawArgs, proRefine, hasPro = f
|
|
|
202
215
|
return toolErrorResult('Error reading scene', error);
|
|
203
216
|
}
|
|
204
217
|
}
|
|
205
|
-
|
|
218
|
+
export const SELECTABLE_STATES = [
|
|
219
|
+
'selectable',
|
|
220
|
+
'not_selectable',
|
|
221
|
+
'candidate',
|
|
222
|
+
'not_resolved',
|
|
223
|
+
];
|
|
224
|
+
function reconcileRegions(model, menu, mode) {
|
|
206
225
|
return model.regions.map((r) => {
|
|
207
226
|
const base = r;
|
|
208
|
-
if (
|
|
227
|
+
if (mode !== 'resolved') {
|
|
228
|
+
const advertised = mode === 'candidate' && menu.some((m) => m.target === r.kind);
|
|
209
229
|
return {
|
|
210
230
|
...base,
|
|
211
231
|
coverage_is_estimate: true,
|
|
212
232
|
selectable: null,
|
|
213
|
-
selectable_state: 'not_resolved',
|
|
233
|
+
selectable_state: advertised ? 'candidate' : 'not_resolved',
|
|
234
|
+
...(advertised ? { selectable_via: 'on_demand' } : {}),
|
|
214
235
|
};
|
|
215
236
|
}
|
|
216
237
|
const hit = menu.find((m) => m.target === r.kind);
|
|
@@ -290,14 +311,17 @@ async function selectByReference(connection, snippet, client, rawArgs, proRefine
|
|
|
290
311
|
const args = validateArgs(selectByReferenceSchema, rawArgs);
|
|
291
312
|
const target = args.target;
|
|
292
313
|
const refresh = args.refresh ?? false;
|
|
293
|
-
|
|
314
|
+
const discriminated = args.label !== undefined ||
|
|
315
|
+
args.instance !== undefined ||
|
|
316
|
+
args.composition_context !== undefined;
|
|
317
|
+
if (!refresh && !discriminated) {
|
|
294
318
|
const loaded = await loadPrecomputedRegion(connection, target);
|
|
295
319
|
if (loaded) {
|
|
296
320
|
return {
|
|
297
321
|
content: [
|
|
298
322
|
{
|
|
299
323
|
type: 'text',
|
|
300
|
-
text: `Selected "${target}" from the saved scene:${target} channel (
|
|
324
|
+
text: `Selected "${target}" from the saved scene:${target} channel (cached by an earlier derive). If the image changed since, re-run with refresh:true. Verify with ps_get_selection_preview.`,
|
|
301
325
|
},
|
|
302
326
|
],
|
|
303
327
|
structuredContent: {
|
|
@@ -326,7 +350,7 @@ async function selectByReference(connection, snippet, client, rawArgs, proRefine
|
|
|
326
350
|
proRefine,
|
|
327
351
|
skyCtx: skyCtxFrom(built),
|
|
328
352
|
});
|
|
329
|
-
if (res.passed &&
|
|
353
|
+
if (res.passed && !discriminated) {
|
|
330
354
|
try {
|
|
331
355
|
await saveSelectionAsSceneChannel(connection, target);
|
|
332
356
|
}
|
|
@@ -392,12 +416,12 @@ export function createSceneTools(connection, snippetClient, opts = {}) {
|
|
|
392
416
|
},
|
|
393
417
|
selectable_state: {
|
|
394
418
|
type: 'string',
|
|
395
|
-
enum: [
|
|
396
|
-
description: '`selectable`: a precomputed channel is ready to load. `not_selectable`: resolution ran and this region did not pass the confidence gate. `not_resolved`: precompute did not run or failed, so absence here is NOT evidence the region is unavailable.',
|
|
419
|
+
enum: [...SELECTABLE_STATES],
|
|
420
|
+
description: '`selectable`: a precomputed channel is ready to load. `not_selectable`: resolution ran and this region did not pass the confidence gate. `candidate`: the DEFAULT read advertised this region without deriving it — ps_select_by_reference scores it when you ask, and it may still turn out not to pass. `not_resolved`: an eagerly-requested precompute did not run or failed, so absence here is NOT evidence the region is unavailable.',
|
|
397
421
|
},
|
|
398
422
|
selectable_via: {
|
|
399
423
|
type: 'string',
|
|
400
|
-
description:
|
|
424
|
+
description: "The method that resolved it, when one did. Reads 'on_demand' for a `candidate` — nothing has resolved it yet and the method is chosen at derive time.",
|
|
401
425
|
},
|
|
402
426
|
selectable_confidence: { type: 'number' },
|
|
403
427
|
},
|
|
@@ -421,7 +445,7 @@ export function createSceneTools(connection, snippetClient, opts = {}) {
|
|
|
421
445
|
{
|
|
422
446
|
tool: {
|
|
423
447
|
name: 'ps_select_by_reference',
|
|
424
|
-
description: 'Select a region by NAME instead of coordinates — the natural-mask alternative to a rectangle — with a CONFIDENCE GATE. target=sky/ground/foliage/subject/face/shadows/highlights/skin/above_horizon resolves through the right Photoshop-native method (threshold for sky, invert-sky−subjects for ground, luminance for shadows/highlights, skin-tone colour ∩ the subject box, the detected face/subject box) and is SCORED before it is offered: a clean region is left selected; an unconfident one is NOT selected and reported as honest absence (the city with no real sky gets no sky). Pro adds precise FACE-FEATURE targets backed by the face mesh — face_skin (the retouch mask: face minus eyes/brows/lips), face_eyes, face_brows, face_lips, face_teeth (mouth opening), face_nose, face_under_eye, face_cheeks — each a real geometry-following selection,
|
|
448
|
+
description: 'Select a region by NAME instead of coordinates — the natural-mask alternative to a rectangle — with a CONFIDENCE GATE. target=sky/ground/foliage/subject/face/shadows/highlights/skin/above_horizon resolves through the right Photoshop-native method (threshold for sky, invert-sky−subjects for ground, luminance for shadows/highlights, skin-tone colour ∩ the subject box, the detected face/subject box) and is SCORED before it is offered: a clean region is left selected; an unconfident one is NOT selected and reported as honest absence (the city with no real sky gets no sky). Pro adds precise FACE-FEATURE targets backed by the face mesh — face_skin (the retouch mask: face minus eyes/brows/lips), face_eyes, face_brows, face_lips, face_teeth (mouth opening), face_nose, face_under_eye, face_cheeks — each a real geometry-following selection, derived on first request and then saved as a scene:face_* channel so repeats load instantly. `passed`/`confidence` are returned. A region derived here is cached as a `scene:*` channel keyed by TARGET ONLY, so a later call for the same target loads it by name; pass `refresh:true` to force a fresh derive after an edit that changes what the region means, and note that narrowing a call with `label`/`instance`/`composition_context` always derives (it neither reads nor writes that shared channel). The structural floor (coherence, horizon alignment) is never tuned; for an artistic/non-standard shot pass `composition_context` (e.g. profile:big_sky) to relax the compositional priors so a legitimately large sky is not rejected. For target=subject with several present, pass `label` and/or `instance`. Build/inspect with ps_read_scene first; verify with ps_get_selection_preview (the red-overlay is the human/agent oversight view). Prefer this over a rectangle for any real-world region.',
|
|
425
449
|
inputSchema: selectByReferenceSchema,
|
|
426
450
|
outputSchema: {
|
|
427
451
|
type: 'object',
|
|
@@ -38,6 +38,19 @@ export const ERROR_CLASS_TABLE = [
|
|
|
38
38
|
pattern: /file not found|map not found|lut not found|could not open lut/i,
|
|
39
39
|
},
|
|
40
40
|
{ errorClass: 'face_not_found', pattern: /no face mesh|no face detected/i },
|
|
41
|
+
{
|
|
42
|
+
errorClass: 'perception_export_failed',
|
|
43
|
+
pattern: /saveAs reported success but no file/i,
|
|
44
|
+
},
|
|
45
|
+
{ errorClass: 'image_decode_failed', pattern: /failed to decode jpeg/i },
|
|
46
|
+
{
|
|
47
|
+
errorClass: 'detection_unavailable',
|
|
48
|
+
pattern: /onnxruntime|onnx runtime/i,
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
errorClass: 'file_io',
|
|
52
|
+
pattern: /\b(ENOENT|EBUSY|EACCES|EPERM|EMFILE|ENOSPC)\b/,
|
|
53
|
+
},
|
|
41
54
|
{
|
|
42
55
|
errorClass: 'schema_validation',
|
|
43
56
|
pattern: /\bvalidat|required.*field|missing required argument|must be.*type|invalid (input|argument)/i,
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = '1.2.
|
|
1
|
+
export const VERSION = '1.2.1';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "editmamei",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.1",
|
|
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
6
|
"editmamei": {
|
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
"1.2.0": [
|
|
12
12
|
"ps_delete_layer",
|
|
13
13
|
"ps_select_layer"
|
|
14
|
+
],
|
|
15
|
+
"1.2.1": [
|
|
16
|
+
"ps_read_scene"
|
|
14
17
|
]
|
|
15
18
|
}
|
|
16
19
|
},
|