editmamei 1.1.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/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/cli/help.js +1 -0
- package/dist/core/server.js +91 -17
- package/dist/core/tool-groups.js +5 -9
- package/dist/core/tool-registry.js +3 -0
- package/dist/core/tool-tiers.js +4 -8
- package/dist/delivery/provision.js +2 -2
- package/dist/kernel/module-lifecycle.js +30 -4
- package/dist/modules/ce/index.js +2 -0
- 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/filter-tools.js +0 -14
- package/dist/tools/group-tools.js +1 -119
- package/dist/tools/layer-tools.js +1 -24
- package/dist/tools/scene-tools.js +45 -21
- 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 +17 -0
- package/dist/version.js +1 -1
- package/package.json +15 -1
|
@@ -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: [
|
|
@@ -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',
|
|
@@ -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',
|
|
@@ -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,
|