editmamei 1.7.1 → 1.8.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 (38) hide show
  1. package/README.md +3 -3
  2. package/dist/backends/gimp/bridge/lib.py +833 -4
  3. package/dist/backends/gimp/bridge/ops.py +1943 -110
  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/config.js +7 -0
  8. package/dist/core/settings.js +7 -0
  9. package/dist/core/tool-activity.js +13 -1
  10. package/dist/core/tool-groups.js +7 -1
  11. package/dist/core/tool-tiers.js +7 -1
  12. package/dist/modules/gimp/index.js +8 -2
  13. package/dist/skills/editmamei-skill.zip +0 -0
  14. package/dist/telemetry/sanitize.js +15 -1
  15. package/dist/tools/adjustment-tools.js +9 -1
  16. package/dist/tools/document-tools.js +50 -9
  17. package/dist/tools/filter-tools.js +2 -1
  18. package/dist/tools/gimp-adjustment-tools.js +3 -3
  19. package/dist/tools/gimp-core-tools.js +7 -8
  20. package/dist/tools/gimp-document-tools.js +4 -1
  21. package/dist/tools/gimp-effect-tools.js +2 -2
  22. package/dist/tools/gimp-geometry-tools.js +2 -2
  23. package/dist/tools/gimp-inspect-tools.js +35 -5
  24. package/dist/tools/gimp-layer-tools.js +5 -24
  25. package/dist/tools/gimp-match-tools.js +133 -0
  26. package/dist/tools/gimp-selection-tools.js +374 -0
  27. package/dist/tools/gimp-shared.js +12 -0
  28. package/dist/tools/gimp-text-tools.js +181 -0
  29. package/dist/tools/gimp-transform-layer-tools.js +215 -0
  30. package/dist/tools/gimp-verify-tools.js +2 -13
  31. package/dist/tools/image-placement-tools.js +6 -1
  32. package/dist/tools/sky-tools.js +2 -1
  33. package/dist/utils/gimp-path.js +18 -22
  34. package/dist/utils/local-path.js +99 -0
  35. package/dist/utils/operation-timeouts.js +7 -1
  36. package/dist/version.js +1 -1
  37. package/package.json +2 -2
  38. package/dist/tools/gimp-mask-tools.js +0 -102
Binary file
Binary file
@@ -50,6 +50,13 @@ const KEYS = {
50
50
  },
51
51
  coerce: coerceBool,
52
52
  },
53
+ 'automation.allow_execute_script': {
54
+ get: (s) => s.automation.allow_execute_script,
55
+ set: (s, v) => {
56
+ s.automation.allow_execute_script = v;
57
+ },
58
+ coerce: coerceBool,
59
+ },
53
60
  ps_path: {
54
61
  get: (s) => s.ps_path,
55
62
  set: (s, v) => {
@@ -21,6 +21,7 @@ function defaults(installId) {
21
21
  return {
22
22
  telemetry: { usage: true, diagnostics: false, install_id: installId },
23
23
  privacy: { send_previews_to_llm: true },
24
+ automation: { allow_execute_script: true },
24
25
  ps_path: null,
25
26
  update_check: true,
26
27
  editor: 'auto',
@@ -34,6 +35,7 @@ function coerce(raw, installId) {
34
35
  const r = raw;
35
36
  const t = (r.telemetry ?? {});
36
37
  const p = (r.privacy ?? {});
38
+ const a = (r.automation ?? {});
37
39
  return {
38
40
  telemetry: {
39
41
  usage: typeof t.usage === 'boolean' ? t.usage : base.telemetry.usage,
@@ -47,6 +49,11 @@ function coerce(raw, installId) {
47
49
  ? p.send_previews_to_llm
48
50
  : base.privacy.send_previews_to_llm,
49
51
  },
52
+ automation: {
53
+ allow_execute_script: typeof a.allow_execute_script === 'boolean'
54
+ ? a.allow_execute_script
55
+ : base.automation.allow_execute_script,
56
+ },
50
57
  ps_path: typeof r.ps_path === 'string' ? r.ps_path : null,
51
58
  update_check: typeof r.update_check === 'boolean' ? r.update_check : base.update_check,
52
59
  editor: typeof r.editor === 'string' && EDITOR_PINS.includes(r.editor)
@@ -1,3 +1,9 @@
1
+ export const RECIPE_TOOLS = new Set([
2
+ 'ps_template_apply',
3
+ 'ps_batch',
4
+ 'ps_sequence',
5
+ 'ps_play_action',
6
+ ]);
1
7
  export const READ_ONLY_TOOLS = new Set([
2
8
  'ps_sequence',
3
9
  'ps_batch',
@@ -29,6 +35,7 @@ export const READ_ONLY_TOOLS = new Set([
29
35
  'gimp_get_preview',
30
36
  'gimp_get_histogram',
31
37
  'gimp_compare',
38
+ 'gimp_get_selection_preview',
32
39
  ]);
33
40
  export const KEPT_WORK_TOOLS = new Set([
34
41
  'ps_export',
@@ -109,14 +116,19 @@ export const MUTATING_TOOLS = new Set([
109
116
  'gimp_crop_document',
110
117
  'gimp_resize_image',
111
118
  'gimp_transform_canvas',
112
- 'gimp_create_mask',
113
119
  'gimp_checkpoint',
114
120
  'gimp_layer',
115
121
  'gimp_bake',
116
122
  'gimp_create_document',
117
123
  'gimp_place_image',
118
124
  'gimp_canvas',
125
+ 'gimp_text',
119
126
  'gimp_convert_image_mode',
127
+ 'gimp_select',
128
+ 'gimp_modify_selection',
129
+ 'gimp_layer_mask',
130
+ 'gimp_transform_layer',
131
+ 'gimp_match_layer',
120
132
  ]);
121
133
  export const RAW_DEVELOP_TOOL = 'ps_develop_raw';
122
134
  export const CAMERA_RAW_TOOL = 'ps_apply_camera_raw';
@@ -187,14 +187,20 @@ export const TOOL_GROUPS = {
187
187
  gimp_add_adjustment: 'adjust',
188
188
  gimp_filter: 'filter',
189
189
  gimp_add_effect: 'filter',
190
- gimp_create_mask: 'masks',
191
190
  gimp_checkpoint: 'document',
192
191
  gimp_layer: 'layers',
193
192
  gimp_bake: 'layers',
193
+ gimp_transform_layer: 'layers',
194
194
  gimp_create_document: 'document',
195
195
  gimp_place_image: 'layers',
196
196
  gimp_canvas: 'document',
197
+ gimp_text: 'layers',
197
198
  gimp_convert_image_mode: 'document',
199
+ gimp_select: 'select',
200
+ gimp_modify_selection: 'select',
201
+ gimp_layer_mask: 'masks',
202
+ gimp_get_selection_preview: 'verify',
203
+ gimp_match_layer: 'layers',
198
204
  };
199
205
  export function groupOf(toolName) {
200
206
  const group = TOOL_GROUPS[toolName];
@@ -102,17 +102,23 @@ export const TOOL_TIERS = {
102
102
  gimp_crop_document: 'community',
103
103
  gimp_resize_image: 'community',
104
104
  gimp_transform_canvas: 'community',
105
- gimp_create_mask: 'community',
106
105
  gimp_get_preview: 'community',
107
106
  gimp_get_histogram: 'community',
108
107
  gimp_compare: 'community',
109
108
  gimp_checkpoint: 'community',
110
109
  gimp_layer: 'community',
111
110
  gimp_bake: 'community',
111
+ gimp_transform_layer: 'community',
112
112
  gimp_create_document: 'community',
113
113
  gimp_place_image: 'community',
114
114
  gimp_canvas: 'community',
115
+ gimp_text: 'community',
115
116
  gimp_convert_image_mode: 'community',
117
+ gimp_select: 'community',
118
+ gimp_modify_selection: 'dev',
119
+ gimp_layer_mask: 'community',
120
+ gimp_get_selection_preview: 'community',
121
+ gimp_match_layer: 'dev',
116
122
  };
117
123
  export function tierOf(toolName) {
118
124
  const tier = TOOL_TIERS[toolName];
@@ -8,11 +8,14 @@ import { createGimpAdjustmentTools } from '../../tools/gimp-adjustment-tools.js'
8
8
  import { createGimpEffectTools } from '../../tools/gimp-effect-tools.js';
9
9
  import { createGimpFilterTools } from '../../tools/gimp-filter-tools.js';
10
10
  import { createGimpGeometryTools } from '../../tools/gimp-geometry-tools.js';
11
- import { createGimpMaskTools } from '../../tools/gimp-mask-tools.js';
12
11
  import { createGimpVerifyTools } from '../../tools/gimp-verify-tools.js';
13
12
  import { createGimpCheckpointTools } from '../../tools/gimp-checkpoint-tools.js';
14
13
  import { createGimpLayerTools } from '../../tools/gimp-layer-tools.js';
14
+ import { createGimpTransformLayerTools } from '../../tools/gimp-transform-layer-tools.js';
15
15
  import { createGimpComposeTools } from '../../tools/gimp-compose-tools.js';
16
+ import { createGimpTextTools } from '../../tools/gimp-text-tools.js';
17
+ import { createGimpSelectionTools } from '../../tools/gimp-selection-tools.js';
18
+ import { createGimpMatchTools } from '../../tools/gimp-match-tools.js';
16
19
  export const gimpFactories = [
17
20
  createGimpCoreTools,
18
21
  createGimpDocumentTools,
@@ -21,11 +24,14 @@ export const gimpFactories = [
21
24
  createGimpEffectTools,
22
25
  createGimpFilterTools,
23
26
  createGimpGeometryTools,
24
- createGimpMaskTools,
25
27
  createGimpVerifyTools,
26
28
  createGimpCheckpointTools,
27
29
  createGimpLayerTools,
30
+ createGimpTransformLayerTools,
28
31
  createGimpComposeTools,
32
+ createGimpTextTools,
33
+ createGimpSelectionTools,
34
+ createGimpMatchTools,
29
35
  ];
30
36
  export const gimpModule = {
31
37
  manifest: { id: 'gimp', name: 'Editmamei GIMP Tools', abi: KERNEL_ABI },
Binary file
@@ -23,8 +23,22 @@ function homedirPrefixes() {
23
23
  return [...set];
24
24
  }
25
25
  const SINGLE_SEG_POSIX = /(^|\s)\/[^\s"'/]+(?=$|\s)/;
26
+ const WORD = String.raw `[^\s"'\\/:]+`;
27
+ const DIR = String.raw `${WORD}(?: ${WORD})*`;
28
+ const TILDE_WORD = String.raw `[^\s"'\\/]+`;
29
+ const TILDE_DIR = String.raw `${TILDE_WORD}(?: ${TILDE_WORD})*`;
30
+ const POSIX_WORD = String.raw `[^\s"'/]+`;
31
+ const POSIX_DIR = String.raw `${POSIX_WORD}(?: ${POSIX_WORD})*`;
32
+ const PATH_TOKEN = new RegExp([
33
+ String.raw `[A-Za-z]:[\\/](?:${DIR}[\\/])*[^\s"']*`,
34
+ String.raw `\\\\(?:${DIR}[\\/])*[^\s"']+`,
35
+ String.raw `file:\/\/[^\s"']*`,
36
+ String.raw `(?<![\w~])~(?:[\\/]${TILDE_DIR}(?=[\\/]))*[\\/]${TILDE_WORD}[\\/]?`,
37
+ String.raw `\/${POSIX_WORD}(?:\/${POSIX_DIR}(?=\/))*\/${POSIX_WORD}\/?`,
38
+ String.raw `(?:^|\s)\/[^\s"'/]+(?=$|\s)`,
39
+ ].join('|'), 'gi');
26
40
  function basenamePaths(s) {
27
- return s.replace(/[A-Za-z]:[\\/][^\s"']*|\\\\[^\s"']+|file:\/\/[^\s"']*|(?:\/[^\s"'/]+){2,}\/?|(?:^|\s)\/[^\s"'/]+(?=$|\s)/gi, (m) => {
41
+ return s.replace(PATH_TOKEN, (m) => {
28
42
  const single = SINGLE_SEG_POSIX.exec(m);
29
43
  if (single)
30
44
  return `${single[1]}/…`;
@@ -1,6 +1,7 @@
1
1
  import { runScript } from '../utils/run-script.js';
2
2
  import { validateArgs } from '../utils/validate.js';
3
3
  import { toolErrorResult, runSnippetTool, applyToActiveLayerProp } from '../utils/tool-helpers.js';
4
+ import { looksLikePath, requireLocalPath } from '../utils/local-path.js';
4
5
  import { getPendingRawDevelop } from '../core/raw-develop-state.js';
5
6
  import { CAMERA_RAW_TOOL } from '../core/tool-activity.js';
6
7
  const addAdjustmentLayerSchema = {
@@ -802,6 +803,11 @@ const FORWARDED_KEYS = new Set([
802
803
  'pos_levels',
803
804
  'thr_level',
804
805
  ]);
806
+ function lutNameOrPath(value) {
807
+ return typeof value === 'string' && looksLikePath(value)
808
+ ? requireLocalPath('cl_lut_name', value)
809
+ : value;
810
+ }
805
811
  async function addAdjustmentLayer(connection, snippetClient, rawArgs) {
806
812
  try {
807
813
  const args = validateArgs(addAdjustmentLayerSchema, rawArgs);
@@ -816,6 +822,8 @@ async function addAdjustmentLayer(connection, snippetClient, rawArgs) {
816
822
  if (args[key] !== undefined)
817
823
  params[key] = args[key];
818
824
  }
825
+ if (typeof params.cl_lut_name === 'string')
826
+ params.cl_lut_name = lutNameOrPath(params.cl_lut_name);
819
827
  if (type === 'color_lookup' && !params.cl_lut_name) {
820
828
  return {
821
829
  content: [
@@ -875,7 +883,7 @@ async function applyColorLookup(connection, snippetClient, rawArgs) {
875
883
  snippet: 'applyColorLookup',
876
884
  errorPrefix: 'Error applying color lookup',
877
885
  params: (args) => ({
878
- lutName: args.cl_lut_name,
886
+ lutName: lutNameOrPath(args.cl_lut_name),
879
887
  applyToActiveLayer: args.apply_to_active_layer ?? false,
880
888
  }),
881
889
  successText: (result) => `Color Lookup applied (BAKE, experimental):\n${JSON.stringify(result, null, 2)}`,
@@ -2,6 +2,7 @@ import { runScript } from '../utils/run-script.js';
2
2
  import { validateArgs } from '../utils/validate.js';
3
3
  import { OPEN_DOCUMENT_TIMEOUT_MS, OPEN_DOCUMENT_REPROBE_TIMEOUT_MS, } from '../utils/operation-timeouts.js';
4
4
  import { toolErrorResult, runSnippetTool } from '../utils/tool-helpers.js';
5
+ import { checkOutputPath, recordOutputPath, requireLocalPath } from '../utils/local-path.js';
5
6
  import { purgeSceneChannels } from '../perception/region-precompute.js';
6
7
  import { Logger } from '../utils/logger.js';
7
8
  const logger = new Logger('document-tools');
@@ -76,7 +77,7 @@ const openDocumentSchema = {
76
77
  properties: {
77
78
  file_path: {
78
79
  type: 'string',
79
- description: 'Absolute path to the file to open',
80
+ description: "Absolute path to the file to open, on this computer's own drives (network shares are refused).",
80
81
  },
81
82
  suppress_dialogs: {
82
83
  type: 'boolean',
@@ -96,7 +97,14 @@ const savePsdSchema = {
96
97
  properties: {
97
98
  output_path: {
98
99
  type: 'string',
99
- description: 'Absolute output path including filename, e.g. E:\\Photos\\Edit\\shell_01.psd',
100
+ description: 'Absolute output path including filename, ending in .psd, e.g. E:\\Photos\\Edit\\shell_01.psd',
101
+ },
102
+ overwrite: {
103
+ type: 'boolean',
104
+ description: 'Replace an existing file at output_path that this session did not write. Default false: such a ' +
105
+ 'save is refused, so an original is never replaced by accident. Set true only when the user asked ' +
106
+ 'to replace that file. A file this session already wrote can be saved over without it.',
107
+ default: false,
100
108
  },
101
109
  maximize_compatibility: {
102
110
  type: 'boolean',
@@ -116,7 +124,14 @@ const exportJpegSchema = {
116
124
  properties: {
117
125
  output_path: {
118
126
  type: 'string',
119
- description: 'Absolute output path including filename.',
127
+ description: 'Absolute output path including filename, ending in .jpg.',
128
+ },
129
+ overwrite: {
130
+ type: 'boolean',
131
+ description: 'Replace an existing file at output_path that this session did not write. Default false: such a ' +
132
+ 'save is refused, so an original is never replaced by accident. Set true only when the user asked ' +
133
+ 'to replace that file. A file this session already wrote can be saved over without it.',
134
+ default: false,
120
135
  },
121
136
  quality: {
122
137
  type: 'integer',
@@ -149,7 +164,14 @@ const exportPngSchema = {
149
164
  properties: {
150
165
  output_path: {
151
166
  type: 'string',
152
- description: 'Absolute output path including filename.',
167
+ description: 'Absolute output path including filename, ending in .png.',
168
+ },
169
+ overwrite: {
170
+ type: 'boolean',
171
+ description: 'Replace an existing file at output_path that this session did not write. Default false: such a ' +
172
+ 'save is refused, so an original is never replaced by accident. Set true only when the user asked ' +
173
+ 'to replace that file. A file this session already wrote can be saved over without it.',
174
+ default: false,
153
175
  },
154
176
  transparent_background: {
155
177
  type: 'boolean',
@@ -531,7 +553,7 @@ async function openDocumentPipeline(connection, snippetClient, rawArgs) {
531
553
  let filePath;
532
554
  try {
533
555
  const args = validateArgs(openDocumentSchema, rawArgs);
534
- filePath = args.file_path;
556
+ filePath = requireLocalPath('file_path', args.file_path);
535
557
  const suppressDialogs = args.suppress_dialogs;
536
558
  const bitDepth = args.bit_depth;
537
559
  const script = await snippetClient.build('openDocumentPipeline', {
@@ -581,8 +603,13 @@ async function openDocumentPipeline(connection, snippetClient, rawArgs) {
581
603
  }
582
604
  async function savePsdPipeline(connection, snippetClient, rawArgs) {
583
605
  let args;
606
+ let outputPath;
584
607
  try {
585
608
  args = validateArgs(savePsdSchema, rawArgs);
609
+ outputPath = checkOutputPath('output_path', args.output_path, {
610
+ extensions: ['.psd'],
611
+ overwrite: args.overwrite === true,
612
+ });
586
613
  }
587
614
  catch (error) {
588
615
  return toolErrorResult('Error saving PSD', error);
@@ -605,11 +632,13 @@ async function savePsdPipeline(connection, snippetClient, rawArgs) {
605
632
  snippet: 'savePsdAsCopy',
606
633
  errorPrefix: 'Error saving PSD',
607
634
  params: (args) => ({
608
- outputPath: args.output_path,
635
+ outputPath,
609
636
  maximizeCompat: args.maximize_compatibility,
610
637
  }),
611
638
  successText: (result) => `PSD saved:\n${JSON.stringify(result, null, 2)}`,
612
639
  });
640
+ if (!res.isError)
641
+ recordOutputPath(outputPath);
613
642
  if (!res.isError && typeof res.structuredContent === 'object' && res.structuredContent !== null) {
614
643
  res.structuredContent.scene_channels_purged = purged;
615
644
  }
@@ -618,7 +647,10 @@ async function savePsdPipeline(connection, snippetClient, rawArgs) {
618
647
  async function exportJpegPipeline(connection, snippetClient, rawArgs) {
619
648
  try {
620
649
  const args = validateArgs(exportJpegSchema, rawArgs);
621
- const outputPath = args.output_path;
650
+ const outputPath = checkOutputPath('output_path', args.output_path, {
651
+ extensions: ['.jpg'],
652
+ overwrite: args.overwrite === true,
653
+ });
622
654
  const qualityPct = args.quality;
623
655
  const quality = Math.round((qualityPct / 100) * 12);
624
656
  const longEdgePx = args.long_edge_px;
@@ -630,6 +662,7 @@ async function exportJpegPipeline(connection, snippetClient, rawArgs) {
630
662
  const script = await snippetClient.build('exportJpegPipeline', params);
631
663
  const result = (await runScript(connection, script));
632
664
  const structured = { ...result, quality: qualityPct, quality_ps_scale: quality };
665
+ recordOutputPath(outputPath);
633
666
  return {
634
667
  content: [
635
668
  {
@@ -645,7 +678,8 @@ async function exportJpegPipeline(connection, snippetClient, rawArgs) {
645
678
  }
646
679
  }
647
680
  async function exportPngPipeline(connection, snippetClient, rawArgs) {
648
- return runSnippetTool({
681
+ let outputPath;
682
+ const res = await runSnippetTool({
649
683
  connection,
650
684
  snippetClient,
651
685
  rawArgs,
@@ -654,8 +688,12 @@ async function exportPngPipeline(connection, snippetClient, rawArgs) {
654
688
  errorPrefix: 'Error exporting PNG',
655
689
  params: (args) => {
656
690
  const longEdgePx = args.long_edge_px;
691
+ outputPath = checkOutputPath('output_path', args.output_path, {
692
+ extensions: ['.png'],
693
+ overwrite: args.overwrite === true,
694
+ });
657
695
  const params = {
658
- outputPath: args.output_path,
696
+ outputPath,
659
697
  transparentBg: args.transparent_background,
660
698
  compression: args.compression,
661
699
  };
@@ -665,4 +703,7 @@ async function exportPngPipeline(connection, snippetClient, rawArgs) {
665
703
  },
666
704
  successText: (result) => `PNG exported:\n${JSON.stringify(result, null, 2)}`,
667
705
  });
706
+ if (!res.isError && outputPath !== undefined)
707
+ recordOutputPath(outputPath);
708
+ return res;
668
709
  }
@@ -3,6 +3,7 @@ import { validateArgs } from '../utils/validate.js';
3
3
  import { OnnxLandmarkDetectionClient } from '../detection/landmark-detection-client.js';
4
4
  import { resolveGatedPlacement, PLACEMENT_SCHEMA } from '../perception/grounding-locate.js';
5
5
  import { toolErrorResult, runSnippetTool, applyToActiveLayerProp, asSmartFilterProp, unknownDiscriminator, } from '../utils/tool-helpers.js';
6
+ import { requireLocalPath } from '../utils/local-path.js';
6
7
  import { LAYER_BLEND_MODES } from '../utils/blend-modes.js';
7
8
  import { runSmartFilterOp } from './smart-object-tools.js';
8
9
  const gaussianBlurSchema = {
@@ -1652,7 +1653,7 @@ async function applyDisplace(connection, snippetClient, rawArgs) {
1652
1653
  snippet: 'applyDisplace',
1653
1654
  errorPrefix: 'Error applying Displace',
1654
1655
  params: (args) => ({
1655
- mapPath: args.map_path,
1656
+ mapPath: requireLocalPath('map_path', args.map_path),
1656
1657
  horizontalScale: args.horizontal_scale ?? 10,
1657
1658
  verticalScale: args.vertical_scale ?? 10,
1658
1659
  displacementMap: args.displacement_map ?? 'stretch_to_fit',
@@ -55,7 +55,7 @@ const adjustSchema = {
55
55
  },
56
56
  mask: {
57
57
  type: 'string',
58
- description: 'Name of a mask channel from gimp_create_mask, confining a NEW filter to it. Only valid ' +
58
+ description: 'Name of a saved selection channel, confining a NEW filter to it. Only valid ' +
59
59
  "when creating (no filter_id) — a filter's mask is fixed at creation. ORDER MATTERS: " +
60
60
  'straighten / flip / resize the canvas first (gimp_transform_canvas, gimp_resize_image), ' +
61
61
  'then crop, THEN add masked adjustments — rotate, flip, and resize all refuse outright ' +
@@ -348,11 +348,11 @@ export function createGimpAdjustmentTools(gimp) {
348
348
  'separate filters for red and blue rather than one filter for both; points are [input, ' +
349
349
  'output] 0-255 pairs including the endpoints. hue_saturation and color_balance likewise ' +
350
350
  'carry ONE `range` per filter. gaussian_blur: radius in pixels at full resolution; ' +
351
- 'combine with a mask from gimp_create_mask for soft-focus or background-blur effects. A ' +
351
+ 'combine with a saved-selection mask for soft-focus or background-blur effects. A ' +
352
352
  "re-edit (filter_id) MERGES: any field you omit keeps the filter's existing value, so " +
353
353
  "re-editing {contrast: 50} on a brightness_contrast filter doesn't reset brightness. Only " +
354
354
  'filters Editmamei created can be re-edited; one added in the GIMP GUI is refused (delete ' +
355
- 'and re-create it). `mask` (a channel name from gimp_create_mask) confines a NEW filter ' +
355
+ 'and re-create it). `mask` (a saved selection channel name) confines a NEW filter ' +
356
356
  '— fixed at creation, cannot change on a re-edit. ORDER MATTERS: straighten / flip / ' +
357
357
  'resize the canvas first, then crop, THEN add any masked adjustment — rotate, flip, and ' +
358
358
  'resize all refuse outright once a masked adjustment filter exists, or any filter not ' +
@@ -145,13 +145,12 @@ workflow avoids.
145
145
 
146
146
  ## Masks
147
147
 
148
- \`gimp_create_mask\` builds a geometric mask (rectangle / ellipse /
149
- gradient) into a NAMED channel. Pass that name as \`mask\` when you
150
- CREATE a filter with \`gimp_add_adjustment\` or \`gimp_add_effect\` to
151
- confine it. A filter's
152
- mask is fixed at creation — re-editing it by \`filter_id\` cannot
153
- change which mask it uses; delete and re-create it with a new mask
154
- instead.
148
+ A selection can be saved into a NAMED channel. Pass the name of a
149
+ channel saved in the image as \`mask\` when you CREATE a filter
150
+ with \`gimp_add_adjustment\` or \`gimp_add_effect\` to confine it.
151
+ A filter's mask is fixed at creation — re-editing it by
152
+ \`filter_id\` cannot change which mask it uses; delete and
153
+ re-create it with a new mask instead.
155
154
 
156
155
  ## Order matters: geometry before masked adjustments
157
156
 
@@ -169,7 +168,7 @@ every geometry op.
169
168
  ## Layers and composites
170
169
 
171
170
  A filter applies to ONE layer. \`gimp_layer\` creates, groups, reorders,
172
- moves and restyles layers and merges or flattens them;
171
+ and restyles layers and merges or flattens them;
173
172
  \`gimp_place_image\` adds another file as a new layer;
174
173
  \`gimp_create_document\` starts a blank canvas. Address layers by the
175
174
  \`layer_id\` these tools and \`gimp_inspect\` report — names can repeat.
@@ -40,7 +40,10 @@ async function gimpOpenDocument(gimp, rawArgs) {
40
40
  type: 'text',
41
41
  text: `Opened ${basename(filePath)} as image ${result.image} ` +
42
42
  `(${result.width}x${result.height}, ${result.base_type}, ${result.precision}). ` +
43
- `${result.layers.length} layer(s): ${result.layers.join(', ') || '(none)'}.`,
43
+ `${result.layers.length} layer(s): ${result.layers.join(', ') || '(none)'}.` +
44
+ (result.orientation_applied !== undefined
45
+ ? ` Rotated upright for EXIF orientation ${result.orientation_applied}.`
46
+ : ''),
44
47
  },
45
48
  ],
46
49
  structuredContent: result,
@@ -41,7 +41,7 @@ const effectSchema = {
41
41
  layer_id: GIMP_LAYER_ID_PROP,
42
42
  mask: {
43
43
  type: 'string',
44
- description: 'Name of a mask channel from gimp_create_mask, confining a NEW filter to it. Only valid ' +
44
+ description: 'Name of a saved selection channel, confining a NEW filter to it. Only valid ' +
45
45
  "when creating (no filter_id) — a filter's mask is fixed at creation. ORDER MATTERS: " +
46
46
  'straighten / flip / resize the canvas first (gimp_transform_canvas, gimp_resize_image), ' +
47
47
  'then crop, THEN add masked effects — rotate, flip, and resize all refuse outright once ' +
@@ -230,7 +230,7 @@ export function createGimpEffectTools(gimp) {
230
230
  'or `layer`, else the selected or top layer), not to the flattened image. A re-edit ' +
231
231
  "(filter_id) MERGES: any field you omit keeps the filter's existing value. Only " +
232
232
  'filters Editmamei created can be re-edited; one added in the GIMP GUI is refused ' +
233
- '(delete and re-create it). `mask` (a channel name from gimp_create_mask) confines a ' +
233
+ '(delete and re-create it). `mask` (a saved selection channel name) confines a ' +
234
234
  'NEW filter — fixed at creation, cannot change on a re-edit. ORDER MATTERS: straighten ' +
235
235
  '/ flip / resize the canvas first, then crop, THEN add any masked effect — rotate, ' +
236
236
  'flip, and resize all refuse outright once a masked filter exists, or any filter not ' +
@@ -38,7 +38,7 @@ const resizeSchema = {
38
38
  },
39
39
  required: ['image'],
40
40
  };
41
- const EFFECT_UPDATE_FAILURES_PROP = {
41
+ export const EFFECT_UPDATE_FAILURES_PROP = {
42
42
  type: 'array',
43
43
  items: { type: 'string' },
44
44
  description: 'Names of effect filters (position/direction/size-dependent ones tracked through geometry ' +
@@ -75,7 +75,7 @@ const transformCanvasSchema = {
75
75
  },
76
76
  required: ['image', 'op'],
77
77
  };
78
- function effectUpdateFailuresNote(failures) {
78
+ export function effectUpdateFailuresNote(failures) {
79
79
  if (!failures || failures.length === 0)
80
80
  return '';
81
81
  const plural = failures.length > 1;
@@ -1,7 +1,7 @@
1
1
  import { validateArgs } from '../utils/validate.js';
2
2
  import { toolGimpErrorResult, unknownDiscriminator } from '../utils/tool-helpers.js';
3
3
  import { pickSchemaDeclaredKeys } from './gimp-shared.js';
4
- const INSPECT_WHATS = ['documents', 'document', 'layers', 'channels', 'filter'];
4
+ const INSPECT_WHATS = ['documents', 'document', 'layers', 'channels', 'filter', 'fonts'];
5
5
  const inspectSchema = {
6
6
  type: 'object',
7
7
  properties: {
@@ -20,12 +20,19 @@ const inspectSchema = {
20
20
  "allows duplicate layer names) and flag `is_text_layer`. 'document'/'layers' cap the tree " +
21
21
  'at 2000 nodes total and report `truncated: true` if the cap was hit, alongside ' +
22
22
  '`top_level_count` and `total_nodes`; if it times out the GIMP session restarts and ' +
23
- 'unsaved work is lost, so save (gimp_save_xcf) first.',
23
+ "unsaved work is lost, so save (gimp_save_xcf) first. A text layer's node also reports " +
24
+ "its text, font, font_size (points), color and alignment. 'fonts' lists the installed " +
25
+ 'font names (no image needed), optionally narrowed by `filter`.',
24
26
  },
25
27
  image: {
26
28
  type: 'integer',
27
29
  description: 'Image id, as returned by gimp_open_document. Required for every `what` except ' +
28
- "'documents'.",
30
+ "'documents' and 'fonts'.",
31
+ },
32
+ filter: {
33
+ type: 'string',
34
+ description: "what='fonts' only: case-insensitive substring to narrow the font names by. The list is " +
35
+ 'sorted and capped at 200 names; `total` counts every match.',
29
36
  },
30
37
  filter_id: {
31
38
  type: 'integer',
@@ -75,6 +82,22 @@ async function gimpInspect(gimp, rawArgs) {
75
82
  structuredContent: { what, documents },
76
83
  };
77
84
  }
85
+ if (what === 'fonts') {
86
+ const result = await gimp.call('fonts', pickSchemaDeclaredKeys(inspectSchema, args));
87
+ const shown = result.fonts.length;
88
+ return {
89
+ content: [
90
+ {
91
+ type: 'text',
92
+ text: `${result.total} font(s) installed` +
93
+ (args.filter ? ` matching "${String(args.filter).slice(0, 100)}"` : '') +
94
+ (shown < result.total ? `, showing the first ${shown}` : '') +
95
+ `: ${result.fonts.join(', ') || '(none)'}.`,
96
+ },
97
+ ],
98
+ structuredContent: { what, ...result },
99
+ };
100
+ }
78
101
  if (what === 'document' || what === 'layers' || what === 'channels' || what === 'filter') {
79
102
  const result = await gimp.call('describe', pickSchemaDeclaredKeys(inspectSchema, args));
80
103
  return {
@@ -100,8 +123,11 @@ export function createGimpInspectTools(gimp) {
100
123
  "WITH coverage (selected_pixels/fraction), the cost 'document' skips, and can stop " +
101
124
  'early on a document with many named channels (`truncated: true`, `channels_skipped`). ' +
102
125
  "'filter' describes one filter by filter_id, in the same shape gimp_filter (op=list) " +
103
- 'reports it in. Layer tree nodes are addressed by `layer_id` (canonical — GIMP allows ' +
104
- "duplicate layer names) and flag `is_text_layer`. 'document'/'layers' cap the tree at " +
126
+ "reports it in. 'fonts' lists the installed font names (no image needed), sorted, " +
127
+ 'optionally narrowed by `filter` (substring), capped at 200 with a `total`. Layer tree ' +
128
+ 'nodes are addressed by `layer_id` (canonical — GIMP allows duplicate layer names) and ' +
129
+ "flag `is_text_layer`; a text layer's node also reports its text, font, font_size " +
130
+ "(points), color and alignment. 'document'/'layers' cap the tree at " +
105
131
  '2000 nodes total and report `truncated: true` if the cap was hit, alongside ' +
106
132
  '`top_level_count` and `total_nodes`; if it times out the GIMP session restarts and ' +
107
133
  'unsaved work is lost, so save (gimp_save_xcf) first.',
@@ -110,6 +136,10 @@ export function createGimpInspectTools(gimp) {
110
136
  type: 'object',
111
137
  properties: {
112
138
  what: { type: 'string' },
139
+ fonts: { type: 'array', items: { type: 'string' } },
140
+ total: { type: 'number' },
141
+ returned: { type: 'number' },
142
+ default: { type: ['string', 'null'] },
113
143
  documents: {
114
144
  type: 'array',
115
145
  items: { type: 'object', properties: { image: { type: 'number' } } },