editmamei 1.8.0 → 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.
@@ -8,6 +8,9 @@ const EFFECT_TYPES = [
8
8
  'lens_blur',
9
9
  'add_noise',
10
10
  'drop_shadow',
11
+ 'outline',
12
+ 'outer_glow',
13
+ 'surface_blur',
11
14
  ];
12
15
  const effectSchema = {
13
16
  type: 'object',
@@ -67,8 +70,11 @@ const effectSchema = {
67
70
  '(default 25 when creating) — capped much lower than the other two effects here (measured ' +
68
71
  'live: expensive at full resolution). drop_shadow: blur radius in pixels at full ' +
69
72
  'resolution, 0..1500 (default 10 when creating), clipped to the layer bounds rather than ' +
70
- 'growing it. Both lens_blur and drop_shadow are SPATIAL (radius-scaled on the proxy ' +
71
- 'preview, see gimp_overview). The bridge enforces the precise range for whichever ' +
73
+ 'growing it. outer_glow: glow blur in pixels, 1..250 (default 20 when creating). ' +
74
+ 'surface_blur: smoothing radius in pixels, 1..6 (default 4 when creating) — capped low ' +
75
+ 'because its cost grows with the square of the radius (a 24 MP export takes ~17 s at 6). ' +
76
+ 'lens_blur, drop_shadow, outer_glow and surface_blur are SPATIAL (radius-scaled on the ' +
77
+ 'proxy preview, see gimp_overview). The bridge enforces the precise range for whichever ' +
72
78
  '`type` you gave.',
73
79
  },
74
80
  softness: {
@@ -189,8 +195,38 @@ const effectSchema = {
189
195
  type: 'number',
190
196
  minimum: 0,
191
197
  maximum: 1,
192
- description: 'drop_shadow only. Default (when creating): 0.5. The shadow color is black and not ' +
193
- 'configurable yet.',
198
+ description: 'drop_shadow: default (when creating) 0.5. The shadow color is black and not ' +
199
+ 'configurable yet. outer_glow: default 0.75 (its color is the `color` field).',
200
+ },
201
+ width: {
202
+ type: 'number',
203
+ minimum: 1,
204
+ maximum: 50,
205
+ description: 'outline only. Stroke thickness in pixels at full resolution, 1..50. Default (when ' +
206
+ "creating): 4. SPATIAL. The stroke grows outward from the layer's alpha edge (round " +
207
+ "corners) and the layer's own pixels are untouched.",
208
+ },
209
+ spread: {
210
+ type: 'number',
211
+ minimum: 0,
212
+ maximum: 50,
213
+ description: "outer_glow only. Pixels the glow's solid core grows past the alpha edge before it " +
214
+ 'blurs, 0..50. Default (when creating): 4. SPATIAL.',
215
+ },
216
+ color: {
217
+ type: 'string',
218
+ pattern: '^#[0-9a-fA-F]{6}$',
219
+ description: 'outline and outer_glow only. "#rrggbb" hex. Default (when creating): "#000000" for ' +
220
+ 'outline, "#ffffff" for outer_glow.',
221
+ },
222
+ edge_preservation: {
223
+ type: 'number',
224
+ minimum: 0,
225
+ maximum: 1,
226
+ description: 'surface_blur only. How much edge contrast survives, 0..1: LOWER smooths harder (near 0 ' +
227
+ 'starts to soften the edges too), HIGHER leaves more detail and noise. Default (when ' +
228
+ 'creating): 0.08, which flattens fine noise and skin texture while keeping hard edges ' +
229
+ 'sharp.',
194
230
  },
195
231
  },
196
232
  required: ['image', 'type'],
@@ -222,7 +258,7 @@ export function createGimpEffectTools(gimp) {
222
258
  name: 'gimp_add_effect',
223
259
  description: 'Headless GIMP: append (or, with filter_id, re-edit in place) one of a small ' +
224
260
  'allow-listed set of GEGL effect filters — vignette, black_white, motion_blur, ' +
225
- 'lens_blur, add_noise, drop_shadow — the same non-destructive, re-editable, maskable ' +
261
+ 'lens_blur, add_noise, drop_shadow, outline, outer_glow, surface_blur — the same non-destructive, re-editable, maskable ' +
226
262
  "filter mechanism gimp_add_adjustment uses, sharing the SAME stack: gimp_filter's " +
227
263
  "op=list/set_visibility/delete manage this tool's filters exactly as it manages " +
228
264
  "gimp_add_adjustment's. `type` picks the effect; a field another effect uses is " +
@@ -237,9 +273,19 @@ export function createGimpEffectTools(gimp) {
237
273
  'created by Editmamei. vignette, motion_blur, and drop_shadow stay locked to the ' +
238
274
  'content through flip, resize, and an exact 90/180/270-degree rotate (their center/' +
239
275
  'angle/offset fields are updated automatically, and gimp_filter op=list reflects the ' +
240
- 'new values afterward) — rotating by any OTHER angle is refused while one of these ' +
241
- 'three is present, rather than letting it drift out of alignment. lens_blur is a ' +
242
- 'uniform soft-focus blur, not a depth-of-field falloff. For a plain blur with no bokeh ' +
276
+ 'new values afterward) — rotating by any OTHER angle is refused while a vignette, a ' +
277
+ 'motion_blur, or a drop_shadow with a non-zero offset is present, rather than letting ' +
278
+ 'it drift out of alignment. outline, outer_glow, surface_blur, lens_blur and a ' +
279
+ 'zero-offset drop_shadow have no direction, so they survive any rotation. Resizing ' +
280
+ 'scales their pixel sizes with the image (never below the 1 px minimum of outline ' +
281
+ 'width, outer_glow radius and surface_blur radius). lens_blur is a ' +
282
+ 'uniform soft-focus blur, not a depth-of-field falloff. outline puts a solid stroke ' +
283
+ "around a layer's transparent edge (meme text, sticker cut-outs); outer_glow adds a " +
284
+ 'soft glow around it (glowing text); surface_blur smooths skin and other texture ' +
285
+ 'while keeping hard edges. outline and outer_glow need a layer with transparency, ' +
286
+ 'and attach to a text layer without rasterizing it: the text stays editable and the ' +
287
+ 'effect follows each edit. For a plain ' +
288
+ 'blur with no bokeh ' +
243
289
  'highlight boost, use gimp_add_adjustment type=gaussian_blur instead. Check the stack ' +
244
290
  'afterward with gimp_filter (op=list).',
245
291
  inputSchema: effectSchema,
@@ -0,0 +1,256 @@
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 FILL_MIN_STOPS = 2;
4
+ export const FILL_MAX_STOPS = 8;
5
+ const OPS = ['solid', 'linear_gradient', 'radial_gradient'];
6
+ export const FILL_MODE_NAMES = [
7
+ 'normal',
8
+ 'multiply',
9
+ 'screen',
10
+ 'overlay',
11
+ 'soft_light',
12
+ 'hard_light',
13
+ 'darken',
14
+ 'lighten',
15
+ 'difference',
16
+ 'exclusion',
17
+ 'addition',
18
+ 'subtract',
19
+ 'divide',
20
+ 'dodge',
21
+ 'burn',
22
+ 'hue',
23
+ 'saturation',
24
+ 'color',
25
+ 'luminosity',
26
+ 'grain_extract',
27
+ 'grain_merge',
28
+ 'lch_color',
29
+ ];
30
+ export const FILL_MAX_COORD_PX = 300_000;
31
+ export const FILL_MAX_STRING_LEN = 255;
32
+ const MIN_AXIS_PX = 1;
33
+ const HEX = /^#[0-9a-fA-F]{6}$/;
34
+ const POINT_PROP = {
35
+ type: 'object',
36
+ properties: {
37
+ x: { type: 'number', minimum: -FILL_MAX_COORD_PX, maximum: FILL_MAX_COORD_PX },
38
+ y: { type: 'number', minimum: -FILL_MAX_COORD_PX, maximum: FILL_MAX_COORD_PX },
39
+ },
40
+ required: ['x', 'y'],
41
+ };
42
+ const fillSchema = {
43
+ type: 'object',
44
+ properties: {
45
+ image: GIMP_IMAGE_PROP,
46
+ op: {
47
+ type: 'string',
48
+ enum: OPS,
49
+ description: "'solid': one flat color. 'linear_gradient': a ramp from start to end. " +
50
+ "'radial_gradient': a ramp from a center out to a radius.",
51
+ },
52
+ layer: {
53
+ ...GIMP_LAYER_PROP,
54
+ description: 'Name of the layer the new fill layer goes directly above (inside its group). Defaults ' +
55
+ 'to the selected layer, or the topmost layer if none is selected.',
56
+ },
57
+ layer_id: GIMP_LAYER_ID_PROP,
58
+ name: {
59
+ type: 'string',
60
+ description: "New layer's name. Default 'Fill' for solid, 'Gradient' for a gradient.",
61
+ },
62
+ color: {
63
+ type: 'string',
64
+ pattern: '^#[0-9a-fA-F]{6}$',
65
+ description: 'solid only: the color as #rrggbb.',
66
+ },
67
+ stops: {
68
+ type: 'array',
69
+ minItems: FILL_MIN_STOPS,
70
+ maxItems: FILL_MAX_STOPS,
71
+ items: {
72
+ type: 'object',
73
+ properties: {
74
+ offset: { type: 'number', minimum: 0, maximum: 1 },
75
+ color: { type: 'string', pattern: '^#[0-9a-fA-F]{6}$' },
76
+ opacity: { type: 'number', minimum: 0, maximum: 100, default: 100 },
77
+ },
78
+ required: ['offset', 'color'],
79
+ },
80
+ description: 'Gradient only: 2-8 color stops {offset 0..1, color #rrggbb, opacity 0..100}, sorted by ' +
81
+ 'offset. Colors blend in sRGB (a black to white stop pair reads 128 at the midpoint). ' +
82
+ 'Before the first stop and after the last, the end colors hold.',
83
+ },
84
+ start: {
85
+ ...POINT_PROP,
86
+ description: 'linear_gradient: where offset 0 sits, in image pixels. Default top center of the canvas.',
87
+ },
88
+ end: {
89
+ ...POINT_PROP,
90
+ description: 'linear_gradient: where offset 1 sits, in image pixels. Default bottom center of the canvas.',
91
+ },
92
+ center: {
93
+ ...POINT_PROP,
94
+ description: 'radial_gradient: where offset 0 sits, in image pixels. Default canvas center.',
95
+ },
96
+ radius: {
97
+ type: 'number',
98
+ minimum: 1,
99
+ maximum: FILL_MAX_COORD_PX,
100
+ description: 'radial_gradient: the distance in pixels at which offset 1 is reached. Default half the ' +
101
+ 'canvas diagonal.',
102
+ },
103
+ mode: {
104
+ type: 'string',
105
+ enum: FILL_MODE_NAMES,
106
+ default: 'normal',
107
+ description: "The new layer's blend mode: gimp_layer's list plus 'lch_color'. 'color' is HSL Color; " +
108
+ "'lch_color' (LCh Color) keeps the lightness underneath exactly and swaps only hue and " +
109
+ 'chroma, the better choice for recoloring.',
110
+ },
111
+ opacity: {
112
+ type: 'number',
113
+ minimum: 0,
114
+ maximum: 100,
115
+ default: 100,
116
+ description: "The new layer's opacity, 0-100.",
117
+ },
118
+ mask: {
119
+ type: 'string',
120
+ description: "The name of a selection channel: becomes the new layer's layer mask, white " +
121
+ 'where the channel is selected, so the fill shows only there.',
122
+ },
123
+ },
124
+ required: ['image', 'op'],
125
+ };
126
+ const bad = (message) => {
127
+ throw new GimpError('invalid_argument', message);
128
+ };
129
+ function isNum(v) {
130
+ return typeof v === 'number' && Number.isFinite(v);
131
+ }
132
+ function checkPoint(name, v) {
133
+ const o = v;
134
+ if (!o || typeof o !== 'object' || !isNum(o['x']) || !isNum(o['y'])) {
135
+ return bad(`${name} must be {x, y} with finite numbers`);
136
+ }
137
+ if (Math.abs(o['x']) > FILL_MAX_COORD_PX || Math.abs(o['y']) > FILL_MAX_COORD_PX) {
138
+ bad(`${name} must be within +/-${FILL_MAX_COORD_PX} px`);
139
+ }
140
+ return { x: o['x'], y: o['y'] };
141
+ }
142
+ export function validateFillArgs(args) {
143
+ const op = args['op'];
144
+ for (const k of ['name', 'mask']) {
145
+ const v = args[k];
146
+ if (typeof v === 'string' && v.length > FILL_MAX_STRING_LEN) {
147
+ bad(`${k} must be at most ${FILL_MAX_STRING_LEN} characters`);
148
+ }
149
+ }
150
+ const only = (keys, why) => {
151
+ for (const k of keys)
152
+ if (args[k] !== undefined)
153
+ bad(`${k} does not apply to ${why}`);
154
+ };
155
+ if (op === 'solid') {
156
+ if (typeof args['color'] !== 'string' || !HEX.test(args['color'])) {
157
+ bad('color must be a color like #rrggbb for op solid');
158
+ }
159
+ only(['stops', 'start', 'end', 'center', 'radius'], 'op solid');
160
+ return;
161
+ }
162
+ only(['color'], 'a gradient (give stops)');
163
+ const stops = args['stops'];
164
+ if (!Array.isArray(stops) || stops.length < FILL_MIN_STOPS || stops.length > FILL_MAX_STOPS) {
165
+ bad(`stops must be a list of ${FILL_MIN_STOPS}..${FILL_MAX_STOPS} stops for op ${String(op)}`);
166
+ }
167
+ let previous = -1;
168
+ stops.forEach((s, i) => {
169
+ const o = s;
170
+ if (!o || typeof o !== 'object' || !isNum(o['offset']) || typeof o['color'] !== 'string') {
171
+ return bad(`stops[${i}] must be {offset, color, opacity?}`);
172
+ }
173
+ if (!HEX.test(o['color']))
174
+ bad(`stops[${i}].color must be a color like #rrggbb`);
175
+ if (o['offset'] < 0 || o['offset'] > 1)
176
+ bad(`stops[${i}].offset must be within 0..1`);
177
+ const opacity = o['opacity'];
178
+ if (opacity !== undefined && (!isNum(opacity) || opacity < 0 || opacity > 100)) {
179
+ bad(`stops[${i}].opacity must be within 0..100`);
180
+ }
181
+ if (o['offset'] < previous)
182
+ bad(`stops must be sorted by offset: stops[${i}] is out of order`);
183
+ previous = o['offset'];
184
+ });
185
+ if (op === 'linear_gradient') {
186
+ only(['center', 'radius'], 'op linear_gradient');
187
+ const start = args['start'] === undefined ? undefined : checkPoint('start', args['start']);
188
+ const end = args['end'] === undefined ? undefined : checkPoint('end', args['end']);
189
+ if (start && end && Math.hypot(end.x - start.x, end.y - start.y) < MIN_AXIS_PX) {
190
+ bad(`start and end must be at least ${MIN_AXIS_PX} pixel apart`);
191
+ }
192
+ }
193
+ else {
194
+ only(['start', 'end'], 'op radial_gradient');
195
+ if (args['center'] !== undefined)
196
+ checkPoint('center', args['center']);
197
+ const radius = args['radius'];
198
+ if (radius !== undefined && (!isNum(radius) || radius < 1 || radius > FILL_MAX_COORD_PX)) {
199
+ bad(`radius must be within 1..${FILL_MAX_COORD_PX} pixels`);
200
+ }
201
+ }
202
+ }
203
+ export function createGimpFillTools(gimp) {
204
+ return [
205
+ {
206
+ tool: {
207
+ name: 'gimp_fill',
208
+ description: 'Headless GIMP: add a NEW layer, canvas-sized, filled with a solid color (op solid), a ' +
209
+ 'linear gradient (start to end) or a radial gradient (center and radius) through 2-8 ' +
210
+ 'color stops. The layer goes directly above the target layer (inside its group), with ' +
211
+ 'the blend mode and opacity you give; mode lch_color tints without touching lightness, ' +
212
+ 'the usual choice for recoloring. Pass mask (a channel name) to confine the fill to ' +
213
+ 'that channel: it becomes the layer mask. Gradients blend in sRGB, are not dithered, ' +
214
+ 'and use image pixels for every point. The current selection is kept and does not clip ' +
215
+ 'the fill. Do NOT use for painting strokes or touching up pixels; use gimp_retouch ' +
216
+ 'for that. Returns layer_id, layer_name, op, mode, opacity and masked.',
217
+ inputSchema: fillSchema,
218
+ outputSchema: {
219
+ type: 'object',
220
+ properties: {
221
+ layer_id: { type: 'number' },
222
+ layer_name: { type: 'string' },
223
+ op: { type: 'string' },
224
+ mode: { type: 'string' },
225
+ opacity: { type: 'number' },
226
+ masked: { type: 'boolean' },
227
+ },
228
+ },
229
+ annotations: {
230
+ title: 'Fill GIMP Layer',
231
+ readOnlyHint: false,
232
+ destructiveHint: false,
233
+ idempotentHint: false,
234
+ openWorldHint: true,
235
+ },
236
+ },
237
+ handler: async (args) => runGimpTool({
238
+ gimp,
239
+ rawArgs: args,
240
+ schema: fillSchema,
241
+ op: 'fill',
242
+ errorPrefix: 'Error filling GIMP layer',
243
+ params: (validated) => {
244
+ validateFillArgs(validated);
245
+ return pickSchemaDeclaredKeys(fillSchema, validated);
246
+ },
247
+ successText: (result) => {
248
+ const r = result;
249
+ return (`Fill: ${r.op.replace('_', ' ')} on new layer "${r.layer_name}" (layer ${r.layer_id}), ` +
250
+ `${r.mode} at ${r.opacity}%${r.masked ? ', masked' : ''}.`);
251
+ },
252
+ }),
253
+ },
254
+ ];
255
+ }
256
+ export const FILL_SCHEMA_FOR_TESTS = fillSchema;
@@ -1,6 +1,7 @@
1
1
  import { validateArgs } from '../utils/validate.js';
2
2
  import { toolGimpErrorResult, unknownDiscriminator } from '../utils/tool-helpers.js';
3
3
  import { GIMP_IMAGE_PROP, pickSchemaDeclaredKeys } from './gimp-shared.js';
4
+ import { capLines, filterLine } from './gimp-result-text.js';
4
5
  const filterSchema = {
5
6
  type: 'object',
6
7
  properties: {
@@ -41,7 +42,9 @@ async function gimpFilter(gimp, rawArgs) {
41
42
  let text;
42
43
  if (op === 'list') {
43
44
  const filters = result.filters ?? [];
44
- text = `${filters.length} filter(s) on image ${args.image}.`;
45
+ text = `${filters.length} filter(s) on image ${args.image}, top of stack first.`;
46
+ if (filters.length > 0)
47
+ text += '\n' + capLines(filters.map(filterLine)).join('\n');
45
48
  }
46
49
  else if (op === 'set_visibility') {
47
50
  text = `Filter ${result.filter_id} visibility set to ${result.visible}.`;
@@ -1,6 +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
+ import { capLines, channelsText, filterLine, layerTreeLines } from './gimp-result-text.js';
4
5
  const INSPECT_WHATS = ['documents', 'document', 'layers', 'channels', 'filter', 'fonts'];
5
6
  const inspectSchema = {
6
7
  type: 'object',
@@ -42,28 +43,35 @@ const inspectSchema = {
42
43
  },
43
44
  required: ['what'],
44
45
  };
46
+ function withLayerTree(summary, layers) {
47
+ const lines = capLines(layerTreeLines(layers));
48
+ return lines.length > 0 ? `${summary}\n${lines.join('\n')}` : summary;
49
+ }
45
50
  function describeSummary(what, args, result) {
46
51
  const truncatedSuffix = result.truncated ? ' (truncated at the node cap)' : '';
47
52
  if (what === 'filter') {
48
- return `Filter ${result.filter_id} ("${result.name}"): ${result.operation}.`;
53
+ return filterLine(result);
49
54
  }
50
55
  if (what === 'layers') {
51
56
  const layers = result.layers ?? [];
52
57
  const topLevelCount = result.top_level_count ?? layers.length;
53
- return `${topLevelCount} top-level layer(s) on image ${args.image}${truncatedSuffix}.`;
58
+ return withLayerTree(`${topLevelCount} top-level layer(s) on image ${args.image}${truncatedSuffix}, top of stack first.`, layers);
54
59
  }
55
60
  if (what === 'channels') {
56
61
  const channels = result.channels ?? [];
57
62
  const skipped = result.channels_skipped;
58
63
  const stoppedEarlySuffix = result.truncated && skipped ? ` (stopped early, ${skipped} channel(s) skipped)` : '';
59
- return `${channels.length} channel(s) on image ${args.image}${stoppedEarlySuffix}.`;
64
+ return (`${channels.length} channel(s) on image ${args.image}${stoppedEarlySuffix}` +
65
+ (channels.length > 0 ? `: ${channelsText(channels)}.` : '.'));
60
66
  }
61
67
  const layers = result.layers ?? [];
62
68
  const channels = result.channels ?? [];
63
69
  const topLevelCount = result.top_level_count ?? layers.length;
64
- return (`Document ${result.image}: ${result.width}x${result.height} ${result.base_type} ` +
70
+ return withLayerTree(`Document ${result.image}: ${result.width}x${result.height} ${result.base_type} ` +
65
71
  `${result.precision}, ${topLevelCount} top-level layer(s)${truncatedSuffix}, ` +
66
- `${channels.length} channel(s).`);
72
+ `${channels.length} channel(s)` +
73
+ (channels.length > 0 ? `: ${channelsText(channels)}.` : '.') +
74
+ (layers.length > 0 ? ' Layers, top of stack first:' : ''), layers);
67
75
  }
68
76
  async function gimpInspect(gimp, rawArgs) {
69
77
  try {
@@ -128,6 +128,8 @@ const layerSchema = {
128
128
  'saturation',
129
129
  'color',
130
130
  'luminosity',
131
+ 'grain_extract',
132
+ 'grain_merge',
131
133
  ],
132
134
  description: 'set only. A fixed blend-mode list, not a raw GEGL/GIMP mode name.',
133
135
  },
@@ -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
+ }