editmamei 0.23.0 → 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 (90) hide show
  1. package/NOTICES.md +6 -30
  2. package/README.md +47 -13
  3. package/dist/api/extendscript/_helpers.js +1 -101
  4. package/dist/api/photoshop-api.js +23 -16
  5. package/dist/api/snippet-client.js +38 -2
  6. package/dist/bin/editmamei-core-darwin-arm64 +0 -0
  7. package/dist/bin/editmamei-core-darwin-x64 +0 -0
  8. package/dist/bin/editmamei-core-win-x64.exe +0 -0
  9. package/dist/core/server.js +60 -184
  10. package/dist/core/session.js +9 -10
  11. package/dist/core/tool-registry.js +4 -4
  12. package/dist/delivery/client.js +50 -1
  13. package/dist/delivery/provision.js +18 -1
  14. package/dist/delivery/signing.js +38 -1
  15. package/dist/delivery/store.js +157 -16
  16. package/dist/detection/detect-active-doc.js +42 -11
  17. package/dist/detection/detection-client.js +3 -3
  18. package/dist/detection/face-detector.js +2 -2
  19. package/dist/detection/geometry.js +14 -0
  20. package/dist/detection/landmark-detection-client.js +3 -3
  21. package/dist/detection/mesh-face.js +2 -3
  22. package/dist/detection/object-detector.js +2 -2
  23. package/dist/detection/runtime.js +14 -3
  24. package/dist/detection/sam-segmenter.js +69 -26
  25. package/dist/kernel/kernel.js +5 -9
  26. package/dist/kernel/module-lifecycle.js +187 -0
  27. package/dist/license/entitlement.js +13 -3
  28. package/dist/license/store.js +8 -1
  29. package/dist/modules/ce/index.js +1 -1
  30. package/dist/perception/facets.js +19 -1
  31. package/dist/perception/grounding-anchors.js +2 -12
  32. package/dist/perception/grounding-locate.js +1 -9
  33. package/dist/perception/grounding-review-crop.js +4 -29
  34. package/dist/perception/overlay.js +62 -0
  35. package/dist/perception/pixel-identity.js +41 -0
  36. package/dist/perception/region-precompute.js +96 -20
  37. package/dist/perception/scene-model.js +93 -50
  38. package/dist/perception/select-recipes.js +109 -68
  39. package/dist/perception/sky-ground-flood.js +0 -5
  40. package/dist/perception/sky-mask-transfer.js +73 -14
  41. package/dist/platform/connection.js +60 -43
  42. package/dist/platform/host-platform.js +16 -0
  43. package/dist/platform/macos-detector.js +41 -70
  44. package/dist/platform/macos-runner.js +94 -0
  45. package/dist/platform/script-queue.js +68 -0
  46. package/dist/platform/script-result.js +39 -0
  47. package/dist/platform/windows-detector.js +49 -66
  48. package/dist/platform/windows-runner.js +84 -0
  49. package/dist/skills/editmamei-skill.zip +0 -0
  50. package/dist/tools/adjustment-tools.js +80 -132
  51. package/dist/tools/brush-tools.js +4 -15
  52. package/dist/tools/channel-compose-tools.js +4 -23
  53. package/dist/tools/detection-tools.js +59 -47
  54. package/dist/tools/document-tools.js +92 -127
  55. package/dist/tools/filter-tools.js +490 -731
  56. package/dist/tools/group-tools.js +75 -156
  57. package/dist/tools/guide-tools.js +52 -83
  58. package/dist/tools/history-tools.js +29 -63
  59. package/dist/tools/image-placement-tools.js +18 -37
  60. package/dist/tools/image-tools.js +23 -51
  61. package/dist/tools/layer-ordering-tools.js +2 -9
  62. package/dist/tools/layer-properties-tools.js +207 -395
  63. package/dist/tools/layer-tools.js +224 -190
  64. package/dist/tools/layer-transform-tools.js +41 -106
  65. package/dist/tools/metadata-tools.js +2 -9
  66. package/dist/tools/path-tools.js +4 -15
  67. package/dist/tools/portrait-tools.js +2 -5
  68. package/dist/tools/preview-tools.js +59 -72
  69. package/dist/tools/retouch-tools.js +33 -71
  70. package/dist/tools/scene-tools.js +123 -75
  71. package/dist/tools/selection-tools.js +386 -566
  72. package/dist/tools/shape-tools.js +2 -9
  73. package/dist/tools/text-tools.js +24 -57
  74. package/dist/tools/transform-canvas-tools.js +21 -54
  75. package/dist/tools/vector-mask-tools.js +2 -9
  76. package/dist/update/check.js +1 -1
  77. package/dist/utils/jpeg-quality.js +1 -0
  78. package/dist/utils/logger.js +55 -45
  79. package/dist/utils/operation-timeouts.js +1 -0
  80. package/dist/utils/run-script.js +7 -2
  81. package/dist/utils/session-log.js +134 -12
  82. package/dist/utils/template-storage.js +5 -6
  83. package/dist/utils/tool-helpers.js +43 -0
  84. package/dist/version.js +1 -1
  85. package/package.json +10 -3
  86. package/LICENSE +0 -81
  87. package/dist/platform/detector.js +0 -32
  88. package/dist/platform/macos-executor.js +0 -151
  89. package/dist/platform/windows-executor.js +0 -160
  90. /package/dist/platform/{script-executor.js → ports.js} +0 -0
@@ -1,5 +1,6 @@
1
1
  import { runScript } from '../utils/run-script.js';
2
2
  import { validateArgs } from '../utils/validate.js';
3
+ import { toolErrorResult, runSnippetTool, applyToActiveLayerProp } from '../utils/tool-helpers.js';
3
4
  const addAdjustmentLayerSchema = {
4
5
  type: 'object',
5
6
  properties: {
@@ -422,9 +423,24 @@ const addAdjustmentLayerSchema = {
422
423
  gm_preset: {
423
424
  type: 'string',
424
425
  enum: ['black_to_white', 'sepia', 'tint'],
425
- description: 'For type=gradient_map. Preset gradient. "black_to_white" is the canonical tonal B&W via gradient. "sepia" is a warm vintage tone. "tint" maps black→tint_color→white using gm_tint_color_hex. Custom gradient stops are not supported in v1 — record an Action for arbitrary gradients.',
426
+ description: 'For type=gradient_map. Preset gradient. "black_to_white" is the canonical tonal B&W via gradient. "sepia" is a warm vintage tone. "tint" maps black→tint_color→white using gm_tint_color_hex. For arbitrary color mappings pass gm_stops instead (overrides the preset).',
426
427
  default: 'black_to_white',
427
428
  },
429
+ gm_stops: {
430
+ type: 'array',
431
+ description: 'For type=gradient_map. Custom color stops overriding gm_preset — each {red,green,blue (0-255), location (0-100 shadows→highlights), midpoint (5-95, default 50)}. At least 2; sorted by location. E.g. a blue→orange stylized grade: [{red:20,green:40,blue:120,location:0},{red:250,green:150,blue:50,location:100}].',
432
+ items: {
433
+ type: 'object',
434
+ properties: {
435
+ red: { type: 'integer', minimum: 0, maximum: 255 },
436
+ green: { type: 'integer', minimum: 0, maximum: 255 },
437
+ blue: { type: 'integer', minimum: 0, maximum: 255 },
438
+ location: { type: 'number', minimum: 0, maximum: 100 },
439
+ midpoint: { type: 'number', minimum: 5, maximum: 95, default: 50 },
440
+ },
441
+ required: ['red', 'green', 'blue', 'location'],
442
+ },
443
+ },
428
444
  gm_tint_color_hex: {
429
445
  type: 'string',
430
446
  description: 'For type=gradient_map with gm_preset=tint. Mid-tone tint color as a hex string like "#5588CC". Only used when gm_preset=tint.',
@@ -510,22 +526,14 @@ const colorLookupBakeSchema = {
510
526
  type: 'string',
511
527
  description: 'Leaf name of a 3DLUT file in PS\'s Presets/3DLUTs/ folder (e.g. "TealOrangePlusContrast.3DL", "FallColors.look", "Kodak 5205 Fuji 3510 (by Adobe).cube") OR an absolute path to a .3DL / .look / .cube file. Extension-agnostic leaf-name resolution (the same base name with a different extension still matches).',
512
528
  },
513
- apply_to_active_layer: {
514
- type: 'boolean',
515
- description: 'If false (default), runs on a DUPLICATE of the active layer; the original is preserved. If true, bakes into the active layer in place.',
516
- default: false,
517
- },
529
+ apply_to_active_layer: applyToActiveLayerProp('the color lookup'),
518
530
  },
519
531
  required: ['cl_lut_name'],
520
532
  };
521
533
  const equalizeSchema = {
522
534
  type: 'object',
523
535
  properties: {
524
- apply_to_active_layer: {
525
- type: 'boolean',
526
- description: 'If false (default), the operation runs on a DUPLICATE of the active layer and the original is preserved. If true, bakes into the active layer in place. Use false unless you specifically want the destructive-in-place behavior.',
527
- default: false,
528
- },
536
+ apply_to_active_layer: applyToActiveLayerProp('the equalize'),
529
537
  },
530
538
  };
531
539
  const shadowsHighlightsSchema = {
@@ -601,11 +609,7 @@ const shadowsHighlightsSchema = {
601
609
  maximum: 50,
602
610
  default: 0.01,
603
611
  },
604
- apply_to_active_layer: {
605
- type: 'boolean',
606
- description: 'If false (default), the op is applied to a duplicate of the active layer named "Shadows/Highlights (<Original Name>)" — original preserved. If true, bakes directly into the active layer.',
607
- default: false,
608
- },
612
+ apply_to_active_layer: applyToActiveLayerProp('the op'),
609
613
  },
610
614
  };
611
615
  const APPLY_ADJUSTMENT_TYPES = ['shadows_highlights', 'equalize', 'color_lookup'];
@@ -632,7 +636,7 @@ export function createAdjustmentTools(connection, snippetClient) {
632
636
  {
633
637
  tool: {
634
638
  name: 'ps_apply_adjustment',
635
- description: "Apply a DESTRUCTIVE tonal adjustment that Photoshop does NOT offer as an adjustment layer — chosen via `type`. Runs on a DUPLICATE of the active layer by default (auto-duplicate-first per Bundle O — the original is preserved; revert by deleting the copy); pass `apply_to_active_layer: true` to bake in place. Auto-rasterizes text/smart-object layers. `shadows_highlights` recovers blown highlights + crushed shadows in one pass (defaults match Adobe's dialog: 35 shadow amount, +20 color correction); `equalize` stretches/flattens the histogram (parameter-free); `color_lookup` bakes a 3DLUT grade (cl_lut_name required — leaf name of a file in Presets/3DLUTs/ or an absolute .cube/.3dl/.look path). For EDITABLE tonal/color work, prefer ps_add_adjustment_layer.",
639
+ description: "Apply a DESTRUCTIVE tonal adjustment that Photoshop does NOT offer as an adjustment layer — chosen via `type`. Runs on a DUPLICATE of the active layer by default (auto-duplicate-first — the original is preserved; revert by deleting the copy); pass `apply_to_active_layer: true` to bake in place. Auto-rasterizes text/smart-object layers. `shadows_highlights` recovers blown highlights + crushed shadows in one pass (defaults match Adobe's dialog: 35 shadow amount, +20 color correction); `equalize` stretches/flattens the histogram (parameter-free); `color_lookup` bakes a 3DLUT grade (cl_lut_name required — leaf name of a file in Presets/3DLUTs/ or an absolute .cube/.3dl/.look path). For EDITABLE tonal/color work, prefer ps_add_adjustment_layer.",
636
640
  inputSchema: APPLY_ADJUSTMENT_INPUT_SCHEMA,
637
641
  outputSchema: {
638
642
  type: 'object',
@@ -665,7 +669,7 @@ export function createAdjustmentTools(connection, snippetClient) {
665
669
  {
666
670
  tool: {
667
671
  name: 'ps_add_adjustment_layer',
668
- description: "Create a non-destructive adjustment layer above the active layer — hoisted out of the active layer's group by default even though Photoshop's own Mk-AdjL placement rule would otherwise nest it INSIDE that group (pass into_active_group:true to keep that native nesting). Supports the full real-Photoshop tonal/color surface: Curves (with S-curve presets), Levels, Hue/Saturation, Brightness/Contrast, Black & White (with optional tint), Color Balance, Photo Filter (preset or custom color), Vibrance, Channel Mixer, Selective Color, Gradient Map (preset), Exposure (stops + offset + gamma), Color Lookup (3DLUT presets or custom file path), and Invert. Values are editable, maskable, and removable. This is the canonical entry point for ALL tonal/color adjustments; the old destructive bake tools (auto_levels / auto_contrast / desaturate / invert) were removed on 2026-05-31 — if you genuinely need a pixel bake, follow this call with `photoshop_merge_visible_layers`. Optionally clips the adjustment to only affect the layer directly below it. If a selection is active at call time, the new layer is automatically masked by it (toggle with mask_from_selection / mask_inverted). For destructive ops that don't have an adjustment-layer equivalent in Photoshop (Shadows/Highlights — single-pass shadow/highlight recovery), use `photoshop_apply_shadows_highlights` which auto-duplicates the active layer to keep the original intact. Returns context (the new adjustment layer becomes active) plus parent_path — the actual containing-group chain, so placement is never silent.",
672
+ description: "Create a non-destructive adjustment layer above the active layer — hoisted out of the active layer's group by default even though Photoshop's own Mk-AdjL placement rule would otherwise nest it INSIDE that group (pass into_active_group:true to keep that native nesting). Supports the full real-Photoshop tonal/color surface: Curves (with S-curve presets), Levels, Hue/Saturation, Brightness/Contrast, Black & White (with optional tint), Color Balance, Photo Filter (preset or custom color), Vibrance, Channel Mixer, Selective Color, Gradient Map (preset), Exposure (stops + offset + gamma), Color Lookup (3DLUT presets or custom file path), and Invert. Values are editable, maskable, and removable. This is the canonical entry point for ALL tonal/color adjustments; the old destructive bake tools (auto_levels / auto_contrast / desaturate / invert) were removed on 2026-05-31 — if you genuinely need a pixel bake, follow this call with `ps_merge` (mode=visible). Optionally clips the adjustment to only affect the layer directly below it. If a selection is active at call time, the new layer is automatically masked by it (toggle with mask_from_selection / mask_inverted). For destructive ops that don't have an adjustment-layer equivalent in Photoshop (Shadows/Highlights — single-pass shadow/highlight recovery), use `ps_apply_adjustment` (type=shadows_highlights) which auto-duplicates the active layer to keep the original intact. Returns context (the new adjustment layer becomes active) plus parent_path — the actual containing-group chain, so placement is never silent.",
669
673
  inputSchema: addAdjustmentLayerSchema,
670
674
  outputSchema: {
671
675
  type: 'object',
@@ -783,6 +787,7 @@ const FORWARDED_KEYS = new Set([
783
787
  'gm_tint_color_hex',
784
788
  'gm_reverse',
785
789
  'gm_dither',
790
+ 'gm_stops',
786
791
  'exp_exposure',
787
792
  'exp_offset',
788
793
  'exp_gamma',
@@ -839,125 +844,68 @@ async function addAdjustmentLayer(connection, snippetClient, rawArgs) {
839
844
  };
840
845
  }
841
846
  catch (error) {
842
- return {
843
- content: [
844
- {
845
- type: 'text',
846
- text: `Error adding adjustment layer: ${error instanceof Error ? error.message : String(error)}`,
847
- },
848
- ],
849
- isError: true,
850
- };
847
+ return toolErrorResult('Error adding adjustment layer', error);
851
848
  }
852
849
  }
853
850
  async function applyColorLookup(connection, snippetClient, rawArgs) {
854
- try {
855
- const args = validateArgs(colorLookupBakeSchema, rawArgs);
856
- const lutName = args.cl_lut_name;
857
- const applyToActiveLayer = args.apply_to_active_layer ?? false;
858
- const script = await snippetClient.build('applyColorLookup', { lutName, applyToActiveLayer });
859
- const result = await runScript(connection, script);
860
- return {
861
- content: [
862
- {
863
- type: 'text',
864
- text: `Color Lookup applied (BAKE, experimental):\n${JSON.stringify(result, null, 2)}`,
865
- },
866
- ],
867
- structuredContent: result,
868
- };
869
- }
870
- catch (error) {
871
- return {
872
- content: [
873
- {
874
- type: 'text',
875
- text: `Error applying color lookup: ${error instanceof Error ? error.message : String(error)}`,
876
- },
877
- ],
878
- isError: true,
879
- };
880
- }
851
+ return runSnippetTool({
852
+ connection,
853
+ snippetClient,
854
+ rawArgs,
855
+ schema: colorLookupBakeSchema,
856
+ snippet: 'applyColorLookup',
857
+ errorPrefix: 'Error applying color lookup',
858
+ params: (args) => ({
859
+ lutName: args.cl_lut_name,
860
+ applyToActiveLayer: args.apply_to_active_layer ?? false,
861
+ }),
862
+ successText: (result) => `Color Lookup applied (BAKE, experimental):\n${JSON.stringify(result, null, 2)}`,
863
+ });
881
864
  }
882
865
  async function applyEqualize(connection, snippetClient, rawArgs) {
883
- try {
884
- const args = validateArgs(equalizeSchema, rawArgs);
885
- const applyToActiveLayer = args.apply_to_active_layer ?? false;
886
- const script = await snippetClient.build('applyEqualize', { applyToActiveLayer });
887
- const result = await runScript(connection, script);
888
- return {
889
- content: [
890
- {
891
- type: 'text',
892
- text: `Equalize applied:\n${JSON.stringify(result, null, 2)}`,
893
- },
894
- ],
895
- structuredContent: result,
896
- };
897
- }
898
- catch (error) {
899
- return {
900
- content: [
901
- {
902
- type: 'text',
903
- text: `Error applying equalize: ${error instanceof Error ? error.message : String(error)}`,
904
- },
905
- ],
906
- isError: true,
907
- };
908
- }
866
+ return runSnippetTool({
867
+ connection,
868
+ snippetClient,
869
+ rawArgs,
870
+ schema: equalizeSchema,
871
+ snippet: 'applyEqualize',
872
+ errorPrefix: 'Error applying equalize',
873
+ params: (args) => ({
874
+ applyToActiveLayer: args.apply_to_active_layer ?? false,
875
+ }),
876
+ successText: (result) => `Equalize applied:\n${JSON.stringify(result, null, 2)}`,
877
+ });
909
878
  }
910
879
  async function applyShadowsHighlights(connection, snippetClient, rawArgs) {
911
- try {
912
- const args = validateArgs(shadowsHighlightsSchema, rawArgs);
913
- const shadowAmount = args.shadow_amount ?? 35;
914
- const shadowWidth = args.shadow_width ?? 50;
915
- const shadowRadius = args.shadow_radius ?? 30;
916
- const highlightAmount = args.highlight_amount ?? 0;
917
- const highlightWidth = args.highlight_width ?? 50;
918
- const highlightRadius = args.highlight_radius ?? 30;
919
- const colorCorrection = args.color_correction ?? 20;
920
- const midtoneContrast = args.midtone_contrast ?? 0;
921
- const blackClip = args.black_clip ?? 0.01;
922
- const whiteClip = args.white_clip ?? 0.01;
923
- const applyToActiveLayer = args.apply_to_active_layer ?? false;
924
- const script = await snippetClient.build('applyShadowsHighlights', {
925
- shadowAmount,
926
- shadowWidth,
927
- shadowRadius,
928
- highlightAmount,
929
- highlightWidth,
930
- highlightRadius,
931
- colorCorrection,
932
- midtoneContrast,
933
- blackClip,
934
- whiteClip,
935
- applyToActiveLayer,
936
- });
937
- const result = await runScript(connection, script);
938
- const r = result;
939
- const target = r.target_was_copy
940
- ? `new copy "${r.target_layer_name ?? '?'}"`
941
- : 'active layer (in place)';
942
- return {
943
- content: [
944
- {
945
- type: 'text',
946
- text: `Shadows/Highlights applied to ${target} (shadow ${shadowAmount}, highlight ${highlightAmount}, color ${colorCorrection}).`,
947
- },
948
- ],
949
- structuredContent: result,
950
- };
951
- }
952
- catch (error) {
953
- return {
954
- content: [
955
- {
956
- type: 'text',
957
- text: `Error applying shadows/highlights: ${error instanceof Error ? error.message : String(error)}`,
958
- },
959
- ],
960
- isError: true,
961
- };
962
- }
880
+ return runSnippetTool({
881
+ connection,
882
+ snippetClient,
883
+ rawArgs,
884
+ schema: shadowsHighlightsSchema,
885
+ snippet: 'applyShadowsHighlights',
886
+ errorPrefix: 'Error applying shadows/highlights',
887
+ params: (args) => ({
888
+ shadowAmount: args.shadow_amount ?? 35,
889
+ shadowWidth: args.shadow_width ?? 50,
890
+ shadowRadius: args.shadow_radius ?? 30,
891
+ highlightAmount: args.highlight_amount ?? 0,
892
+ highlightWidth: args.highlight_width ?? 50,
893
+ highlightRadius: args.highlight_radius ?? 30,
894
+ colorCorrection: args.color_correction ?? 20,
895
+ midtoneContrast: args.midtone_contrast ?? 0,
896
+ blackClip: args.black_clip ?? 0.01,
897
+ whiteClip: args.white_clip ?? 0.01,
898
+ applyToActiveLayer: args.apply_to_active_layer ?? false,
899
+ }),
900
+ successText: (result, args) => {
901
+ const r = result;
902
+ const target = r.target_was_copy
903
+ ? `new copy "${r.target_layer_name ?? '?'}"`
904
+ : 'active layer (in place)';
905
+ const shadowAmount = args.shadow_amount ?? 35;
906
+ const highlightAmount = args.highlight_amount ?? 0;
907
+ const colorCorrection = args.color_correction ?? 20;
908
+ return `Shadows/Highlights applied to ${target} (shadow ${shadowAmount}, highlight ${highlightAmount}, color ${colorCorrection}).`;
909
+ },
910
+ });
963
911
  }
@@ -1,6 +1,7 @@
1
1
  import { SUPPORTED_BRUSH_TOOLS } from '../api/brush-tool-names.js';
2
2
  import { runScript } from '../utils/run-script.js';
3
3
  import { validateArgs } from '../utils/validate.js';
4
+ import { toolErrorResult, applyToActiveLayerProp } from '../utils/tool-helpers.js';
4
5
  import { OnnxLandmarkDetectionClient } from '../detection/landmark-detection-client.js';
5
6
  import { resolveExpectedPlacement, PLACEMENT_SCHEMA } from '../perception/grounding-locate.js';
6
7
  const TOOLS_REQUIRING_SOURCE = ['clone_stamp', 'healing_brush'];
@@ -109,11 +110,7 @@ const applyBrushStrokeSchema = {
109
110
  maximum: 50,
110
111
  default: 0,
111
112
  },
112
- apply_to_active_layer: {
113
- type: 'boolean',
114
- description: 'If false (default), the stroke runs on a duplicate named "Brush Stroke (<original name>)" — the original is preserved, undo is delete-the-copy (Bundle O pattern). If true, the stroke bakes into the active layer directly.',
115
- default: false,
116
- },
113
+ apply_to_active_layer: applyToActiveLayerProp('the stroke'),
117
114
  },
118
115
  required: ['tool', 'brush_size'],
119
116
  };
@@ -122,7 +119,7 @@ export function createBrushTools(connection, snippetClient, client = new OnnxLan
122
119
  {
123
120
  tool: {
124
121
  name: 'ps_apply_brush_stroke',
125
- description: 'Paint along a path with one of PS\'s 16 brush-family tools — supply the path EITHER as an anchor-relational `placement` (preferred: a path relation → the stroke traces a resolved, gate-verified curve along a traced edge / landmark / between two anchors, no pixel-guessing) OR as an explicit `path` list of anchor points — the retouch tools (`healing_brush`, `clone_stamp`, `burn`, `dodge`, `blur`, `sharpen`, `smudge`), the paint family (`brush`, `pencil`, `eraser`), and the specialty tools (`pattern_stamp`, `art_history_brush`, `history_brush`, `color_replacement`, `background_eraser`, `sponge`). The `path` parameter takes a list of anchor points with optional bezier handles, so the same tool handles straight-line strokes, freeform curves, and closed shapes — all by varying the path geometry. **Reach for this when**: (a) cloning out a distraction along a specific shape (clone_stamp with a source_point + a path tracing the unwanted edge); (b) healing a scratch or seam (healing_brush with source_point); (c) dodging / burning to redirect tonal balance along a contour; (d) painting a freehand line into the canvas (brush + foreground_color). Active layer must be a normal pixel layer (background auto-promotes); rasterize adjustment/shape/text/smart-object layers first. Auto-duplicates per Bundle O — the original is preserved and a "Brush Stroke (<name>)" copy receives the paint. **Brush dynamics**: hardness, opacity, and flow are independently settable via `hardness_pct` / `opacity_pct` / `flow_pct` (the live tool options are mutated before stroking and restored to the user\'s prior state in `finally`). Sampled brushes (custom shape-stamp presets) silently ignore hardness/diameter mutations — vary their character via `brush_preset` instead.',
122
+ description: 'Paint along a path with one of PS\'s 16 brush-family tools — supply the path EITHER as an anchor-relational `placement` (preferred: a path relation → the stroke traces a resolved, gate-verified curve along a traced edge / landmark / between two anchors, no pixel-guessing) OR as an explicit `path` list of anchor points — the retouch tools (`healing_brush`, `clone_stamp`, `burn`, `dodge`, `blur`, `sharpen`, `smudge`), the paint family (`brush`, `pencil`, `eraser`), and the specialty tools (`pattern_stamp`, `art_history_brush`, `history_brush`, `color_replacement`, `background_eraser`, `sponge`). The `path` parameter takes a list of anchor points with optional bezier handles, so the same tool handles straight-line strokes, freeform curves, and closed shapes — all by varying the path geometry. **Reach for this when**: (a) cloning out a distraction along a specific shape (clone_stamp with a source_point + a path tracing the unwanted edge); (b) healing a scratch or seam (healing_brush with source_point); (c) dodging / burning to redirect tonal balance along a contour; (d) painting a freehand line into the canvas (brush + foreground_color). Active layer must be a normal pixel layer (background auto-promotes); rasterize adjustment/shape/text/smart-object layers first. Auto-duplicates — the original is preserved and a "Brush Stroke (<name>)" copy receives the paint. **Brush dynamics**: hardness, opacity, and flow are independently settable via `hardness_pct` / `opacity_pct` / `flow_pct` (the live tool options are mutated before stroking and restored to the user\'s prior state in `finally`). Sampled brushes (custom shape-stamp presets) silently ignore hardness/diameter mutations — vary their character via `brush_preset` instead.',
126
123
  inputSchema: applyBrushStrokeSchema,
127
124
  outputSchema: {
128
125
  type: 'object',
@@ -292,15 +289,7 @@ async function applyBrushStroke(connection, snippetClient, client, rawArgs) {
292
289
  };
293
290
  }
294
291
  catch (error) {
295
- return {
296
- content: [
297
- {
298
- type: 'text',
299
- text: `Error applying brush stroke: ${error instanceof Error ? error.message : String(error)}`,
300
- },
301
- ],
302
- isError: true,
303
- };
292
+ return toolErrorResult('Error applying brush stroke', error);
304
293
  }
305
294
  }
306
295
  function deterministicNoise(i, anchorSeed, axisSalt) {
@@ -1,5 +1,6 @@
1
1
  import { runScript } from '../utils/run-script.js';
2
2
  import { validateArgs } from '../utils/validate.js';
3
+ import { toolErrorResult, applyToActiveLayerProp } from '../utils/tool-helpers.js';
3
4
  const BLEND_MODES = [
4
5
  'normal',
5
6
  'multiply',
@@ -45,11 +46,7 @@ const applyImageSchema = {
45
46
  maximum: 100,
46
47
  default: 100,
47
48
  },
48
- apply_to_active_layer: {
49
- type: 'boolean',
50
- description: 'If false (default) the composite is baked onto a DUPLICATE of the active layer (original preserved — delete the copy to revert). Set true to bake destructively in place.',
51
- default: false,
52
- },
49
+ apply_to_active_layer: applyToActiveLayerProp('the composite'),
53
50
  },
54
51
  required: ['blend'],
55
52
  };
@@ -182,15 +179,7 @@ async function applyImage(connection, snippetClient, rawArgs) {
182
179
  };
183
180
  }
184
181
  catch (error) {
185
- return {
186
- content: [
187
- {
188
- type: 'text',
189
- text: `Error in ps_apply_image: ${error instanceof Error ? error.message : String(error)}`,
190
- },
191
- ],
192
- isError: true,
193
- };
182
+ return toolErrorResult('Error in ps_apply_image', error);
194
183
  }
195
184
  }
196
185
  async function calculations(connection, snippetClient, rawArgs) {
@@ -228,14 +217,6 @@ async function calculations(connection, snippetClient, rawArgs) {
228
217
  };
229
218
  }
230
219
  catch (error) {
231
- return {
232
- content: [
233
- {
234
- type: 'text',
235
- text: `Error in ps_calculations: ${error instanceof Error ? error.message : String(error)}`,
236
- },
237
- ],
238
- isError: true,
239
- };
220
+ return toolErrorResult('Error in ps_calculations', error);
240
221
  }
241
222
  }
@@ -1,7 +1,11 @@
1
- import { decode, encode } from 'jpeg-js';
1
+ import { encode } from 'jpeg-js';
2
2
  import { validateArgs } from '../utils/validate.js';
3
3
  import { detectActiveDoc } from '../detection/detect-active-doc.js';
4
+ import { ANNOTATED_PREVIEW_JPEG_QUALITY } from '../utils/jpeg-quality.js';
4
5
  import { OnnxDetectionClient, } from '../detection/detection-client.js';
6
+ import { toolErrorResult } from '../utils/tool-helpers.js';
7
+ import { computePixelIdentity, docKeyFrom, samePixelIdentity, } from '../perception/pixel-identity.js';
8
+ import { ANNOTATION_RGB, annotationThickness, drawBoxOutline, } from '../perception/overlay.js';
5
9
  const detectSchema = {
6
10
  type: 'object',
7
11
  properties: {
@@ -46,36 +50,15 @@ const detectSchema = {
46
50
  },
47
51
  },
48
52
  };
49
- function drawBoxes(bytes, boxes) {
50
- const img = decode(bytes, { useTArray: true, formatAsRGBA: true });
51
- const { width: w, height: h, data } = img;
52
- const thickness = Math.max(2, Math.round(Math.max(w, h) / 400));
53
- const setPx = (x, y, r, g, b) => {
54
- if (x < 0 || y < 0 || x >= w || y >= h)
55
- return;
56
- const i = (y * w + x) * 4;
57
- data[i] = r;
58
- data[i + 1] = g;
59
- data[i + 2] = b;
60
- data[i + 3] = 255;
61
- };
53
+ export function drawBoxes(img, boxes) {
54
+ const { width: w, height: h } = img;
55
+ const data = Uint8Array.from(img.data);
56
+ const out = { data, width: w, height: h };
57
+ const thickness = annotationThickness(out);
62
58
  for (const { bbox, rgb } of boxes) {
63
- const x1 = Math.round(bbox[0]);
64
- const y1 = Math.round(bbox[1]);
65
- const x2 = Math.round(bbox[2]);
66
- const y2 = Math.round(bbox[3]);
67
- for (let x = x1; x <= x2; x++)
68
- for (let k = 0; k < thickness; k++) {
69
- setPx(x, y1 + k, rgb[0], rgb[1], rgb[2]);
70
- setPx(x, y2 - k, rgb[0], rgb[1], rgb[2]);
71
- }
72
- for (let y = y1; y <= y2; y++)
73
- for (let k = 0; k < thickness; k++) {
74
- setPx(x1 + k, y, rgb[0], rgb[1], rgb[2]);
75
- setPx(x2 - k, y, rgb[0], rgb[1], rgb[2]);
76
- }
59
+ drawBoxOutline(out, bbox, rgb, thickness);
77
60
  }
78
- return encode({ data, width: w, height: h }, 88).data;
61
+ return encode({ data, width: w, height: h }, ANNOTATED_PREVIEW_JPEG_QUALITY).data;
79
62
  }
80
63
  function summarizeObjects(objects) {
81
64
  if (!objects || objects.length === 0)
@@ -85,13 +68,27 @@ function summarizeObjects(objects) {
85
68
  counts.set(o.label, (counts.get(o.label) ?? 0) + 1);
86
69
  return [...counts.entries()].map(([l, n]) => (n > 1 ? `${l}×${n}` : l)).join(', ');
87
70
  }
88
- async function detect(connection, client, rawArgs) {
71
+ let detectCache = null;
72
+ export function __clearDetectCache() {
73
+ detectCache = null;
74
+ }
75
+ async function detect(connection, client, rawArgs, detectDeps) {
89
76
  try {
90
77
  const args = validateArgs(detectSchema, rawArgs);
91
78
  const target = args.target ?? 'both';
92
79
  const annotate = args.annotate ?? true;
93
80
  const wantFaces = target === 'faces' || target === 'both';
94
81
  const wantObjects = target === 'objects' || target === 'both';
82
+ const detectKey = JSON.stringify({
83
+ faces: wantFaces,
84
+ objects: wantObjects,
85
+ maxDimension: args.max_dimension ?? null,
86
+ faceThreshold: args.face_threshold ?? null,
87
+ objectThreshold: args.object_threshold ?? null,
88
+ maxObjects: args.max_objects ?? null,
89
+ });
90
+ let hit = null;
91
+ let pendingIdentity = null;
95
92
  const det = await detectActiveDoc(connection, client, {
96
93
  faces: wantFaces,
97
94
  objects: wantObjects,
@@ -99,22 +96,41 @@ async function detect(connection, client, rawArgs) {
99
96
  faceThreshold: args.face_threshold,
100
97
  objectThreshold: args.object_threshold,
101
98
  maxObjects: args.max_objects,
102
- });
103
- const mapped = det.result;
99
+ shouldDetect: ({ decoded, context, docWidth, docHeight }) => {
100
+ if (!decoded)
101
+ return true;
102
+ const identity = computePixelIdentity(decoded, docKeyFrom(context, docWidth, docHeight));
103
+ if (detectCache &&
104
+ detectCache.optionsKey === detectKey &&
105
+ samePixelIdentity(detectCache.identity, identity)) {
106
+ hit = detectCache;
107
+ return false;
108
+ }
109
+ pendingIdentity = identity;
110
+ return true;
111
+ },
112
+ }, detectDeps);
113
+ const mapped = hit ? hit.result : det.result;
114
+ const rawDetections = hit ? hit.raw : det.raw;
115
+ if (!hit && pendingIdentity) {
116
+ detectCache = {
117
+ identity: pendingIdentity,
118
+ optionsKey: detectKey,
119
+ result: det.result,
120
+ raw: det.raw,
121
+ };
122
+ }
104
123
  const content = [];
105
- if (annotate) {
124
+ if (annotate && det.decoded) {
106
125
  try {
107
126
  const boxes = [
108
- ...(det.raw.faces ?? []).map((f) => ({
109
- bbox: f.bbox,
110
- rgb: [0, 220, 255],
111
- })),
112
- ...(det.raw.objects ?? []).map((o) => ({
127
+ ...(rawDetections.faces ?? []).map((f) => ({ bbox: f.bbox, rgb: ANNOTATION_RGB.face })),
128
+ ...(rawDetections.objects ?? []).map((o) => ({
113
129
  bbox: o.bbox,
114
- rgb: [255, 0, 220],
130
+ rgb: ANNOTATION_RGB.object,
115
131
  })),
116
132
  ];
117
- const annotated = drawBoxes(det.exportBytes, boxes);
133
+ const annotated = drawBoxes(det.decoded, boxes);
118
134
  content.push({
119
135
  type: 'image',
120
136
  data: annotated.toString('base64'),
@@ -150,14 +166,10 @@ async function detect(connection, client, rawArgs) {
150
166
  };
151
167
  }
152
168
  catch (error) {
153
- const msg = error instanceof Error ? error.message : String(error);
154
- return {
155
- content: [{ type: 'text', text: `Error during detection: ${msg}` }],
156
- isError: true,
157
- };
169
+ return toolErrorResult('Error during detection', error);
158
170
  }
159
171
  }
160
- export function createDetectionTools(connection, _snippetClient, client = new OnnxDetectionClient()) {
172
+ export function createDetectionTools(connection, _snippetClient, client = new OnnxDetectionClient(), detectDeps) {
161
173
  return [
162
174
  {
163
175
  tool: {
@@ -203,7 +215,7 @@ export function createDetectionTools(connection, _snippetClient, client = new On
203
215
  idempotentHint: true,
204
216
  },
205
217
  },
206
- handler: async (args) => detect(connection, client, args),
218
+ handler: async (args) => detect(connection, client, args, detectDeps),
207
219
  },
208
220
  ];
209
221
  }