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.
- package/README.md +5 -4
- package/dist/backends/gimp/bridge/lib.py +833 -4
- package/dist/backends/gimp/bridge/ops.py +1943 -110
- package/dist/bin/editmamei-core-darwin-arm64 +0 -0
- package/dist/bin/editmamei-core-darwin-x64 +0 -0
- package/dist/bin/editmamei-core-win-x64.exe +0 -0
- package/dist/cli/config.js +7 -0
- package/dist/cli/help.js +2 -2
- package/dist/cli/install.js +2 -2
- package/dist/core/settings.js +7 -0
- package/dist/core/tool-activity.js +13 -1
- package/dist/core/tool-groups.js +7 -1
- package/dist/core/tool-tiers.js +7 -1
- package/dist/install-channel.js +2 -0
- package/dist/modules/gimp/index.js +8 -2
- package/dist/skills/editmamei-skill.zip +0 -0
- package/dist/telemetry/client.js +23 -16
- package/dist/telemetry/outbox.js +248 -28
- package/dist/telemetry/sanitize.js +15 -1
- package/dist/tools/adjustment-tools.js +9 -1
- package/dist/tools/document-tools.js +50 -9
- package/dist/tools/filter-tools.js +2 -1
- package/dist/tools/gimp-adjustment-tools.js +3 -3
- package/dist/tools/gimp-core-tools.js +7 -8
- package/dist/tools/gimp-document-tools.js +4 -1
- package/dist/tools/gimp-effect-tools.js +2 -2
- package/dist/tools/gimp-geometry-tools.js +2 -2
- package/dist/tools/gimp-inspect-tools.js +35 -5
- package/dist/tools/gimp-layer-tools.js +5 -24
- package/dist/tools/gimp-match-tools.js +133 -0
- package/dist/tools/gimp-selection-tools.js +374 -0
- package/dist/tools/gimp-shared.js +12 -0
- package/dist/tools/gimp-text-tools.js +181 -0
- package/dist/tools/gimp-transform-layer-tools.js +215 -0
- package/dist/tools/gimp-verify-tools.js +2 -13
- package/dist/tools/image-placement-tools.js +6 -1
- package/dist/tools/sky-tools.js +2 -1
- package/dist/update/check.js +2 -0
- package/dist/utils/gimp-path.js +18 -22
- package/dist/utils/local-path.js +99 -0
- package/dist/utils/operation-timeouts.js +7 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
- 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:
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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. '
|
|
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 |
|
|
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.
|
|
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 (
|
|
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
|
|
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;
|