editmamei 1.7.1 → 1.9.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 (45) hide show
  1. package/README.md +3 -3
  2. package/dist/backends/gimp/bridge/lib.py +1790 -26
  3. package/dist/backends/gimp/bridge/ops.py +3013 -168
  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/server.js +58 -13
  9. package/dist/core/settings.js +7 -0
  10. package/dist/core/tool-activity.js +15 -1
  11. package/dist/core/tool-groups.js +9 -1
  12. package/dist/core/tool-tiers.js +9 -1
  13. package/dist/delivery/client.js +3 -0
  14. package/dist/modules/gimp/index.js +12 -2
  15. package/dist/skills/editmamei-skill.zip +0 -0
  16. package/dist/telemetry/sanitize.js +15 -1
  17. package/dist/tools/adjustment-tools.js +9 -1
  18. package/dist/tools/document-tools.js +118 -21
  19. package/dist/tools/filter-tools.js +2 -1
  20. package/dist/tools/gimp-adjustment-tools.js +7 -6
  21. package/dist/tools/gimp-checkpoint-tools.js +7 -1
  22. package/dist/tools/gimp-core-tools.js +10 -11
  23. package/dist/tools/gimp-document-tools.js +100 -18
  24. package/dist/tools/gimp-effect-tools.js +56 -10
  25. package/dist/tools/gimp-fill-tools.js +256 -0
  26. package/dist/tools/gimp-filter-tools.js +4 -1
  27. package/dist/tools/gimp-geometry-tools.js +2 -2
  28. package/dist/tools/gimp-inspect-tools.js +48 -10
  29. package/dist/tools/gimp-layer-tools.js +7 -24
  30. package/dist/tools/gimp-match-tools.js +133 -0
  31. package/dist/tools/gimp-result-text.js +104 -0
  32. package/dist/tools/gimp-retouch-tools.js +238 -0
  33. package/dist/tools/gimp-selection-tools.js +374 -0
  34. package/dist/tools/gimp-shared.js +12 -0
  35. package/dist/tools/gimp-text-tools.js +241 -0
  36. package/dist/tools/gimp-transform-layer-tools.js +215 -0
  37. package/dist/tools/gimp-verify-tools.js +13 -15
  38. package/dist/tools/image-placement-tools.js +6 -1
  39. package/dist/tools/sky-tools.js +2 -1
  40. package/dist/utils/gimp-path.js +18 -22
  41. package/dist/utils/local-path.js +99 -0
  42. package/dist/utils/operation-timeouts.js +12 -2
  43. package/dist/version.js +1 -1
  44. package/package.json +5 -5
  45. package/dist/tools/gimp-mask-tools.js +0 -102
@@ -0,0 +1,104 @@
1
+ export const MAX_LISTED_LINES = 60;
2
+ export const MAX_PARAMS_CHARS = 240;
3
+ export const MAX_NAME_CHARS = 120;
4
+ export const MAX_LISTED_CHANNELS = 30;
5
+ export function quoted(name) {
6
+ const s = String(name ?? '');
7
+ return JSON.stringify(s.length > MAX_NAME_CHARS ? `${s.slice(0, MAX_NAME_CHARS)}...` : s);
8
+ }
9
+ function token(s) {
10
+ return /^[\w.:#+-]+$/.test(s) ? s : quoted(s);
11
+ }
12
+ function valueText(v) {
13
+ if (typeof v === 'number')
14
+ return String(Number.isInteger(v) ? v : Number(v.toFixed(3)));
15
+ if (typeof v === 'string')
16
+ return token(v);
17
+ if (typeof v === 'boolean')
18
+ return String(v);
19
+ return JSON.stringify(v);
20
+ }
21
+ export function paramsText(params, cap = MAX_PARAMS_CHARS) {
22
+ if (!params || typeof params !== 'object')
23
+ return '';
24
+ const text = Object.entries(params)
25
+ .filter(([k, v]) => v !== null && v !== undefined && k !== 'mask')
26
+ .map(([k, v]) => `${token(k)}=${valueText(v)}`)
27
+ .join(' ');
28
+ return text.length > cap ? `${text.slice(0, cap)}...` : text;
29
+ }
30
+ export function capLines(lines, max = MAX_LISTED_LINES) {
31
+ if (lines.length <= max)
32
+ return lines;
33
+ return [...lines.slice(0, max), `...and ${lines.length - max} more`];
34
+ }
35
+ export function filterLine(f) {
36
+ const kind = token(typeof f.type === 'string' && f.type ? f.type : String(f.operation ?? ''));
37
+ const hidden = f.visible === false ? ', hidden' : '';
38
+ const mask = f.mask ? `, mask ${quoted(f.mask)}` : '';
39
+ const source = String(f.source ?? 'unknown');
40
+ const sourceText = source === 'readback' ? 'readback (GEGL names and units)' : token(source);
41
+ const params = paramsText(f.params);
42
+ return (`#${String(f.filter_id)} ${quoted(f.name)} ${kind} on layer ${quoted(f.layer)} ` +
43
+ `(id ${String(f.layer_id)})${hidden}${mask}, ${sourceText}` +
44
+ (params ? `: ${params}` : ''));
45
+ }
46
+ function layerFlags(n) {
47
+ const flags = [];
48
+ if (n.is_group)
49
+ flags.push('group');
50
+ if (n.is_text_layer)
51
+ flags.push('text');
52
+ if (n.visible === false)
53
+ flags.push('hidden');
54
+ if (typeof n.opacity === 'number' && n.opacity < 100)
55
+ flags.push(`opacity ${valueText(n.opacity)}`);
56
+ if (typeof n.mode === 'string' && !n.mode.startsWith('normal'))
57
+ flags.push(`mode ${token(n.mode)}`);
58
+ return flags.length ? `, ${flags.join(', ')}` : '';
59
+ }
60
+ export function layerTreeLines(nodes) {
61
+ const lines = [];
62
+ const stack = [];
63
+ const push = (list, depth) => {
64
+ if (!Array.isArray(list))
65
+ return;
66
+ for (let i = list.length - 1; i >= 0; i--)
67
+ stack.push({ node: list[i], depth });
68
+ };
69
+ push(nodes, 0);
70
+ while (stack.length > 0) {
71
+ const { node, depth } = stack.pop();
72
+ lines.push(`${' '.repeat(depth)}- ${quoted(node.name)} (id ${String(node.layer_id)}${layerFlags(node)})`);
73
+ push(node.children, depth + 1);
74
+ }
75
+ return lines;
76
+ }
77
+ export function channelsText(channels) {
78
+ if (!Array.isArray(channels) || channels.length === 0)
79
+ return '(none)';
80
+ const named = channels
81
+ .slice(0, MAX_LISTED_CHANNELS)
82
+ .map((c) => typeof c.fraction === 'number'
83
+ ? `${quoted(c.name)} (${(c.fraction * 100).toFixed(1)}% selected)`
84
+ : quoted(c.name))
85
+ .join(', ');
86
+ const rest = channels.length - MAX_LISTED_CHANNELS;
87
+ return rest > 0 ? `${named}, ...and ${rest} more` : named;
88
+ }
89
+ function statsText(s) {
90
+ if (!s)
91
+ return 'n/a';
92
+ return (`mean=${valueText(s.mean)} median=${valueText(s.median)} ` +
93
+ `p5=${valueText(s.p5)} p95=${valueText(s.p95)}`);
94
+ }
95
+ export function regionStatsLines(a, b) {
96
+ const channels = Object.keys(a ?? {});
97
+ return channels.map((ch) => {
98
+ const sa = a?.[ch];
99
+ const sb = b?.[ch];
100
+ const delta = typeof sa?.mean === 'number' && typeof sb?.mean === 'number' ? sb.mean - sa.mean : undefined;
101
+ const deltaText = delta === undefined ? '' : `; B-A mean ${delta >= 0 ? '+' : ''}${valueText(delta)}`;
102
+ return `${ch}: A ${statsText(sa)} | B ${statsText(sb)}${deltaText}`;
103
+ });
104
+ }
@@ -0,0 +1,238 @@
1
+ import { GimpError } from '../backends/gimp/errors.js';
2
+ import { GIMP_IMAGE_PROP, GIMP_LAYER_ID_PROP, GIMP_LAYER_PROP, pickSchemaDeclaredKeys, runGimpTool, } from './gimp-shared.js';
3
+ export const RETOUCH_MAX_POINTS = 500;
4
+ export const RETOUCH_MAX_SPOTS = 50;
5
+ export const RETOUCH_MAX_BRUSH = 1000;
6
+ export const RETOUCH_MAX_SPOT_RADIUS = 500;
7
+ export const RETOUCH_MAX_STROKE_WORK = 2000000;
8
+ export const RETOUCH_MAX_SPOT_WORK = 200000;
9
+ const METHODS = ['heal', 'clone', 'spot_heal'];
10
+ const TARGETS = ['retouch_layer', 'in_place'];
11
+ const retouchSchema = {
12
+ type: 'object',
13
+ properties: {
14
+ image: GIMP_IMAGE_PROP,
15
+ layer: {
16
+ ...GIMP_LAYER_PROP,
17
+ description: 'Name of the pixel layer to retouch. Defaults to the selected layer, or the topmost layer ' +
18
+ 'if none is selected.',
19
+ },
20
+ layer_id: GIMP_LAYER_ID_PROP,
21
+ method: {
22
+ type: 'string',
23
+ enum: METHODS,
24
+ description: "'heal': paint a stroke that blends the sampled texture into its surroundings (blemishes, " +
25
+ "dust, scratches). 'clone': paint a stroke that copies the sampled pixels exactly. " +
26
+ "'spot_heal': round spots, each healed from an automatically chosen nearby source.",
27
+ },
28
+ points: {
29
+ type: 'array',
30
+ minItems: 1,
31
+ maxItems: RETOUCH_MAX_POINTS,
32
+ items: { type: 'array', minItems: 2, maxItems: 2, items: { type: 'number' } },
33
+ description: 'heal / clone: the stroke as [[x, y], ...] in image pixels (1-500 points), every one ' +
34
+ 'inside the layer.',
35
+ },
36
+ source: {
37
+ type: 'object',
38
+ properties: { x: { type: 'number' }, y: { type: 'number' } },
39
+ required: ['x', 'y'],
40
+ description: 'heal / clone: the image-pixel point to sample for points[0]; the rest of the stroke ' +
41
+ 'samples at the same offset. Give this OR source_offset.',
42
+ },
43
+ source_offset: {
44
+ type: 'object',
45
+ properties: { dx: { type: 'number' }, dy: { type: 'number' } },
46
+ required: ['dx', 'dy'],
47
+ description: 'heal / clone: sample this many pixels from each stroke point (source = point + offset). ' +
48
+ 'Give this OR source.',
49
+ },
50
+ brush_size: {
51
+ type: 'number',
52
+ minimum: 1,
53
+ maximum: RETOUCH_MAX_BRUSH,
54
+ default: 20,
55
+ description: 'heal / clone: brush diameter in pixels. spot_heal sizes its own brush.',
56
+ },
57
+ hardness: {
58
+ type: 'number',
59
+ minimum: 0,
60
+ maximum: 1,
61
+ description: 'heal / clone: brush edge hardness, 0 soft to 1 hard. Default 0.5 for heal, 0.8 for ' +
62
+ 'clone. Not allowed with spot_heal.',
63
+ },
64
+ source_layer_id: {
65
+ ...GIMP_LAYER_ID_PROP,
66
+ description: 'clone only: sample from this pixel layer instead of the one being painted. The source ' +
67
+ 'point plus the stroke must lie inside it.',
68
+ },
69
+ spots: {
70
+ type: 'array',
71
+ minItems: 1,
72
+ maxItems: RETOUCH_MAX_SPOTS,
73
+ items: {
74
+ type: 'object',
75
+ properties: { x: { type: 'number' }, y: { type: 'number' }, radius: { type: 'number' } },
76
+ required: ['x', 'y', 'radius'],
77
+ },
78
+ description: 'spot_heal: [{x, y, radius}] in image pixels (1-50 spots, radius 1-500). The source for ' +
79
+ 'each is picked from 8 directions at about 2.2x and 3.5x the radius, by how closely its ' +
80
+ 'brightness and texture match the ring around the spot. Every source is chosen before ' +
81
+ 'anything is painted, so a spot with no usable source fails the whole call unchanged.',
82
+ },
83
+ target: {
84
+ type: 'string',
85
+ enum: TARGETS,
86
+ default: 'retouch_layer',
87
+ description: "'retouch_layer': paint on a 'Retouch' copy directly above the layer (made on the first " +
88
+ 'call, reused while it stays directly above the layer it was made from); needs a Normal ' +
89
+ "mode, 100% opacity layer without a layer mask. 'in_place': paint the layer itself.",
90
+ },
91
+ },
92
+ required: ['image', 'method'],
93
+ };
94
+ const bad = (message) => {
95
+ throw new GimpError('invalid_argument', message);
96
+ };
97
+ function isNum(v) {
98
+ return typeof v === 'number' && Number.isFinite(v);
99
+ }
100
+ function checkXY(name, v, keys) {
101
+ const o = v;
102
+ if (!o || typeof o !== 'object' || !isNum(o[keys[0]]) || !isNum(o[keys[1]])) {
103
+ bad(`${name} must be {${keys[0]}, ${keys[1]}} with finite numbers`);
104
+ }
105
+ }
106
+ export function validateRetouchArgs(args) {
107
+ const method = args['method'];
108
+ if (method === 'spot_heal') {
109
+ const spots = args['spots'];
110
+ if (!Array.isArray(spots) || spots.length < 1 || spots.length > RETOUCH_MAX_SPOTS) {
111
+ bad(`spots must be a list of 1..${RETOUCH_MAX_SPOTS} spots for method spot_heal`);
112
+ }
113
+ spots.forEach((s, i) => {
114
+ checkXY(`spots[${i}]`, s, ['x', 'y']);
115
+ const r = s['radius'];
116
+ if (!isNum(r) || r < 1 || r > RETOUCH_MAX_SPOT_RADIUS) {
117
+ bad(`spots[${i}].radius must be within 1..${RETOUCH_MAX_SPOT_RADIUS}`);
118
+ }
119
+ });
120
+ for (const k of ['points', 'source', 'source_offset', 'source_layer_id', 'hardness']) {
121
+ if (args[k] !== undefined)
122
+ bad(`${k} does not apply to method spot_heal`);
123
+ }
124
+ const spotWork = spots.reduce((a, sp) => a + sp.radius ** 2, 0);
125
+ if (spotWork > RETOUCH_MAX_SPOT_WORK) {
126
+ bad('these spots are too large to heal in one call: split them into several calls or use ' +
127
+ `smaller radii (the sum of radius squared is ${spotWork}, the limit is ${RETOUCH_MAX_SPOT_WORK})`);
128
+ }
129
+ return;
130
+ }
131
+ const points = args['points'];
132
+ if (!Array.isArray(points) || points.length < 1 || points.length > RETOUCH_MAX_POINTS) {
133
+ bad(`points must be a list of 1..${RETOUCH_MAX_POINTS} [x, y] points for method ${String(method)}`);
134
+ }
135
+ points.forEach((p, i) => {
136
+ if (!Array.isArray(p) || p.length !== 2 || !isNum(p[0]) || !isNum(p[1])) {
137
+ bad(`points[${i}] must be [x, y] with finite numbers`);
138
+ }
139
+ });
140
+ const hasSource = args['source'] !== undefined;
141
+ const hasOffset = args['source_offset'] !== undefined;
142
+ if (hasSource === hasOffset)
143
+ bad('give exactly one of source {x, y} or source_offset {dx, dy}');
144
+ if (hasSource)
145
+ checkXY('source', args['source'], ['x', 'y']);
146
+ else
147
+ checkXY('source_offset', args['source_offset'], ['dx', 'dy']);
148
+ if (args['spots'] !== undefined)
149
+ bad(`spots does not apply to method ${String(method)}`);
150
+ const brush = args['brush_size'] ?? 20;
151
+ const pts = points;
152
+ let length = 0;
153
+ for (let i = 1; i < pts.length; i++) {
154
+ length += Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]);
155
+ }
156
+ const strokeWork = (length + brush) * brush;
157
+ if (strokeWork > RETOUCH_MAX_STROKE_WORK) {
158
+ bad('this stroke is too long for its brush size to paint in one call: split the stroke into ' +
159
+ 'several calls or use a smaller brush ' +
160
+ `(about ${Math.round(strokeWork)} px of path x brush, the limit is ${RETOUCH_MAX_STROKE_WORK})`);
161
+ }
162
+ if (method === 'heal' && args['source_layer_id'] !== undefined) {
163
+ bad('source_layer_id applies to method clone only');
164
+ }
165
+ }
166
+ export function createGimpRetouchTools(gimp) {
167
+ return [
168
+ {
169
+ tool: {
170
+ name: 'gimp_retouch',
171
+ description: 'Headless GIMP: heal or clone pixels with a round brush. method heal paints a stroke ' +
172
+ 'that samples texture from elsewhere and blends it into its surroundings, for blemishes, ' +
173
+ 'dust and scratches; clone paints a stroke that copies the sampled pixels exactly; ' +
174
+ "spot_heal takes round spots {x, y, radius} and picks each one's source automatically, " +
175
+ 'from the nearby area whose brightness and texture best match the ring around the spot. ' +
176
+ 'Everything is in image pixels. By default (target retouch_layer) the paint lands on a ' +
177
+ '"Retouch" copy of the layer directly above it, shown only where you painted, so the ' +
178
+ 'original and the picture everywhere else stay untouched; later calls reuse that copy ' +
179
+ 'while it stays directly above the layer it was made from. target in_place paints the ' +
180
+ 'layer itself, and is required for a layer that is below 100% opacity, not in Normal ' +
181
+ 'mode, or has a layer mask; a layer with semi-transparent pixels also shows its original ' +
182
+ 'through the copy, so use in_place there. The current selection is kept and does not clip the ' +
183
+ 'retouch. Refuses layer groups, text layers, locked or hidden layers, indexed images, ' +
184
+ 'and any point or spot outside the layer. A call has a work limit (a long stroke with a ' +
185
+ 'big brush, or many large spots): split it into several calls or use a smaller brush. ' +
186
+ 'Do NOT use for removing whole objects; a stroke or spot only blends in texture ' +
187
+ 'at the size of the brush. Returns layer_id, layer_name, created_layer, painted_bbox ' +
188
+ "[x, y, w, h] and, for spot_heal, each spot's chosen source offset and score (lower is " +
189
+ 'a closer match).',
190
+ inputSchema: retouchSchema,
191
+ outputSchema: {
192
+ type: 'object',
193
+ properties: {
194
+ layer_id: { type: 'number' },
195
+ layer_name: { type: 'string' },
196
+ created_layer: { type: 'boolean' },
197
+ method: { type: 'string' },
198
+ target: { type: 'string' },
199
+ painted_bbox: { type: 'array' },
200
+ spots: { type: 'array' },
201
+ brush_size: { type: 'number' },
202
+ hardness: { type: 'number' },
203
+ },
204
+ },
205
+ annotations: {
206
+ title: 'Retouch GIMP Layer',
207
+ readOnlyHint: false,
208
+ destructiveHint: true,
209
+ idempotentHint: false,
210
+ openWorldHint: true,
211
+ },
212
+ },
213
+ handler: async (args) => runGimpTool({
214
+ gimp,
215
+ rawArgs: args,
216
+ schema: retouchSchema,
217
+ op: 'retouch',
218
+ errorPrefix: 'Error retouching GIMP layer',
219
+ params: (validated) => {
220
+ validateRetouchArgs(validated);
221
+ return pickSchemaDeclaredKeys(retouchSchema, validated);
222
+ },
223
+ successText: (result) => {
224
+ const r = result;
225
+ const [x, y, w, h] = r.painted_bbox;
226
+ const what = r.spots
227
+ ? `${r.spots.length} spot(s) healed`
228
+ : r.method === 'clone'
229
+ ? 'cloned'
230
+ : 'healed';
231
+ return (`Retouch: ${what} on "${r.layer_name}" (layer ${r.layer_id}` +
232
+ `${r.created_layer ? ', new Retouch layer' : ''}), painted area ${w}x${h} at ${x},${y}.`);
233
+ },
234
+ }),
235
+ },
236
+ ];
237
+ }
238
+ export const RETOUCH_SCHEMA_FOR_TESTS = retouchSchema;