editmamei 1.7.0 → 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 (44) hide show
  1. package/README.md +5 -4
  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/cli/help.js +2 -2
  9. package/dist/cli/install.js +2 -2
  10. package/dist/core/settings.js +7 -0
  11. package/dist/core/tool-activity.js +13 -1
  12. package/dist/core/tool-groups.js +7 -1
  13. package/dist/core/tool-tiers.js +7 -1
  14. package/dist/install-channel.js +2 -0
  15. package/dist/modules/gimp/index.js +8 -2
  16. package/dist/skills/editmamei-skill.zip +0 -0
  17. package/dist/telemetry/client.js +23 -16
  18. package/dist/telemetry/outbox.js +248 -28
  19. package/dist/telemetry/sanitize.js +15 -1
  20. package/dist/tools/adjustment-tools.js +9 -1
  21. package/dist/tools/document-tools.js +50 -9
  22. package/dist/tools/filter-tools.js +2 -1
  23. package/dist/tools/gimp-adjustment-tools.js +3 -3
  24. package/dist/tools/gimp-core-tools.js +7 -8
  25. package/dist/tools/gimp-document-tools.js +4 -1
  26. package/dist/tools/gimp-effect-tools.js +2 -2
  27. package/dist/tools/gimp-geometry-tools.js +2 -2
  28. package/dist/tools/gimp-inspect-tools.js +35 -5
  29. package/dist/tools/gimp-layer-tools.js +5 -24
  30. package/dist/tools/gimp-match-tools.js +133 -0
  31. package/dist/tools/gimp-selection-tools.js +374 -0
  32. package/dist/tools/gimp-shared.js +12 -0
  33. package/dist/tools/gimp-text-tools.js +181 -0
  34. package/dist/tools/gimp-transform-layer-tools.js +215 -0
  35. package/dist/tools/gimp-verify-tools.js +2 -13
  36. package/dist/tools/image-placement-tools.js +6 -1
  37. package/dist/tools/sky-tools.js +2 -1
  38. package/dist/update/check.js +2 -0
  39. package/dist/utils/gimp-path.js +18 -22
  40. package/dist/utils/local-path.js +99 -0
  41. package/dist/utils/operation-timeouts.js +7 -1
  42. package/dist/version.js +1 -1
  43. package/package.json +2 -2
  44. package/dist/tools/gimp-mask-tools.js +0 -102
@@ -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' } } },
@@ -8,7 +8,6 @@ const LAYER_OPS = [
8
8
  'duplicate',
9
9
  'select',
10
10
  'set',
11
- 'move',
12
11
  'reorder',
13
12
  'merge_down',
14
13
  'flatten',
@@ -31,10 +30,7 @@ const layerSchema = {
31
30
  'target for gimp_add_adjustment and friends when no layer is named) and is the one sub-op ' +
32
31
  "that changes nothing else. 'set' changes opacity, mode, visible, and/or name (at least " +
33
32
  'one required, name may not be empty) — mode is a blend mode from a fixed list, not a raw ' +
34
- "GEGL/GIMP name. 'move' repositions a layer to an ABSOLUTE x/y (not a delta), bounded to " +
35
- 'stay near the canvas — REFUSED when the layer, or a group containing it, carries a ' +
36
- "masked or unverifiable adjustment filter, since a filter's mask does not travel with " +
37
- "content that moves beneath it. 'reorder' " +
33
+ "GEGL/GIMP name. 'reorder' " +
38
34
  "changes a layer's stack position and/or moves it into a group (parent_group) or out to " +
39
35
  'top-level (to_top_level: true) — neither given keeps the layer in its current group and ' +
40
36
  'only changes `position`; REFUSED if parent_group would nest a group inside itself or one ' +
@@ -139,14 +135,6 @@ const layerSchema = {
139
135
  type: 'boolean',
140
136
  description: 'set only.',
141
137
  },
142
- x: {
143
- type: 'integer',
144
- description: "move only. The layer's new ABSOLUTE horizontal offset in document pixels (not a delta).",
145
- },
146
- y: {
147
- type: 'integer',
148
- description: "move only. The layer's new ABSOLUTE vertical offset in document pixels (not a delta).",
149
- },
150
138
  discard_hidden: {
151
139
  type: 'boolean',
152
140
  default: false,
@@ -169,8 +157,6 @@ function layerSuccessText(op, result, args) {
169
157
  return `Duplicated as layer ${result.layer_id} ("${result.name}").`;
170
158
  case 'select':
171
159
  return `Selected layer ${result.layer_id} ("${result.name}").`;
172
- case 'move':
173
- return `Moved layer ${result.layer_id} to (${result.x}, ${result.y}).`;
174
160
  case 'reorder':
175
161
  return (`Reordered layer ${result.layer_id}` +
176
162
  (result.parent_group != null
@@ -277,7 +263,7 @@ export function createGimpLayerTools(gimp) {
277
263
  tool: {
278
264
  name: 'gimp_layer',
279
265
  description: 'Headless GIMP: layer management. op: create | create_group | delete | duplicate | ' +
280
- 'select | set | move | reorder | merge_down | flatten (a group is created, addressed, ' +
266
+ 'select | set | reorder | merge_down | flatten (a group is created, addressed, ' +
281
267
  'and reordered the same as any other layer — there is no separate group tool). ' +
282
268
  'Addressed by layer_id (canonical — an id from gimp_inspect or a prior gimp_layer/' +
283
269
  'gimp_bake result) or layer (name; GIMP allows duplicate names, so layer_id is the only ' +
@@ -286,10 +272,7 @@ export function createGimpLayerTools(gimp) {
286
272
  'first when in doubt. duplicate REFUSES outright when the layer (or, for a group, any ' +
287
273
  "descendant) carries an Editmamei filter, since the copy's filter would collide with " +
288
274
  "the original's own ledger record; bake it first (gimp_bake) or delete it, then " +
289
- 'duplicate. move takes an ABSOLUTE x/y (not a delta) and REFUSES when the layer, or ' +
290
- 'a group containing it, carries a masked or unverifiable adjustment filter — a ' +
291
- "filter's mask does not travel with content that moves beneath it, the same physics " +
292
- 'gimp_transform_canvas refuses on. merge_down REFUSES outright when the SOURCE layer ' +
275
+ 'duplicate. merge_down REFUSES outright when the SOURCE layer ' +
293
276
  'itself is hidden (GIMP cannot merge a hidden layer down); it and ' +
294
277
  'flatten both bake every live filter they touch into pixels first (masked filters ' +
295
278
  'included) and rasterize any VISIBLE text layer in the process (rasterized_text); ' +
@@ -308,8 +291,6 @@ export function createGimpLayerTools(gimp) {
308
291
  opacity: { type: 'number' },
309
292
  mode: { type: 'string' },
310
293
  visible: { type: 'boolean' },
311
- x: { type: ['number', 'null'] },
312
- y: { type: ['number', 'null'] },
313
294
  parent_group: { type: ['number', 'null'] },
314
295
  rasterized_text: { type: 'boolean' },
315
296
  has_alpha: { type: 'boolean' },
@@ -340,9 +321,9 @@ export function createGimpLayerTools(gimp) {
340
321
  'filters; a text layer stays a text layer (baking never rasterizes one). A masked ' +
341
322
  "filter's confinement survives the bake exactly. Defaults to the selected layer, or the " +
342
323
  'topmost layer if none is selected, the same as any other gimp_layer addressing. Baking ' +
343
- 'clears any masked-filter refusal (gimp_layer op=move, gimp_canvas, gimp_resize_image, ' +
324
+ 'clears any masked-filter refusal (gimp_canvas, gimp_resize_image, ' +
344
325
  'gimp_transform_canvas) that filter would otherwise trigger — it is the sanctioned way ' +
345
- 'to make a masked adjustment safe to move, resize, rotate, or flip around. all: true ' +
326
+ 'to make a masked adjustment safe to resize, rotate, or flip around. all: true ' +
346
327
  'bakes every ordinary layer with a live filter at once; a group layer is always skipped, ' +
347
328
  'even one that carries its own filter (GIMP cannot merge filters on a group item) — ' +
348
329
  'reported under skipped_groups_with_filters rather than silently ignored. Targeting a ' +
@@ -0,0 +1,133 @@
1
+ import { GIMP_IMAGE_PROP, GIMP_LAYER_ID_PROP, GIMP_LAYER_PROP, runGimpTool, } from './gimp-shared.js';
2
+ const matchSchema = {
3
+ type: 'object',
4
+ properties: {
5
+ image: GIMP_IMAGE_PROP,
6
+ layer: {
7
+ ...GIMP_LAYER_PROP,
8
+ description: 'Name of the pasted layer to match. Defaults to the selected layer, or the topmost layer ' +
9
+ 'if none is selected. A layer inside a layer group can be named directly.',
10
+ },
11
+ layer_id: GIMP_LAYER_ID_PROP,
12
+ match: {
13
+ type: 'string',
14
+ enum: ['both', 'color', 'tone'],
15
+ default: 'both',
16
+ description: "'color' and 'both' write one curves filter each for red, green and blue (matching each " +
17
+ "channel's mean and spread also matches overall tone, so 'both' adds no separate value " +
18
+ "curve). 'tone' writes ONE value curve only, which moves brightness and contrast without " +
19
+ 'shifting the colour balance.',
20
+ },
21
+ strength: {
22
+ type: 'number',
23
+ minimum: 0,
24
+ maximum: 100,
25
+ default: 70,
26
+ description: 'How far to move toward the reference, 0-100. 0 changes nothing (identity curves); 100 ' +
27
+ 'matches the reference mean and spread (contrast gain is limited to 0.5-2x).',
28
+ },
29
+ reference: {
30
+ type: 'string',
31
+ enum: ['surround', 'below'],
32
+ default: 'surround',
33
+ description: "'surround': a ring of the composite beneath, just outside the layer's visible edge " +
34
+ "(the light the subject sits in). 'below': the whole composite beneath the layer's box.",
35
+ },
36
+ surround_px: {
37
+ type: 'integer',
38
+ minimum: 1,
39
+ maximum: 400,
40
+ description: "reference 'surround' only. Ring width in pixels. Default about 12% of the layer's " +
41
+ 'longer side, clamped to 16-400.',
42
+ },
43
+ edge_contract_px: {
44
+ type: 'integer',
45
+ minimum: 0,
46
+ maximum: 20,
47
+ default: 0,
48
+ description: "Pull the layer's visible edge in by this many pixels, through its layer mask (one is " +
49
+ 'made from its alpha if it has none) to hide a cut-out fringe. 0 leaves the edge alone.',
50
+ },
51
+ edge_feather_px: {
52
+ type: 'integer',
53
+ minimum: 0,
54
+ maximum: 50,
55
+ default: 0,
56
+ description: "Soften the layer's visible edge by this many pixels through its layer mask, after any " +
57
+ "contraction. 0 leaves the edge alone. The layer's own alpha is never changed.",
58
+ },
59
+ },
60
+ required: ['image'],
61
+ };
62
+ export function createGimpMatchTools(gimp) {
63
+ return [
64
+ {
65
+ tool: {
66
+ name: 'gimp_match_layer',
67
+ description: 'Headless GIMP: make a pasted or composited layer sit in its scene by pulling its colour ' +
68
+ "and tone toward the surrounding pixels. Measures the layer's visible pixels (alpha and " +
69
+ 'layer mask) against a reference region of everything below it, then adds LIVE curves ' +
70
+ 'filters named "Match red/green/blue" (match: both or color) or "Match value" (match: ' +
71
+ "tone) that move the layer's per-channel mean and spread toward the reference by " +
72
+ '`strength`. The filters are ordinary ones: list, hide, re-edit or delete them with ' +
73
+ 'gimp_filter and gimp_add_adjustment (filter_id); nothing bakes until gimp_export. ' +
74
+ 'Calling it again REPLACES the previous match filters on that layer instead of stacking ' +
75
+ 'them. The layer is measured as it renders with its other filters, so run it after the ' +
76
+ 'layer is placed, cut out and transformed. Optional edge_contract_px / edge_feather_px ' +
77
+ 'refine the layer mask to hide a cut-out fringe. Refuses a layer group, a text layer ' +
78
+ '(bake it first), a locked layer, a layer with no visible layers beneath it, and a ' +
79
+ 'region with fewer than 500 pixels. The result reports the filters made, each ' +
80
+ "channel's before/after mean and std (0-255; `after` is the map's expected value) and " +
81
+ 'the pixel counts measured.',
82
+ inputSchema: matchSchema,
83
+ outputSchema: {
84
+ type: 'object',
85
+ properties: {
86
+ layer: { type: 'string' },
87
+ layer_id: { type: 'number' },
88
+ match: { type: 'string' },
89
+ reference: { type: 'string' },
90
+ strength: { type: 'number' },
91
+ surround_px: { type: ['number', 'null'] },
92
+ filters: { type: 'array' },
93
+ replaced_filter_ids: { type: 'array' },
94
+ measured: { type: 'object' },
95
+ channels: { type: 'object' },
96
+ edge: { type: ['object', 'null'] },
97
+ },
98
+ },
99
+ annotations: {
100
+ title: 'Match GIMP Layer To Scene',
101
+ readOnlyHint: false,
102
+ destructiveHint: false,
103
+ idempotentHint: false,
104
+ openWorldHint: true,
105
+ },
106
+ },
107
+ handler: async (args) => runGimpTool({
108
+ gimp,
109
+ rawArgs: args,
110
+ schema: matchSchema,
111
+ op: 'match_layer',
112
+ errorPrefix: 'Error matching GIMP layer',
113
+ successText: (result) => {
114
+ const r = result;
115
+ const moved = Object.entries(r.channels)
116
+ .map(([channel, c]) => `${channel} ${c.before.mean.toFixed(0)} -> ${c.after.mean.toFixed(0)} ` +
117
+ `(reference ${c.reference.mean.toFixed(0)})`)
118
+ .join(', ');
119
+ return (`Matched "${r.layer}" to its ${r.reference} at ${r.strength}% (${r.match}): ` +
120
+ `${r.filters.length} curves filter(s)` +
121
+ (r.replaced_filter_ids.length
122
+ ? `, replacing ${r.replaced_filter_ids.length} earlier`
123
+ : '') +
124
+ `. Means ${moved}.` +
125
+ (r.edge
126
+ ? ` Edge: contracted ${r.edge.contract_px} px, feathered ${r.edge.feather_px} px via the layer mask.`
127
+ : ''));
128
+ },
129
+ }),
130
+ },
131
+ ];
132
+ }
133
+ export const MATCH_SCHEMA_FOR_TESTS = matchSchema;