editmamei 1.3.0 → 1.4.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.
@@ -316,6 +316,24 @@ const selectPolygonSchema = {
316
316
  },
317
317
  required: ['points'],
318
318
  };
319
+ const selectFocusAreaSchema = {
320
+ type: 'object',
321
+ properties: {
322
+ in_focus_radius: {
323
+ type: 'number',
324
+ 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.',
325
+ default: 4.07,
326
+ minimum: 0.1,
327
+ maximum: 15,
328
+ },
329
+ soft_mask: {
330
+ type: 'boolean',
331
+ 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).',
332
+ default: false,
333
+ },
334
+ selection_type: selectionTypeFragment,
335
+ },
336
+ };
319
337
  const modifyEdgeSchema = {
320
338
  type: 'object',
321
339
  properties: {
@@ -376,6 +394,12 @@ const selectionPreviewSchema = {
376
394
  maximum: 4096,
377
395
  default: 800,
378
396
  },
397
+ image: {
398
+ type: 'string',
399
+ enum: ['overlay', 'mask', 'both'],
400
+ default: 'overlay',
401
+ description: "Which rendered image(s) to return inline. 'overlay' (default): a 50% red wash over the selected area (Quick Mask-style — most intuitive). 'mask': a B/W mask (black=selected, white=not) instead. 'both': the overlay followed by the mask, for when you need to compare them side by side. selection_info is returned regardless of this choice.",
402
+ },
379
403
  },
380
404
  };
381
405
  const saveSelectionToChannelSchema = {
@@ -437,6 +461,7 @@ const SELECT_MODES = [
437
461
  'skin_tones',
438
462
  'out_of_gamut',
439
463
  'polygon',
464
+ 'focus_area',
440
465
  ];
441
466
  const SELECT_INPUT_SCHEMA = {
442
467
  type: 'object',
@@ -455,8 +480,9 @@ const SELECT_INPUT_SCHEMA = {
455
480
  'skin_tones: select skin-coloured pixels (+fuzziness; use_faces=true adds face-aware refinement). ' +
456
481
  'out_of_gamut: select colours outside the printable CMYK gamut (no params). ' +
457
482
  'polygon: points [{x,y},...] in ABSOLUTE document pixels (min 3, auto-closes) — covers polygonal/freehand lasso. Coordinate-driven: you must know the pixel positions (use ps_inspect / ps_get_preview to aim, or ps_path create_from_placement → load_as_selection for a grounded outline). ' +
483
+ 'focus_area: select what the lens rendered SHARP by depth of field, not by subject or colour (+in_focus_radius, soft_mask) — takes no coordinates; check whole_canvas_selected in the result before trusting it. ' +
458
484
  'rectangle/ellipse/magic_wand also take a grounded `placement` instead of raw coords (region → the bbox; point → the wand click). ' +
459
- 'rectangle/ellipse/polygon/color_range/luminance_range/magic_wand/skin_tones/out_of_gamut also take selection_type to combine with an existing selection.',
485
+ 'rectangle/ellipse/polygon/focus_area/color_range/luminance_range/magic_wand/skin_tones/out_of_gamut also take selection_type to combine with an existing selection.',
460
486
  },
461
487
  ...selectRectangleSchema.properties,
462
488
  ...selectColorRangeSchema.properties,
@@ -464,6 +490,7 @@ const SELECT_INPUT_SCHEMA = {
464
490
  ...magicWandSchema.properties,
465
491
  ...colorPresetSchema.properties,
466
492
  ...selectPolygonSchema.properties,
493
+ ...selectFocusAreaSchema.properties,
467
494
  placement: {
468
495
  ...PLACEMENT_SCHEMA,
469
496
  description: 'Grounded coordinates (rectangle/ellipse/magic_wand): NAME anchors + a relation instead of guessing pixels. rectangle/ellipse ← a REGION relation (inside/gap) → the selection bounding box; magic_wand ← a POINT relation (centroid/extremum/grid) → the click. Verified by the objective gate; wins over the raw edges/x-y. See the placement-resolver tool, when this build has one, for the vocabulary.',
@@ -580,7 +607,7 @@ export function createSelectionTools(connection, snippetClient, client = new Onn
580
607
  {
581
608
  tool: {
582
609
  name: 'ps_select',
583
- description: 'Create a NEW selection — choose with `mode`. (To edit the CURRENT selection instead — including growing it by colour similarity — use ps_modify_selection.) `all` selects the canvas; `none` deselects; `inverse` inverts the current selection (e.g. select the subject, then inverse to act on the background). `rectangle` (left/top/right/bottom, optional feather_px to avoid hard block-edges in smooth sky). `ellipse` (left/top/right/bottom bounding box + anti_alias — circles/ovals). `color_range` (target red/green/blue + fuzziness — "select all the red / skin tones"). `luminance_range` (highlights/shadows/midtones — foundation for glow / dodge-burn). `magic_wand` (click x/y + tolerance, contiguous). `grow` / `similar` are DEPRECATED here (they act on the CURRENT selection, not a new one) — use ps_modify_selection(op=grow|similar) instead; kept for one release for backward compatibility, identical behaviour. rectangle/ellipse/magic_wand also accept a grounded `placement` (NAME a region/point instead of guessing pixels — resolved + gate-verified). The geometric/color/wand modes take selection_type (replace|add|subtract|intersect) to combine with an existing selection and return a rich selection_info bundle — verify it (or ps_get_selection_preview) before committing to a mask.',
610
+ description: 'Create a NEW selection — choose with `mode`. (To edit the CURRENT selection instead — including growing it by colour similarity — use ps_modify_selection.) `all` selects the canvas; `none` deselects; `inverse` inverts the current selection (e.g. select the subject, then inverse to act on the background). `rectangle` (left/top/right/bottom, optional feather_px to avoid hard block-edges in smooth sky). `ellipse` (left/top/right/bottom bounding box + anti_alias — circles/ovals). `color_range` (target red/green/blue + fuzziness — "select all the red / skin tones"). `luminance_range` (highlights/shadows/midtones — foundation for glow / dodge-burn). `magic_wand` (click x/y + tolerance, contiguous). `focus_area` selects by depth of field rather than subject or colour (+in_focus_radius, soft_mask) — check whole_canvas_selected/warning in the result before trusting it. `grow` / `similar` are DEPRECATED here (they act on the CURRENT selection, not a new one) — use ps_modify_selection(op=grow|similar) instead; kept for one release for backward compatibility, identical behaviour. rectangle/ellipse/magic_wand also accept a grounded `placement` (NAME a region/point instead of guessing pixels — resolved + gate-verified). The geometric/color/wand modes take selection_type (replace|add|subtract|intersect) to combine with an existing selection and return a rich selection_info bundle — verify it (or ps_get_selection_preview) before committing to a mask.',
584
611
  inputSchema: SELECT_INPUT_SCHEMA,
585
612
  outputSchema: {
586
613
  type: 'object',
@@ -606,6 +633,24 @@ export function createSelectionTools(connection, snippetClient, client = new Onn
606
633
  preset: { type: 'string' },
607
634
  point_count: { type: 'number' },
608
635
  placement: { type: 'object' },
636
+ strategy_used: {
637
+ type: 'string',
638
+ description: 'mode=focus_area: "executeAction:focusMask".',
639
+ },
640
+ in_focus_radius: { type: 'number', description: 'mode=focus_area: radius used.' },
641
+ soft_mask: { type: 'boolean', description: 'mode=focus_area: soft_mask used.' },
642
+ active_layer_temporarily_changed: {
643
+ type: 'boolean',
644
+ description: 'mode=focus_area: true if the active layer was not an ordinary pixel layer and detection was temporarily retargeted to the bottom layer. Restored before return.',
645
+ },
646
+ whole_canvas_selected: {
647
+ type: 'boolean',
648
+ description: 'mode=focus_area: true when the RAW detection (before any selection_type combine) covered essentially the entire canvas — usually a non-result. selection_info reports the FINAL, post-combine selection and the two can legitimately disagree.',
649
+ },
650
+ warning: {
651
+ type: ['string', 'null'],
652
+ description: 'mode=focus_area: set when whole_canvas_selected is true.',
653
+ },
609
654
  selection_info: selectionInfoFragment,
610
655
  },
611
656
  },
@@ -660,7 +705,7 @@ export function createSelectionTools(connection, snippetClient, client = new Onn
660
705
  {
661
706
  tool: {
662
707
  name: 'ps_get_selection_preview',
663
- description: 'Render TWO inline JPEGs so the agent can visually verify what is currently selected: (1) an OVERLAY of the document with a 50% red wash over the selected area (Quick Mask-style — most intuitive); (2) a B/W MASK where black = selected, white = not. Heavier than the selection_info bundle (~2-4s) — call this when the stats look off or before committing a mask. Does NOT modify the source document.',
708
+ description: "Render an inline JPEG so the agent can visually verify what is currently selected: by default a red-wash OVERLAY (50% red over the selected area, Quick Mask-style — most intuitive); pass `image:'mask'` for a B/W MASK (black = selected, white = not) instead, or `image:'both'` for both. selection_info is always returned regardless of `image`. Heavier than the selection_info bundle alone (~2-4s) — call this when the stats look off or before committing a mask. Does NOT modify the source document.",
664
709
  inputSchema: selectionPreviewSchema,
665
710
  outputSchema: {
666
711
  type: 'object',
@@ -673,7 +718,7 @@ export function createSelectionTools(connection, snippetClient, client = new Onn
673
718
  },
674
719
  },
675
720
  annotations: {
676
- title: 'Get Selection Preview (overlay + mask)',
721
+ title: 'Get Selection Preview',
677
722
  readOnlyHint: true,
678
723
  idempotentHint: true,
679
724
  },
@@ -817,42 +862,6 @@ export function createSelectionTools(connection, snippetClient, client = new Onn
817
862
  },
818
863
  handler: async (args) => selectSky(connection, snippetClient, args),
819
864
  },
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
- },
856
865
  ];
857
866
  }
858
867
  const selectSubjectSchema = {
@@ -877,72 +886,77 @@ const selectSkySchema = {
877
886
  selection_type: selectionTypeFragment,
878
887
  },
879
888
  };
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
- };
889
+ function selectionInfoOf(result) {
890
+ return result.selection_info;
891
+ }
898
892
  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) => ({
893
+ try {
894
+ const args = validateArgs(selectFocusAreaSchema, rawArgs);
895
+ const selectionType = normalizeSelectionType(args.selection_type);
896
+ const script = await snippetClient.build('selectFocusArea', {
908
897
  inFocusRadius: args.in_focus_radius ?? 4.07,
909
898
  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
- });
899
+ selectionType,
900
+ });
901
+ const result = (await runScript(connection, script, SELECT_FOCUS_AREA_TIMEOUT_MS));
902
+ let text = describeSelectionFacts(`Focus Area selection (${selectionType})`, selectionInfoOf(result));
903
+ if (result.warning) {
904
+ text += ` WARNING: ${String(result.warning)}`;
905
+ }
906
+ return {
907
+ content: [{ type: 'text', text }],
908
+ structuredContent: result,
909
+ };
910
+ }
911
+ catch (error) {
912
+ return toolErrorResult('Error running Focus Area selection', error);
913
+ }
914
914
  }
915
915
  async function selectSubject(connection, snippetClient, rawArgs) {
916
- return runSnippetTool({
917
- connection,
918
- snippetClient,
919
- rawArgs,
920
- schema: selectSubjectSchema,
921
- snippet: 'selectSubject',
922
- errorPrefix: 'Error running Select Subject',
923
- timeoutMs: SELECT_SUBJECT_TIMEOUT_MS,
924
- params: (args) => ({
916
+ try {
917
+ const args = validateArgs(selectSubjectSchema, rawArgs);
918
+ const selectionType = normalizeSelectionType(args.selection_type);
919
+ const script = await snippetClient.build('selectSubject', {
925
920
  sampleAllLayers: args.sample_all_layers ?? true,
926
- selectionType: normalizeSelectionType(args.selection_type),
927
- }),
928
- successText: (_result, args) => `Select Subject (${normalizeSelectionType(args.selection_type)}) complete`,
929
- });
921
+ selectionType,
922
+ });
923
+ const result = (await runScript(connection, script, SELECT_SUBJECT_TIMEOUT_MS));
924
+ return {
925
+ content: [
926
+ {
927
+ type: 'text',
928
+ text: describeSelectionFacts(`Select Subject (${selectionType})`, selectionInfoOf(result)),
929
+ },
930
+ ],
931
+ structuredContent: result,
932
+ };
933
+ }
934
+ catch (error) {
935
+ return toolErrorResult('Error running Select Subject', error);
936
+ }
930
937
  }
931
938
  async function selectSky(connection, snippetClient, rawArgs) {
932
- return runSnippetTool({
933
- connection,
934
- snippetClient,
935
- rawArgs,
936
- schema: selectSkySchema,
937
- snippet: 'selectSky',
938
- errorPrefix: 'Error running Select Sky',
939
- timeoutMs: SELECT_SKY_TIMEOUT_MS,
940
- params: (args) => ({
939
+ try {
940
+ const args = validateArgs(selectSkySchema, rawArgs);
941
+ const selectionType = normalizeSelectionType(args.selection_type);
942
+ const script = await snippetClient.build('selectSky', {
941
943
  sampleAllLayers: args.sample_all_layers ?? true,
942
- selectionType: normalizeSelectionType(args.selection_type),
943
- }),
944
- successText: (_result, args) => `Select Sky (${normalizeSelectionType(args.selection_type)}) complete`,
945
- });
944
+ selectionType,
945
+ });
946
+ const result = (await runScript(connection, script, SELECT_SKY_TIMEOUT_MS));
947
+ return {
948
+ content: [
949
+ {
950
+ type: 'text',
951
+ text: describeSelectionFacts(`Select Sky (${selectionType})`, selectionInfoOf(result)),
952
+ },
953
+ ],
954
+ structuredContent: result,
955
+ };
956
+ }
957
+ catch (error) {
958
+ return toolErrorResult('Error running Select Sky', error);
959
+ }
946
960
  }
947
961
  async function select(connection, snippetClient, detClient, rawArgs) {
948
962
  const mode = rawArgs.mode;
@@ -974,6 +988,8 @@ async function select(connection, snippetClient, detClient, rawArgs) {
974
988
  return selectColorPreset(connection, snippetClient, 'out_of_gamut', rest);
975
989
  case 'polygon':
976
990
  return selectPolygon(connection, snippetClient, rest);
991
+ case 'focus_area':
992
+ return selectFocusArea(connection, snippetClient, rest);
977
993
  default:
978
994
  return unknownDiscriminator('select mode', mode, SELECT_MODES);
979
995
  }
@@ -1038,6 +1054,25 @@ export function normalizeSelectionType(raw) {
1038
1054
  ? v
1039
1055
  : 'replace';
1040
1056
  }
1057
+ async function readSelectionInfo(connection, snippetClient) {
1058
+ const script = await snippetClient.build('getSelectionState');
1059
+ return (await runScript(connection, script));
1060
+ }
1061
+ function describeSelectionFacts(label, info) {
1062
+ if (!info || typeof info.has_selection !== 'boolean') {
1063
+ return `${label} complete — selection facts unknown (the result carried no readable selection_info).`;
1064
+ }
1065
+ if (!info.has_selection) {
1066
+ return `${label} completed but the resulting selection is empty.`;
1067
+ }
1068
+ if (!info.bounds) {
1069
+ const reason = typeof info.error === 'string' ? ` (${info.error})` : '';
1070
+ return `${label} complete — a selection exists but its bounds/coverage could not be measured${reason}.`;
1071
+ }
1072
+ const b = info.bounds;
1073
+ const pct = (info.area_percent ?? 0).toFixed(1);
1074
+ return `${label} complete — selection (${b.left}, ${b.top}) to (${b.right}, ${b.bottom}), ${pct}% of canvas.`;
1075
+ }
1041
1076
  function describeMaskOutcome(result) {
1042
1077
  if (result.maskCreated)
1043
1078
  return 'Layer mask created from selection';
@@ -1395,8 +1430,7 @@ async function magicWand(connection, snippetClient, detClient, rawArgs) {
1395
1430
  }
1396
1431
  export async function getSelectionInfoHandler(connection, snippetClient) {
1397
1432
  try {
1398
- const script = await snippetClient.build('getSelectionState');
1399
- const result = (await runScript(connection, script));
1433
+ const result = (await readSelectionInfo(connection, snippetClient));
1400
1434
  const summary = result.has_selection
1401
1435
  ? `Active selection: ${(result.area_percent ?? 0).toFixed(1)}% of canvas (${(result.pixel_count ?? 0).toLocaleString()} px), bounds-fill ratio ${(result.bounds_fill_ratio ?? 0).toFixed(2)}, edge complexity ${(result.edge_complexity ?? 0).toFixed(2)}`
1402
1436
  : 'No active selection.';
@@ -1413,6 +1447,7 @@ async function getSelectionPreview(connection, snippetClient, rawArgs) {
1413
1447
  try {
1414
1448
  const args = validateArgs(selectionPreviewSchema, rawArgs);
1415
1449
  const maxDimension = args.max_dimension ?? 800;
1450
+ const image = args.image ?? 'overlay';
1416
1451
  const dir = await TempDir.create('editmamei-sel-preview-');
1417
1452
  try {
1418
1453
  const overlayPath = dir.path('overlay.jpg');
@@ -1436,17 +1471,28 @@ async function getSelectionPreview(connection, snippetClient, rawArgs) {
1436
1471
  }
1437
1472
  const overlayBytes = await readFile(overlayPath);
1438
1473
  const maskBytes = await readFile(maskPath);
1439
- const overlayB64 = overlayBytes.toString('base64');
1440
- const maskB64 = maskBytes.toString('base64');
1474
+ const text = image === 'both'
1475
+ ? `Selection preview rendered. Image 1 = overlay (red wash on selected area). Image 2 = mask (black=selected, white=not).`
1476
+ : image === 'mask'
1477
+ ? `Selection preview rendered. Image 1 = mask (black=selected, white=not).`
1478
+ : `Selection preview rendered. Image 1 = overlay (red wash on selected area).`;
1479
+ const content = [{ type: 'text', text }];
1480
+ if (image === 'overlay' || image === 'both') {
1481
+ content.push({
1482
+ type: 'image',
1483
+ data: overlayBytes.toString('base64'),
1484
+ mimeType: 'image/jpeg',
1485
+ });
1486
+ }
1487
+ if (image === 'mask' || image === 'both') {
1488
+ content.push({
1489
+ type: 'image',
1490
+ data: maskBytes.toString('base64'),
1491
+ mimeType: 'image/jpeg',
1492
+ });
1493
+ }
1441
1494
  return {
1442
- content: [
1443
- {
1444
- type: 'text',
1445
- text: `Selection preview rendered. Image 1 = overlay (red wash on selected area). Image 2 = mask (black=selected, white=not).`,
1446
- },
1447
- { type: 'image', data: overlayB64, mimeType: 'image/jpeg' },
1448
- { type: 'image', data: maskB64, mimeType: 'image/jpeg' },
1449
- ],
1495
+ content,
1450
1496
  structuredContent: {
1451
1497
  rendered: true,
1452
1498
  max_dimension: result.max_dimension ?? maxDimension,