editmamei 0.24.1 → 1.0.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 (46) hide show
  1. package/NOTICES.md +6 -30
  2. package/README.md +39 -7
  3. package/dist/api/extendscript/_helpers.js +1 -1
  4. package/dist/api/photoshop-api.js +20 -14
  5. package/dist/bin/editmamei-core-darwin-arm64 +0 -0
  6. package/dist/bin/editmamei-core-darwin-x64 +0 -0
  7. package/dist/bin/editmamei-core-win-x64.exe +0 -0
  8. package/dist/core/server.js +6 -6
  9. package/dist/core/session.js +9 -10
  10. package/dist/core/tool-registry.js +4 -4
  11. package/dist/delivery/store.js +10 -2
  12. package/dist/detection/detect-active-doc.js +1 -1
  13. package/dist/kernel/module-lifecycle.js +7 -1
  14. package/dist/perception/region-precompute.js +13 -5
  15. package/dist/perception/select-recipes.js +2 -2
  16. package/dist/perception/sky-mask-transfer.js +1 -1
  17. package/dist/platform/connection.js +32 -46
  18. package/dist/platform/host-platform.js +16 -0
  19. package/dist/platform/macos-detector.js +41 -71
  20. package/dist/platform/macos-runner.js +94 -0
  21. package/dist/platform/script-result.js +39 -0
  22. package/dist/platform/windows-detector.js +44 -68
  23. package/dist/platform/windows-runner.js +84 -0
  24. package/dist/skills/editmamei-skill.zip +0 -0
  25. package/dist/tools/adjustment-tools.js +18 -2
  26. package/dist/tools/brush-tools.js +1 -1
  27. package/dist/tools/document-tools.js +9 -2
  28. package/dist/tools/filter-tools.js +1 -1
  29. package/dist/tools/layer-tools.js +132 -7
  30. package/dist/tools/path-tools.js +1 -1
  31. package/dist/tools/preview-tools.js +8 -8
  32. package/dist/tools/retouch-tools.js +1 -1
  33. package/dist/tools/scene-tools.js +29 -2
  34. package/dist/tools/selection-tools.js +81 -3
  35. package/dist/update/check.js +1 -1
  36. package/dist/utils/logger.js +53 -46
  37. package/dist/utils/session-log.js +1 -1
  38. package/dist/utils/tool-helpers.js +9 -3
  39. package/dist/version.js +1 -1
  40. package/package.json +10 -3
  41. package/LICENSE +0 -81
  42. package/dist/platform/detector.js +0 -32
  43. package/dist/platform/macos-executor.js +0 -96
  44. package/dist/platform/windows-executor.js +0 -105
  45. /package/dist/platform/{script-executor.js → ports.js} +0 -0
  46. /package/dist/platform/{executor-queue.js → script-queue.js} +0 -0
@@ -75,7 +75,7 @@ const savePsdSchema = {
75
75
  },
76
76
  keep_scene_channels: {
77
77
  type: 'boolean',
78
- description: "Keep the managed scene:* alpha channels ps_read_scene precomputes. Default false — they are DERIVED masks (rebuilt by the next scene read) and each is a full-resolution channel, so baking them into the .psd bloats it badly (~51MB per channel on a 51MP document). Set true only if you want the saved file to carry the masks. The result reports scene_channels_purged either way, so it's never silent.",
78
+ description: "Keep the managed scene:* alpha channels ps_read_scene precomputes. Default false — they are DERIVED masks (rebuilt by the next scene read) and each is a full-resolution channel, so baking them into the .psd bloats it badly (~51MB per channel on a 51MP document). Set true only if you want the saved file to carry the masks. The result reports scene_channels_purged either way, so it's never silent. NOTE: the `scene:` channel-name prefix is RESERVED — the purge matches on the prefix alone, so a hand-made channel named e.g. `scene:mine` is deleted along with the derived ones. Name your own channels anything else.",
79
79
  default: false,
80
80
  },
81
81
  },
@@ -415,8 +415,15 @@ async function openDocumentPipeline(connection, snippetClient, rawArgs) {
415
415
  }
416
416
  }
417
417
  async function savePsdPipeline(connection, snippetClient, rawArgs) {
418
+ let args;
419
+ try {
420
+ args = validateArgs(savePsdSchema, rawArgs);
421
+ }
422
+ catch (error) {
423
+ return toolErrorResult('Error saving PSD', error);
424
+ }
418
425
  let purged = 0;
419
- if (rawArgs.keep_scene_channels !== true) {
426
+ if (args.keep_scene_channels !== true) {
420
427
  try {
421
428
  purged = await purgeSceneChannels(connection);
422
429
  }
@@ -928,7 +928,7 @@ export function createFilterTools(connection, snippetClient, client = new OnnxLa
928
928
  {
929
929
  tool: {
930
930
  name: 'ps_apply_filter',
931
- description: 'Apply a Photoshop filter to a DUPLICATE of the active layer by default (auto-duplicate-first per Bundle O — the original is preserved, undo by deleting the copy). Pass `apply_to_active_layer: true` to bake into the original. Auto-rasterizes text/smart-object layers. Choose the filter with `type`; each type takes its own parameters (see the `type` field). Covers blur (gaussian_blur/motion_blur/lens_blur/radial_blur), sharpen (`sharpen`=Unsharp Mask, `smart_sharpen`), noise (`noise`=Add Noise, `reduce_noise`), high_pass, pixelate, distort, displace, and oil_paint.',
931
+ description: 'Apply a Photoshop filter to a DUPLICATE of the active layer by default (auto-duplicate-first — the original is preserved, undo by deleting the copy). Pass `apply_to_active_layer: true` to bake into the original. Auto-rasterizes text/smart-object layers. Choose the filter with `type`; each type takes its own parameters (see the `type` field). Covers blur (gaussian_blur/motion_blur/lens_blur/radial_blur), sharpen (`sharpen`=Unsharp Mask, `smart_sharpen`), noise (`noise`=Add Noise, `reduce_noise`), high_pass, pixelate, distort, displace, and oil_paint.',
932
932
  inputSchema: FILTER_INPUT_SCHEMA,
933
933
  outputSchema: FILTER_OUTPUT_SCHEMA,
934
934
  annotations: {
@@ -79,20 +79,106 @@ const addFillLayerSchema = {
79
79
  properties: {
80
80
  fill_type: {
81
81
  type: 'string',
82
- enum: ['solid_color'],
83
- description: 'Fill-layer type. Currently solid_color (gradient / pattern fills planned).',
82
+ enum: ['solid_color', 'gradient'],
83
+ description: 'Fill-layer type. solid_color takes red/green/blue (required for that type); gradient takes the gradient_* params + stops (pattern fills still planned).',
84
84
  default: 'solid_color',
85
85
  },
86
- red: { type: 'integer', description: 'Solid color red (0-255).', minimum: 0, maximum: 255 },
87
- green: { type: 'integer', description: 'Solid color green (0-255).', minimum: 0, maximum: 255 },
88
- blue: { type: 'integer', description: 'Solid color blue (0-255).', minimum: 0, maximum: 255 },
86
+ red: {
87
+ type: 'integer',
88
+ description: 'fill_type=solid_color: red (0-255). Required for solid_color.',
89
+ minimum: 0,
90
+ maximum: 255,
91
+ },
92
+ green: {
93
+ type: 'integer',
94
+ description: 'fill_type=solid_color: green (0-255). Required for solid_color.',
95
+ minimum: 0,
96
+ maximum: 255,
97
+ },
98
+ blue: {
99
+ type: 'integer',
100
+ description: 'fill_type=solid_color: blue (0-255). Required for solid_color.',
101
+ minimum: 0,
102
+ maximum: 255,
103
+ },
104
+ gradient_type: {
105
+ type: 'string',
106
+ enum: ['linear', 'radial', 'angle', 'reflected', 'diamond'],
107
+ description: 'fill_type=gradient: gradient geometry. linear for skies/fades, radial for vignettes/glows.',
108
+ default: 'linear',
109
+ },
110
+ angle: {
111
+ type: 'number',
112
+ minimum: -180,
113
+ maximum: 180,
114
+ description: 'fill_type=gradient: gradient angle in degrees (Photoshop convention: 90 runs the first stop from the bottom up; 0 runs it left to right).',
115
+ default: 90,
116
+ },
117
+ scale: {
118
+ type: 'number',
119
+ minimum: 10,
120
+ maximum: 150,
121
+ description: 'fill_type=gradient: gradient scale percent (compress/stretch the ramp).',
122
+ default: 100,
123
+ },
124
+ reverse: {
125
+ type: 'boolean',
126
+ description: 'fill_type=gradient: reverse the stop order.',
127
+ default: false,
128
+ },
129
+ dither: {
130
+ type: 'boolean',
131
+ description: 'fill_type=gradient: dither to reduce banding.',
132
+ default: true,
133
+ },
134
+ offset_x: {
135
+ type: 'number',
136
+ minimum: -100,
137
+ maximum: 100,
138
+ description: 'fill_type=gradient: horizontal center offset percent.',
139
+ default: 0,
140
+ },
141
+ offset_y: {
142
+ type: 'number',
143
+ minimum: -100,
144
+ maximum: 100,
145
+ description: 'fill_type=gradient: vertical center offset percent.',
146
+ default: 0,
147
+ },
148
+ stops: {
149
+ type: 'array',
150
+ description: 'fill_type=gradient: color stops, each {red,green,blue (0-255), location (0-100 along the ramp), midpoint (5-95, default 50)}. At least 2 when supplied; sorted by location. Default: black at 0 → white at 100.',
151
+ items: {
152
+ type: 'object',
153
+ properties: {
154
+ red: { type: 'integer', minimum: 0, maximum: 255 },
155
+ green: { type: 'integer', minimum: 0, maximum: 255 },
156
+ blue: { type: 'integer', minimum: 0, maximum: 255 },
157
+ location: { type: 'number', minimum: 0, maximum: 100 },
158
+ midpoint: { type: 'number', minimum: 5, maximum: 95, default: 50 },
159
+ },
160
+ required: ['red', 'green', 'blue', 'location'],
161
+ },
162
+ },
163
+ opacity_stops: {
164
+ type: 'array',
165
+ description: 'fill_type=gradient: opacity stops, each {opacity (0-100), location (0-100), midpoint (5-95, default 50)}. Use e.g. 100→0 for a fade-to-transparent wash. Default: fully opaque.',
166
+ items: {
167
+ type: 'object',
168
+ properties: {
169
+ opacity: { type: 'number', minimum: 0, maximum: 100 },
170
+ location: { type: 'number', minimum: 0, maximum: 100 },
171
+ midpoint: { type: 'number', minimum: 5, maximum: 95, default: 50 },
172
+ },
173
+ required: ['opacity', 'location'],
174
+ },
175
+ },
89
176
  into_active_group: {
90
177
  type: 'boolean',
91
178
  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
179
  default: false,
93
180
  },
94
181
  },
95
- required: ['red', 'green', 'blue'],
96
182
  };
97
183
  const selectLayerSchema = {
98
184
  type: 'object',
@@ -198,7 +284,7 @@ export function createLayerTools(connection, snippetClient) {
198
284
  {
199
285
  tool: {
200
286
  name: 'ps_add_fill_layer',
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.)",
287
+ description: "Add a non-destructive SOLID COLOR or GRADIENT fill layer (an editable content layer — distinct from ps_fill_layer, which bakes color into the active pixel layer). fill_type=gradient is the go-to for sky fades, color washes, and vignettes (radial + reverse + multiply blend); combine with opacity_stops 100→0 for fade-to-transparent, or ps_bake_layer to rasterize. For fading a layer out via its MASK use ps_layer_mask op=gradient instead. Hoisted out of the active layer's group by default (pass into_active_group:true to keep Photoshop's native nesting). The new fill layer becomes active.",
202
288
  inputSchema: addFillLayerSchema,
203
289
  outputSchema: {
204
290
  type: 'object',
@@ -206,6 +292,11 @@ export function createLayerTools(connection, snippetClient) {
206
292
  created: { type: 'boolean' },
207
293
  fill_type: { type: 'string' },
208
294
  color: { type: 'object' },
295
+ gradient_type: { type: 'string' },
296
+ angle: { type: 'number' },
297
+ scale: { type: 'number' },
298
+ reverse: { type: 'boolean' },
299
+ stop_count: { type: 'number' },
209
300
  layer_name: { type: 'string' },
210
301
  hoisted: {
211
302
  type: 'boolean',
@@ -325,6 +416,40 @@ async function fillLayer(connection, snippetClient, rawArgs) {
325
416
  });
326
417
  }
327
418
  async function addFillLayer(connection, snippetClient, rawArgs) {
419
+ if (rawArgs.fill_type === 'gradient') {
420
+ return runSnippetTool({
421
+ connection,
422
+ snippetClient,
423
+ rawArgs,
424
+ schema: addFillLayerSchema,
425
+ snippet: 'addGradientFillLayer',
426
+ errorPrefix: 'Error adding gradient fill layer',
427
+ params: (args) => {
428
+ const params = {
429
+ gradient_type: args.gradient_type ?? 'linear',
430
+ angle: args.angle ?? 90,
431
+ scale: args.scale ?? 100,
432
+ reverse: args.reverse ?? false,
433
+ dither: args.dither ?? true,
434
+ offset_x: args.offset_x ?? 0,
435
+ offset_y: args.offset_y ?? 0,
436
+ into_active_group: args.into_active_group ?? false,
437
+ };
438
+ if (args.stops !== undefined)
439
+ params.stops = args.stops;
440
+ if (args.opacity_stops !== undefined)
441
+ params.opacity_stops = args.opacity_stops;
442
+ return params;
443
+ },
444
+ successText: (result, args) => {
445
+ const stopCount = result.stop_count;
446
+ return `Gradient fill layer added: ${args.gradient_type ?? 'linear'}, angle ${args.angle ?? 90}${stopCount !== undefined ? `, ${stopCount} stops` : ''}`;
447
+ },
448
+ });
449
+ }
450
+ if (rawArgs.red === undefined || rawArgs.green === undefined || rawArgs.blue === undefined) {
451
+ return toolErrorResult('Error adding fill layer', new Error('fill_type=solid_color requires red, green, and blue (0-255).'));
452
+ }
328
453
  return runSnippetTool({
329
454
  connection,
330
455
  snippetClient,
@@ -113,7 +113,7 @@ export function createPathTools(connection, snippetClient, client = new OnnxLand
113
113
  {
114
114
  tool: {
115
115
  name: 'ps_path',
116
- description: 'Work with editable vector PATHS — the connective tissue between selections, brush strokes, and clipping. One op-discriminated tool: `create_from_selection` (selection → work path), `create_from_placement` (NAME a curve → editable saved path, the grounded pen), `save` (name the work path), `list`, `delete`, `load_as_selection` (path → selection), `stroke` (paint a brush-family tool along a path), `fill` (fill a path region), `set_clipping` (mark a saved path as the clipping path). **Reach for this when**: tracing a named curve (an edge, a landmark contour) into an editable path, turning a precise selection into a reusable/strokeable outline, converting a saved path back to a selection, or outlining/filling a shape exactly. create_from_selection and load_as_selection are an exact round-trip. stroke/fill bake pixels and auto-duplicate the target layer (Bundle O). Every op returns path_info (counts + per-path subpath/anchor totals) so you can verify the path landed; load_as_selection also returns selection_info.',
116
+ description: 'Work with editable vector PATHS — the connective tissue between selections, brush strokes, and clipping. One op-discriminated tool: `create_from_selection` (selection → work path), `create_from_placement` (NAME a curve → editable saved path, the grounded pen), `save` (name the work path), `list`, `delete`, `load_as_selection` (path → selection), `stroke` (paint a brush-family tool along a path), `fill` (fill a path region), `set_clipping` (mark a saved path as the clipping path). **Reach for this when**: tracing a named curve (an edge, a landmark contour) into an editable path, turning a precise selection into a reusable/strokeable outline, converting a saved path back to a selection, or outlining/filling a shape exactly. create_from_selection and load_as_selection are an exact round-trip. stroke/fill bake pixels and auto-duplicate the target layer. Every op returns path_info (counts + per-path subpath/anchor totals) so you can verify the path landed; load_as_selection also returns selection_info.',
117
117
  inputSchema: pathInputSchema,
118
118
  outputSchema: {
119
119
  type: 'object',
@@ -200,7 +200,7 @@ export function createPreviewTools(connection, snippetClient) {
200
200
  {
201
201
  tool: {
202
202
  name: 'ps_get_preview',
203
- description: 'Render the active Photoshop document as a flattened, downscaled image and return it inline so the calling AI can see the current visual state. Use this to verify edits visually, judge whether adjustments went too far or not far enough, and decide what to do next. Read-only with respect to the working document (renders a duplicate that is closed immediately). The optional `annotations` array draws overlays in document-space coordinates: `rectangle` / `guide` / `point` / `selection` for spatial verification (Bundle Q, 2026-06-02), and `grid` / `composition` for coordinate readout and compositional evaluation (added 2026-06-09 — grid styles `every` / `thirds` / `quarters` / `phi`, composition styles `diagonals` / `triangles` / `fibonacci_grid` / `golden_spiral`). Use spatial annotations to verify ("did the placed image land inside the frame interior?") — pass both target and actual bounds in different colors. Use grid / composition annotations to EVALUATE composition (balance, thirds, leading lines, focal-point placement) — not to read a coordinate for a tool call. To place or select precisely, do NOT read a pixel off a grid (unreliable) — NAME the intent to the on-device perception tools instead: `ps_detect` (COCO objects → real document-pixel boxes), `ps_select_by_reference` (a named target → a real selection, not a guessed rectangle), plus any spatial-grounding locator / selection tools present in `tools/list`. Without annotations, behaves identically to a plain preview.',
203
+ description: 'Render the active Photoshop document as a flattened, downscaled image and return it inline so the calling AI can see the current visual state. Use this to verify edits visually, judge whether adjustments went too far or not far enough, and decide what to do next. Read-only with respect to the working document (renders a duplicate that is closed immediately). The optional `annotations` array draws overlays in document-space coordinates: `rectangle` / `guide` / `point` / `selection` for spatial verification, and `grid` / `composition` for coordinate readout and compositional evaluation (added 2026-06-09 — grid styles `every` / `thirds` / `quarters` / `phi`, composition styles `diagonals` / `triangles` / `fibonacci_grid` / `golden_spiral`). Use spatial annotations to verify ("did the placed image land inside the frame interior?") — pass both target and actual bounds in different colors. Use grid / composition annotations to EVALUATE composition (balance, thirds, leading lines, focal-point placement) — not to read a coordinate for a tool call. To place or select precisely, do NOT read a pixel off a grid (unreliable) — NAME the intent to the on-device perception tools instead: `ps_detect` (COCO objects → real document-pixel boxes), `ps_select_by_reference` (a named target → a real selection, not a guessed rectangle), plus any spatial-grounding locator / selection tools present in `tools/list`. Without annotations, behaves identically to a plain preview.',
204
204
  inputSchema: previewSchema,
205
205
  outputSchema: {
206
206
  type: 'object',
@@ -224,7 +224,7 @@ export function createPreviewTools(connection, snippetClient) {
224
224
  {
225
225
  tool: {
226
226
  name: 'ps_get_layer_bounds_diff',
227
- description: 'Numeric verification: compute the per-edge pixel deltas between a layer\'s actual bounds and a target rectangle. Returns left/top/right/bottom deltas (actual − target), scale ratio (actual_size / target_size), centroid offset, and a one-word verdict ("aligned", "shifted right", "layer too small", etc.). Use this AFTER place_image / scale_layer / move_layer to confirm the operation produced the intended result — far more reliable than eyeballing a preview for fine alignment. Read-only. Layers nested in groups are found by name recursively. Bundle Q (2026-06-02).',
227
+ description: 'Numeric verification: compute the per-edge pixel deltas between a layer\'s actual bounds and a target rectangle. Returns left/top/right/bottom deltas (actual − target), scale ratio (actual_size / target_size), centroid offset, and a one-word verdict ("aligned", "shifted right", "layer too small", etc.). Use this AFTER place_image / scale_layer / move_layer to confirm the operation produced the intended result — far more reliable than eyeballing a preview for fine alignment. Read-only. Layers nested in groups are found by name recursively.',
228
228
  inputSchema: boundsDiffSchema,
229
229
  outputSchema: {
230
230
  type: 'object',
@@ -283,7 +283,7 @@ export function createPreviewTools(connection, snippetClient) {
283
283
  {
284
284
  tool: {
285
285
  name: 'ps_compare_regions',
286
- description: 'Quantitative region comparison — the NUMERIC verification answer when "does it look right?" via get_preview is too subjective. Captures the histogram of two rectangular regions on the same document and reports per-channel mean / stdev / median for each region plus the deltas. **Reach for this when**: (a) "did the placed image match the room\'s lighting?" — sample a region of the placed image vs a region of the surrounding wall; (b) "did this adjustment do anything visible in the shadows?" — sample the same dark region before and after; (c) "is region A noticeably warmer/cooler than region B?" — read the channel means directly. Single-pixel sampling via a 1×1 rect is supported. Read-only. Bundle Q (2026-06-02).',
286
+ description: 'Quantitative region comparison — the NUMERIC verification answer when "does it look right?" via get_preview is too subjective. Captures the histogram of two rectangular regions on the same document and reports per-channel mean / stdev / median for each region plus the deltas. **Reach for this when**: (a) "did the placed image match the room\'s lighting?" — sample a region of the placed image vs a region of the surrounding wall; (b) "did this adjustment do anything visible in the shadows?" — sample the same dark region before and after; (c) "is region A noticeably warmer/cooler than region B?" — read the channel means directly. Single-pixel sampling via a 1×1 rect is supported. Read-only.',
287
287
  inputSchema: compareRegionsSchema,
288
288
  outputSchema: {
289
289
  type: 'object',
@@ -359,7 +359,7 @@ function buildAnnotationsScript(annotations) {
359
359
  }
360
360
  }
361
361
  return `
362
- // ANNOTATIONS BLOCK (Bundle Q)
362
+ // ANNOTATIONS BLOCK
363
363
  (function () {
364
364
  var docW = dup.width.as('px');
365
365
  var docH = dup.height.as('px');
@@ -643,7 +643,7 @@ function buildAnnotationsScript(annotations) {
643
643
  // it's just not a mathematically-perfect golden spiral when
644
644
  // the canvas isn't golden. maxLevels capped at 8 to keep the
645
645
  // total fill count bounded for the 90s annotated-preview
646
- // timeout (Bundle Q hotfix 3).
646
+ // timeout.
647
647
  function drawFibonacciOverlay(startCorner, c, sw, drawSpiral) {
648
648
  try {
649
649
  // Track the active rectangle as we shrink it.
@@ -779,7 +779,7 @@ async function getPreview(connection, rawArgs) {
779
779
  ${getContextInfo}
780
780
 
781
781
  if (app.documents.length === 0) {
782
- throw new Error('No active document');
782
+ throw new Error('No document is open in Photoshop');
783
783
  }
784
784
  // Defensive cleanup: a prior killed wrapper.vbs (annotated previews
785
785
  // timing out at 30s) leaves a stale "<orig> __mcp_preview__" doc
@@ -895,7 +895,7 @@ async function getLayerBoundsDiff(connection, rawArgs) {
895
895
  const script = `
896
896
  ${getContextInfo}
897
897
 
898
- if (app.documents.length === 0) throw new Error('No active document');
898
+ if (app.documents.length === 0) throw new Error('No document is open in Photoshop');
899
899
  var doc = app.activeDocument;
900
900
 
901
901
  ${normNameHelper}
@@ -1037,7 +1037,7 @@ async function compareRegions(connection, rawArgs) {
1037
1037
  const script = `
1038
1038
  ${getContextInfo}
1039
1039
 
1040
- if (app.documents.length === 0) throw new Error('No active document');
1040
+ if (app.documents.length === 0) throw new Error('No document is open in Photoshop');
1041
1041
  var doc = app.activeDocument;
1042
1042
 
1043
1043
  function statsForRect(left, top, right, bottom) {
@@ -370,7 +370,7 @@ export function createRetouchTools(connection, snippetClient, detClient = new On
370
370
  {
371
371
  tool: {
372
372
  name: 'ps_retouch',
373
- description: 'Selection-driven content-aware retouch — choose the technique with `method`. Requires an active selection (throws if none, so Content-Aware Fill never silently fills the whole layer). Auto-duplicates the active layer by default so the original is preserved (auto-duplicate-first per Bundle O); pass `apply_to_active_layer: true` to bake into the original. `content_aware_fill` synthesizes a fill from surroundings (remove wires/objects/blemishes); `patch` samples replacement pixels from a chosen offset — or NAME the source region with `source_placement` (grounded + gated); `content_aware_move` relocates the selection and fills the source — offset, or NAME where with `destination_placement`.',
373
+ description: 'Selection-driven content-aware retouch — choose the technique with `method`. Requires an active selection (throws if none, so Content-Aware Fill never silently fills the whole layer). Auto-duplicates the active layer by default so the original is preserved (auto-duplicate-first); pass `apply_to_active_layer: true` to bake into the original. `content_aware_fill` synthesizes a fill from surroundings (remove wires/objects/blemishes); `patch` samples replacement pixels from a chosen offset — or NAME the source region with `source_placement` (grounded + gated); `content_aware_move` relocates the selection and fills the source — offset, or NAME where with `destination_placement`.',
374
374
  inputSchema: RETOUCH_INPUT_SCHEMA,
375
375
  outputSchema: RETOUCH_OUTPUT_SCHEMA,
376
376
  annotations: {
@@ -83,7 +83,7 @@ const sceneSchema = {
83
83
  save_regions: {
84
84
  type: 'boolean',
85
85
  default: true,
86
- description: 'Precompute every confident region (sky/ground/shadows/highlights/skin/subject/face — and, on a Pro host with a face, the face-feature set scene:face_skin/_eyes/_brows/_lips/_teeth/_nose/_under_eye/_cheeks) and SAVE each as a managed `scene:*` alpha channel, returning the `regions` MENU of what is confidently selectable (each with its method + confidence). ps_select_by_reference then loads the saved channel instantly. Set false for a light read with no channels.',
86
+ description: 'Precompute every confident region (sky/ground/shadows/highlights/skin/subject/face — and, on a Pro host with a face, the face-feature set scene:face_skin/_eyes/_brows/_lips/_teeth/_nose/_under_eye/_cheeks) and SAVE each as a managed `scene:*` alpha channel, returning the `regions` MENU of what is confidently selectable (each with its method + confidence). ps_select_by_reference then loads the saved channel instantly. Set false for a light read with no channels. The `scene:` channel-name prefix is RESERVED: channels matching it are treated as derived and are deleted on the next scene read and on ps_save_psd, so do not give a channel you want to keep a `scene:`-prefixed name.',
87
87
  },
88
88
  composition_context: {
89
89
  type: 'object',
@@ -375,7 +375,34 @@ export function createSceneTools(connection, snippetClient, opts = {}) {
375
375
  doc: { type: 'object' },
376
376
  subjects: { type: 'array', items: { type: 'object' } },
377
377
  faces: { type: 'array', items: { type: 'object' } },
378
- regions: { type: 'array', items: { type: 'object' } },
378
+ regions: {
379
+ type: 'array',
380
+ items: {
381
+ type: 'object',
382
+ properties: {
383
+ kind: { type: 'string' },
384
+ coverage: { type: 'number' },
385
+ coverage_is_estimate: {
386
+ type: 'boolean',
387
+ description: 'Always true today: `coverage` is the coarse histogram split, never a measured mask. It and the real selection routinely disagree (a night shot read sky at 0.08 while the gated selection found a real sky at 0.83) — trust `selectable_confidence` and the precomputed channel over this number. Carried as a field rather than left implicit so a future measured-coverage path can report false.',
388
+ },
389
+ selectable: {
390
+ type: ['boolean', 'null'],
391
+ description: 'Tristate as null when the region was never resolved — never read this as a plain boolean; branch on `selectable_state`.',
392
+ },
393
+ selectable_state: {
394
+ type: 'string',
395
+ enum: ['selectable', 'not_selectable', 'not_resolved'],
396
+ description: '`selectable`: a precomputed channel is ready to load. `not_selectable`: resolution ran and this region did not pass the confidence gate. `not_resolved`: precompute did not run or failed, so absence here is NOT evidence the region is unavailable.',
397
+ },
398
+ selectable_via: {
399
+ type: 'string',
400
+ description: 'The method that resolved it (only when selectable).',
401
+ },
402
+ selectable_confidence: { type: 'number' },
403
+ },
404
+ },
405
+ },
379
406
  region_menu: { type: 'array', items: { type: 'object' } },
380
407
  horizon: { type: 'object' },
381
408
  tonal_zones: { type: 'object' },
@@ -522,7 +522,35 @@ const SELECTION_CHANNEL_INPUT_SCHEMA = {
522
522
  },
523
523
  required: ['op', 'channel_name'],
524
524
  };
525
- const LAYER_MASK_OPS = ['create', 'delete', 'apply'];
525
+ const LAYER_MASK_OPS = ['create', 'delete', 'apply', 'gradient'];
526
+ const MASK_GRADIENT_PROPS = {
527
+ fade_to: {
528
+ type: 'string',
529
+ enum: ['bottom', 'top', 'left', 'right'],
530
+ description: 'op=gradient: the side that ends fully HIDDEN (mask black). The opposite side stays fully visible. E.g. a water reflection fades with fade_to=bottom.',
531
+ default: 'bottom',
532
+ },
533
+ start: {
534
+ type: 'number',
535
+ minimum: 0,
536
+ maximum: 1,
537
+ description: 'op=gradient: 0-1 fraction along the fade direction where the fade begins — the layer stays fully visible up to here.',
538
+ default: 0,
539
+ },
540
+ end: {
541
+ type: 'number',
542
+ minimum: 0,
543
+ maximum: 1,
544
+ description: 'op=gradient: 0-1 fraction where the fade completes — fully hidden from here on. Must be greater than start.',
545
+ default: 1,
546
+ },
547
+ extent: {
548
+ type: 'string',
549
+ enum: ['layer', 'canvas'],
550
+ description: "op=gradient: measure start/end over the active layer's pixel bounds (default) or the whole canvas. A layer with no pixel bounds falls back to canvas.",
551
+ default: 'layer',
552
+ },
553
+ };
526
554
  const LAYER_MASK_INPUT_SCHEMA = {
527
555
  type: 'object',
528
556
  properties: {
@@ -531,11 +559,17 @@ const LAYER_MASK_INPUT_SCHEMA = {
531
559
  enum: [...LAYER_MASK_OPS],
532
560
  description: 'create: add a layer mask on the active layer (if a selection is active it reveals the selection — the "mask to the frame opening" answer; adjustment layers load the selection into their built-in mask). ' +
533
561
  'delete: remove the mask (layer pixels preserved). ' +
534
- 'apply: DESTRUCTIVE — bake the mask into pixels (pixels outside the mask are lost).',
562
+ 'apply: DESTRUCTIVE — bake the mask into pixels (pixels outside the mask are lost). ' +
563
+ 'gradient: draw a linear white→black fade INTO the mask (auto-creates a reveal-all mask if none) — the universal fade-out primitive (reflections, sky blends, edge feathering). REPLACES existing mask content.',
535
564
  },
565
+ ...MASK_GRADIENT_PROPS,
536
566
  },
537
567
  required: ['op'],
538
568
  };
569
+ const MASK_GRADIENT_ARGS_SCHEMA = {
570
+ type: 'object',
571
+ properties: { ...MASK_GRADIENT_PROPS },
572
+ };
539
573
  export function createSelectionTools(connection, snippetClient, client = new OnnxLandmarkDetectionClient()) {
540
574
  return [
541
575
  {
@@ -640,7 +674,7 @@ export function createSelectionTools(connection, snippetClient, client = new Onn
640
674
  {
641
675
  tool: {
642
676
  name: 'ps_layer_mask',
643
- description: 'Manage the active layer\'s mask — choose with `op`. `create`: add a layer mask; if a selection is active it reveals the selection and hides the rest (the one-call "mask the placed image to the frame opening" — make the selection first, then create); with no selection, a reveal-all mask; adjustment layers load the current selection into their built-in mask. `delete`: remove the mask (layer pixels preserved). `apply`: DESTRUCTIVE — permanently bake the mask into the pixels (outside-mask pixels lost). Prefer keeping masks non-destructive unless flattening for export. **Don\'t write `Mk Chnl At=Msk` AM scripts in execute_script — this tool already does that.**',
677
+ description: 'Manage the active layer\'s mask — choose with `op`. `create`: add a layer mask; if a selection is active it reveals the selection and hides the rest (the one-call "mask the placed image to the frame opening" — make the selection first, then create); with no selection, a reveal-all mask; adjustment layers load the current selection into their built-in mask. `delete`: remove the mask (layer pixels preserved). `apply`: DESTRUCTIVE — permanently bake the mask into the pixels (outside-mask pixels lost). `gradient`: draw a linear fade into the mask (fade_to names the side that ends hidden; start/end place the fade; auto-creates the mask; replaces existing mask content and clears any active selection first) — the standard fade for reflections, sky blends, and soft edge falloff. Verify with ps_get_preview. Prefer keeping masks non-destructive unless flattening for export. **Don\'t write `Mk Chnl At=Msk` or `Grdn` AM scripts in execute_script — this tool already does both.**',
644
678
  inputSchema: LAYER_MASK_INPUT_SCHEMA,
645
679
  outputSchema: {
646
680
  type: 'object',
@@ -651,6 +685,26 @@ export function createSelectionTools(connection, snippetClient, client = new Onn
651
685
  hadSelection: { type: 'boolean' },
652
686
  maskDeleted: { type: 'boolean' },
653
687
  maskApplied: { type: 'boolean' },
688
+ mask_gradient: { type: 'boolean' },
689
+ created_mask: {
690
+ type: 'boolean',
691
+ description: 'op=gradient: true when the reveal-all mask had to be created first.',
692
+ },
693
+ fade_to: { type: 'string' },
694
+ from: {
695
+ type: 'object',
696
+ description: 'op=gradient: fully-visible end of the drawn fade, doc pixels.',
697
+ },
698
+ to: {
699
+ type: 'object',
700
+ description: 'op=gradient: fully-hidden end of the drawn fade, doc pixels.',
701
+ },
702
+ extent: {
703
+ type: 'string',
704
+ description: "op=gradient: 'layer' or 'canvas' — reports the extent actually used.",
705
+ },
706
+ bounds_used: { type: 'object' },
707
+ layer_name: { type: 'string' },
654
708
  context: { type: 'object' },
655
709
  },
656
710
  },
@@ -891,6 +945,7 @@ async function selectionChannel(connection, snippetClient, rawArgs) {
891
945
  }
892
946
  }
893
947
  async function layerMask(connection, snippetClient, rawArgs) {
948
+ const { op: _omit, ...rest } = rawArgs;
894
949
  switch (rawArgs.op) {
895
950
  case 'create':
896
951
  return createLayerMask(connection, snippetClient);
@@ -898,6 +953,8 @@ async function layerMask(connection, snippetClient, rawArgs) {
898
953
  return deleteLayerMask(connection, snippetClient);
899
954
  case 'apply':
900
955
  return applyLayerMask(connection, snippetClient);
956
+ case 'gradient':
957
+ return maskGradient(connection, snippetClient, rest);
901
958
  default:
902
959
  return unknownDiscriminator('layer_mask op', rawArgs.op, LAYER_MASK_OPS);
903
960
  }
@@ -1412,6 +1469,27 @@ async function applyLayerMask(connection, snippetClient) {
1412
1469
  successText: () => 'Layer mask applied (merged to layer)',
1413
1470
  });
1414
1471
  }
1472
+ async function maskGradient(connection, snippetClient, rawArgs) {
1473
+ return runSnippetTool({
1474
+ connection,
1475
+ snippetClient,
1476
+ rawArgs,
1477
+ schema: MASK_GRADIENT_ARGS_SCHEMA,
1478
+ snippet: 'maskGradient',
1479
+ errorPrefix: 'Error drawing mask gradient',
1480
+ params: (args) => ({
1481
+ fade_to: args.fade_to ?? 'bottom',
1482
+ start: args.start ?? 0,
1483
+ end: args.end ?? 1,
1484
+ extent: args.extent ?? 'layer',
1485
+ }),
1486
+ successText: (result, args) => {
1487
+ const r = result;
1488
+ const created = r.created_mask ? ' (mask created)' : '';
1489
+ return `Mask gradient drawn: fade to ${args.fade_to ?? 'bottom'}${created}\nResult: ${JSON.stringify(result)}`;
1490
+ },
1491
+ });
1492
+ }
1415
1493
  async function saveSelectionToChannel(connection, snippetClient, rawArgs) {
1416
1494
  return runSnippetTool({
1417
1495
  connection,
@@ -1,7 +1,7 @@
1
1
  import { VERSION } from '../version.js';
2
2
  import { resolveInstallChannel } from '../install-channel.js';
3
3
  const DEFAULT_DIST_TAGS_URL = 'https://registry.npmjs.org/-/package/editmamei/dist-tags';
4
- const RELEASES_URL = 'https://github.com/editmamei/editmamei-wiki/releases/latest';
4
+ const RELEASES_URL = 'https://editmamei.com/download';
5
5
  export function resolveUpdateCheckUrl(env = process.env) {
6
6
  const override = env.EDITMAMEI_UPDATE_CHECK_URL;
7
7
  return override && override.length > 0 ? override : DEFAULT_DIST_TAGS_URL;