editmamei 0.22.4 → 0.23.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.
@@ -205,7 +205,29 @@ export function duplicateForOp(opName, applyToActiveLayer) {
205
205
  var __opTargetIsCopy = true;
206
206
  `;
207
207
  }
208
+ export const countLayersRecursiveHelper = `
209
+ function __countLayersRecursive(layers) {
210
+ var total = 0;
211
+ var n = 0;
212
+ try { n = layers.length; } catch (eN) { n = 0; }
213
+ for (var i = 0; i < n; i++) {
214
+ var candidate = null;
215
+ try { candidate = layers[i]; } catch (eC) { continue; }
216
+ if (!candidate) continue;
217
+ total++;
218
+ var isGroup = false;
219
+ try { isGroup = (candidate instanceof LayerSet); } catch (eG) {}
220
+ if (isGroup) {
221
+ var childLayers = null;
222
+ try { childLayers = candidate.layers; } catch (eCl) { childLayers = null; }
223
+ if (childLayers) total += __countLayersRecursive(childLayers);
224
+ }
225
+ }
226
+ return total;
227
+ }
228
+ `;
208
229
  export const getContextInfo = `
230
+ ${countLayersRecursiveHelper}
209
231
  function getContextInfo() {
210
232
  // The whole body is wrapped in try/catch — a destructive op that
211
233
  // succeeded but then triggered "document closed" / "active layer gone"
@@ -228,6 +250,7 @@ function getContextInfo() {
228
250
  resolution: doc.resolution,
229
251
  colorMode: String(doc.mode),
230
252
  layerCount: doc.layers.length,
253
+ total_layer_count: __countLayersRecursive(doc.layers),
231
254
  hasSelection: (function () {
232
255
  // ExtendScript throws "No such element" when accessing doc.selection.bounds
233
256
  // with no active selection, and that error is NOT catchable when DoJavaScript
@@ -242,12 +265,35 @@ function getContextInfo() {
242
265
 
243
266
  if (doc.activeLayer) {
244
267
  var layer = doc.activeLayer;
268
+
269
+ // layer.visible on the ExtendScript DOM returns EFFECTIVE
270
+ // visibility (parent-chain AND own flag) NO MATTER which proxy
271
+ // resolves it -- re-reading through a fresh doc.layers walk does
272
+ // NOT recover the layer's own flag (verified live 2026-07: a
273
+ // visible child inside a hidden group reads visible:false from
274
+ // every DOM proxy, active-layer or walked). The own flag is only
275
+ // readable via Action Manager's Vsbl property, keyed by the
276
+ // layer's stable id (putIdentifier, not putName -- names aren't
277
+ // unique). Confirmed live on both an ArtLayer and a LayerSet.
278
+ // Falls back to the DOM (effective) value if the AM read throws,
279
+ // so a failure degrades to the old behavior instead of breaking
280
+ // the context block every tool returns.
281
+ var __effectiveVisible = layer.visible;
282
+ var __ownVisible = __effectiveVisible;
283
+ try {
284
+ var __visRef = new ActionReference();
285
+ __visRef.putProperty(app.charIDToTypeID('Prpr'), app.charIDToTypeID('Vsbl'));
286
+ __visRef.putIdentifier(app.charIDToTypeID('Lyr '), layer.id);
287
+ __ownVisible = app.executeActionGet(__visRef).getBoolean(app.charIDToTypeID('Vsbl'));
288
+ } catch (eOwn) {}
289
+
245
290
  context.activeLayer = {
246
291
  name: layer.name,
247
292
  kind: String(layer.kind),
248
293
  opacity: layer.opacity,
249
294
  blendMode: String(layer.blendMode),
250
- visible: layer.visible,
295
+ visible: __ownVisible,
296
+ effectively_visible: __effectiveVisible,
251
297
  locked: layer.allLocked,
252
298
  isBackground: layer.isBackgroundLayer
253
299
  };
@@ -302,3 +348,141 @@ function getMinimalContextInfo() {
302
348
  }
303
349
  }
304
350
  `;
351
+ export const parentPathHelper = `
352
+ function __parentPathOf(doc, layer) {
353
+ function __ppWalk(layers, trail) {
354
+ var n = 0;
355
+ try { n = layers.length; } catch (eN) {}
356
+ for (var i = 0; i < n; i++) {
357
+ var candidate = null;
358
+ try { candidate = layers[i]; } catch (eC) { continue; }
359
+ if (!candidate) continue;
360
+ if (candidate === layer) return trail;
361
+ var isGroup = false;
362
+ try { isGroup = (candidate instanceof LayerSet); } catch (eG) {}
363
+ if (isGroup) {
364
+ var cname = null;
365
+ try { cname = candidate.name; } catch (eNm) {}
366
+ var childLayers = null;
367
+ try { childLayers = candidate.layers; } catch (eCl) {}
368
+ if (childLayers) {
369
+ var found = __ppWalk(childLayers, trail.concat([cname]));
370
+ if (found !== null) return found;
371
+ }
372
+ }
373
+ }
374
+ return null;
375
+ }
376
+ return __ppWalk(doc.layers, []);
377
+ }
378
+ `;
379
+ export const hoistFromActiveGroupHelper = `
380
+ function __hoistFromActiveGroupIfNeeded(doc, preMkActive, newLayer, intoActiveGroup) {
381
+ if (intoActiveGroup) return false;
382
+ var preWasGroup = false;
383
+ try { preWasGroup = (preMkActive instanceof LayerSet); } catch (eG) {}
384
+ if (!preWasGroup) return false;
385
+ var landedInside = false;
386
+ try {
387
+ var n = preMkActive.layers.length;
388
+ for (var i = 0; i < n; i++) {
389
+ if (preMkActive.layers[i] === newLayer) { landedInside = true; break; }
390
+ }
391
+ } catch (eIn) {}
392
+ if (!landedInside) return false;
393
+ try {
394
+ newLayer.move(preMkActive, ElementPlacement.PLACEBEFORE);
395
+ try { doc.activeLayer = newLayer; } catch (eA) {}
396
+ return true;
397
+ } catch (eMove) {}
398
+ return false;
399
+ }
400
+ `;
401
+ export const layerResolveHelpers = `
402
+ function __safeGet(fn, fallback) {
403
+ try { var v = fn(); return (v === undefined ? fallback : v); }
404
+ catch (e) { return fallback; }
405
+ }
406
+
407
+ function __findLayerPath(layers, target, prefix) {
408
+ var n = __safeGet(function () { return layers.length; }, 0);
409
+ for (var i = 0; i < n; i++) {
410
+ var candidate = __safeGet(function () { return layers[i]; }, null);
411
+ if (!candidate) continue;
412
+ if (candidate === target) return prefix.concat([i]);
413
+ var isGroup = false;
414
+ try { isGroup = (candidate instanceof LayerSet); } catch (eG) {}
415
+ if (isGroup) {
416
+ var childLayers = __safeGet(function () { return candidate.layers; }, null);
417
+ if (childLayers) {
418
+ var found = __findLayerPath(childLayers, target, prefix.concat([i]));
419
+ if (found) return found;
420
+ }
421
+ }
422
+ }
423
+ return null;
424
+ }
425
+
426
+ function __findLayerById(layers, id) {
427
+ var n = __safeGet(function () { return layers.length; }, 0);
428
+ for (var i = 0; i < n; i++) {
429
+ var candidate = __safeGet(function () { return layers[i]; }, null);
430
+ if (!candidate) continue;
431
+ var cid = __safeGet(function () { return candidate.id; }, null);
432
+ if (cid === id) return candidate;
433
+ var isGroup = false;
434
+ try { isGroup = (candidate instanceof LayerSet); } catch (eG) {}
435
+ if (isGroup) {
436
+ var childLayers = __safeGet(function () { return candidate.layers; }, null);
437
+ if (childLayers) {
438
+ var found = __findLayerById(childLayers, id);
439
+ if (found) return found;
440
+ }
441
+ }
442
+ }
443
+ return null;
444
+ }
445
+
446
+ function __resolvePath(doc, path) {
447
+ var current = doc;
448
+ for (var i = 0; i < path.length; i++) {
449
+ var idx = path[i];
450
+ current = __safeGet(function () { return current.layers[idx]; }, null);
451
+ if (!current) return null;
452
+ }
453
+ return current;
454
+ }
455
+
456
+ function __captureLayerIdentity(doc, layer) {
457
+ var id = null;
458
+ try { if (typeof layer.id !== 'undefined') { id = layer.id; } } catch (eId) {}
459
+ var path = null;
460
+ if (id === null) {
461
+ path = __findLayerPath(doc.layers, layer, []);
462
+ }
463
+ return { id: id, path: path };
464
+ }
465
+
466
+ function __resolveLayerFresh(doc, identity) {
467
+ if (identity.id !== null) {
468
+ var byId = __findLayerById(doc.layers, identity.id);
469
+ if (byId) return byId;
470
+ }
471
+ if (identity.path) {
472
+ return __resolvePath(doc, identity.path);
473
+ }
474
+ return null;
475
+ }
476
+
477
+ function __resolveLayerFreshOrActive(doc, identity) {
478
+ var resolved = __resolveLayerFresh(doc, identity);
479
+ if (resolved) return resolved;
480
+ // Identity resolution came up empty even though the write may have
481
+ // landed — e.g. Photoshop auto-promoting a Background layer as a side
482
+ // effect of the property write. Fall back to the current active layer
483
+ // (these setters always operate on doc.activeLayer, and Photoshop keeps
484
+ // it pointed at the promoted layer) rather than treating an unresolved
485
+ // identity as proof the write failed.
486
+ return __safeGet(function () { return doc.activeLayer; }, null);
487
+ }
488
+ `;
Binary file
Binary file
@@ -83,28 +83,34 @@ export class MacOSExecutor {
83
83
  const dir = await TempDir.create('editmamei-mac-');
84
84
  try {
85
85
  const jsxPath = await dir.write('script.jsx', script);
86
- const wrapperPath = await dir.write('wrapper.scpt', this.createAppleScriptWrapper(jsxPath));
87
- const { stdout, stderr } = await runChildWithTimeout('osascript', [wrapperPath], {
86
+ const wrapperPath = await dir.write('wrapper.scpt', this.createAppleScriptWrapper(jsxPath, timeout));
87
+ const { stdout, stderr, exitCode } = await runChildWithTimeout('osascript', [wrapperPath], {
88
88
  timeout,
89
89
  diagLabel: 'osascript wrapper.scpt',
90
90
  });
91
91
  if (stderr) {
92
92
  this.logger.warn('Script execution warning:', stderr);
93
93
  }
94
+ if (exitCode !== 0) {
95
+ throw new Error(`osascript exited with code ${exitCode}: ${stderr.trim() || '(no stderr)'}`);
96
+ }
94
97
  return this.parseResult(stdout);
95
98
  }
96
99
  finally {
97
100
  await dir.cleanup();
98
101
  }
99
102
  }
100
- createAppleScriptWrapper(jsxPath) {
103
+ createAppleScriptWrapper(jsxPath, timeoutMs = 30000) {
101
104
  const posixPath = jsxPath.replace(/\\/g, '/');
102
105
  if (posixPath.includes('"') || posixPath.includes("'") || posixPath.includes('\n')) {
103
106
  throw new Error('jsxPath contains a character that would break AppleScript interpolation');
104
107
  }
105
- return `tell application "${this.appName}"
106
- \tdo javascript "$.evalFile(decodeURI('${encodeURI(posixPath)}'))"
107
- end tell`;
108
+ const timeoutSeconds = Number.isFinite(timeoutMs) && timeoutMs > 0 ? Math.max(1, Math.ceil(timeoutMs / 1000)) : 1;
109
+ return `with timeout of ${timeoutSeconds} seconds
110
+ \ttell application "${this.appName}"
111
+ \t\tdo javascript "$.evalFile(decodeURI('${encodeURI(posixPath)}'))"
112
+ \tend tell
113
+ end timeout`;
108
114
  }
109
115
  parseResult(output) {
110
116
  const trimmed = output.trim();
@@ -87,9 +87,12 @@ export async function runChildWithTimeout(command, args, options) {
87
87
  });
88
88
  child.on('exit', (code, signal) => {
89
89
  if (timedOut) {
90
- settle(() => reject(new Error(`Script execution timeout after ${timeout}ms (${diagLabel}). The child process was killed; ` +
91
- `if Photoshop has a modal dialog open (license, missing font, GPU init, "Discard?" prompt), ` +
92
- `dismiss it in Photoshop and retry.`)));
90
+ settle(() => reject(new Error(`Script execution timeout after ${timeout}ms (${diagLabel}). The child process was killed, ` +
91
+ `but Photoshop runs as a separate process and may have kept executing — the operation ` +
92
+ `could still have completed. Check Photoshop's actual state before retrying. Common ` +
93
+ `causes: a genuinely slow operation (e.g. a large RAW file's first Camera Raw engine ` +
94
+ `init) exceeding the timeout, or a modal dialog open in Photoshop (license, missing ` +
95
+ `font, GPU init, "Discard?" prompt) — dismiss it if present.`)));
93
96
  return;
94
97
  }
95
98
  if (overflowed) {
Binary file
@@ -495,6 +495,11 @@ const addAdjustmentLayerSchema = {
495
495
  description: 'Only meaningful when mask_from_selection is true AND there is an active selection. If true, the resulting mask is inverted — so the adjustment affects EVERYTHING OUTSIDE the selection rather than inside. Common idiom: "I selected the sky but want to adjust everything else." Defaults to false.',
496
496
  default: false,
497
497
  },
498
+ into_active_group: {
499
+ type: 'boolean',
500
+ description: "Photoshop's Mk-AdjL descriptor carries no placement target, so with a GROUP active it would natively nest the new layer INSIDE that group. Default false hoists the new layer back out so it lands above the active layer/group as a sibling, matching this tool's documented placement. Pass true to keep the new layer nested inside the active group instead.",
501
+ default: false,
502
+ },
498
503
  },
499
504
  required: ['type'],
500
505
  };
@@ -660,7 +665,7 @@ export function createAdjustmentTools(connection, snippetClient) {
660
665
  {
661
666
  tool: {
662
667
  name: 'ps_add_adjustment_layer',
663
- description: "Create a non-destructive adjustment layer above the active layer. Supports the full real-Photoshop tonal/color surface: Curves (with S-curve presets), Levels, Hue/Saturation, Brightness/Contrast, Black & White (with optional tint), Color Balance, Photo Filter (preset or custom color), Vibrance, Channel Mixer, Selective Color, Gradient Map (preset), Exposure (stops + offset + gamma), Color Lookup (3DLUT presets or custom file path), and Invert. Values are editable, maskable, and removable. This is the canonical entry point for ALL tonal/color adjustments; the old destructive bake tools (auto_levels / auto_contrast / desaturate / invert) were removed on 2026-05-31 — if you genuinely need a pixel bake, follow this call with `photoshop_merge_visible_layers`. Optionally clips the adjustment to only affect the layer directly below it. If a selection is active at call time, the new layer is automatically masked by it (toggle with mask_from_selection / mask_inverted). For destructive ops that don't have an adjustment-layer equivalent in Photoshop (Shadows/Highlights — single-pass shadow/highlight recovery), use `photoshop_apply_shadows_highlights` which auto-duplicates the active layer to keep the original intact. Returns context — the new adjustment layer becomes active.",
668
+ description: "Create a non-destructive adjustment layer above the active layer — hoisted out of the active layer's group by default even though Photoshop's own Mk-AdjL placement rule would otherwise nest it INSIDE that group (pass into_active_group:true to keep that native nesting). Supports the full real-Photoshop tonal/color surface: Curves (with S-curve presets), Levels, Hue/Saturation, Brightness/Contrast, Black & White (with optional tint), Color Balance, Photo Filter (preset or custom color), Vibrance, Channel Mixer, Selective Color, Gradient Map (preset), Exposure (stops + offset + gamma), Color Lookup (3DLUT presets or custom file path), and Invert. Values are editable, maskable, and removable. This is the canonical entry point for ALL tonal/color adjustments; the old destructive bake tools (auto_levels / auto_contrast / desaturate / invert) were removed on 2026-05-31 — if you genuinely need a pixel bake, follow this call with `photoshop_merge_visible_layers`. Optionally clips the adjustment to only affect the layer directly below it. If a selection is active at call time, the new layer is automatically masked by it (toggle with mask_from_selection / mask_inverted). For destructive ops that don't have an adjustment-layer equivalent in Photoshop (Shadows/Highlights — single-pass shadow/highlight recovery), use `photoshop_apply_shadows_highlights` which auto-duplicates the active layer to keep the original intact. Returns context (the new adjustment layer becomes active) plus parent_path — the actual containing-group chain, so placement is never silent.",
664
669
  inputSchema: addAdjustmentLayerSchema,
665
670
  outputSchema: {
666
671
  type: 'object',
@@ -677,6 +682,15 @@ export function createAdjustmentTools(connection, snippetClient) {
677
682
  mask_inverted: { type: 'boolean' },
678
683
  mask_inversion_error: { type: ['string', 'null'] },
679
684
  clipError: { type: 'string' },
685
+ hoisted: {
686
+ type: 'boolean',
687
+ description: 'True when the new layer 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.',
688
+ },
689
+ parent_path: {
690
+ type: ['array', 'null'],
691
+ items: { type: 'string' },
692
+ description: 'The containing-group name chain (outermost first), empty array at the document root.',
693
+ },
680
694
  context: { type: 'object' },
681
695
  },
682
696
  },
@@ -785,6 +799,7 @@ async function addAdjustmentLayer(connection, snippetClient, rawArgs) {
785
799
  const name = args.name;
786
800
  const maskFromSelection = args.mask_from_selection ?? true;
787
801
  const maskInverted = args.mask_inverted ?? false;
802
+ const intoActiveGroup = args.into_active_group ?? false;
788
803
  const params = {};
789
804
  for (const key of FORWARDED_KEYS) {
790
805
  if (args[key] !== undefined)
@@ -806,6 +821,7 @@ async function addAdjustmentLayer(connection, snippetClient, rawArgs) {
806
821
  clip_to_below: clipToBelow,
807
822
  mask_from_selection: maskFromSelection,
808
823
  mask_inverted: maskInverted,
824
+ into_active_group: intoActiveGroup,
809
825
  ...params,
810
826
  };
811
827
  if (name !== undefined)
@@ -1,5 +1,6 @@
1
1
  import { runScript } from '../utils/run-script.js';
2
2
  import { validateArgs } from '../utils/validate.js';
3
+ import { OPEN_DOCUMENT_TIMEOUT_MS, OPEN_DOCUMENT_REPROBE_TIMEOUT_MS, } from '../utils/operation-timeouts.js';
3
4
  const createDocumentSchema = {
4
5
  type: 'object',
5
6
  properties: {
@@ -375,13 +376,27 @@ async function closeDocument(connection, snippetClient, rawArgs) {
375
376
  };
376
377
  }
377
378
  }
379
+ function isScriptTimeoutError(message) {
380
+ return /Script execution timeout|AppleEvent timed out|(?:^|[\s(])-1712(?:[\s)]|$)/i.test(message);
381
+ }
382
+ async function reprobeOpenDocument(connection, snippetClient, filePath) {
383
+ try {
384
+ const probeScript = await snippetClient.build('probeOpenDocument', { filePath });
385
+ const result = (await runScript(connection, probeScript, OPEN_DOCUMENT_REPROBE_TIMEOUT_MS));
386
+ return result.success ? result : null;
387
+ }
388
+ catch {
389
+ return null;
390
+ }
391
+ }
378
392
  async function openDocumentPipeline(connection, snippetClient, rawArgs) {
393
+ let filePath;
379
394
  try {
380
395
  const args = validateArgs(openDocumentSchema, rawArgs);
381
- const filePath = args.file_path;
396
+ filePath = args.file_path;
382
397
  const suppressDialogs = args.suppress_dialogs;
383
398
  const script = await snippetClient.build('openDocumentPipeline', { filePath, suppressDialogs });
384
- const result = await runScript(connection, script);
399
+ const result = await runScript(connection, script, OPEN_DOCUMENT_TIMEOUT_MS);
385
400
  return {
386
401
  content: [
387
402
  {
@@ -393,11 +408,28 @@ async function openDocumentPipeline(connection, snippetClient, rawArgs) {
393
408
  };
394
409
  }
395
410
  catch (error) {
411
+ const message = error instanceof Error ? error.message : String(error);
412
+ if (filePath !== undefined && isScriptTimeoutError(message)) {
413
+ const reprobed = await reprobeOpenDocument(connection, snippetClient, filePath);
414
+ if (reprobed) {
415
+ return {
416
+ content: [
417
+ {
418
+ type: 'text',
419
+ text: `Document opened (the open exceeded the ${OPEN_DOCUMENT_TIMEOUT_MS}ms budget and was ` +
420
+ `reported as a timeout, but a post-timeout check confirmed it actually completed):\n` +
421
+ JSON.stringify(reprobed, null, 2),
422
+ },
423
+ ],
424
+ structuredContent: reprobed,
425
+ };
426
+ }
427
+ }
396
428
  return {
397
429
  content: [
398
430
  {
399
431
  type: 'text',
400
- text: `Error opening document: ${error instanceof Error ? error.message : String(error)}`,
432
+ text: `Error opening document: ${message}`,
401
433
  },
402
434
  ],
403
435
  isError: true,
@@ -27,6 +27,11 @@ const createGroupSchema = {
27
27
  items: { type: 'string' },
28
28
  description: 'Optional list of existing layer names to move into the new group. The first listed name ends up on top of the group stack. Layers not found are returned in `not_found`.',
29
29
  },
30
+ into_active_group: {
31
+ type: 'boolean',
32
+ description: "Photoshop's Mk-layerSection descriptor carries no placement target, so with a GROUP active it would natively nest the new group INSIDE that group. Default false hoists the new group back out so it lands above the active layer/group as a sibling, matching this tool's documented placement. Pass true to keep it nested inside the active group instead.",
33
+ default: false,
34
+ },
30
35
  },
31
36
  required: ['name'],
32
37
  };
@@ -78,7 +83,7 @@ export function createGroupTools(connection, snippetClient) {
78
83
  {
79
84
  tool: {
80
85
  name: 'ps_create_group',
81
- description: 'Create a new layer group (LayerSet) above the active layer with the given name. 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.',
86
+ description: `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.`,
82
87
  inputSchema: createGroupSchema,
83
88
  outputSchema: {
84
89
  type: 'object',
@@ -87,6 +92,15 @@ export function createGroupTools(connection, snippetClient) {
87
92
  groupName: { type: 'string' },
88
93
  moved_count: { type: 'number' },
89
94
  not_found: { type: 'array', items: { type: 'string' } },
95
+ hoisted: {
96
+ type: 'boolean',
97
+ 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.',
98
+ },
99
+ parent_path: {
100
+ type: ['array', 'null'],
101
+ items: { type: 'string' },
102
+ description: 'The containing-group name chain (outermost first), empty array at the document root.',
103
+ },
90
104
  context: { type: 'object' },
91
105
  },
92
106
  },
@@ -283,7 +297,8 @@ async function createGroup(connection, snippetClient, rawArgs) {
283
297
  const args = validateArgs(createGroupSchema, rawArgs);
284
298
  const name = args.name;
285
299
  const layers = args.layers;
286
- const params = { name };
300
+ const intoActiveGroup = args.into_active_group ?? false;
301
+ const params = { name, into_active_group: intoActiveGroup };
287
302
  if (layers !== undefined)
288
303
  params.layerNames = layers;
289
304
  const script = await snippetClient.build('createGroup', params);
@@ -84,6 +84,16 @@ const duplicateLayerSchema = {
84
84
  },
85
85
  },
86
86
  };
87
+ const copyToNewLayerSchema = {
88
+ type: 'object',
89
+ properties: {
90
+ into_active_group: {
91
+ type: 'boolean',
92
+ description: "Photoshop's CpTL (Layer via Copy) event carries no placement target, so with a GROUP active it would natively nest the new layer INSIDE that group. Default false hoists the new layer back out so it lands above the active layer/group as a sibling. Pass true to keep it nested inside the active group instead.",
93
+ default: false,
94
+ },
95
+ },
96
+ };
87
97
  const addLayerStyleSchema = {
88
98
  type: 'object',
89
99
  properties: {
@@ -247,11 +257,20 @@ export function createLayerPropertiesTools(connection, snippetClient) {
247
257
  outputSchema: {
248
258
  type: 'object',
249
259
  properties: {
250
- updated: { type: 'boolean' },
251
260
  property: { type: 'string' },
252
261
  value: { type: ['number', 'string'] },
262
+ requested: { type: ['number', 'string', 'boolean'] },
263
+ verified: { type: 'boolean' },
264
+ verification_unreadable: {
265
+ type: 'boolean',
266
+ description: 'visibility only: true when the write itself did not throw but the own-flag verification read failed (Action Manager error, or the layer had no readable id) — verified is false, but this is NOT proof the write failed, just that it could not be confirmed.',
267
+ },
253
268
  fill_opacity: { type: 'number' },
269
+ requested_fill_opacity: { type: 'number' },
270
+ fill_opacity_verified: { type: 'boolean' },
254
271
  opacity: { type: 'number' },
272
+ requested_opacity: { type: ['number', 'null'] },
273
+ opacity_verified: { type: ['boolean', 'null'] },
255
274
  visible: { type: 'boolean' },
256
275
  locked: { type: 'boolean' },
257
276
  name: { type: 'string' },
@@ -278,6 +297,11 @@ export function createLayerPropertiesTools(connection, snippetClient) {
278
297
  properties: {
279
298
  originalName: { type: 'string' },
280
299
  newName: { type: 'string' },
300
+ parent_path: {
301
+ type: ['array', 'null'],
302
+ items: { type: 'string' },
303
+ description: "The containing-group name chain (outermost first), empty array at the document root. layer.duplicate() is parent-preserving by DOM semantics, so this always matches the original layer's placement.",
304
+ },
281
305
  context: { type: 'object' },
282
306
  },
283
307
  },
@@ -291,8 +315,8 @@ export function createLayerPropertiesTools(connection, snippetClient) {
291
315
  {
292
316
  tool: {
293
317
  name: 'ps_copy_to_new_layer',
294
- description: 'Copy the current selection into a NEW layer above the active one — Photoshop\'s "Layer via Copy" (Ctrl+J). The source layer is left untouched and the new copied layer becomes active. With an active selection only the selected pixels are lifted; with no selection it copies the whole active layer (a plain duplicate). Reach for this to isolate a region for independent transforms or filters (stretch, light rays, a local grade) without altering the source. Returns context so the caller sees the new active layer.',
295
- inputSchema: emptySchema,
318
+ description: 'Copy the current selection into a NEW layer above the active one — Photoshop\'s "Layer via Copy" (Ctrl+J) — hoisted out of the active layer\'s group by default even though the underlying CpTL event carries no placement target and would otherwise nest the new layer INSIDE that group (pass into_active_group:true to keep that native nesting). The source layer is left untouched and the new copied layer becomes active. With an active selection only the selected pixels are lifted; with no selection it copies the whole active layer (a plain duplicate). Reach for this to isolate a region for independent transforms or filters (stretch, light rays, a local grade) without altering the source. Returns context so the caller sees the new active layer.',
319
+ inputSchema: copyToNewLayerSchema,
296
320
  outputSchema: {
297
321
  type: 'object',
298
322
  properties: {
@@ -301,6 +325,15 @@ export function createLayerPropertiesTools(connection, snippetClient) {
301
325
  original_active_layer_name: { type: 'string' },
302
326
  layer_count_before: { type: 'number' },
303
327
  layer_count_after: { type: 'number' },
328
+ hoisted: {
329
+ type: 'boolean',
330
+ description: 'True when the new layer 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.',
331
+ },
332
+ parent_path: {
333
+ type: ['array', 'null'],
334
+ items: { type: 'string' },
335
+ description: 'The containing-group name chain (outermost first), empty array at the document root.',
336
+ },
304
337
  context: { type: 'object' },
305
338
  },
306
339
  },
@@ -309,7 +342,7 @@ export function createLayerPropertiesTools(connection, snippetClient) {
309
342
  idempotentHint: false,
310
343
  },
311
344
  },
312
- handler: async () => layerViaCopy(connection, snippetClient),
345
+ handler: async (args) => layerViaCopy(connection, snippetClient, args),
313
346
  },
314
347
  {
315
348
  tool: {
@@ -493,23 +526,25 @@ async function setLayerOpacity(connection, snippetClient, rawArgs) {
493
526
  if (opacity === undefined && fillPercent === undefined) {
494
527
  throw new Error('set_layer_opacity requires opacity and/or fill_percent.');
495
528
  }
496
- let label;
497
529
  let buildParams;
498
530
  if (fillPercent !== undefined) {
499
531
  buildParams = { fillOpacity: fillPercent };
500
532
  if (opacity !== undefined)
501
533
  buildParams.opacity = opacity;
502
- label =
503
- opacity !== undefined
504
- ? `opacity ${opacity}% + fill ${fillPercent}%`
505
- : `fill ${fillPercent}%`;
506
534
  }
507
535
  else {
508
536
  buildParams = { opacity };
509
- label = `opacity ${opacity}%`;
510
537
  }
511
538
  const script = await snippetClient.build('setLayerOpacity', buildParams);
512
539
  const result = await runScript(connection, script);
540
+ const r = result;
541
+ const parts = [];
542
+ const opacityActual = r.value ?? (r.requested_opacity != null ? r.opacity : undefined);
543
+ if (opacityActual !== undefined)
544
+ parts.push(`opacity ${opacityActual}%`);
545
+ if (r.fill_opacity !== undefined)
546
+ parts.push(`fill ${r.fill_opacity}%`);
547
+ const label = parts.length > 0 ? parts.join(' + ') : 'opacity';
513
548
  return {
514
549
  content: [
515
550
  {
@@ -538,11 +573,13 @@ async function setLayerBlendMode(connection, snippetClient, rawArgs) {
538
573
  const blendMode = args.blend_mode;
539
574
  const script = await snippetClient.build('setLayerBlendMode', { blendMode });
540
575
  const result = await runScript(connection, script);
576
+ const r = result;
577
+ const text = r.value !== undefined ? `Layer blend mode set to ${r.value}` : 'Layer blend mode set';
541
578
  return {
542
579
  content: [
543
580
  {
544
581
  type: 'text',
545
- text: `Layer blend mode set to ${blendMode}`,
582
+ text,
546
583
  },
547
584
  ],
548
585
  structuredContent: result,
@@ -566,11 +603,13 @@ async function setLayerVisibility(connection, snippetClient, rawArgs) {
566
603
  const visible = args.visible;
567
604
  const script = await snippetClient.build('setLayerVisibility', { visible });
568
605
  const result = await runScript(connection, script);
606
+ const r = result;
607
+ const text = r.visible !== undefined ? `Layer ${r.visible ? 'shown' : 'hidden'}` : 'Layer visibility set';
569
608
  return {
570
609
  content: [
571
610
  {
572
611
  type: 'text',
573
- text: `Layer ${visible ? 'shown' : 'hidden'}`,
612
+ text,
574
613
  },
575
614
  ],
576
615
  structuredContent: result,
@@ -594,11 +633,13 @@ async function setLayerLocked(connection, snippetClient, rawArgs) {
594
633
  const locked = args.locked;
595
634
  const script = await snippetClient.build('setLayerLocked', { locked });
596
635
  const result = await runScript(connection, script);
636
+ const r = result;
637
+ const text = r.locked !== undefined ? `Layer ${r.locked ? 'locked' : 'unlocked'}` : 'Layer lock state set';
597
638
  return {
598
639
  content: [
599
640
  {
600
641
  type: 'text',
601
- text: `Layer ${locked ? 'locked' : 'unlocked'}`,
642
+ text,
602
643
  },
603
644
  ],
604
645
  structuredContent: result,
@@ -622,11 +663,15 @@ async function renameLayer(connection, snippetClient, rawArgs) {
622
663
  const name = args.name;
623
664
  const script = await snippetClient.build('renameLayer', { newName: name });
624
665
  const result = await runScript(connection, script);
666
+ const r = result;
667
+ const text = r.newName !== undefined
668
+ ? `Layer renamed to: ${r.newName}\nResult: ${JSON.stringify(result)}`
669
+ : `Layer renamed\nResult: ${JSON.stringify(result)}`;
625
670
  return {
626
671
  content: [
627
672
  {
628
673
  type: 'text',
629
- text: `Layer renamed to: ${name}\nResult: ${JSON.stringify(result)}`,
674
+ text,
630
675
  },
631
676
  ],
632
677
  structuredContent: result,
@@ -675,9 +720,13 @@ async function duplicateLayer(connection, snippetClient, rawArgs) {
675
720
  };
676
721
  }
677
722
  }
678
- async function layerViaCopy(connection, snippetClient) {
723
+ async function layerViaCopy(connection, snippetClient, rawArgs) {
679
724
  try {
680
- const script = await snippetClient.build('layerViaCopy');
725
+ const args = validateArgs(copyToNewLayerSchema, rawArgs);
726
+ const intoActiveGroup = args.into_active_group ?? false;
727
+ const script = await snippetClient.build('layerViaCopy', {
728
+ into_active_group: intoActiveGroup,
729
+ });
681
730
  const result = (await runScript(connection, script));
682
731
  return {
683
732
  content: [
@@ -86,6 +86,11 @@ const addFillLayerSchema = {
86
86
  red: { type: 'integer', description: 'Solid color red (0-255).', minimum: 0, maximum: 255 },
87
87
  green: { type: 'integer', description: 'Solid color green (0-255).', minimum: 0, maximum: 255 },
88
88
  blue: { type: 'integer', description: 'Solid color blue (0-255).', minimum: 0, maximum: 255 },
89
+ into_active_group: {
90
+ type: 'boolean',
91
+ description: "Photoshop's Mk-contentLayer descriptor carries no placement target, so with a GROUP active it would natively nest the new fill layer INSIDE that group. Default false hoists the new layer back out so it lands above the active layer/group as a sibling. Pass true to keep it nested inside the active group instead.",
92
+ default: false,
93
+ },
89
94
  },
90
95
  required: ['red', 'green', 'blue'],
91
96
  };
@@ -111,6 +116,11 @@ export function createLayerTools(connection, snippetClient) {
111
116
  properties: {
112
117
  created: { type: 'boolean' },
113
118
  layerName: { type: 'string' },
119
+ parent_path: {
120
+ type: ['array', 'null'],
121
+ items: { type: 'string' },
122
+ description: 'The containing-group name chain (outermost first), empty array at the document root.',
123
+ },
114
124
  context: { type: 'object' },
115
125
  },
116
126
  },
@@ -188,7 +198,7 @@ export function createLayerTools(connection, snippetClient) {
188
198
  {
189
199
  tool: {
190
200
  name: 'ps_add_fill_layer',
191
- description: 'Add a non-destructive SOLID COLOR fill layer (an editable, re-colorable content layer — distinct from ps_fill_layer, which bakes color into the active pixel layer). The new fill layer becomes active. Use for color washes, base backgrounds, and clipped color overlays. (Gradient and pattern fill types are planned.)',
201
+ description: "Add a non-destructive SOLID COLOR fill layer (an editable, re-colorable content layer — distinct from ps_fill_layer, which bakes color into the active pixel layer). Hoisted out of the active layer's group by default even though Photoshop's own Mk-contentLayer placement rule would otherwise nest it INSIDE that group (pass into_active_group:true to keep that native nesting). The new fill layer becomes active. Use for color washes, base backgrounds, and clipped color overlays. (Gradient and pattern fill types are planned.)",
192
202
  inputSchema: addFillLayerSchema,
193
203
  outputSchema: {
194
204
  type: 'object',
@@ -197,6 +207,15 @@ export function createLayerTools(connection, snippetClient) {
197
207
  fill_type: { type: 'string' },
198
208
  color: { type: 'object' },
199
209
  layer_name: { type: 'string' },
210
+ hoisted: {
211
+ type: 'boolean',
212
+ description: 'True when the new layer 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.',
213
+ },
214
+ parent_path: {
215
+ type: ['array', 'null'],
216
+ items: { type: 'string' },
217
+ description: 'The containing-group name chain (outermost first), empty array at the document root.',
218
+ },
200
219
  context: { type: 'object' },
201
220
  },
202
221
  },
@@ -359,7 +378,13 @@ async function addFillLayer(connection, snippetClient, rawArgs) {
359
378
  const red = args.red;
360
379
  const green = args.green;
361
380
  const blue = args.blue;
362
- const script = await snippetClient.build('addFillLayer', { red, green, blue });
381
+ const intoActiveGroup = args.into_active_group ?? false;
382
+ const script = await snippetClient.build('addFillLayer', {
383
+ red,
384
+ green,
385
+ blue,
386
+ into_active_group: intoActiveGroup,
387
+ });
363
388
  const result = await runScript(connection, script);
364
389
  return {
365
390
  content: [
@@ -4,6 +4,7 @@ import { jsLit, jsNum } from '../utils/jsx.js';
4
4
  import { runScript } from '../utils/run-script.js';
5
5
  import { TempDir, userOwnedTempRoot } from '../utils/temp.js';
6
6
  import { validateArgs } from '../utils/validate.js';
7
+ import { ANNOTATED_PREVIEW_TIMEOUT_MS } from '../utils/operation-timeouts.js';
7
8
  const COLOR_NAME_HEX = {
8
9
  red: '#FF0000',
9
10
  blue: '#0066FF',
@@ -836,7 +837,7 @@ async function getPreview(connection, rawArgs) {
836
837
  throw e;
837
838
  }
838
839
  `;
839
- const execTimeoutMs = annotations.length > 0 ? 90000 : undefined;
840
+ const execTimeoutMs = annotations.length > 0 ? ANNOTATED_PREVIEW_TIMEOUT_MS : undefined;
840
841
  const result = (await runScript(connection, script, execTimeoutMs));
841
842
  const bytes = await readFile(tempPath);
842
843
  const base64 = bytes.toString('base64');
@@ -4,6 +4,7 @@ import { TempDir } from '../utils/temp.js';
4
4
  import { validateArgs } from '../utils/validate.js';
5
5
  import { OnnxLandmarkDetectionClient } from '../detection/landmark-detection-client.js';
6
6
  import { resolveExpectedPlacement, PLACEMENT_SCHEMA } from '../perception/grounding-locate.js';
7
+ import { SELECT_SUBJECT_TIMEOUT_MS, SELECT_SKY_TIMEOUT_MS } from '../utils/operation-timeouts.js';
7
8
  export const SELECTION_TYPE_ENUM = ['replace', 'add', 'subtract', 'intersect'];
8
9
  export const selectionTypeFragment = {
9
10
  type: 'string',
@@ -778,7 +779,7 @@ async function selectSubject(connection, snippetClient, rawArgs) {
778
779
  const sampleAllLayers = args.sample_all_layers ?? true;
779
780
  const selectionType = normalizeSelectionType(args.selection_type);
780
781
  const script = await snippetClient.build('selectSubject', { sampleAllLayers, selectionType });
781
- const result = await runScript(connection, script, 120000);
782
+ const result = await runScript(connection, script, SELECT_SUBJECT_TIMEOUT_MS);
782
783
  return {
783
784
  content: [{ type: 'text', text: `Select Subject (${selectionType}) complete` }],
784
785
  structuredContent: result,
@@ -802,7 +803,7 @@ async function selectSky(connection, snippetClient, rawArgs) {
802
803
  const sampleAllLayers = args.sample_all_layers ?? true;
803
804
  const selectionType = normalizeSelectionType(args.selection_type);
804
805
  const script = await snippetClient.build('selectSky', { sampleAllLayers, selectionType });
805
- const result = await runScript(connection, script, 120000);
806
+ const result = await runScript(connection, script, SELECT_SKY_TIMEOUT_MS);
806
807
  return {
807
808
  content: [{ type: 'text', text: `Select Sky (${selectionType}) complete` }],
808
809
  structuredContent: result,
@@ -75,6 +75,11 @@ const shapeInputSchema = {
75
75
  ...rgbColorFragment,
76
76
  description: 'rectangle/ellipse stroke color when stroke_width>0. RGB 0-255. Default black.',
77
77
  },
78
+ into_active_group: {
79
+ type: 'boolean',
80
+ description: "Photoshop's Mk-contentLayer descriptor carries no placement target, so with a GROUP active it would natively nest the new shape layer INSIDE that group. Default false hoists the new layer back out so it lands above the active layer/group as a sibling. Pass true to keep it nested inside the active group instead.",
81
+ default: false,
82
+ },
78
83
  },
79
84
  required: ['type'],
80
85
  };
@@ -83,7 +88,7 @@ export function createShapeTools(connection, snippetClient, client = new OnnxLan
83
88
  {
84
89
  tool: {
85
90
  name: 'ps_shape',
86
- description: 'Draw a vector SHAPE layer — `rectangle` (optionally rounded via corner_radius), `ellipse`, or `line` — filled with a solid color, optionally stroked. Aim it EITHER by anchor-relational `placement` (preferred: name anchors + a relation and the resolver computes the geometry, verified by an objective gate — no pixel-guessing; rectangle/ellipse ← a region relation, line ← a path relation) OR by ABSOLUTE document pixels (top-left origin: rectangle/ellipse take left/top/right/bottom; line takes start_x/start_y → end_x/end_y plus weight — you must know the pixel positions, so prefer the anchor-relational `placement` path above and verify the result with a preview). Creates a new vector layer (non-destructive — delete it to remove). (AM-only; verified live on PS 27.2.0.)',
91
+ description: "Draw a vector SHAPE layer — `rectangle` (optionally rounded via corner_radius), `ellipse`, or `line` — filled with a solid color, optionally stroked. Hoisted out of the active layer's group by default even though the underlying Mk-contentLayer descriptor carries no placement target and would otherwise nest the new layer INSIDE that group (pass into_active_group:true to keep that native nesting). Aim it EITHER by anchor-relational `placement` (preferred: name anchors + a relation and the resolver computes the geometry, verified by an objective gate — no pixel-guessing; rectangle/ellipse ← a region relation, line ← a path relation) OR by ABSOLUTE document pixels (top-left origin: rectangle/ellipse take left/top/right/bottom; line takes start_x/start_y → end_x/end_y plus weight — you must know the pixel positions, so prefer the anchor-relational `placement` path above and verify the result with a preview). Creates a new vector layer (non-destructive — delete it to remove). (AM-only; verified live on PS 27.2.0.)",
87
92
  inputSchema: shapeInputSchema,
88
93
  outputSchema: {
89
94
  type: 'object',
@@ -92,6 +97,15 @@ export function createShapeTools(connection, snippetClient, client = new OnnxLan
92
97
  shape_type: { type: 'string' },
93
98
  layer_name: { type: 'string' },
94
99
  stroked: { type: 'boolean' },
100
+ hoisted: {
101
+ type: 'boolean',
102
+ description: 'True when the new layer 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.',
103
+ },
104
+ parent_path: {
105
+ type: ['array', 'null'],
106
+ items: { type: 'string' },
107
+ description: 'The containing-group name chain (outermost first), empty array at the document root.',
108
+ },
95
109
  placement: {
96
110
  type: 'object',
97
111
  description: 'Present when anchor-relational placement was used: the resolved geometry + gate verdict.',
@@ -174,6 +188,7 @@ async function createShape(connection, snippetClient, client, rawArgs) {
174
188
  else
175
189
  params.weight = args.weight ?? 4;
176
190
  colorParams(args.fill_color, 'fill', params);
191
+ params.into_active_group = args.into_active_group ?? false;
177
192
  if (type !== 'line') {
178
193
  params.strokeWidth = args.stroke_width ?? 0;
179
194
  colorParams(args.stroke_color, 'stroke', params);
@@ -0,0 +1,6 @@
1
+ export const OPEN_DOCUMENT_TIMEOUT_MS = 120_000;
2
+ export const OPEN_DOCUMENT_REPROBE_TIMEOUT_MS = 10_000;
3
+ export const CAMERA_RAW_FILTER_TIMEOUT_MS = 120_000;
4
+ export const SELECT_SUBJECT_TIMEOUT_MS = 120_000;
5
+ export const SELECT_SKY_TIMEOUT_MS = 120_000;
6
+ export const ANNOTATED_PREVIEW_TIMEOUT_MS = 90_000;
@@ -24,6 +24,7 @@ export const ERROR_CLASS_TABLE = [
24
24
  },
25
25
  { errorClass: 'layer_not_found', pattern: /layer .* not found|no layer named/i },
26
26
  { errorClass: 'ps_command_unavailable', pattern: /not currently available/i },
27
+ { errorClass: 'timeout', pattern: /timed? ?out|Script execution timeout|exceeded.*bytes/i },
27
28
  {
28
29
  errorClass: 'ps_modal_blocking',
29
30
  pattern: /modal.*dialog|dialog.*blocking|blocked.*modal|photoshop.*modal/i,
@@ -32,7 +33,6 @@ export const ERROR_CLASS_TABLE = [
32
33
  errorClass: 'ps_not_running',
33
34
  pattern: /CreateObject|photoshop.*not.*running|cannot connect.*photoshop|connection.*failed/i,
34
35
  },
35
- { errorClass: 'timeout', pattern: /timed? ?out|Script execution timeout|exceeded.*bytes/i },
36
36
  ];
37
37
  export function classifyError(error) {
38
38
  if (error === undefined)
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.22.4';
1
+ export const VERSION = '0.23.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "editmamei",
3
- "version": "0.22.4",
3
+ "version": "0.23.0",
4
4
  "description": "Editmamei — Unlock Photoshop with natural-language photo editing (Community Edition)",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",