editmamei 1.0.3 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +9 -9
  2. package/dist/api/extendscript/_helpers.js +70 -2
  3. package/dist/api/photoshop-api.js +17 -2
  4. package/dist/bin/editmamei-core-darwin-arm64 +0 -0
  5. package/dist/bin/editmamei-core-darwin-x64 +0 -0
  6. package/dist/bin/editmamei-core-win-x64.exe +0 -0
  7. package/dist/cli/activate.js +1 -1
  8. package/dist/cli/config.js +1 -1
  9. package/dist/cli/deactivate.js +1 -1
  10. package/dist/core/raw-develop-state.js +13 -0
  11. package/dist/core/server.js +147 -18
  12. package/dist/core/tool-groups.js +18 -21
  13. package/dist/core/tool-tiers.js +7 -10
  14. package/dist/detection/runtime.js +2 -2
  15. package/dist/modules/ce/index.js +2 -0
  16. package/dist/perception/region-scorer.js +1 -1
  17. package/dist/platform/connection.js +15 -0
  18. package/dist/skills/editmamei-skill.zip +0 -0
  19. package/dist/telemetry/client.js +15 -2
  20. package/dist/telemetry/sanitize.js +1 -0
  21. package/dist/tools/adjustment-tools.js +21 -3
  22. package/dist/tools/detection-tools.js +1 -1
  23. package/dist/tools/document-tools.js +5 -2
  24. package/dist/tools/filter-tools.js +118 -15
  25. package/dist/tools/group-tools.js +146 -146
  26. package/dist/tools/history-tools.js +1 -1
  27. package/dist/tools/inspect-tools.js +22 -3
  28. package/dist/tools/layer-tools.js +4 -27
  29. package/dist/tools/overview-tools.js +37 -8
  30. package/dist/tools/preview-tools.js +3 -2
  31. package/dist/tools/scene-tools.js +1 -1
  32. package/dist/tools/selection-tools.js +91 -18
  33. package/dist/tools/sky-tools.js +126 -0
  34. package/dist/tools/smart-object-tools.js +163 -0
  35. package/dist/tools/text-tools.js +62 -41
  36. package/dist/update/check.js +65 -6
  37. package/dist/update/session-fixes.js +33 -0
  38. package/dist/utils/jsx.js +2 -1
  39. package/dist/utils/operation-timeouts.js +2 -0
  40. package/dist/utils/session-log.js +46 -6
  41. package/dist/utils/temp.js +1 -1
  42. package/dist/utils/tool-helpers.js +23 -0
  43. package/dist/version.js +1 -1
  44. package/package.json +13 -2
@@ -2,10 +2,10 @@ import { readFile } from 'node:fs/promises';
2
2
  import { runScript } from '../utils/run-script.js';
3
3
  import { TempDir } from '../utils/temp.js';
4
4
  import { validateArgs } from '../utils/validate.js';
5
- import { toolErrorResult, runSnippetTool } from '../utils/tool-helpers.js';
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',
@@ -450,8 +450,8 @@ const SELECT_INPUT_SCHEMA = {
450
450
  'color_range: target red/green/blue (+fuzziness) — "select all the red / skin". ' +
451
451
  'luminance_range: luminance highlights|shadows|midtones (+fuzziness, lower_limit, upper_limit) — glow/dodge-burn foundation. ' +
452
452
  'magic_wand: click x/y (+tolerance, contiguous, anti_alias, sample_all_layers). ' +
453
- 'grow: expand the CURRENT selection to adjacent similar-colour pixels (+tolerance, anti_alias). ' +
454
- 'similar: expand the CURRENT selection to ALL similar-colour pixels document-wide (+tolerance, anti_alias). ' +
453
+ 'grow: DEPRECATED here — use ps_modify_selection(op=grow) instead (kept for one release for backward compatibility, identical behaviour). Expands the CURRENT selection to adjacent similar-colour pixels (+tolerance, anti_alias). ' +
454
+ 'similar: DEPRECATED here — use ps_modify_selection(op=similar) instead (kept for one release for backward compatibility, identical behaviour). Expands the CURRENT selection to ALL similar-colour pixels document-wide (+tolerance, anti_alias). ' +
455
455
  'skin_tones: select skin-coloured pixels (+fuzziness; use_faces=true adds face-aware refinement). ' +
456
456
  'out_of_gamut: select colours outside the printable CMYK gamut (no params). ' +
457
457
  '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). ' +
@@ -479,6 +479,8 @@ const MODIFY_SELECTION_OPS = [
479
479
  'border',
480
480
  'smooth',
481
481
  'transform',
482
+ 'grow',
483
+ 'similar',
482
484
  ];
483
485
  const MODIFY_SELECTION_INPUT_SCHEMA = {
484
486
  type: 'object',
@@ -491,13 +493,16 @@ const MODIFY_SELECTION_INPUT_SCHEMA = {
491
493
  'expand: grow the selection outward by `amount` px. contract: shrink it inward by `amount` px. ' +
492
494
  'border: replace the selection with a `amount`-px-wide band straddling its edge. ' +
493
495
  'smooth: round off the selection corners with a `amount`-px radius. ' +
496
+ 'grow: expand the selection to adjacent similar-colour pixels (+tolerance, anti_alias). ' +
497
+ 'similar: expand the selection to ALL similar-colour pixels document-wide (+tolerance, anti_alias). ' +
494
498
  'transform: relatively scale (scale_x_percent / scale_y_percent), rotate (rotate_degrees) and/or translate (offset_x / offset_y) the marching ants — not pixels. ' +
495
- 'expand/contract/border/smooth all require an active selection and take `amount`; expand/contract/smooth also take at_canvas_bounds.',
499
+ 'expand/contract/border/smooth all require an active selection and take `amount`; expand/contract/smooth also take at_canvas_bounds. grow/similar also require an active selection.',
496
500
  },
497
501
  ...featherSelectionSchema.properties,
498
502
  ...refineEdgeSchema.properties,
499
503
  ...modifyEdgeSchema.properties,
500
504
  ...transformSelectionSchema.properties,
505
+ ...growSelectionSchema.properties,
501
506
  },
502
507
  required: ['op'],
503
508
  };
@@ -575,7 +580,7 @@ export function createSelectionTools(connection, snippetClient, client = new Onn
575
580
  {
576
581
  tool: {
577
582
  name: 'ps_select',
578
- description: 'Create or modify a selection — choose with `mode`. `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` expand the CURRENT selection to similar-colour pixels (adjacent vs document-wide; both honour `tolerance`). 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.',
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.',
579
584
  inputSchema: SELECT_INPUT_SCHEMA,
580
585
  outputSchema: {
581
586
  type: 'object',
@@ -614,7 +619,7 @@ export function createSelectionTools(connection, snippetClient, client = new Onn
614
619
  {
615
620
  tool: {
616
621
  name: 'ps_modify_selection',
617
- description: "Modify the CURRENT selection — choose with `op`. `feather` softens by radius_px (use when a selection was made hard, e.g. after select all/inverse, before applying an adjustment). `refine_edge` runs Select-and-Mask's global sliders headlessly (smooth, feather, contrast, shift_edge, edge-detection radius, decontaminate) to clean halos and soft/fuzzy edges (hair) after a rough color-range / magic-wand / subject selection. `expand` / `contract` grow / shrink the selection by `amount` px. `border` replaces the selection with an `amount`-px band around its edge. `smooth` rounds the corners with an `amount`-px radius. `transform` relatively scales (scale_x_percent / scale_y_percent), rotates (rotate_degrees) and/or translates (offset_x / offset_y) the marching ants — pixels are untouched. All require an active selection and return selection_info.",
622
+ description: "Modify the CURRENT selection — choose with `op`. (To create a NEW selection instead, use ps_select.) `feather` softens by radius_px (use when a selection was made hard, e.g. after select all/inverse, before applying an adjustment). `refine_edge` runs Select-and-Mask's global sliders headlessly (smooth, feather, contrast, shift_edge, edge-detection radius, decontaminate) to clean halos and soft/fuzzy edges (hair) after a rough color-range / magic-wand / subject selection. `expand` / `contract` grow / shrink the selection by `amount` px. `border` replaces the selection with an `amount`-px band around its edge. `smooth` rounds the corners with an `amount`-px radius. `grow` / `similar` expand the selection to similar-colour pixels by `tolerance` (+anti_alias) — `grow` to adjacent pixels only, `similar` document-wide. `transform` relatively scales (scale_x_percent / scale_y_percent), rotates (rotate_degrees) and/or translates (offset_x / offset_y) the marching ants — pixels are untouched. All require an active selection and return selection_info.",
618
623
  inputSchema: MODIFY_SELECTION_INPUT_SCHEMA,
619
624
  outputSchema: {
620
625
  type: 'object',
@@ -623,6 +628,8 @@ export function createSelectionTools(connection, snippetClient, client = new Onn
623
628
  refined: { type: 'boolean' },
624
629
  modified: { type: 'boolean' },
625
630
  transformed: { type: 'boolean' },
631
+ selected: { type: 'boolean', description: 'op=grow/similar: true on success.' },
632
+ method: { type: 'string', description: 'op=grow/similar: "grow" or "similar".' },
626
633
  mode: { type: 'string' },
627
634
  amount_px: { type: 'number' },
628
635
  scale_x_percent: { type: 'number' },
@@ -637,6 +644,8 @@ export function createSelectionTools(connection, snippetClient, client = new Onn
637
644
  contrast: { type: 'number' },
638
645
  shift_edge: { type: 'number' },
639
646
  decontaminate: { type: 'boolean' },
647
+ tolerance: { type: 'number', description: 'op=grow/similar: tolerance used.' },
648
+ anti_alias: { type: 'boolean', description: 'op=grow/similar: anti_alias used.' },
640
649
  output: { type: 'string' },
641
650
  selection_info: selectionInfoFragment,
642
651
  },
@@ -808,6 +817,42 @@ export function createSelectionTools(connection, snippetClient, client = new Onn
808
817
  },
809
818
  handler: async (args) => selectSky(connection, snippetClient, args),
810
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
+ },
811
856
  ];
812
857
  }
813
858
  const selectSubjectSchema = {
@@ -832,6 +877,41 @@ const selectSkySchema = {
832
877
  selection_type: selectionTypeFragment,
833
878
  },
834
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
+ }
835
915
  async function selectSubject(connection, snippetClient, rawArgs) {
836
916
  return runSnippetTool({
837
917
  connection,
@@ -864,17 +944,6 @@ async function selectSky(connection, snippetClient, rawArgs) {
864
944
  successText: (_result, args) => `Select Sky (${normalizeSelectionType(args.selection_type)}) complete`,
865
945
  });
866
946
  }
867
- function unknownDiscriminator(kind, value, allowed) {
868
- return {
869
- content: [
870
- {
871
- type: 'text',
872
- text: `Error: unknown ${kind} "${String(value)}". Allowed: ${allowed.join(', ')}.`,
873
- },
874
- ],
875
- isError: true,
876
- };
877
- }
878
947
  async function select(connection, snippetClient, detClient, rawArgs) {
879
948
  const mode = rawArgs.mode;
880
949
  const { mode: _omit, ...rest } = rawArgs;
@@ -924,6 +993,10 @@ async function modifySelection(connection, snippetClient, rawArgs) {
924
993
  return modifySelectionEdge(connection, snippetClient, op, rest);
925
994
  case 'transform':
926
995
  return transformSelection(connection, snippetClient, rest);
996
+ case 'grow':
997
+ return growSelection(connection, snippetClient, 'grow', rest);
998
+ case 'similar':
999
+ return growSelection(connection, snippetClient, 'similar', rest);
927
1000
  default:
928
1001
  return unknownDiscriminator('modify_selection op', op, MODIFY_SELECTION_OPS);
929
1002
  }
@@ -0,0 +1,126 @@
1
+ import { runSnippetTool } from '../utils/tool-helpers.js';
2
+ import { SKY_REPLACEMENT_TIMEOUT_MS } from '../utils/operation-timeouts.js';
3
+ const PLACEHOLDER_SKY_ID = '00000000-0000-0000-0000-000000000000';
4
+ const replaceSkyInputSchema = {
5
+ type: 'object',
6
+ properties: {
7
+ sky_file: {
8
+ type: 'string',
9
+ description: 'ABSOLUTE path to the image to use as the sky. Any image Photoshop can open works — it does not need to be a registered Photoshop sky preset. The built-in skies live as .jpg files under the Sky_Presets folder inside your Photoshop settings directory, and their paths can be passed here directly.',
10
+ },
11
+ sky_name: {
12
+ type: 'string',
13
+ description: 'Label recorded on the operation. Cosmetic — it does not select the sky, sky_file does.',
14
+ default: 'Custom Sky',
15
+ },
16
+ shift_edge: {
17
+ type: 'number',
18
+ description: 'Moves the sky/foreground boundary inward (negative) or outward (positive). Use it when the horizon leaves a halo or eats into the foreground.',
19
+ default: 0,
20
+ minimum: -100,
21
+ maximum: 100,
22
+ },
23
+ border_smoothness: {
24
+ type: 'number',
25
+ description: 'Softens the boundary between sky and foreground. 0 is a hard cut.',
26
+ default: 50,
27
+ minimum: 0,
28
+ maximum: 100,
29
+ },
30
+ brightness: {
31
+ type: 'number',
32
+ description: 'Brightness of the replaced sky itself.',
33
+ default: 0,
34
+ minimum: -100,
35
+ maximum: 100,
36
+ },
37
+ temperature: {
38
+ type: 'number',
39
+ description: 'Warms (positive) or cools (negative) the replaced sky.',
40
+ default: 0,
41
+ minimum: -100,
42
+ maximum: 100,
43
+ },
44
+ harmonization_opacity: {
45
+ type: 'number',
46
+ description: 'How strongly the foreground is colour-graded to match the new sky. This is what sells the composite — 0 leaves the foreground untouched and usually reads as pasted-on.',
47
+ default: 35,
48
+ minimum: 0,
49
+ maximum: 100,
50
+ },
51
+ foreground_lighting_opacity: {
52
+ type: 'number',
53
+ description: 'Strength of the relighting applied to the foreground so it appears lit by the new sky.',
54
+ default: 78,
55
+ minimum: 0,
56
+ maximum: 100,
57
+ },
58
+ edge_lighting_opacity: {
59
+ type: 'number',
60
+ description: 'Strength of the light wrap along the foreground edge where it meets the sky.',
61
+ default: 70,
62
+ minimum: 0,
63
+ maximum: 100,
64
+ },
65
+ },
66
+ required: ['sky_file'],
67
+ };
68
+ async function replaceSky(connection, snippetClient, rawArgs) {
69
+ return runSnippetTool({
70
+ connection,
71
+ snippetClient,
72
+ rawArgs,
73
+ schema: replaceSkyInputSchema,
74
+ snippet: 'replaceSky',
75
+ errorPrefix: 'Error running Sky Replacement',
76
+ timeoutMs: SKY_REPLACEMENT_TIMEOUT_MS,
77
+ params: (args) => ({
78
+ skyPath: args.sky_file,
79
+ skyName: args.sky_name ?? 'Custom Sky',
80
+ skyId: PLACEHOLDER_SKY_ID,
81
+ shiftEdge: args.shift_edge ?? 0,
82
+ borderSmoothness: args.border_smoothness ?? 50,
83
+ brightness: args.brightness ?? 0,
84
+ temperature: args.temperature ?? 0,
85
+ harmonizationOpacity: args.harmonization_opacity ?? 35,
86
+ foregroundLightingOpacity: args.foreground_lighting_opacity ?? 78,
87
+ edgeLightingOpacity: args.edge_lighting_opacity ?? 70,
88
+ }),
89
+ successText: (_result, args) => `Sky replaced using ${String(args.sky_file)}`,
90
+ });
91
+ }
92
+ export function createSkyTools(connection, snippetClient) {
93
+ return [
94
+ {
95
+ tool: {
96
+ name: 'ps_replace_sky',
97
+ description: 'Run Photoshop\'s Sky Replacement (Adobe Sensei): detect the sky, composite a replacement, and relight/colour-grade the foreground to match. Non-destructive — everything lands in a "Sky Replacement Group" of editable layers above the original, so the result can be tuned or deleted afterwards. sky_file takes an ABSOLUTE path to ANY image on disk, not only Photoshop\'s built-in presets. Reach for harmonization_opacity and foreground_lighting_opacity when the composite reads as pasted-on; those two carry most of the believability. Fails cleanly when the image has no detectable sky (indoor or closed compositions).',
98
+ inputSchema: replaceSkyInputSchema,
99
+ outputSchema: {
100
+ type: 'object',
101
+ properties: {
102
+ replaced: { type: 'boolean' },
103
+ strategy_used: { type: 'string' },
104
+ group_name: {
105
+ type: 'string',
106
+ description: 'Name of the layer group Photoshop created.',
107
+ },
108
+ group_layers: {
109
+ type: 'array',
110
+ items: { type: 'string' },
111
+ description: 'Layers inside the group, top to bottom — typically the sky layer, an edge-lighting group, a foreground-lighting layer and a foreground-colour curves layer.',
112
+ },
113
+ sky_file: { type: 'string' },
114
+ sky_name: { type: 'string' },
115
+ context: { type: 'object' },
116
+ },
117
+ },
118
+ annotations: {
119
+ title: 'Replace Sky (Sensei)',
120
+ idempotentHint: false,
121
+ },
122
+ },
123
+ handler: async (args) => replaceSky(connection, snippetClient, args),
124
+ },
125
+ ];
126
+ }
@@ -0,0 +1,163 @@
1
+ import { validateArgs } from '../utils/validate.js';
2
+ import { runSnippetTool, toolErrorResult } from '../utils/tool-helpers.js';
3
+ import { LAYER_BLEND_MODES } from '../utils/blend-modes.js';
4
+ const SMART_FILTER_OPS = ['list', 'set_visibility', 'set_blend', 'remove'];
5
+ const smartFilterInputSchema = {
6
+ type: 'object',
7
+ properties: {
8
+ op: {
9
+ type: 'string',
10
+ enum: [...SMART_FILTER_OPS],
11
+ description: 'list: read every Smart Filter on the active layer (index, name, type, enabled, opacity, blend mode). Read-only — call it first to get the indices the other ops take. ' +
12
+ 'set_visibility: turn one filter on or off without removing it (needs `index` + `enabled`). ' +
13
+ "set_blend: change one filter's `opacity` and/or `blend_mode` (needs `index` + at least one of them). " +
14
+ 'remove: delete one filter from the stack (needs `index`). Removing a filter renumbers every index above it — re-run op=list before the next index-taking call.',
15
+ },
16
+ index: {
17
+ type: 'integer',
18
+ description: "1-based index of the filter to act on, as reported by op=list. 1 is the FIRST-APPLIED filter (bottom of the Smart Filters stack in the Layers panel). Required for every op except 'list'.",
19
+ minimum: 1,
20
+ maximum: 1000,
21
+ },
22
+ enabled: {
23
+ type: 'boolean',
24
+ description: 'set_visibility only: true shows the filter, false hides it. The filter stays in the stack either way and keeps all its settings.',
25
+ },
26
+ opacity: {
27
+ type: 'number',
28
+ description: 'set_blend only: filter opacity 0-100. Omit to leave the current opacity untouched.',
29
+ minimum: 0,
30
+ maximum: 100,
31
+ },
32
+ blend_mode: {
33
+ type: 'string',
34
+ enum: [...LAYER_BLEND_MODES],
35
+ description: 'set_blend only: how the filter result composites against the unfiltered layer. Same names as ps_set_layer. Omit to leave the current mode untouched.',
36
+ },
37
+ },
38
+ required: ['op'],
39
+ };
40
+ export async function runSmartFilterOp(connection, snippetClient, rawArgs) {
41
+ let args;
42
+ try {
43
+ args = validateArgs(smartFilterInputSchema, rawArgs);
44
+ }
45
+ catch (error) {
46
+ return toolErrorResult('Error in ps_filter', error);
47
+ }
48
+ const op = args.op;
49
+ if (op !== 'list' && args.index === undefined) {
50
+ return toolErrorResult('Error in ps_filter', new Error(`op=${op} needs an \`index\`. Call op=list first to see the filters and their indices.`));
51
+ }
52
+ switch (op) {
53
+ case 'list':
54
+ return runSnippetTool({
55
+ connection,
56
+ snippetClient,
57
+ rawArgs: args,
58
+ schema: smartFilterInputSchema,
59
+ snippet: 'listSmartFilters',
60
+ errorPrefix: 'Error listing Smart Filters',
61
+ successText: (result) => summarizeList(result),
62
+ });
63
+ case 'set_visibility': {
64
+ if (args.enabled === undefined) {
65
+ return toolErrorResult('Error in ps_filter', new Error('op=set_visibility needs `enabled` (true to show the filter, false to hide it).'));
66
+ }
67
+ return runSnippetTool({
68
+ connection,
69
+ snippetClient,
70
+ rawArgs: args,
71
+ schema: smartFilterInputSchema,
72
+ snippet: 'setSmartFilterVisibility',
73
+ errorPrefix: 'Error setting Smart Filter visibility',
74
+ params: (a) => ({ index: a.index, enabled: a.enabled }),
75
+ successText: (result) => {
76
+ const r = result;
77
+ return `${r.enabled ? 'Showed' : 'Hid'} Smart Filter ${String(r.index)} (${String(r.filter_name)}) on "${String(r.layer_name)}".`;
78
+ },
79
+ });
80
+ }
81
+ case 'set_blend': {
82
+ if (args.opacity === undefined && args.blend_mode === undefined) {
83
+ return toolErrorResult('Error in ps_filter', new Error('op=set_blend needs at least one of `opacity` or `blend_mode`.'));
84
+ }
85
+ return runSnippetTool({
86
+ connection,
87
+ snippetClient,
88
+ rawArgs: args,
89
+ schema: smartFilterInputSchema,
90
+ snippet: 'setSmartFilterBlend',
91
+ errorPrefix: 'Error setting Smart Filter blend',
92
+ params: (a) => {
93
+ const p = { index: a.index };
94
+ if (a.opacity !== undefined)
95
+ p.opacity = a.opacity;
96
+ if (a.blend_mode !== undefined)
97
+ p.blendMode = a.blend_mode;
98
+ return p;
99
+ },
100
+ successText: (result) => {
101
+ const r = result;
102
+ const opacity = Number(r.opacity);
103
+ const opacityText = Number.isFinite(opacity) ? opacity.toFixed(1) : String(r.opacity);
104
+ return `Smart Filter ${String(r.index)} (${String(r.filter_name)}) is now ${String(r.blend_mode)} at ${opacityText}% opacity on "${String(r.layer_name)}".`;
105
+ },
106
+ });
107
+ }
108
+ case 'remove':
109
+ return runSnippetTool({
110
+ connection,
111
+ snippetClient,
112
+ rawArgs: args,
113
+ schema: smartFilterInputSchema,
114
+ snippet: 'removeSmartFilter',
115
+ errorPrefix: 'Error removing Smart Filter',
116
+ params: (a) => ({ index: a.index }),
117
+ successText: (result) => {
118
+ const r = result;
119
+ return `Removed Smart Filter ${String(r.index)} (${String(r.removed_filter_name)}) from "${String(r.layer_name)}". ${String(r.remaining_count)} remaining. Indices renumbered — re-run op=list before the next index-taking call.`;
120
+ },
121
+ });
122
+ default:
123
+ return toolErrorResult('Error in ps_filter', new Error(`Unknown smart-filter op: ${String(op)}. Allowed: ${SMART_FILTER_OPS.join(', ')}.`));
124
+ }
125
+ }
126
+ function summarizeList(r) {
127
+ const layer = String(r.layer_name ?? '');
128
+ if (r.is_smart_object !== true) {
129
+ return `"${layer}" is not a Smart Object, so it has no Smart Filters. Convert it with ps_convert_to_smart_object to make filters re-editable.`;
130
+ }
131
+ const filters = Array.isArray(r.filters) ? r.filters : [];
132
+ if (filters.length === 0) {
133
+ return `"${layer}" is a Smart Object with no Smart Filters yet. Add one with ps_filter as_smart_filter=true.`;
134
+ }
135
+ const lines = filters.map((f) => {
136
+ const state = f.enabled === false ? ' [hidden]' : '';
137
+ const blend = f.blend_mode === 'NORMAL' && f.opacity === 100
138
+ ? ''
139
+ : ` — ${String(f.blend_mode)} @ ${String(f.opacity)}%`;
140
+ return ` ${String(f.index)}. ${String(f.name)} (${String(f.type)})${blend}${state}`;
141
+ });
142
+ return `"${layer}" has ${filters.length} Smart Filter${filters.length === 1 ? '' : 's'} (1 = first applied, bottom of the stack):\n${lines.join('\n')}`;
143
+ }
144
+ export async function getSmartObjectInfoHandler(connection, snippetClient) {
145
+ return runSnippetTool({
146
+ connection,
147
+ snippetClient,
148
+ rawArgs: {},
149
+ schema: { type: 'object', properties: {} },
150
+ snippet: 'getSmartObjectInfo',
151
+ errorPrefix: 'Error reading Smart Object info',
152
+ successText: (result) => {
153
+ const r = result;
154
+ const layer = String(r.layer_name ?? '');
155
+ if (r.is_smart_object !== true) {
156
+ return `"${layer}" is not a Smart Object (kind: ${String(r.layer_kind)}).`;
157
+ }
158
+ const storage = r.linked === true ? `linked to ${String(r.file_reference)}` : 'embedded';
159
+ const n = Number(r.smart_filter_count ?? 0);
160
+ return `"${layer}" is a ${storage} Smart Object with ${n} Smart Filter${n === 1 ? '' : 's'}.`;
161
+ },
162
+ });
163
+ }
@@ -1,6 +1,7 @@
1
1
  import { runScript } from '../utils/run-script.js';
2
2
  import { validateArgs } from '../utils/validate.js';
3
- import { toolErrorResult, runSnippetTool } from '../utils/tool-helpers.js';
3
+ import { toolErrorResult, runSnippetTool, unknownDiscriminator } from '../utils/tool-helpers.js';
4
+ import { createTextLayer, createTextLayerSchema } from './layer-tools.js';
4
5
  const setTextFontSchema = {
5
6
  type: 'object',
6
7
  properties: {
@@ -69,77 +70,97 @@ const updateTextContentSchema = {
69
70
  },
70
71
  required: ['text'],
71
72
  };
72
- const SET_TEXT_PROPERTIES = ['font', 'color', 'alignment', 'content'];
73
- const SET_TEXT_INPUT_SCHEMA = {
73
+ const TEXT_OPS = ['create', 'set_content', 'set_font', 'set_color', 'set_alignment'];
74
+ export const TEXT_OP_SCHEMAS = {
75
+ create: createTextLayerSchema,
76
+ set_content: updateTextContentSchema,
77
+ set_font: setTextFontSchema,
78
+ set_color: setTextColorSchema,
79
+ set_alignment: setTextAlignmentSchema,
80
+ };
81
+ const TEXT_INPUT_SCHEMA = {
74
82
  type: 'object',
75
83
  properties: {
76
- property: {
84
+ op: {
77
85
  type: 'string',
78
- enum: [...SET_TEXT_PROPERTIES],
79
- description: 'Which text attribute to set on the active text layer: ' +
80
- 'font(font_name, optional font_size); color(red, green, blue); ' +
81
- 'alignment(alignment); content(text).',
86
+ enum: [...TEXT_OPS],
87
+ description: 'create: new text layer with `text` at `x`/`y` (default 100,100) and `font_size` (default 24). ' +
88
+ "set_content: replace the active text layer's content with `text`. " +
89
+ "set_font: set the active text layer's font to `font_name` (optionally `font_size`). " +
90
+ "set_color: set the active text layer's color to `red`/`green`/`blue`. " +
91
+ "set_alignment: set the active text layer's `alignment`.",
82
92
  },
83
- ...setTextFontSchema.properties,
93
+ ...createTextLayerSchema.properties,
84
94
  ...setTextColorSchema.properties,
85
95
  ...setTextAlignmentSchema.properties,
86
- ...updateTextContentSchema.properties,
96
+ font_name: setTextFontSchema.properties.font_name,
97
+ text: {
98
+ type: 'string',
99
+ description: 'create: initial text content. set_content: new text content, replacing the existing text.',
100
+ },
101
+ font_size: {
102
+ type: 'number',
103
+ description: "Font size in points, up to 1296 — Photoshop's own ceiling for the Character panel. create: initial size (default 24). set_font: new size (optional — omit to leave the current size unchanged).",
104
+ default: 24,
105
+ minimum: 1,
106
+ maximum: 1296,
107
+ },
87
108
  },
88
- required: ['property'],
109
+ required: ['op'],
89
110
  };
90
111
  export function createTextTools(connection, snippetClient) {
91
112
  return [
92
113
  {
93
114
  tool: {
94
- name: 'ps_set_text',
95
- description: 'Set an attribute of the currently active text layer — font (family or PostScript name, optionally size), color (RGB), alignment, or text content — selected via `property`. Idempotent. Throws if the active layer is not a text layer. Font names accept either the PostScript name ("ArialMT") or family name ("Arial", resolved to its Regular/first variant); throws clearly if no installed font matches.',
96
- inputSchema: SET_TEXT_INPUT_SCHEMA,
115
+ name: 'ps_text',
116
+ description: 'Text layer — create it or style it, chosen with `op`. create: a new text layer with `text` at `x`/`y` and `font_size`. set_content/set_font/set_color/set_alignment act on the currently active text layer (throws if it isn\'t one). Font names accept either the PostScript name ("ArialMT") or family name ("Arial", resolved to its Regular/first variant); throws clearly if no installed font matches. The set_* ops are idempotent.',
117
+ inputSchema: TEXT_INPUT_SCHEMA,
97
118
  outputSchema: {
98
119
  type: 'object',
99
120
  properties: {
100
- requested: { type: 'string' },
101
- font: { type: 'string' },
102
- size: { type: 'number' },
121
+ created: { type: 'boolean', description: 'op=create: true on success.' },
122
+ layerName: { type: 'string', description: 'op=create: the new layer name.' },
123
+ position: { type: 'object', description: 'op=create: {x, y}.' },
124
+ fontSize: { type: 'number', description: 'op=create: the size applied.' },
125
+ requested: { type: 'string', description: 'op=set_font: the font requested.' },
126
+ font: { type: 'string', description: 'op=set_font: the font actually matched.' },
127
+ size: { type: 'number', description: 'op=set_font: the size applied, if given.' },
103
128
  matched_by: {
104
129
  type: 'string',
105
130
  enum: ['postScriptName', 'family+regular', 'family', 'name'],
131
+ description: 'op=set_font: how `font_name` was resolved.',
106
132
  },
107
- color: { type: 'string' },
108
- alignment: { type: 'string' },
109
- text: { type: 'string' },
133
+ color: { type: 'string', description: 'op=set_color: the RGB() string applied.' },
134
+ alignment: { type: 'string', description: 'op=set_alignment: the alignment applied.' },
135
+ text: { type: 'string', description: 'op=create/set_content: the text content.' },
136
+ context: { type: 'object' },
110
137
  },
111
138
  },
112
139
  annotations: {
113
- title: 'Set Text',
114
- idempotentHint: true,
140
+ title: 'Text',
141
+ idempotentHint: false,
115
142
  },
116
143
  },
117
- handler: async (args) => setText(connection, snippetClient, args),
144
+ handler: async (args) => textDispatch(connection, snippetClient, args),
118
145
  },
119
146
  ];
120
147
  }
121
- async function setText(connection, snippetClient, rawArgs) {
122
- const property = rawArgs.property;
123
- const { property: _omit, ...rest } = rawArgs;
124
- switch (property) {
125
- case 'font':
148
+ async function textDispatch(connection, snippetClient, rawArgs) {
149
+ const op = rawArgs.op;
150
+ const { op: _omit, ...rest } = rawArgs;
151
+ switch (op) {
152
+ case 'create':
153
+ return createTextLayer(connection, snippetClient, rest);
154
+ case 'set_content':
155
+ return updateTextContent(connection, snippetClient, rest);
156
+ case 'set_font':
126
157
  return setTextFont(connection, snippetClient, rest);
127
- case 'color':
158
+ case 'set_color':
128
159
  return setTextColor(connection, snippetClient, rest);
129
- case 'alignment':
160
+ case 'set_alignment':
130
161
  return setTextAlignment(connection, snippetClient, rest);
131
- case 'content':
132
- return updateTextContent(connection, snippetClient, rest);
133
162
  default:
134
- return {
135
- content: [
136
- {
137
- type: 'text',
138
- text: `Error: unknown text property "${String(property)}". Allowed: ${SET_TEXT_PROPERTIES.join(', ')}.`,
139
- },
140
- ],
141
- isError: true,
142
- };
163
+ return unknownDiscriminator('text op', op, TEXT_OPS);
143
164
  }
144
165
  }
145
166
  async function setTextFont(connection, snippetClient, rawArgs) {