editmamei 1.5.1 → 1.7.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 (46) hide show
  1. package/README.md +22 -1
  2. package/dist/backends/detect-editors.js +79 -0
  3. package/dist/backends/gimp/backend.js +99 -0
  4. package/dist/backends/gimp/bridge/lib.py +1988 -0
  5. package/dist/backends/gimp/bridge/ops.py +3120 -0
  6. package/dist/backends/gimp/detect.js +86 -0
  7. package/dist/backends/gimp/errors.js +21 -0
  8. package/dist/backends/gimp/launcher.js +6 -0
  9. package/dist/backends/gimp/session.js +632 -0
  10. package/dist/bin/editmamei-core-darwin-arm64 +0 -0
  11. package/dist/bin/editmamei-core-darwin-x64 +0 -0
  12. package/dist/bin/editmamei-core-win-x64.exe +0 -0
  13. package/dist/cli/config.js +28 -0
  14. package/dist/core/preview-privacy.js +54 -0
  15. package/dist/core/server.js +98 -49
  16. package/dist/core/settings.js +25 -0
  17. package/dist/core/tool-activity.js +122 -0
  18. package/dist/core/tool-groups.js +24 -0
  19. package/dist/core/tool-tiers.js +24 -0
  20. package/dist/diagnostics/collect.js +4 -0
  21. package/dist/index.js +5 -1
  22. package/dist/kernel/kernel.js +3 -0
  23. package/dist/modules/gimp/index.js +43 -0
  24. package/dist/skills/editmamei-skill.zip +0 -0
  25. package/dist/telemetry/activity.js +1 -93
  26. package/dist/tools/adjustment-tools.js +2 -1
  27. package/dist/tools/gimp-adjustment-tools.js +385 -0
  28. package/dist/tools/gimp-checkpoint-tools.js +541 -0
  29. package/dist/tools/gimp-compose-tools.js +399 -0
  30. package/dist/tools/gimp-core-tools.js +302 -0
  31. package/dist/tools/gimp-document-tools.js +321 -0
  32. package/dist/tools/gimp-effect-tools.js +267 -0
  33. package/dist/tools/gimp-filter-tools.js +94 -0
  34. package/dist/tools/gimp-geometry-tools.js +223 -0
  35. package/dist/tools/gimp-inspect-tools.js +157 -0
  36. package/dist/tools/gimp-layer-tools.js +385 -0
  37. package/dist/tools/gimp-mask-tools.js +102 -0
  38. package/dist/tools/gimp-shared.js +58 -0
  39. package/dist/tools/gimp-verify-tools.js +409 -0
  40. package/dist/utils/gimp-path.js +27 -0
  41. package/dist/utils/operation-timeouts.js +24 -0
  42. package/dist/utils/session-log.js +19 -1
  43. package/dist/utils/tool-helpers.js +14 -0
  44. package/dist/utils/validate.js +11 -0
  45. package/dist/version.js +1 -1
  46. package/package.json +3 -3
@@ -0,0 +1,1988 @@
1
+ # Pure, gi-free helpers for the GIMP bridge: PGM codec, histogram statistics,
2
+ # the `editmamei-filters` parasite ledger's parse/serialize, the request/
3
+ # response transport primitives (id/path derivation, atomic read/write, the
4
+ # request listing, and the request-processing pipeline itself), version-
5
+ # string parsing, and cross-platform process liveness.
6
+ #
7
+ # No `gi` import anywhere in this file — it runs standalone under
8
+ # `python -m unittest test_lib.py`, with no GIMP and no numpy. ops.py
9
+ # is exec'd by the bridge's batch line (not imported as a package), so it has
10
+ # no package context of its own to import a sibling module relatively; it
11
+ # adds its own directory to `sys.path` first and imports this module by name.
12
+ # "gi-free" describes the GIMP/GObject dependency, not I/O — the transport
13
+ # helpers below do real file I/O, same as any other stdlib code would.
14
+
15
+ import collections
16
+ import json
17
+ import math
18
+ import os
19
+ import re
20
+ import sys
21
+ import time
22
+ import traceback
23
+
24
+ LEDGER_VERSION = 1
25
+ LEDGER_WRITER = 'editmamei'
26
+
27
+ HIST_CHANNELS = ('luminance', 'red', 'green', 'blue')
28
+
29
+ # Sizes gimp_get_preview / the internal preview proxy may render at. Bounding
30
+ # this to a small fixed set keeps ops.py's PROXIES cache (one full duplicate
31
+ # of the document per (image, max_px) pair) at a bounded 3 entries per open
32
+ # image, instead of growing one entry per distinct value a caller ever asks
33
+ # for.
34
+ ALLOWED_PREVIEW_SIZES = (512, 1024, 2048)
35
+
36
+
37
+ def validate_max_px(value):
38
+ """Validate a requested preview/proxy render size. Raises ValueError for
39
+ anything outside ALLOWED_PREVIEW_SIZES; returns `value` unchanged otherwise."""
40
+ if value not in ALLOWED_PREVIEW_SIZES:
41
+ raise ValueError('max_px must be one of %s' % (ALLOWED_PREVIEW_SIZES,))
42
+ return value
43
+
44
+
45
+ # A region histogram is computed on the (cheap) preview proxy when the region maps to at
46
+ # least this many proxy pixels on a side; otherwise it falls back to a full-resolution crop.
47
+ # Below this, the proxy has too few samples for the requested rectangle to mean anything.
48
+ MIN_PROXY_REGION_PX = 64
49
+
50
+
51
+ # ---- adjustment `type` -> GEGL operation, and pure (gi-free) parameter validation ----------
52
+ #
53
+ # Every adjust `type` maps to exactly one GEGL/GIMP operation, and its numeric ranges are
54
+ # validated here (pure Python, unit-tested without GIMP) before ops.py ever touches a
55
+ # DrawableFilter config. Keys in the dicts these `build_*_params` functions return are the
56
+ # GEGL property names verbatim (hyphenated), so ops.py's setters can set them directly.
57
+
58
+ ADJUST_OPERATIONS = {
59
+ 'curves': 'gimp:curves',
60
+ 'levels': 'gimp:levels',
61
+ 'exposure': 'gegl:exposure',
62
+ 'brightness_contrast': 'gimp:brightness-contrast',
63
+ 'hue_saturation': 'gimp:hue-saturation',
64
+ 'color_balance': 'gimp:color-balance',
65
+ 'color_temperature': 'gegl:color-temperature',
66
+ 'shadows_highlights': 'gegl:shadows-highlights',
67
+ 'saturation': 'gegl:saturation',
68
+ 'vibrance': 'gegl:vibrance',
69
+ 'sharpen': 'gegl:unsharp-mask',
70
+ 'noise_reduction': 'gegl:noise-reduction',
71
+ 'gaussian_blur': 'gegl:gaussian-blur',
72
+ }
73
+
74
+ # Length-typed GEGL properties that must be multiplied by the preview proxy's scale factor
75
+ # when a filter is mirrored onto it (see ops.py's `_mirror_filters`) -- an explicit allow-list
76
+ # per operation, not a heuristic (e.g. "any property named radius"), since a wrong guess here
77
+ # would silently mis-scale a filter that isn't actually spatial. Each entry's scaled value is
78
+ # also clamped to the operation's own pspec minimum (`ops.py` reads that back from the live
79
+ # config) so a small enough source scale never leaves GObject silently keeping the pre-scale
80
+ # value instead of the (invalid, too-small) one we asked for.
81
+ SPATIAL_SCALE_PROPS = {
82
+ 'gegl:shadows-highlights': ('radius',),
83
+ 'gegl:unsharp-mask': ('std-dev',),
84
+ 'gegl:gaussian-blur': ('std-dev-x', 'std-dev-y'),
85
+ 'gegl:motion-blur-linear': ('length',),
86
+ 'gegl:focus-blur': ('blur-radius',),
87
+ 'gegl:dropshadow': ('x', 'y', 'radius'),
88
+ # gegl:vignette and gegl:mono-mixer are deliberately absent: vignette's radius/x/y are
89
+ # proportional (fractions of the LAYER's own size, not absolute pixel lengths), and
90
+ # mono-mixer is a per-pixel channel-weight filter with no spatial extent at all.
91
+ }
92
+
93
+ # Integer-valued properties that are scaled by the proxy factor too, but as a ROUNDED count
94
+ # rather than a continuous length -- `noise-reduction`'s `iterations` is a neighbourhood size,
95
+ # not a geometric radius, so scaling it is only an approximation of the full-res effect (unlike
96
+ # the exact scale-equivariance a Gaussian-blur-based radius gets); never below 1 (0 iterations
97
+ # is a no-op filter, which would silently discard the effect entirely on a small enough proxy).
98
+ INT_SPATIAL_SCALE_PROPS = {
99
+ 'gegl:noise-reduction': ('iterations',),
100
+ }
101
+
102
+ HUE_RANGES = ('all', 'red', 'yellow', 'green', 'cyan', 'blue', 'magenta')
103
+ TRANSFER_MODES = ('shadows', 'midtones', 'highlights')
104
+
105
+ # GIMP 3.2.6's file-tiff-export `compression` property is a plain string, not an introspectable
106
+ # enum -- these are the values that measured as accepted (an invalid string is silently ignored
107
+ # by GObject with a stderr warning and the export keeps its prior value, so a bad value here
108
+ # would export successfully with the WRONG compression rather than failing loudly). TIFF tag 259
109
+ # (Compression) values measured for each, on an 8-bit RGB export: none=1, lzw=5, packbits=32773,
110
+ # jpeg=7, adobe_deflate=8.
111
+ #
112
+ # `ccittfax3`/`ccittfax4` (CCITT Group 3/4) are deliberately NOT in this list: they are
113
+ # bilevel-only schemes, and applied to this engine's RGB content they measured as producing a
114
+ # degenerate ~8-byte file with no readable IFD at all -- a silent success reporting a byte count,
115
+ # not an error, for a genuinely broken output file. GIMP's own property still accepts the
116
+ # strings; this engine refuses them anyway rather than exposing an option two of whose seven
117
+ # values always produce a corrupt export.
118
+ TIFF_COMPRESSIONS = ('none', 'lzw', 'packbits', 'jpeg', 'adobe_deflate')
119
+
120
+ MASK_TYPES = ('rectangle', 'ellipse', 'gradient_linear', 'gradient_radial')
121
+
122
+ # gimp_layer's blend-mode allow-list: user-facing name -> the `Gimp.LayerMode` enum member NAME
123
+ # (a string; ops.py does `getattr(Gimp.LayerMode, LAYER_MODES[mode])` since this module stays
124
+ # gi-free). Deliberately the NON-legacy modes only, and only the ones with a plain Photoshop-style
125
+ # name -- GIMP 3.2.6 exposes ~30 more (LCH_*, *_LEGACY, DISSOLVE, BEHIND, ...) this tool doesn't
126
+ # surface. Probed live (GIMP 3.2.6) and goldened via each member's own `.value_nick`:
127
+ # normal=normal multiply=multiply screen=screen overlay=overlay soft_light=softlight
128
+ # hard_light=hardlight darken=darken-only lighten=lighten-only difference=difference
129
+ # exclusion=exclusion addition=addition subtract=subtract divide=divide dodge=dodge burn=burn
130
+ # hue=hsv-hue saturation=hsv-saturation color=hsl-color luminosity=luminance
131
+ # hue/saturation/color/luminosity map to the HSV/HSL (not LCH) variants -- those are the ones
132
+ # GIMP's own UI labels plainly "Hue"/"Saturation"/"Color"/"Luminosity" without a "(LCH)" suffix.
133
+ LAYER_MODES = {
134
+ 'normal': 'NORMAL',
135
+ 'multiply': 'MULTIPLY',
136
+ 'screen': 'SCREEN',
137
+ 'overlay': 'OVERLAY',
138
+ 'soft_light': 'SOFTLIGHT',
139
+ 'hard_light': 'HARDLIGHT',
140
+ 'darken': 'DARKEN_ONLY',
141
+ 'lighten': 'LIGHTEN_ONLY',
142
+ 'difference': 'DIFFERENCE',
143
+ 'exclusion': 'EXCLUSION',
144
+ 'addition': 'ADDITION',
145
+ 'subtract': 'SUBTRACT',
146
+ 'divide': 'DIVIDE',
147
+ 'dodge': 'DODGE',
148
+ 'burn': 'BURN',
149
+ 'hue': 'HSV_HUE',
150
+ 'saturation': 'HSV_SATURATION',
151
+ 'color': 'HSL_COLOR',
152
+ 'luminosity': 'LUMINANCE',
153
+ }
154
+
155
+
156
+ def validate_layer_mode(value):
157
+ if value not in LAYER_MODES:
158
+ raise ValueError('mode must be one of %s' % sorted(LAYER_MODES))
159
+ return value
160
+
161
+
162
+ # gimp_layer op=create's fill options -- verified live that `Gimp.FillType.TRANSPARENT` exists
163
+ # and that a freshly created layer's content is otherwise undefined, so `create` always fills
164
+ # explicitly rather than trusting whatever `Gimp.Layer.new` leaves behind.
165
+ LAYER_FILLS = ('white', 'black', 'transparent')
166
+
167
+ # gimp_create_document / gimp_convert_image_mode's base-type choices -- indexed is deliberately
168
+ # absent (gimp_create_document has nothing to build a palette from, and gimp_convert_image_mode
169
+ # refuses an indexed source outright; see ops.py's own comments on both).
170
+ IMAGE_MODES = ('rgb', 'grayscale')
171
+
172
+ # Formats `_export_stripped` (ops.py) will write; every export/preview/compare raster save goes
173
+ # through it, and it refuses any other extension outright rather than falling back to a bare,
174
+ # metadata-unaware save.
175
+ EXPORT_FORMATS = {'.jpg': 'jpeg', '.jpeg': 'jpeg', '.png': 'png', '.webp': 'webp',
176
+ '.tif': 'tiff', '.tiff': 'tiff'}
177
+
178
+ BIT_DEPTHS = (8, 16)
179
+
180
+ # A DoS floor for `resize`: bounds a single call's memory/time cost regardless of how large the
181
+ # SOURCE document already is (crop/rotate/flip only ever shrink-or-preserve the existing canvas,
182
+ # so they don't need this; resize is the one op that can grow it arbitrarily from a tiny source).
183
+ MAX_RESIZE_SIDE_PX = 30_000
184
+ MAX_RESIZE_MEGAPIXELS = 250
185
+
186
+ MAX_FEATHER_PX = 1000
187
+
188
+
189
+ def require(args, name):
190
+ """Fetch a required field from an op's `args`, raising ValueError naming it -- the
191
+ classifier maps that to `invalid_argument`. A bare `args[name]` raises KeyError instead,
192
+ which classifies as the much less actionable `gimp_op_failed`."""
193
+ if name not in args or args[name] is None:
194
+ raise ValueError('%s is required' % name)
195
+ return args[name]
196
+
197
+
198
+ def require_bool(args, name):
199
+ """Like `require`, but also rejects anything that isn't a real JSON boolean -- `bool("false")`
200
+ is `True` in Python (any non-empty string is truthy), so a caller that sends the STRING
201
+ "false" for e.g. `visible` would otherwise silently set it True instead of raising."""
202
+ value = require(args, name)
203
+ if not isinstance(value, bool):
204
+ raise ValueError('%s must be a boolean, got %r' % (name, value))
205
+ return value
206
+
207
+
208
+ def optional_bool(args, name, default=False):
209
+ """Like `require_bool`, but returns `default` when `name` is absent or explicitly null --
210
+ for an opt-in flag (e.g. `discard_hidden`, `all`) rather than one the caller must always
211
+ supply. Still rejects a non-boolean when the field IS present, the same `bool("false")`
212
+ trap `require_bool` guards against."""
213
+ if name not in args or args[name] is None:
214
+ return default
215
+ if not isinstance(args[name], bool):
216
+ raise ValueError('%s must be a boolean, got %r' % (name, args[name]))
217
+ return args[name]
218
+
219
+
220
+ def validate_range(name, value, lo, hi):
221
+ """Validate a float field is within [lo, hi]. Raises ValueError naming
222
+ `name` (which the bridge's error classifier maps to `invalid_argument`)."""
223
+ value = float(value)
224
+ if not lo <= value <= hi:
225
+ raise ValueError('%s must be within %s..%s' % (name, lo, hi))
226
+ return value
227
+
228
+
229
+ def validate_int_range(name, value, lo, hi):
230
+ value = int(value)
231
+ if not lo <= value <= hi:
232
+ raise ValueError('%s must be within %s..%s' % (name, lo, hi))
233
+ return value
234
+
235
+
236
+ def validate_choice(name, value, choices):
237
+ if value not in choices:
238
+ raise ValueError('%s must be one of %s' % (name, sorted(choices)))
239
+ return value
240
+
241
+
242
+ def validate_region(region, img_width, img_height):
243
+ """A `region` dict must be a rectangle with positive size, entirely within the image --
244
+ partly outside (a negative origin, or one edge past the image bounds) or fully outside both
245
+ raise ValueError naming the field, rather than silently clamping to whatever sliver of it
246
+ happens to overlap (a 1px-wide crop is a confusing result to debug, not a helpful default).
247
+ Returns (x, y, width, height) as ints."""
248
+ x = require(region, 'x')
249
+ y = require(region, 'y')
250
+ width = require(region, 'width')
251
+ height = require(region, 'height')
252
+ x, y, width, height = int(x), int(y), int(width), int(height)
253
+ if width <= 0 or height <= 0:
254
+ raise ValueError('region width and height must be positive')
255
+ if x < 0 or y < 0 or x + width > img_width or y + height > img_height:
256
+ raise ValueError(
257
+ 'region [%d,%d,%d,%d] must lie entirely within the %dx%d image'
258
+ % (x, y, width, height, img_width, img_height)
259
+ )
260
+ return x, y, width, height
261
+
262
+
263
+ def validate_resize_dims(width, height):
264
+ """A DoS floor on `resize`'s target dimensions: each side capped, and the product capped
265
+ separately (a very wide, very short target could pass a per-side check yet still allocate an
266
+ enormous buffer)."""
267
+ if width <= 0 or height <= 0:
268
+ raise ValueError('width and height must be positive')
269
+ if width > MAX_RESIZE_SIDE_PX or height > MAX_RESIZE_SIDE_PX:
270
+ raise ValueError('width and height must each be at most %d px' % MAX_RESIZE_SIDE_PX)
271
+ megapixels = (width * height) / 1_000_000.0
272
+ if megapixels > MAX_RESIZE_MEGAPIXELS:
273
+ raise ValueError('resize target must be at most %d MP' % MAX_RESIZE_MEGAPIXELS)
274
+ return width, height
275
+
276
+
277
+ # gimp_create_document's own megapixel ceiling, precision-aware: MAX_RESIZE_MEGAPIXELS (250) is
278
+ # sized for 8-bit-per-channel content; a 16-bit image is 2 bytes/channel (half the pixel budget
279
+ # for the same memory footprint, 125 MP) and a 32-bit float image is 4 bytes/channel (a quarter,
280
+ # 60 MP -- rounded down from the exact 62.5 to a plain number). `create_document` is the one op
281
+ # that picks its own bit depth up front (`open`/`resize`/etc. all work on whatever precision an
282
+ # already-open image happens to be), so it is the one place this DoS floor needs to vary by
283
+ # precision rather than assuming 8-bit throughout.
284
+ DOCUMENT_MEGAPIXEL_CAP = {'8': MAX_RESIZE_MEGAPIXELS, '16': 125, '32': 60}
285
+
286
+
287
+ def validate_document_dims(width, height, precision='8'):
288
+ """Like `validate_resize_dims`, but the megapixel ceiling depends on `precision` ('8', '16', or
289
+ '32') -- see `DOCUMENT_MEGAPIXEL_CAP`'s own comment for why create_document needs its own
290
+ variant instead of the plain 8-bit-assuming one."""
291
+ if width <= 0 or height <= 0:
292
+ raise ValueError('width and height must be positive')
293
+ if width > MAX_RESIZE_SIDE_PX or height > MAX_RESIZE_SIDE_PX:
294
+ raise ValueError('width and height must each be at most %d px' % MAX_RESIZE_SIDE_PX)
295
+ cap = DOCUMENT_MEGAPIXEL_CAP[precision]
296
+ megapixels = (width * height) / 1_000_000.0
297
+ if megapixels > cap:
298
+ raise ValueError('a %s-bit document must be at most %d MP' % (precision, cap))
299
+ return width, height
300
+
301
+
302
+ def validate_feather_px(value):
303
+ return validate_range('feather_px', value, 0.0, MAX_FEATHER_PX)
304
+
305
+
306
+ _HEX_COLOR_RE = re.compile(r'^#[0-9a-fA-F]{6}$')
307
+
308
+ # gimp_canvas's fill choices: the same white/black/transparent LAYER_FILLS a new layer gets, plus
309
+ # a #rrggbb hex color the fixed enum can't express -- validated by regex here since it's an open
310
+ # set, not a membership check like every other choice validator in this file.
311
+ CANVAS_FILLS = LAYER_FILLS
312
+
313
+
314
+ def validate_canvas_fill(value):
315
+ if value in CANVAS_FILLS or (isinstance(value, str) and _HEX_COLOR_RE.fullmatch(value)):
316
+ return value
317
+ raise ValueError(
318
+ "fill must be one of %s, or a '#rrggbb' hex color, got %r" % (sorted(CANVAS_FILLS), value)
319
+ )
320
+
321
+
322
+ # gimp_canvas's anchor grid -> the fraction of the GROWTH (new size minus old size) that lands
323
+ # BEFORE the existing content on each axis. 0.0 pins that edge (no padding there); 1.0 puts all
324
+ # the padding there instead; 0.5 splits it evenly. Keyed by the full 3x3 grid a Photoshop-style
325
+ # "Canvas Size" anchor picker offers.
326
+ _CANVAS_ANCHOR_FRACTIONS = {
327
+ 'top_left': (0.0, 0.0), 'top_center': (0.5, 0.0), 'top_right': (1.0, 0.0),
328
+ 'middle_left': (0.0, 0.5), 'center': (0.5, 0.5), 'middle_right': (1.0, 0.5),
329
+ 'bottom_left': (0.0, 1.0), 'bottom_center': (0.5, 1.0), 'bottom_right': (1.0, 1.0),
330
+ }
331
+ CANVAS_ANCHORS = tuple(_CANVAS_ANCHOR_FRACTIONS)
332
+
333
+
334
+ def canvas_anchor_offset(anchor, old_width, old_height, new_width, new_height):
335
+ """(offset_x, offset_y) for `Image.resize(new_width, new_height, offset_x, offset_y)` that
336
+ places the EXISTING old_width x old_height content at `anchor` within the new, larger canvas.
337
+ Pure arithmetic (gi-free) -- ops.py's `op_canvas` supplies the live image's own before/after
338
+ dimensions and passes the result straight to the bridge primitive."""
339
+ if anchor not in _CANVAS_ANCHOR_FRACTIONS:
340
+ raise ValueError('anchor must be one of %s' % sorted(_CANVAS_ANCHOR_FRACTIONS))
341
+ # Floor, not round: the growth is always >= 0 (gimp_canvas is extend-only), so floor is just
342
+ # `int()` truncation here -- picked over `round()` because Python's round() is round-half-to-
343
+ # EVEN (banker's rounding), which would silently flip which side of an odd split gets the
344
+ # extra pixel depending on whether that half-pixel count happens to be even or odd. floor()
345
+ # always gives the same, simpler rule: the leading edge (top/left) gets the smaller share.
346
+ fx, fy = _CANVAS_ANCHOR_FRACTIONS[anchor]
347
+ return (
348
+ math.floor((new_width - old_width) * fx),
349
+ math.floor((new_height - old_height) * fy),
350
+ )
351
+
352
+
353
+ def pct_to_unit(name, value, lo=-100.0, hi=100.0):
354
+ """User-facing -100..100 (or a narrower lo..hi) percent-like value -> the -1..1 unit
355
+ GEGL/GIMP properties in this family use."""
356
+ return validate_range(name, value, lo, hi) / 100.0
357
+
358
+
359
+ def degrees_to_unit(name, value, lo=-180.0, hi=180.0):
360
+ """User-facing -180..180 degrees -> the -1..1 unit `gimp:hue-saturation`'s `hue` uses."""
361
+ return validate_range(name, value, lo, hi) / 180.0
362
+
363
+
364
+ def resolve_field(args, user_key, defaults, gegl_key, convert):
365
+ """One GEGL-unit field for an `adjust` type: the caller's OWN value (converted from
366
+ user-facing units via `convert`) when `user_key` is present in `args`; otherwise whatever is
367
+ already in `defaults` under `gegl_key` -- the type's hardcoded creation defaults when a
368
+ filter is being CREATED, or the existing live filter's own ledger params when it's being
369
+ RE-EDITED by `filter_id`.
370
+
371
+ This is what makes a re-edit a MERGE rather than a reset: re-editing `{contrast: 50}` on a
372
+ brightness_contrast filter must not silently snap `brightness` back to 0 just because this
373
+ particular call didn't mention it."""
374
+ if user_key in args:
375
+ return convert(args[user_key])
376
+ return defaults[gegl_key]
377
+
378
+
379
+ def build_exposure_params(args, defaults):
380
+ # GEGL's own `exposure` pspec is unbounded (+/- DBL_MAX); +/-10 stops is a bound a real
381
+ # photograph never needs and keeps the field meaningful as a user-facing range.
382
+ return {
383
+ 'exposure': resolve_field(
384
+ args, 'exposure', defaults, 'exposure',
385
+ lambda v: validate_range('exposure', v, -10.0, 10.0),
386
+ ),
387
+ 'black-level': resolve_field(
388
+ args, 'black_level', defaults, 'black-level',
389
+ lambda v: validate_range('black_level', v, -0.1, 0.1),
390
+ ),
391
+ }
392
+
393
+
394
+ def build_brightness_contrast_params(args, defaults):
395
+ return {
396
+ 'brightness': resolve_field(
397
+ args, 'brightness', defaults, 'brightness', lambda v: pct_to_unit('brightness', v)
398
+ ),
399
+ 'contrast': resolve_field(
400
+ args, 'contrast', defaults, 'contrast', lambda v: pct_to_unit('contrast', v)
401
+ ),
402
+ }
403
+
404
+
405
+ def build_hue_saturation_params(args, defaults):
406
+ return {
407
+ 'range': resolve_field(
408
+ args, 'range', defaults, 'range', lambda v: validate_choice('range', v, HUE_RANGES)
409
+ ),
410
+ 'hue': resolve_field(args, 'hue', defaults, 'hue', lambda v: degrees_to_unit('hue', v)),
411
+ 'saturation': resolve_field(
412
+ args, 'saturation', defaults, 'saturation', lambda v: pct_to_unit('saturation', v)
413
+ ),
414
+ 'lightness': resolve_field(
415
+ args, 'lightness', defaults, 'lightness', lambda v: pct_to_unit('lightness', v)
416
+ ),
417
+ }
418
+
419
+
420
+ def build_color_balance_params(args, defaults):
421
+ # One filter targets one range (shadows/midtones/highlights), the same one-filter-per-facet
422
+ # idiom as curves' one-filter-per-channel -- a full three-range grade is three filters.
423
+ return {
424
+ 'range': resolve_field(
425
+ args, 'range', defaults, 'range', lambda v: validate_choice('range', v, TRANSFER_MODES)
426
+ ),
427
+ 'cyan-red': resolve_field(
428
+ args, 'cyan_red', defaults, 'cyan-red', lambda v: pct_to_unit('cyan_red', v)
429
+ ),
430
+ 'magenta-green': resolve_field(
431
+ args, 'magenta_green', defaults, 'magenta-green', lambda v: pct_to_unit('magenta_green', v)
432
+ ),
433
+ 'yellow-blue': resolve_field(
434
+ args, 'yellow_blue', defaults, 'yellow-blue', lambda v: pct_to_unit('yellow_blue', v)
435
+ ),
436
+ 'preserve-luminosity': resolve_field(
437
+ args, 'preserve_luminosity', defaults, 'preserve-luminosity', bool
438
+ ),
439
+ }
440
+
441
+
442
+ def build_color_temperature_params(args, defaults):
443
+ # `from_kelvin` (what the photo currently looks shot at) -> `original-temperature`;
444
+ # `to_kelvin` (the corrected target) -> `intended-temperature`.
445
+ return {
446
+ 'original-temperature': resolve_field(
447
+ args, 'from_kelvin', defaults, 'original-temperature',
448
+ lambda v: validate_range('from_kelvin', v, 1000.0, 12000.0),
449
+ ),
450
+ 'intended-temperature': resolve_field(
451
+ args, 'to_kelvin', defaults, 'intended-temperature',
452
+ lambda v: validate_range('to_kelvin', v, 1000.0, 12000.0),
453
+ ),
454
+ }
455
+
456
+
457
+ def build_shadows_highlights_params(args, defaults):
458
+ return {
459
+ 'shadows': resolve_field(
460
+ args, 'shadows', defaults, 'shadows', lambda v: validate_range('shadows', v, -100.0, 100.0)
461
+ ),
462
+ 'highlights': resolve_field(
463
+ args, 'highlights', defaults, 'highlights',
464
+ lambda v: validate_range('highlights', v, -100.0, 100.0),
465
+ ),
466
+ 'whitepoint': resolve_field(
467
+ args, 'whitepoint', defaults, 'whitepoint',
468
+ lambda v: validate_range('whitepoint', v, -10.0, 10.0),
469
+ ),
470
+ 'radius': resolve_field(
471
+ args, 'radius', defaults, 'radius', lambda v: validate_range('radius', v, 0.1, 1500.0)
472
+ ),
473
+ 'compress': resolve_field(
474
+ args, 'compress', defaults, 'compress', lambda v: validate_range('compress', v, 0.0, 100.0)
475
+ ),
476
+ 'shadows-ccorrect': resolve_field(
477
+ args, 'shadows_ccorrect', defaults, 'shadows-ccorrect',
478
+ lambda v: validate_range('shadows_ccorrect', v, 0.0, 100.0),
479
+ ),
480
+ 'highlights-ccorrect': resolve_field(
481
+ args, 'highlights_ccorrect', defaults, 'highlights-ccorrect',
482
+ lambda v: validate_range('highlights_ccorrect', v, 0.0, 100.0),
483
+ ),
484
+ }
485
+
486
+
487
+ def build_saturation_params(args, defaults):
488
+ return {
489
+ 'scale': resolve_field(
490
+ args, 'scale', defaults, 'scale', lambda v: validate_range('scale', v, 0.0, 10.0)
491
+ )
492
+ }
493
+
494
+
495
+ def build_vibrance_params(args, defaults):
496
+ return {
497
+ 'vibrance': resolve_field(
498
+ args, 'vibrance', defaults, 'vibrance', lambda v: validate_range('vibrance', v, -100.0, 100.0)
499
+ ),
500
+ 'saturation': resolve_field(
501
+ args, 'saturation', defaults, 'saturation', lambda v: validate_range('saturation', v, 0.0, 10.0)
502
+ ),
503
+ }
504
+
505
+
506
+ def build_sharpen_params(args, defaults):
507
+ return {
508
+ 'std-dev': resolve_field(
509
+ args, 'radius', defaults, 'std-dev', lambda v: validate_range('radius', v, 0.0, 1500.0)
510
+ ),
511
+ 'scale': resolve_field(
512
+ args, 'amount', defaults, 'scale', lambda v: validate_range('amount', v, 0.0, 300.0)
513
+ ),
514
+ 'threshold': resolve_field(
515
+ args, 'threshold', defaults, 'threshold', lambda v: validate_range('threshold', v, 0.0, 1.0)
516
+ ),
517
+ }
518
+
519
+
520
+ def build_noise_reduction_params(args, defaults):
521
+ return {
522
+ 'iterations': resolve_field(
523
+ args, 'strength', defaults, 'iterations',
524
+ lambda v: validate_int_range('strength', v, 1, 32),
525
+ )
526
+ }
527
+
528
+
529
+ def build_gaussian_blur_params(args, defaults):
530
+ # One user-facing `radius` drives both axes: a symmetric blur, the only kind the tool exposes.
531
+ # std-dev-x and std-dev-y always hold the same value, so the merge base can read either.
532
+ std_dev = resolve_field(
533
+ args, 'radius', defaults, 'std-dev-x', lambda v: validate_range('radius', v, 0.0, 1500.0)
534
+ )
535
+ return {'std-dev-x': std_dev, 'std-dev-y': std_dev}
536
+
537
+
538
+ def validate_levels(params):
539
+ """Cross-field checks for a resolved levels record (user units, 0-255 levels). The setter
540
+ range-checks each level on its own; these are the relationships it can't see: gamma within
541
+ the tool's 0.1..10 bound (GEGL's own pspec is wider, so an out-of-range value would otherwise
542
+ render), and an input range that isn't empty or inverted."""
543
+ validate_range('gamma', params['gamma'], 0.1, 10.0)
544
+ if not params['in_low'] < params['in_high']:
545
+ raise ValueError(
546
+ 'in_low (%s) must be less than in_high (%s)' % (params['in_low'], params['in_high'])
547
+ )
548
+ return params
549
+
550
+
551
+ ADJUST_PARAM_BUILDERS = {
552
+ 'exposure': build_exposure_params,
553
+ 'brightness_contrast': build_brightness_contrast_params,
554
+ 'hue_saturation': build_hue_saturation_params,
555
+ 'color_balance': build_color_balance_params,
556
+ 'color_temperature': build_color_temperature_params,
557
+ 'shadows_highlights': build_shadows_highlights_params,
558
+ 'saturation': build_saturation_params,
559
+ 'vibrance': build_vibrance_params,
560
+ 'sharpen': build_sharpen_params,
561
+ 'noise_reduction': build_noise_reduction_params,
562
+ 'gaussian_blur': build_gaussian_blur_params,
563
+ }
564
+
565
+ # Creation-time defaults, already in GEGL-property units -- the `defaults` a builder receives
566
+ # when there is no existing filter to re-edit (a fresh `resolve_field` call for every field
567
+ # then falls through to these, since the caller gave none of them).
568
+ ADJUST_CREATE_DEFAULTS = {
569
+ 'exposure': {'exposure': 0.0, 'black-level': 0.0},
570
+ 'brightness_contrast': {'brightness': 0.0, 'contrast': 0.0},
571
+ 'hue_saturation': {'range': 'all', 'hue': 0.0, 'saturation': 0.0, 'lightness': 0.0},
572
+ 'color_balance': {
573
+ 'range': 'midtones', 'cyan-red': 0.0, 'magenta-green': 0.0, 'yellow-blue': 0.0,
574
+ 'preserve-luminosity': True,
575
+ },
576
+ 'color_temperature': {'original-temperature': 6500.0, 'intended-temperature': 6500.0},
577
+ 'shadows_highlights': {
578
+ 'shadows': 0.0, 'highlights': 0.0, 'whitepoint': 0.0, 'radius': 100.0, 'compress': 50.0,
579
+ 'shadows-ccorrect': 100.0, 'highlights-ccorrect': 50.0,
580
+ },
581
+ 'saturation': {'scale': 1.0},
582
+ 'vibrance': {'vibrance': 0.0, 'saturation': 1.0},
583
+ 'sharpen': {'std-dev': 3.0, 'scale': 0.5, 'threshold': 0.0},
584
+ 'noise_reduction': {'iterations': 4},
585
+ 'gaussian_blur': {'std-dev-x': 1.5, 'std-dev-y': 1.5},
586
+ }
587
+
588
+
589
+ # ---- gimp_add_effect: allow-listed GEGL effect filters --------------------------------------
590
+ #
591
+ # The parallel tables to ADJUST_OPERATIONS/ADJUST_PARAM_BUILDERS/ADJUST_CREATE_DEFAULTS above,
592
+ # for `gimp_add_effect`'s `type` field rather than `gimp_add_adjustment`'s `type` field -- a
593
+ # DIFFERENT bridge op (`effect`, dispatched to `op_effect` in ops.py -- gimp_add_effect is its
594
+ # own tool, tier 'dev', a sibling of gimp_add_adjustment rather than an op on gimp_filter), so
595
+ # these stay separate dicts rather than merged into the ADJUST_* ones; only the GEGL-property
596
+ # SETTERS functions in ops.py are shared across both (an operation name is an operation name
597
+ # regardless of which tool created the filter).
598
+ #
599
+ # Deliberately excludes gegl:gaussian-blur (already ADJUST_OPERATIONS' `gaussian_blur`, see
600
+ # gimp-adjustment-tools.ts) and gegl:c2g: c2g excluded, ~35s full-res export at 24 MP (measured
601
+ # live), over the ~30s budget -- does not ship here.
602
+ EFFECT_OPERATIONS = {
603
+ 'vignette': 'gegl:vignette',
604
+ 'black_white': 'gegl:mono-mixer',
605
+ 'motion_blur': 'gegl:motion-blur-linear',
606
+ # gegl:focus-blur, NOT gegl:lens-blur -- see build_lens_blur_params' own comment: lens-blur
607
+ # exists in this GIMP's GEGL, but GIMP itself refuses to attach it as a non-destructive
608
+ # DrawableFilter (an 'aux'-pad operation), so it is unusable in this bridge's architecture.
609
+ 'lens_blur': 'gegl:focus-blur',
610
+ 'add_noise': 'gegl:noise-rgb',
611
+ 'drop_shadow': 'gegl:dropshadow',
612
+ }
613
+
614
+ # The inverse of ADJUST_OPERATIONS and EFFECT_OPERATIONS together, for a ledger record written
615
+ # without a `type` field -- one shared map, since `op_list_filters` looks a record's operation up
616
+ # here regardless of which tool (gimp_add_adjustment or gimp_add_effect) created it.
617
+ OPERATION_TYPES = {operation: type_ for type_, operation in ADJUST_OPERATIONS.items()}
618
+ OPERATION_TYPES.update({operation: type_ for type_, operation in EFFECT_OPERATIONS.items()})
619
+
620
+ # Every validate_range/validate_int_range call below uses `val`, not `v`, as its lambda's
621
+ # parameter name -- deliberately, so it is NOT matched by gimp-adjustment-tools.test.ts's own
622
+ # `parseLibPyBounds` regex (which requires the literal substring ", v,"). That regex scans this
623
+ # WHOLE file, not just ADJUST_PARAM_BUILDERS, so an accidental match here would silently fold a
624
+ # filter-effect bound into an unrelated adjust field's drift check (e.g. sharpen's `amount`) --
625
+ # `val` keeps the two files' schema-bounds-drift tests fully decoupled.
626
+
627
+
628
+ def build_vignette_params(args, defaults):
629
+ # Proportional/scale-invariant, relative to the LAYER's own extent (the drawable this filter
630
+ # attaches to, not the document canvas -- every GEGL op here runs per-drawable): x/y are a
631
+ # fraction of width/height. `radius`'s own reference measured live as NOT a single clean
632
+ # formula (e.g. a plain half-diagonal or half-width) across every aspect ratio tried -- rather
633
+ # than assert a specific geometric claim this bridge can't fully verify, the description
634
+ # instead states the one thing confirmed true and load-bearing for SPATIAL_SCALE_PROPS: it is
635
+ # relative, not absolute pixels, so the same value looks visually equivalent at any
636
+ # resolution. Unlike the spatial effects below, nothing here needs a SPATIAL_SCALE_PROPS entry
637
+ # -- the same value renders correctly on the preview proxy as at full resolution.
638
+ return {
639
+ 'radius': resolve_field(
640
+ args, 'radius', defaults, 'radius', lambda val: validate_range('radius', val, 0.0, 3.0)
641
+ ),
642
+ 'softness': resolve_field(
643
+ args, 'softness', defaults, 'softness',
644
+ lambda val: validate_range('softness', val, 0.0, 1.0),
645
+ ),
646
+ 'gamma': resolve_field(
647
+ args, 'gamma', defaults, 'gamma', lambda val: validate_range('gamma', val, 0.1, 10.0)
648
+ ),
649
+ 'x': resolve_field(
650
+ args, 'center_x', defaults, 'x', lambda val: validate_range('center_x', val, 0.0, 1.0)
651
+ ),
652
+ 'y': resolve_field(
653
+ args, 'center_y', defaults, 'y', lambda val: validate_range('center_y', val, 0.0, 1.0)
654
+ ),
655
+ }
656
+
657
+
658
+ def build_black_white_params(args, defaults):
659
+ # `preserve_luminosity`: when True, GEGL rescales the weighted sum so the output matches the
660
+ # ORIGINAL pixel's brightness even when the three weights don't sum to 1 -- verified live, a
661
+ # flat 128-gray input stays 128 with weights (0.1, 0.1, 0.1) and preserve=True, but comes back
662
+ # darker (measured 0x48) with the same weights and preserve=False. Weights summing to exactly
663
+ # 0 (e.g. 0,0,0) do NOT divide by zero or produce NaN either way -- verified live, GEGL returns
664
+ # plain black -- so no extra guard is needed here for that case.
665
+ return {
666
+ 'red': resolve_field(
667
+ args, 'red_weight', defaults, 'red',
668
+ lambda val: validate_range('red_weight', val, -5.0, 5.0),
669
+ ),
670
+ 'green': resolve_field(
671
+ args, 'green_weight', defaults, 'green',
672
+ lambda val: validate_range('green_weight', val, -5.0, 5.0),
673
+ ),
674
+ 'blue': resolve_field(
675
+ args, 'blue_weight', defaults, 'blue',
676
+ lambda val: validate_range('blue_weight', val, -5.0, 5.0),
677
+ ),
678
+ # require_bool, not a bare `bool()` cast (bool("false") is True in Python -- any
679
+ # non-empty string is truthy) -- resolve_field's own convert callback only ever sees the
680
+ # raw VALUE, not (args, name), so the merge-not-reset fallback is inlined here instead of
681
+ # going through resolve_field for this one field.
682
+ 'preserve-luminosity': (
683
+ require_bool(args, 'preserve_luminosity')
684
+ if 'preserve_luminosity' in args
685
+ else defaults['preserve-luminosity']
686
+ ),
687
+ }
688
+
689
+
690
+ def build_motion_blur_params(args, defaults):
691
+ return {
692
+ 'length': resolve_field(
693
+ args, 'length', defaults, 'length', lambda val: validate_range('length', val, 0.0, 1000.0)
694
+ ),
695
+ 'angle': resolve_field(
696
+ args, 'angle', defaults, 'angle', lambda val: validate_range('angle', val, -180.0, 180.0)
697
+ ),
698
+ }
699
+
700
+
701
+ def build_lens_blur_params(args, defaults):
702
+ # gegl:lens-blur (GIMP 3.2's GEGL does ship it) turned out unusable here -- verified live,
703
+ # `gimp-drawable-append-filter` refuses it outright ("effects with an 'aux' pad cannot be
704
+ # applied non-destructively"), so every call would silently attach nothing (no exception, no
705
+ # ledger record, filter count staying 0). gegl:focus-blur DOES attach; its blur amount
706
+ # property is named `blur-radius`, not `radius` (the external field name here stays `radius`
707
+ # regardless -- see EFFECT_CREATE_DEFAULTS). Two more properties are forced in ops.py's
708
+ # `_set_lens_blur`, not part of this dict and not user-configurable:
709
+ # - `radius` (an unrelated, same-named property sizing an in-focus zone that never blurs) is
710
+ # forced to 0, so the effect is a uniform blur, not a tilt-shift one.
711
+ # - `blur-type` is forced to 'lens' (its own default is 'gaussian', which would make this a
712
+ # second, redundant gaussian_blur and leave `highlight_factor` inert -- verified live:
713
+ # 'lens' mode produces a real bokeh highlight boost, a bright spot's halo reaching ~3x
714
+ # farther out at highlight_factor 1 vs 0, and 'lens' mode DOES attach as a non-destructive
715
+ # filter despite being the more elaborate mode -- the 'aux'-pad restriction that sank
716
+ # gegl:lens-blur does not apply inside this meta-operation).
717
+ # `blur-radius`'s cap is lowered from the union's 1500 to 150: 'lens' mode measured live as
718
+ # much more expensive than a separable blur (~35s for a full-res 24 MP export at radius 300,
719
+ # ~17s at 150), so the cap keeps the worst case comfortably under the ~30s budget every
720
+ # effect here is held to.
721
+ return {
722
+ 'blur-radius': resolve_field(
723
+ args, 'radius', defaults, 'blur-radius',
724
+ lambda val: validate_range('radius', val, 0.0, 150.0),
725
+ ),
726
+ 'highlight-factor': resolve_field(
727
+ args, 'highlight_factor', defaults, 'highlight-factor',
728
+ lambda val: validate_range('highlight_factor', val, 0.0, 1.0),
729
+ ),
730
+ }
731
+
732
+
733
+ def build_add_noise_params(args, defaults):
734
+ # One user-facing `noise_amount` drives red/green/blue uniformly, the same "one field, several
735
+ # identical GEGL properties" idiom `build_gaussian_blur_params` uses for std-dev-x/std-dev-y.
736
+ amount = resolve_field(
737
+ args, 'noise_amount', defaults, 'red',
738
+ lambda val: validate_range('noise_amount', val, 0.0, 1.0),
739
+ )
740
+ return {
741
+ 'red': amount,
742
+ 'green': amount,
743
+ 'blue': amount,
744
+ 'alpha': resolve_field(
745
+ args, 'alpha', defaults, 'alpha', lambda val: validate_range('alpha', val, 0.0, 1.0)
746
+ ),
747
+ 'seed': resolve_field(
748
+ args, 'seed', defaults, 'seed',
749
+ lambda val: validate_int_range('seed', val, 0, 4294967295),
750
+ ),
751
+ }
752
+
753
+
754
+ def build_drop_shadow_params(args, defaults):
755
+ # Only meaningful on a layer with an alpha channel -- a shadow is cast from what's transparent
756
+ # around the opaque content; on a fully opaque layer there is nothing for it to show through.
757
+ return {
758
+ 'x': resolve_field(
759
+ args, 'offset_x', defaults, 'x', lambda val: validate_range('offset_x', val, -500.0, 500.0)
760
+ ),
761
+ 'y': resolve_field(
762
+ args, 'offset_y', defaults, 'y', lambda val: validate_range('offset_y', val, -500.0, 500.0)
763
+ ),
764
+ 'radius': resolve_field(
765
+ args, 'radius', defaults, 'radius', lambda val: validate_range('radius', val, 0.0, 1500.0)
766
+ ),
767
+ 'opacity': resolve_field(
768
+ args, 'opacity', defaults, 'opacity', lambda val: validate_range('opacity', val, 0.0, 1.0)
769
+ ),
770
+ }
771
+
772
+
773
+ EFFECT_PARAM_BUILDERS = {
774
+ 'vignette': build_vignette_params,
775
+ 'black_white': build_black_white_params,
776
+ 'motion_blur': build_motion_blur_params,
777
+ 'lens_blur': build_lens_blur_params,
778
+ 'add_noise': build_add_noise_params,
779
+ 'drop_shadow': build_drop_shadow_params,
780
+ }
781
+
782
+ # Creation-time defaults, already in GEGL-property units -- probed live via `describe_operation`
783
+ # against GIMP 3.2.6's real GEGL pspecs, except vignette's radius and softness: GEGL's own 1.2/0.8
784
+ # renders the corners black, so these are chosen for a subtle vignette (corners about a quarter
785
+ # darker, measured on real photos).
786
+ EFFECT_CREATE_DEFAULTS = {
787
+ 'vignette': {'radius': 2.0, 'softness': 1.0, 'gamma': 2.0, 'x': 0.5, 'y': 0.5},
788
+ 'black_white': {'red': 0.333, 'green': 0.333, 'blue': 0.333, 'preserve-luminosity': False},
789
+ 'motion_blur': {'length': 10.0, 'angle': 0.0},
790
+ 'lens_blur': {'blur-radius': 25.0, 'highlight-factor': 0.0},
791
+ 'add_noise': {'red': 0.2, 'green': 0.2, 'blue': 0.2, 'alpha': 0.0, 'seed': 0},
792
+ 'drop_shadow': {'x': 20.0, 'y': 20.0, 'radius': 10.0, 'opacity': 0.5},
793
+ }
794
+
795
+
796
+ def _scaled(factor):
797
+ # Rounded so a GEGL value that went through a /100 or /180 on the way in reads back as the
798
+ # number the caller typed (0.2 * 100 is 20.000000000000004 in binary floating point). A value
799
+ # given with at most 6 decimals round-trips exactly; one with more comes back rounded to 6,
800
+ # a difference of about 1e-8 in GEGL units, far below one 8-bit level.
801
+ return lambda v: round(v * factor, 6)
802
+
803
+
804
+ def _same(v):
805
+ return v
806
+
807
+
808
+ # GEGL-unit ledger params -> the tool's own field names and units, per adjust type: the inverse
809
+ # of each build_*_params above, as (user_key, gegl_key, convert). `gimp_filter op=list` reports
810
+ # these, so a model can pass a listed value straight back on a re-edit. curves/levels already
811
+ # store user units.
812
+ USER_FIELDS = {
813
+ 'exposure': (
814
+ ('exposure', 'exposure', _same),
815
+ ('black_level', 'black-level', _same),
816
+ ),
817
+ 'brightness_contrast': (
818
+ ('brightness', 'brightness', _scaled(100)),
819
+ ('contrast', 'contrast', _scaled(100)),
820
+ ),
821
+ 'hue_saturation': (
822
+ ('range', 'range', _same),
823
+ ('hue', 'hue', _scaled(180)),
824
+ ('saturation', 'saturation', _scaled(100)),
825
+ ('lightness', 'lightness', _scaled(100)),
826
+ ),
827
+ 'color_balance': (
828
+ ('range', 'range', _same),
829
+ ('cyan_red', 'cyan-red', _scaled(100)),
830
+ ('magenta_green', 'magenta-green', _scaled(100)),
831
+ ('yellow_blue', 'yellow-blue', _scaled(100)),
832
+ ('preserve_luminosity', 'preserve-luminosity', _same),
833
+ ),
834
+ 'color_temperature': (
835
+ ('from_kelvin', 'original-temperature', _same),
836
+ ('to_kelvin', 'intended-temperature', _same),
837
+ ),
838
+ 'shadows_highlights': (
839
+ ('shadows', 'shadows', _same),
840
+ ('highlights', 'highlights', _same),
841
+ ('whitepoint', 'whitepoint', _same),
842
+ ('radius', 'radius', _same),
843
+ ('compress', 'compress', _same),
844
+ ('shadows_ccorrect', 'shadows-ccorrect', _same),
845
+ ('highlights_ccorrect', 'highlights-ccorrect', _same),
846
+ ),
847
+ 'saturation': (('scale', 'scale', _same),),
848
+ 'vibrance': (
849
+ ('vibrance', 'vibrance', _same),
850
+ ('saturation', 'saturation', _same),
851
+ ),
852
+ 'sharpen': (
853
+ ('radius', 'std-dev', _same),
854
+ ('amount', 'scale', _same),
855
+ ('threshold', 'threshold', _same),
856
+ ),
857
+ 'noise_reduction': (('strength', 'iterations', _same),),
858
+ 'gaussian_blur': (('radius', 'std-dev-x', _same),),
859
+ # ---- gimp_add_effect's effects (EFFECT_OPERATIONS, not ADJUST_OPERATIONS) ------------------
860
+ 'vignette': (
861
+ ('radius', 'radius', _same),
862
+ ('softness', 'softness', _same),
863
+ ('gamma', 'gamma', _same),
864
+ ('center_x', 'x', _same),
865
+ ('center_y', 'y', _same),
866
+ ),
867
+ 'black_white': (
868
+ ('red_weight', 'red', _same),
869
+ ('green_weight', 'green', _same),
870
+ ('blue_weight', 'blue', _same),
871
+ ('preserve_luminosity', 'preserve-luminosity', _same),
872
+ ),
873
+ 'motion_blur': (
874
+ ('length', 'length', _same),
875
+ ('angle', 'angle', _same),
876
+ ),
877
+ 'lens_blur': (
878
+ ('radius', 'blur-radius', _same),
879
+ ('highlight_factor', 'highlight-factor', _same),
880
+ ),
881
+ 'add_noise': (
882
+ ('noise_amount', 'red', _same),
883
+ ('alpha', 'alpha', _same),
884
+ ('seed', 'seed', _same),
885
+ ),
886
+ 'drop_shadow': (
887
+ ('offset_x', 'x', _same),
888
+ ('offset_y', 'y', _same),
889
+ ('radius', 'radius', _same),
890
+ ('opacity', 'opacity', _same),
891
+ ),
892
+ }
893
+
894
+ CURVES_USER_FIELDS = ('channel', 'points')
895
+ LEVELS_USER_FIELDS = ('channel', 'in_low', 'in_high', 'gamma', 'out_low', 'out_high')
896
+
897
+ # Keys every adjust/effect call may carry whatever its type: addressing, identity, and the mask.
898
+ FILTER_COMMON_KEYS = frozenset(('image', 'type', 'layer', 'layer_id', 'filter_id', 'mask', 'name'))
899
+
900
+
901
+ def type_fields(type_):
902
+ """The tool-facing field names `type_`'s builder reads (same tables `user_params` reports)."""
903
+ if type_ == 'curves':
904
+ return CURVES_USER_FIELDS
905
+ if type_ == 'levels':
906
+ return LEVELS_USER_FIELDS
907
+ return tuple(user for user, _gegl, _convert in USER_FIELDS.get(type_, ()))
908
+
909
+
910
+ def reject_foreign_fields(type_, args):
911
+ """Refuse any field `type_` does not read. The tool schemas are flat (one property per name
912
+ across every type), so without this a field meant for another type -- `saturation` on type
913
+ `saturation`, whose knob is `scale` -- validates, is ignored by the builder, and creates a
914
+ filter that does nothing. None values are ignored (an omitted field)."""
915
+ own = type_fields(type_)
916
+ foreign = sorted(
917
+ k for k, v in args.items()
918
+ if v is not None and k not in FILTER_COMMON_KEYS and k not in own
919
+ )
920
+ if foreign:
921
+ raise ValueError(
922
+ "type '%s' does not use field(s) %s; its fields are: %s"
923
+ % (type_, ', '.join(foreign), ', '.join(own) or '(none)')
924
+ )
925
+
926
+
927
+ def user_params(type_, params):
928
+ """A ledger record's `params` in the tool's own field names and units -- what `list` reports
929
+ for a filter this bridge created. `type_` may be None for a record written before `type` was
930
+ stored; the caller passes the type derived from the operation instead. The filter's `mask` is
931
+ reported separately, so it is not repeated here. Unknown types fall back to the raw params
932
+ (minus `mask`) rather than guessing."""
933
+ if type_ == 'curves':
934
+ return {k: params[k] for k in CURVES_USER_FIELDS if k in params}
935
+ if type_ == 'levels':
936
+ return {k: params[k] for k in LEVELS_USER_FIELDS if k in params}
937
+ fields = USER_FIELDS.get(type_)
938
+ if fields is None:
939
+ return {k: v for k, v in params.items() if k != 'mask'}
940
+ return {user: convert(params[gegl]) for user, gegl, convert in fields if gegl in params}
941
+
942
+
943
+ def json_safe(value):
944
+ """A readback property value made JSON-serialisable: plain JSON scalars pass through, lists
945
+ and dicts are walked, a GeglColor becomes its [r, g, b, a] (see below), and anything else (a
946
+ path object, bytes) becomes its str(). One foreign filter with such a property must not fail
947
+ `list` for the whole image."""
948
+ if isinstance(value, float) and not math.isfinite(value):
949
+ return str(value) # JSON has no NaN/Infinity; Node's JSON.parse rejects them
950
+ if value is None or isinstance(value, (bool, int, float, str)):
951
+ return value
952
+ if isinstance(value, (list, tuple)):
953
+ return [json_safe(v) for v in value]
954
+ if isinstance(value, dict):
955
+ return {str(k): json_safe(v) for k, v in value.items()}
956
+ # lib.py is gi-free (no `import gi`/`Gegl`), so a GeglColor can only be recognized by duck
957
+ # type, not `isinstance` -- the same reproducibility problem `op_describe_operation`'s own
958
+ # GeglColor handling exists to fix (a bare str() embeds a live object pointer address that
959
+ # differs every run, e.g. a foreign vignette/dropshadow filter's `color` property read back
960
+ # through `op_list_filters`), solved the same way: `get_rgba()` is the stable, meaningful
961
+ # summary. Any other object that merely happens to expose a zero-arg `get_rgba()` would be
962
+ # vanishingly unlikely and still degrades safely to a 4-number list.
963
+ get_rgba = getattr(value, 'get_rgba', None)
964
+ if callable(get_rgba):
965
+ try:
966
+ return [json_safe(c) for c in get_rgba()]
967
+ except Exception:
968
+ pass
969
+ return str(value)
970
+
971
+ # The only operations `describe_operation` will probe -- an allow-list, not "any GEGL/GIMP
972
+ # operation name the caller cares to ask about": the probe instantiates a real DrawableFilter,
973
+ # and an unbounded operation name is an unnecessary surface (arbitrary-op instantiation, error
974
+ # text from GIMP's own PDB) for a probe whose only real job is confirming the schema of the
975
+ # operations this engine actually uses.
976
+ ALLOWED_DESCRIBE_OPERATIONS = frozenset(ADJUST_OPERATIONS.values()) | frozenset(EFFECT_OPERATIONS.values())
977
+
978
+
979
+ # ---- geometry-op masked-filter detection (pure logic; ops.py supplies the live GIMP state) -----
980
+
981
+
982
+ def stale_ledger_names(filters, live_names):
983
+ """Names present in the ledger's `filters` dict that no longer match any LIVE filter name.
984
+ These must be pruned before a geometry op's masked-filter check runs, or a filter someone
985
+ deleted outside `filter op=delete` (the GUI, a foreign tool, a document edited elsewhere)
986
+ would go on blocking rotate/flip/resize forever for a filter that isn't even there."""
987
+ return set(filters) - set(live_names)
988
+
989
+
990
+ def classify_geometry_filters(filters, live_filters):
991
+ """Decide, for every LIVE filter currently on the image, whether it's masked, unmasked, or
992
+ impossible to verify -- the basis for `rotate`/`flip`/`resize`'s refusal check, which iterates
993
+ LIVE filters rather than trusting the ledger alone (a live filter the ledger doesn't know
994
+ about might still be masked; the ledger alone can't say either way).
995
+
996
+ `filters` is the ledger's own {name: {"operation":..., "params": {...}}} map (already pruned
997
+ of stale entries -- see `stale_ledger_names`). `live_filters` is an iterable of
998
+ (name, operation) pairs for every filter actually attached to a layer right now.
999
+
1000
+ A live filter counts as OURS only when the ledger has a record under the SAME name AND that
1001
+ record's operation matches the live filter's own operation (the same double-check
1002
+ `_existing_filter` uses elsewhere, so a name collision with a different operation is never
1003
+ mistaken for a match) -- for an ours filter, `masked` is decided by that record's own `mask`
1004
+ param. Anything else (no record at all, or a name/operation the ledger doesn't recognise) is
1005
+ reported `unverifiable`: it might be masked, and there is no way to tell, so the caller must
1006
+ treat it as if it were.
1007
+
1008
+ A name seen on MORE THAN ONE live filter is unverifiable too, regardless of what the ledger
1009
+ says: the ledger's {name: record} shape can only ever describe ONE of them, so a lookup by
1010
+ name can silently answer for the wrong filter -- a masked one hiding behind an unmasked one's
1011
+ ledger record (or the reverse) would otherwise slip through unnoticed. This bridge's own
1012
+ create path never produces a duplicate (`_unique_name` checks every layer first), but nothing
1013
+ stops a foreign filter (the GUI, a hand-edited document) from colliding with one this bridge
1014
+ DID create.
1015
+
1016
+ Returns (masked_names, unverifiable_names), both lists, in the order `live_filters` was
1017
+ given (a duplicate name appears in `unverifiable` once per occurrence, not de-duplicated, so
1018
+ the caller's own count of what it iterated still lines up)."""
1019
+ live_filters = list(live_filters)
1020
+ name_counts = {}
1021
+ for name, _operation in live_filters:
1022
+ name_counts[name] = name_counts.get(name, 0) + 1
1023
+ masked = []
1024
+ unverifiable = []
1025
+ for name, operation in live_filters:
1026
+ if name_counts[name] > 1:
1027
+ unverifiable.append(name)
1028
+ continue
1029
+ rec = filters.get(name)
1030
+ if rec and rec.get('operation') == operation:
1031
+ if rec.get('params', {}).get('mask'):
1032
+ masked.append(name)
1033
+ else:
1034
+ unverifiable.append(name)
1035
+ return masked, unverifiable
1036
+
1037
+
1038
+ def unique_name(taken, base):
1039
+ """`base`, or `base` suffixed " 2", " 3", ... until it is not in `taken` (an iterable of
1040
+ already-used names). GIMP allows duplicate names for both filters and layers, so nothing on
1041
+ the GIMP side stops a caller who doesn't check first -- this is what backs the "unique names
1042
+ enforced" contract for both the filter ledger (ops.py's `_unique_name`) and `gimp_layer`'s own
1043
+ layer naming (`_unique_layer_name`)."""
1044
+ taken = set(taken)
1045
+ name, n = base, 2
1046
+ while name in taken:
1047
+ name, n = '%s %d' % (base, n), n + 1
1048
+ return name
1049
+
1050
+
1051
+ # ---- geometry transforms for direction/position-dependent EFFECT params ---------------------
1052
+ #
1053
+ # Scoped to gimp_add_effect's new effects only: vignette (center_x/center_y), motion_blur
1054
+ # (angle), and drop_shadow (offset_x/offset_y, radius) have params whose MEANING is tied to a
1055
+ # direction or a position, which flip/rotate/resize can silently misalign relative to the
1056
+ # content unless the param itself is transformed the same way the pixels were. black_white and
1057
+ # add_noise are pure per-pixel operations (no direction or position at all) -- neither needs a
1058
+ # flip/rotate entry, and both are simply absent from those two tables below. lens_blur is a
1059
+ # symmetric radial blur (no direction either) but DOES scale under resize (see
1060
+ # resize_effect_params). Existing gimp_add_adjustment types (gaussian_blur, sharpen,
1061
+ # shadows_highlights) are NOT covered here -- a deliberate scope cut, not an oversight.
1062
+ #
1063
+ # ONLY exact cases are supported: flip (horizontal/vertical) for all three; rotate by an exact
1064
+ # right angle (0/90/180/270, mod 360) for all three; any uniform or anisotropic resize. Rotating
1065
+ # by anything else while one of these effects is present is REFUSED outright by ops.py's
1066
+ # `op_rotate` (see `is_right_angle_degrees`) rather than silently approximated -- vignette's own
1067
+ # param is a fraction of its LAYER's own extent (see build_vignette_params), and a layer's own
1068
+ # bounding box only has a well-defined "old vs new size" relationship at a right angle (swap at
1069
+ # 90/270, unchanged at 0/180); at an arbitrary angle a rotated rectangle's bounding box depends on
1070
+ # more than just the old width/height, so there is no single honest formula for the layer's own
1071
+ # new extent. Refusing is simpler and more honest than a formula that would be exact for two of
1072
+ # the three effects and wrong for the third.
1073
+ #
1074
+ # ops.py calls these BEFORE mutating the image at all (`_snapshot_effect_transform`): every
1075
+ # affected filter's new params are computed from a SNAPSHOT of the ledger and validated against
1076
+ # this bridge's own field ranges (`validate_effect_transform`) first, and the WHOLE geometry op is
1077
+ # refused up front if anything would land out of range -- only once every filter's new params are
1078
+ # known-valid does the geometry mutation itself run, followed by pushing the precomputed values
1079
+ # into the live GEGL config and the ledger. This also happens only once `_refuse_if_masked_
1080
+ # filters` has already confirmed every live filter is unmasked and ledgered.
1081
+
1082
+
1083
+ def _wrap_angle_deg(angle):
1084
+ """Wrap to the (-180, 180] range validate_range enforces for every `angle` field here."""
1085
+ wrapped = ((angle + 180.0) % 360.0) - 180.0
1086
+ if wrapped <= -180.0:
1087
+ wrapped += 360.0
1088
+ return wrapped
1089
+
1090
+
1091
+ def is_right_angle_degrees(degrees, tolerance=1e-6):
1092
+ """True when `degrees` is 0/90/180/270 (mod 360, either sign) within `tolerance` -- the only
1093
+ rotations op_rotate allows while a position/direction-dependent effect (vignette, motion_blur,
1094
+ drop_shadow) is present. See this section's own comment for why any other angle is refused
1095
+ rather than approximated."""
1096
+ normalized = degrees % 90.0
1097
+ return normalized < tolerance or normalized > 90.0 - tolerance
1098
+
1099
+
1100
+ def _dims_after_right_angle_rotation(width, height, degrees):
1101
+ """The new (width, height) of a rectangle rotated by an exact right angle about its own
1102
+ center: 90/270 (mod 360) swap the two; 0/180 leave them as they were. Only ever called with a
1103
+ `degrees` `is_right_angle_degrees` has already confirmed."""
1104
+ normalized = degrees % 180.0
1105
+ if abs(normalized - 90.0) < 1e-6:
1106
+ return height, width
1107
+ return width, height
1108
+
1109
+
1110
+ def _right_angle_k(degrees):
1111
+ """`degrees` as a whole number of 90-degree clockwise steps, 0-3. `is_right_angle_degrees`
1112
+ only confirms `degrees` is within floating-point TOLERANCE of an exact multiple of 90 (e.g.
1113
+ 89.9999999 or 90.0000003 both pass) -- rounding to the nearest integer multiple here snaps it
1114
+ to the exact canonical step before any trig happens, so that tolerance-level noise can never
1115
+ reach `_right_angle_cos_sin`."""
1116
+ return round(degrees / 90.0) % 4
1117
+
1118
+
1119
+ def _right_angle_cos_sin(degrees):
1120
+ """Exact cos/sin for a right-angle rotation, looked up from {0, 1, -1} rather than computed
1121
+ via `math.cos`/`math.sin(math.radians(...))` -- the latter returns a tiny nonzero (e.g.
1122
+ 6.123233995736766e-17) for cos(90 degrees) instead of an exact 0.0. Left uncorrected, that
1123
+ noise propagates into a value that should land EXACTLY on its pre-rotation number (a vignette
1124
+ center at a canvas edge, 0.0 or 1.0; a drop shadow offset at the schema's own bound, +/-500),
1125
+ and `gimp_filter op=list` would report something like 4.999999999999999e-17 or
1126
+ 500.00000000000006 instead of a clean value -- and, worse, could push a value that started
1127
+ exactly AT a validated bound just outside it."""
1128
+ k = _right_angle_k(degrees)
1129
+ cos_by_k = (1.0, 0.0, -1.0, 0.0)
1130
+ sin_by_k = (0.0, 1.0, 0.0, -1.0)
1131
+ return cos_by_k[k], sin_by_k[k]
1132
+
1133
+
1134
+ # How many decimal places a transformed float is rounded to before it is validated or written to
1135
+ # the ledger -- defence in depth alongside the exact right-angle trig above: resize's sqrt/hypot
1136
+ # math has genuine (much smaller) irrational rounding noise of its own that no lookup table can
1137
+ # remove, and this keeps every transform's output equally clean rather than exact-only for
1138
+ # rotation and merely "close" for resize.
1139
+ _TRANSFORM_ROUND_NDIGITS = 9
1140
+
1141
+
1142
+ def _round_transform_value(value):
1143
+ """A single transformed float, rounded to `_TRANSFORM_ROUND_NDIGITS` places. Applied ONLY to
1144
+ the specific field(s) a flip_effect_params/rotate_effect_params/resize_effect_params branch
1145
+ below actually computes -- never blanket-applied to a whole params dict. A value this module
1146
+ never touches (every gimp_add_adjustment type's own fields, since none of them are in this
1147
+ table; black_white/add_noise's fields; the other two effects' fields when only one of the
1148
+ three is present) must come back bit-for-bit IDENTICAL to its input: `_snapshot_effect_
1149
+ transform`'s own `new_params == params` check is what decides whether a filter needs
1150
+ re-applying at all, and rounding a value that was never actually touched would manufacture a
1151
+ spurious "this filter changed" for it -- re-setting, re-ledgering, and (if the live update ever
1152
+ failed) wrongly reporting it in `effect_update_failures`, on every single flip/rotate/resize
1153
+ regardless of what it actually did. hue_saturation's own `hue` (stored as degrees/180, a
1154
+ non-terminating binary fraction for most degree values) is exactly the kind of value a
1155
+ blanket, whole-dict round would perturb in its 9th-10th decimal place -- already enough for
1156
+ `==` to call it "changed" even though nothing about it should have moved at all."""
1157
+ return round(value, _TRANSFORM_ROUND_NDIGITS)
1158
+
1159
+
1160
+ def rotate_point_fraction(x_frac, y_frac, degrees, old_width, old_height, new_width, new_height):
1161
+ """Rotate a point expressed as a FRACTION of a layer's own width/height (vignette's center_x/
1162
+ center_y) by `degrees` -- always an exact right angle, see `is_right_angle_degrees` -- using
1163
+ the exact same clockwise-positive convention `ops.py`'s `op_rotate` applies to layer/channel
1164
+ pixels via `Item.transform_rotate`, re-expressing the result as a fraction of the
1165
+ possibly-different NEW extent (old/new either match or swap at a right angle).
1166
+
1167
+ Rotating a rectangle around its own center always leaves the new bounding box centered at
1168
+ that SAME physical point, so the old and new centers coincide regardless of whether the
1169
+ dimensions swapped -- the old center (in old-extent coordinates) and the new center (in
1170
+ new-extent coordinates) name the identical point in space, which is what lets this convert
1171
+ between the two coordinate frames with no separate translation term. Uses the EXACT cos/sin
1172
+ lookup (`_right_angle_cos_sin`), not raw trig, so a point already at an extent's edge (0.0 or
1173
+ 1.0) lands exactly back on an edge rather than a hair off it."""
1174
+ old_cx, old_cy = old_width / 2.0, old_height / 2.0
1175
+ new_cx, new_cy = new_width / 2.0, new_height / 2.0
1176
+ dx = x_frac * old_width - old_cx
1177
+ dy = y_frac * old_height - old_cy
1178
+ cos_t, sin_t = _right_angle_cos_sin(degrees)
1179
+ rx = dx * cos_t - dy * sin_t
1180
+ ry = dx * sin_t + dy * cos_t
1181
+ return (new_cx + rx) / new_width, (new_cy + ry) / new_height
1182
+
1183
+
1184
+ def flip_effect_params(operation, params, orientation):
1185
+ """New params for a ledgered effect filter's `operation` after a flip along `orientation`
1186
+ ('horizontal' or 'vertical'), so the effect stays locked to the content instead of the raw
1187
+ pixel grid. Verified live: vignette's `x`/`y` are plain image-pixel-convention fractions (0 =
1188
+ left/top edge, increasing right/down); motion-blur-linear's `angle` is 0 = along +x
1189
+ (horizontal), 90 = along +y (vertical, i.e. downward), increasing CLOCKWISE -- the same sense
1190
+ `op_rotate` uses; dropshadow's `x`/`y` are plain pixel offsets (positive = right/down).
1191
+ Operations with no direction-dependent param -- black_white, add_noise, lens_blur, and every
1192
+ OTHER operation this table has no branch for at all (every gimp_add_adjustment type included)
1193
+ -- come back as the EXACT SAME object, untouched: `_snapshot_effect_transform` compares the
1194
+ result to the input by `==` to decide whether a filter needs re-applying at all, so this must
1195
+ never manufacture a spurious difference (a stray rounding of a value it was never asked to
1196
+ change) for a filter it has nothing to say about."""
1197
+ if operation == 'gegl:vignette':
1198
+ params = dict(params)
1199
+ if orientation == 'horizontal':
1200
+ params['x'] = _round_transform_value(1.0 - params['x'])
1201
+ else:
1202
+ params['y'] = _round_transform_value(1.0 - params['y'])
1203
+ return params
1204
+ if operation == 'gegl:motion-blur-linear':
1205
+ params = dict(params)
1206
+ if orientation == 'horizontal':
1207
+ params['angle'] = _round_transform_value(_wrap_angle_deg(180.0 - params['angle']))
1208
+ else:
1209
+ params['angle'] = _round_transform_value(_wrap_angle_deg(-params['angle']))
1210
+ return params
1211
+ if operation == 'gegl:dropshadow':
1212
+ params = dict(params)
1213
+ if orientation == 'horizontal':
1214
+ params['x'] = _round_transform_value(-params['x'])
1215
+ else:
1216
+ params['y'] = _round_transform_value(-params['y'])
1217
+ return params
1218
+ return params
1219
+
1220
+
1221
+ def rotate_effect_params(operation, params, degrees, layer_width, layer_height):
1222
+ """New params for a ledgered effect filter's `operation` after a rotate by an exact right
1223
+ angle (0/90/180/270 mod 360 -- op_rotate refuses any other angle while a filter this table
1224
+ covers is present, see `is_right_angle_degrees`). `layer_width`/`layer_height` are the OWNING
1225
+ LAYER's own PRE-rotation dimensions (not the canvas/image's): vignette's center_x/center_y are
1226
+ a fraction of the LAYER's own extent (build_vignette_params), and a layer's own bounding box
1227
+ swaps width/height under a 90/270 rotation independently of whether the canvas itself grows
1228
+ (`expand`) to match. motion_blur and drop_shadow need no dimensions at all -- their params are
1229
+ layer-agnostic at a right angle (see flip_effect_params's own doc comment for the angle/offset
1230
+ conventions reused here, including why every OTHER operation -- every gimp_add_adjustment type
1231
+ included -- comes back as the exact same object, untouched). `degrees` is snapped to its exact
1232
+ canonical step (`_right_angle_k`) before use everywhere below, including the plain addition
1233
+ for motion_blur's angle -- not just where trig is involved -- so a tolerance-fuzzy `degrees`
1234
+ (e.g. 90.0000003) can never leak into a stored value."""
1235
+ if operation == 'gegl:vignette':
1236
+ params = dict(params)
1237
+ new_width, new_height = _dims_after_right_angle_rotation(layer_width, layer_height, degrees)
1238
+ x, y = rotate_point_fraction(
1239
+ params['x'], params['y'], degrees, layer_width, layer_height, new_width, new_height
1240
+ )
1241
+ params['x'], params['y'] = _round_transform_value(x), _round_transform_value(y)
1242
+ return params
1243
+ if operation == 'gegl:motion-blur-linear':
1244
+ params = dict(params)
1245
+ canonical_degrees = _right_angle_k(degrees) * 90.0
1246
+ params['angle'] = _round_transform_value(_wrap_angle_deg(params['angle'] + canonical_degrees))
1247
+ return params
1248
+ if operation == 'gegl:dropshadow':
1249
+ params = dict(params)
1250
+ cos_t, sin_t = _right_angle_cos_sin(degrees)
1251
+ dx, dy = params['x'], params['y']
1252
+ params['x'] = _round_transform_value(dx * cos_t - dy * sin_t)
1253
+ params['y'] = _round_transform_value(dx * sin_t + dy * cos_t)
1254
+ return params
1255
+ return params
1256
+
1257
+
1258
+ def resize_effect_params(operation, params, scale_x, scale_y):
1259
+ """New params for a ledgered effect filter's `operation` after a resize that scales width by
1260
+ `scale_x` and height by `scale_y` (independently -- gimp_resize_image's width+height form can
1261
+ stretch aspect).
1262
+
1263
+ motion_blur is a directional vector (length, angle), so an ANISOTROPIC resize (scale_x !=
1264
+ scale_y) changes both: treating the blur direction as a unit vector (cos(angle), sin(angle))
1265
+ in the same clockwise-positive, y-down convention flip_effect_params documents, its two
1266
+ components scale independently by (scale_x, scale_y); the resulting vector's own length and
1267
+ angle are the new length and angle -- length' = length * hypot(scale_x * cos(angle), scale_y *
1268
+ sin(angle)), angle' = atan2(scale_y * sin(angle), scale_x * cos(angle)). Exact for any
1269
+ scale_x/scale_y, and reduces to plain isotropic scaling (length * scale_x, angle unchanged)
1270
+ when scale_x == scale_y, since hypot(s*cos,s*sin) == s and atan2(s*sin,s*cos) == atan2(sin,cos)
1271
+ for any positive s.
1272
+
1273
+ lens_blur's `blur-radius` and drop_shadow's `radius` are ISOTROPIC (one scalar radius, no
1274
+ direction), scaled by the geometric mean of scale_x/scale_y -- exact when the resize is
1275
+ uniform, and the least-wrong single number when it isn't (a circular blur/shadow has no single
1276
+ exact radius under an anisotropic stretch). drop_shadow's offset_x/offset_y scale along their
1277
+ own axis exactly. vignette's radius/x/y are already proportional (a fraction of the layer's
1278
+ own, now-resized extent) and need no change at all -- absent from this table on purpose.
1279
+ black_white and add_noise have no absolute-pixel param either, and every OTHER operation this
1280
+ table has no branch for at all (every gimp_add_adjustment type included) comes back as the
1281
+ exact same object, untouched -- see flip_effect_params's own doc comment for why that matters."""
1282
+ if operation == 'gegl:motion-blur-linear':
1283
+ params = dict(params)
1284
+ theta = math.radians(params['angle'])
1285
+ vx = scale_x * math.cos(theta)
1286
+ vy = scale_y * math.sin(theta)
1287
+ params['length'] = _round_transform_value(params['length'] * math.hypot(vx, vy))
1288
+ params['angle'] = _round_transform_value(_wrap_angle_deg(math.degrees(math.atan2(vy, vx))))
1289
+ return params
1290
+ if operation == 'gegl:focus-blur':
1291
+ params = dict(params)
1292
+ isotropic_scale = math.sqrt(scale_x * scale_y)
1293
+ params['blur-radius'] = _round_transform_value(params['blur-radius'] * isotropic_scale)
1294
+ return params
1295
+ if operation == 'gegl:dropshadow':
1296
+ params = dict(params)
1297
+ isotropic_scale = math.sqrt(scale_x * scale_y)
1298
+ params['x'] = _round_transform_value(params['x'] * scale_x)
1299
+ params['y'] = _round_transform_value(params['y'] * scale_y)
1300
+ params['radius'] = _round_transform_value(params['radius'] * isotropic_scale)
1301
+ return params
1302
+ return params
1303
+
1304
+
1305
+ # Bounds for the GEGL properties a geometry transform can touch, keyed by operation -- the SAME
1306
+ # ranges build_vignette_params/build_motion_blur_params/build_lens_blur_params/
1307
+ # build_drop_shadow_params validate on create/re-edit, so a value THIS bridge computes from a
1308
+ # flip/rotate/resize can never silently land somewhere those entry points would have refused (a
1309
+ # plain GObject property setter would otherwise just clamp it there without telling anyone).
1310
+ EFFECT_TRANSFORM_BOUNDS = {
1311
+ 'gegl:vignette': {'x': (0.0, 1.0), 'y': (0.0, 1.0)},
1312
+ 'gegl:motion-blur-linear': {'length': (0.0, 1000.0), 'angle': (-180.0, 180.0)},
1313
+ 'gegl:focus-blur': {'blur-radius': (0.0, 150.0)},
1314
+ 'gegl:dropshadow': {'x': (-500.0, 500.0), 'y': (-500.0, 500.0), 'radius': (0.0, 1500.0)},
1315
+ }
1316
+
1317
+ # The user-facing (gimp_add_effect) field name for each EFFECT_TRANSFORM_BOUNDS entry -- so a
1318
+ # refusal message can say "center_x", "offset_x", or "radius" (what the model actually typed and
1319
+ # what gimp_filter op=list reports back), not the internal GEGL property name ("x") a caller has
1320
+ # never seen. TestEffectTransformBoundsMatchBuilders (test_lib.py) pins that every entry here maps
1321
+ # to a real field the matching build_*_params function actually validates.
1322
+ EFFECT_TRANSFORM_FIELD_NAMES = {
1323
+ ('gegl:vignette', 'x'): 'center_x',
1324
+ ('gegl:vignette', 'y'): 'center_y',
1325
+ ('gegl:motion-blur-linear', 'length'): 'length',
1326
+ ('gegl:motion-blur-linear', 'angle'): 'angle',
1327
+ ('gegl:focus-blur', 'blur-radius'): 'radius',
1328
+ ('gegl:dropshadow', 'x'): 'offset_x',
1329
+ ('gegl:dropshadow', 'y'): 'offset_y',
1330
+ ('gegl:dropshadow', 'radius'): 'radius',
1331
+ }
1332
+
1333
+
1334
+ def validate_effect_transform(op_name, operation, filter_name, new_params):
1335
+ """Refuse `op_name` (the geometry op about to run) outright if any of `new_params` -- already
1336
+ computed for `operation` by flip_effect_params/rotate_effect_params/resize_effect_params --
1337
+ would leave the range its own create/re-edit path enforces. Called BEFORE the geometry
1338
+ mutation runs (ops.py's `_snapshot_effect_transform`), so a filter that would end up out of
1339
+ range never gets a chance to silently clamp via GObject's own property setter instead of a
1340
+ clear, actionable refusal -- and the geometry op never partially applies. Names the filter
1341
+ (`filter_name`, the same way `_refuse_if_masked_filters` does) and the field in gimp_add_
1342
+ effect's OWN terms (`EFFECT_TRANSFORM_FIELD_NAMES`), not the raw GEGL property name."""
1343
+ for prop, (lo, hi) in EFFECT_TRANSFORM_BOUNDS.get(operation, {}).items():
1344
+ if prop not in new_params:
1345
+ continue
1346
+ value = new_params[prop]
1347
+ if not lo <= value <= hi:
1348
+ field = EFFECT_TRANSFORM_FIELD_NAMES.get((operation, prop), prop)
1349
+ # "the %s field of filter %r" rather than "%r's %s" -- the latter puts repr()'s own
1350
+ # closing quote directly against a literal possessive "'s", printing as a confusing
1351
+ # doubled apostrophe ("'Motion Blur''s"). %.6g (not %.4g): enough significant figures
1352
+ # that a value just barely out of range (1000.1 against a 1000.0 bound) still shows
1353
+ # the ".1" that IS the reason for the refusal, instead of rounding it away to "1000".
1354
+ raise ValueError(
1355
+ '%s would leave the %s field of filter %r at %.6g, outside its %s..%s range. '
1356
+ 'Delete it (gimp_filter op=delete) and re-add it after this geometry change.'
1357
+ % (op_name, field, filter_name, value, lo, hi)
1358
+ )
1359
+
1360
+
1361
+ def region_to_proxy_px(region, scale):
1362
+ """Map a document-pixel `region` dict into proxy-pixel integer bounds at the proxy's
1363
+ `scale` factor (0 < scale <= 1). Returns (x, y, width, height); width/height are at least
1364
+ 1px so a tiny region never rounds down to an empty crop. Assumes `region` was already
1365
+ validated (`validate_region`) against the FULL-RES image -- this only rescales it."""
1366
+ x = round(float(region['x']) * scale)
1367
+ y = round(float(region['y']) * scale)
1368
+ w = max(1, round(float(region['width']) * scale))
1369
+ h = max(1, round(float(region['height']) * scale))
1370
+ return x, y, w, h
1371
+
1372
+
1373
+ def read_pgm(raw):
1374
+ """Decode minimal binary PGM (P5, maxval 255) bytes. Returns (width, height, data).
1375
+
1376
+ Bounds-checked against `raw` running out before the four header fields
1377
+ (magic, width, height, maxval) are found — an empty file or one truncated
1378
+ mid-header used to spin forever: `b''.isspace()` is `False`, so the
1379
+ "scan until whitespace" loop read `raw[pos:pos+1]` past the end forever,
1380
+ each iteration seeing the same empty slice and treating it as "another
1381
+ non-whitespace byte" rather than "out of input"."""
1382
+ n = len(raw)
1383
+ fields, pos = [], 0
1384
+ while len(fields) < 4:
1385
+ while pos < n and raw[pos:pos + 1].isspace():
1386
+ pos += 1
1387
+ if pos >= n:
1388
+ raise ValueError('truncated PGM header')
1389
+ start = pos
1390
+ while pos < n and not raw[pos:pos + 1].isspace():
1391
+ pos += 1
1392
+ fields.append(raw[start:pos])
1393
+ if fields[0] != b'P5' or int(fields[3]) != 255:
1394
+ raise ValueError('mask must be a binary 8-bit PGM (P5, maxval 255)')
1395
+ w, h = int(fields[1]), int(fields[2])
1396
+ data = raw[pos + 1:pos + 1 + w * h]
1397
+ if len(data) != w * h:
1398
+ raise ValueError('truncated PGM')
1399
+ return w, h, data
1400
+
1401
+
1402
+ def write_pgm(w, h, data):
1403
+ """Encode (width, height, data) as binary PGM (P5, maxval 255) bytes."""
1404
+ return b'P5\n%d %d\n255\n' % (w, h) + bytes(data)
1405
+
1406
+
1407
+ def channel_stats(data):
1408
+ """mean/median/percentiles/256-bin histogram for one channel's raw byte data."""
1409
+ counts = collections.Counter(data)
1410
+ bins = [counts.get(v, 0) for v in range(256)]
1411
+ n = len(data)
1412
+
1413
+ def pct(p):
1414
+ target, acc = p * n, 0
1415
+ for v, c in enumerate(bins):
1416
+ acc += c
1417
+ if acc >= target:
1418
+ return v
1419
+ return 255
1420
+
1421
+ return {
1422
+ 'mean': round(sum(v * c for v, c in enumerate(bins)) / n, 3),
1423
+ 'median': pct(0.5),
1424
+ 'p1': pct(0.01),
1425
+ 'p5': pct(0.05),
1426
+ 'p95': pct(0.95),
1427
+ 'p99': pct(0.99),
1428
+ 'bins': bins,
1429
+ }
1430
+
1431
+
1432
+ # ---- editmamei-filters ledger (persistent image parasite) -------------------
1433
+
1434
+ _warned_messages = set()
1435
+
1436
+
1437
+ def _warn_once(message):
1438
+ """Log `message` to stderr, but never more than once per distinct
1439
+ message for the life of this process — a corrupt or foreign parasite is
1440
+ read on every op that touches live filters, and re-logging identically
1441
+ on each one would just be noise."""
1442
+ if message in _warned_messages:
1443
+ return
1444
+ _warned_messages.add(message)
1445
+ sys.stderr.write('%s\n' % message)
1446
+
1447
+
1448
+ def _decode_ledger_bytes(raw):
1449
+ """Normalize `raw` (str, bytes/bytearray, or falsy) to a str, or None if
1450
+ it can't be decoded as UTF-8. Bytes arrive here directly from a GIMP
1451
+ parasite's raw data — `.decode('utf-8')` on a corrupt/foreign parasite
1452
+ used to happen at the CALLER (ops.py), outside any try/except, which
1453
+ would take the whole op down instead of degrading to "ledger absent"."""
1454
+ if not raw:
1455
+ return ''
1456
+ if isinstance(raw, (bytes, bytearray)):
1457
+ try:
1458
+ return raw.decode('utf-8')
1459
+ except UnicodeDecodeError:
1460
+ return None
1461
+ return raw
1462
+
1463
+
1464
+ def _valid_filter_record(record):
1465
+ """A ledger filter record must be a dict with a str `operation` and a
1466
+ dict `params` — anything else (a stray string, a list, a dict missing
1467
+ one of those fields) is dropped as if the entry were never there, rather
1468
+ than crashing whatever later reads `record['operation']`."""
1469
+ return (
1470
+ isinstance(record, dict)
1471
+ and isinstance(record.get('operation'), str)
1472
+ and isinstance(record.get('params'), dict)
1473
+ )
1474
+
1475
+
1476
+ def _validate_filters(filters):
1477
+ valid = {}
1478
+ dropped = False
1479
+ for name, record in filters.items():
1480
+ if _valid_filter_record(record):
1481
+ valid[name] = record
1482
+ else:
1483
+ dropped = True
1484
+ if dropped:
1485
+ _warn_once('editmamei-filters parasite has a malformed filter record; skipping it')
1486
+ return valid
1487
+
1488
+
1489
+ def parse_ledger(raw):
1490
+ """Parse the parasite's raw data (str, or bytes/bytearray straight from
1491
+ `parasite.get_data()`) into (filters_by_name, unknown_top_level,
1492
+ raw_filters_by_name).
1493
+
1494
+ - No data (parasite absent): ({}, {}, {}).
1495
+ - Bytes that aren't valid UTF-8, unparsable JSON, or JSON that isn't an
1496
+ object: the whole ledger is treated as absent (logged once, not every
1497
+ call) rather than raising — a corrupt parasite must degrade to "no
1498
+ bridge-applied filters known" (every filter then falls back to
1499
+ readback), never take the op down. `ledger_is_undecodable` is how a
1500
+ caller distinguishes this case from a genuinely empty ledger before
1501
+ deciding whether a rewrite is safe to persist.
1502
+ - Versioned vs. legacy is decided by SHAPE, not by the presence of a "v"
1503
+ key alone: a dict with an int "v" AND a dict "filters" is versioned;
1504
+ anything else (including a legacy document that happens to contain a
1505
+ literal key named "v") is the pre-versioning format, where the
1506
+ parasite bytes ARE the filters map directly (no wrapper). Treated as
1507
+ v1-compatible so files written before the ledger was versioned keep
1508
+ working.
1509
+ - Versioned + "v" == LEDGER_VERSION: "filters" is the map; any OTHER
1510
+ top-level key is returned as `unknown_top_level` so a future writer's
1511
+ extra fields round-trip untouched through serialize_ledger.
1512
+ - Versioned + "v" != LEDGER_VERSION (in practice: greater, from a future
1513
+ writer): every filter is treated as absent — the caller falls back to
1514
+ readback for all of them, per the documented contract that we never
1515
+ guess at a schema we don't recognize. `unknown_top_level` still
1516
+ carries the non-filters keys through, but the caller must NOT persist
1517
+ a rewrite in this case (see ops.py's `_ledger_put`) — we don't
1518
+ understand the current document well enough to safely overwrite it.
1519
+ - Every returned filter record is individually shape-checked (dict, str
1520
+ `operation`, dict `params`) before landing in `filters_by_name`; a
1521
+ malformed one is dropped as if it were never in the ledger for READ
1522
+ purposes (logged once), but it still appears, untouched, in
1523
+ `raw_filters_by_name` — a rewrite that starts from the raw map instead
1524
+ of the validated one preserves malformed or foreign records verbatim
1525
+ rather than silently dropping them the next time this bridge writes
1526
+ the ledger.
1527
+ """
1528
+ text = _decode_ledger_bytes(raw)
1529
+ if text is None:
1530
+ _warn_once('editmamei-filters parasite is not valid UTF-8; treating as absent')
1531
+ return {}, {}, {}
1532
+ if not text:
1533
+ return {}, {}, {}
1534
+ try:
1535
+ doc = json.loads(text)
1536
+ except (ValueError, TypeError):
1537
+ _warn_once('editmamei-filters parasite is not valid JSON; treating as absent')
1538
+ return {}, {}, {}
1539
+ if not isinstance(doc, dict):
1540
+ _warn_once('editmamei-filters parasite is not a JSON object; treating as absent')
1541
+ return {}, {}, {}
1542
+ versioned = isinstance(doc.get('v'), int) and isinstance(doc.get('filters'), dict)
1543
+ if not versioned:
1544
+ raw_filters = dict(doc)
1545
+ return _validate_filters(raw_filters), {}, raw_filters
1546
+ unknown = {k: v for k, v in doc.items() if k not in ('v', 'writer', 'filters')}
1547
+ raw_filters = dict(doc['filters'])
1548
+ if doc['v'] != LEDGER_VERSION:
1549
+ return {}, unknown, raw_filters
1550
+ return _validate_filters(raw_filters), unknown, raw_filters
1551
+
1552
+
1553
+ def ledger_is_undecodable(raw):
1554
+ """True when the parasite's raw bytes can't be read as a ledger AT ALL —
1555
+ not valid UTF-8, not valid JSON, or valid JSON that isn't even an object
1556
+ — as opposed to a well-formed document this bridge just doesn't
1557
+ recognize every filter in (that's `ledger_is_newer_version`). Callers use
1558
+ this to refuse a rewrite entirely rather than overwrite bytes they never
1559
+ actually parsed: `parse_ledger`'s "treat as absent" degrade is safe to
1560
+ READ from (an op just gets no bridge-applied history), but overwriting
1561
+ those same bytes with a fresh `filters: {}` document would permanently
1562
+ lose whatever they actually were. Absent (`raw` falsy) is NOT
1563
+ undecodable — there is nothing there to lose."""
1564
+ text = _decode_ledger_bytes(raw)
1565
+ if text is None:
1566
+ return True
1567
+ if not text:
1568
+ return False
1569
+ try:
1570
+ doc = json.loads(text)
1571
+ except (ValueError, TypeError):
1572
+ return True
1573
+ return not isinstance(doc, dict)
1574
+
1575
+
1576
+ def ledger_is_newer_version(raw):
1577
+ """True when the parasite is a versioned document (int "v" + dict
1578
+ "filters") whose version is newer than this bridge understands. Callers
1579
+ use this to refuse to rewrite a document they can't safely round-trip —
1580
+ the filter still applies to the live image either way; only the
1581
+ persisted record is skipped. `raw` may be str or bytes/bytearray."""
1582
+ text = _decode_ledger_bytes(raw)
1583
+ if not text:
1584
+ return False
1585
+ try:
1586
+ doc = json.loads(text)
1587
+ except (ValueError, TypeError):
1588
+ return False
1589
+ if not isinstance(doc, dict):
1590
+ return False
1591
+ if not (isinstance(doc.get('v'), int) and isinstance(doc.get('filters'), dict)):
1592
+ return False
1593
+ return doc['v'] > LEDGER_VERSION
1594
+
1595
+
1596
+ def serialize_ledger(filters_by_name, unknown_top_level=None):
1597
+ """Serialize filters_by_name as the current-version ledger, preserving any
1598
+ unknown_top_level keys carried over from parse_ledger byte-for-byte
1599
+ (as JSON values -- not literal bytes, but nothing under them is rewritten)."""
1600
+ doc = dict(unknown_top_level or {})
1601
+ doc['v'] = LEDGER_VERSION
1602
+ doc['writer'] = LEDGER_WRITER
1603
+ doc['filters'] = filters_by_name
1604
+ return json.dumps(doc)
1605
+
1606
+
1607
+ def merged_ledger_for_write(raw, filters, unknown, removed=None):
1608
+ """Compute the bytes to persist as the editmamei-filters parasite, or
1609
+ None if the write should be SKIPPED entirely -- the caller's filter still
1610
+ applied to the live image either way; only the persisted record is
1611
+ skipped in that case (the caller logs it, this function just decides).
1612
+
1613
+ `raw` is the CURRENT parasite bytes, read fresh right before writing.
1614
+ `filters`/`unknown` are the caller's own updated view (e.g. `filters`
1615
+ with one new or edited record merged in) of what was read from this same
1616
+ `raw` a moment earlier, via `parse_ledger`. `removed`, if given, is an
1617
+ iterable of filter NAMES to drop even though `raw` (re-read fresh, and so
1618
+ possibly written by someone else since `filters` was derived) still has
1619
+ them -- a plain `dict.update` only ever ADDS or OVERWRITES keys present
1620
+ in `filters`; it can never express "this name is gone now," which is
1621
+ exactly what deleting a filter needs (the deleted name is simply absent
1622
+ from `filters`, and used to come back to life because `raw_filters`
1623
+ still had a copy of it and nothing ever told the merge to drop it).
1624
+
1625
+ Returns None (skip the write) when:
1626
+ - `raw` is a newer version than this bridge understands
1627
+ (`ledger_is_newer_version`) -- writing would silently downgrade a
1628
+ future writer's document to this bridge's own schema.
1629
+ - `raw` isn't decodable as a ledger at all (`ledger_is_undecodable`) --
1630
+ writing would permanently destroy bytes that were never actually
1631
+ parsed in the first place.
1632
+
1633
+ Otherwise, re-parses `raw` for its own `raw_filters_by_name` (which
1634
+ preserves a malformed or foreign record verbatim -- see `parse_ledger`),
1635
+ drops every name in `removed` from that, and returns the serialized
1636
+ ledger as UTF-8 bytes: those raw filters merged with `filters` (`filters`
1637
+ wins on a shared key, since it's the caller's own newer view), keeping
1638
+ `unknown` as the top-level passthrough fields. This is the ONE place
1639
+ that decides whether and how a rewrite happens -- callers (`ops.py`'s
1640
+ `_ledger_put`) just hand it bytes in and get bytes-or-None back, with no
1641
+ merge or skip logic of their own to keep in sync with this module's."""
1642
+ if ledger_is_newer_version(raw) or ledger_is_undecodable(raw):
1643
+ return None
1644
+ _valid, _existing_unknown, raw_filters = parse_ledger(raw)
1645
+ merged = dict(raw_filters)
1646
+ for name in (removed or ()):
1647
+ merged.pop(name, None)
1648
+ merged.update(filters)
1649
+ return serialize_ledger(merged, unknown).encode('utf-8')
1650
+
1651
+
1652
+ # ---- request/response transport ---------------------------------------------
1653
+
1654
+ _REQUEST_NAME_RE = re.compile(r'^req-(\d+)\.json$')
1655
+
1656
+
1657
+ def id_from_filename(path):
1658
+ """Recover the numeric id from a `req-<id>.json` path, or None if the
1659
+ basename doesn't match that naming convention."""
1660
+ match = _REQUEST_NAME_RE.match(os.path.basename(path))
1661
+ return int(match.group(1)) if match else None
1662
+
1663
+
1664
+ def response_path(rpc_dir, req_id):
1665
+ """Where the response for request `req_id` is written -- ALWAYS derived
1666
+ from the id and the rpc directory, never from anything inside the
1667
+ request body. The request used to carry its own `resp` path; a bridge
1668
+ that trusted it would let a malformed/malicious request steer a write
1669
+ anywhere else on disk, so the field is no longer read at all."""
1670
+ return os.path.join(rpc_dir, 'resp-%d.json' % req_id)
1671
+
1672
+
1673
+ def list_requests(rpc_dir):
1674
+ """`req-<id>.json` names in id order, silently skipping anything that
1675
+ doesn't match the naming convention rather than letting a stray file
1676
+ crash the sort (a bare int() on a non-numeric name would take the whole
1677
+ serve loop down with it)."""
1678
+ numbered = []
1679
+ for name in os.listdir(rpc_dir):
1680
+ if not (name.startswith('req-') and name.endswith('.json')):
1681
+ continue
1682
+ try:
1683
+ req_id = int(name[4:-5])
1684
+ except ValueError:
1685
+ continue
1686
+ numbered.append((req_id, name))
1687
+ numbered.sort()
1688
+ return [name for _req_id, name in numbered]
1689
+
1690
+
1691
+ def read_request(req_path, attempts=5, delay_s=0.02, on_retry=None):
1692
+ """Read + parse a request file. Retries ONLY on OSError (e.g. a
1693
+ PermissionError from anti-virus or a file indexer holding a brief lock on
1694
+ a just-renamed file on Windows) -- a JSONDecodeError means the content
1695
+ itself is bad and is raised immediately; retrying that would just mask a
1696
+ genuinely malformed file rather than a transient lock.
1697
+
1698
+ `on_retry(exc, attempt)`, if given, is called right before each sleep --
1699
+ the bridge logs the exception type through it; tests can assert on it
1700
+ without capturing stderr."""
1701
+ last_err = None
1702
+ for attempt in range(attempts):
1703
+ try:
1704
+ with open(req_path, encoding='utf-8') as fh:
1705
+ return json.load(fh)
1706
+ except json.JSONDecodeError:
1707
+ raise
1708
+ except OSError as e:
1709
+ last_err = e
1710
+ if on_retry:
1711
+ on_retry(e, attempt)
1712
+ time.sleep(delay_s)
1713
+ raise last_err
1714
+
1715
+
1716
+ def write_response(path, resp):
1717
+ """Atomic tmp+rename write. Falls back to a minimal, definitely-JSON-
1718
+ serializable error response if `resp` itself can't be encoded rather than
1719
+ leaving the caller's request hanging forever with no response file at
1720
+ all. Catches ANY exception from `json.dump`, not just TypeError -- a
1721
+ circular reference raises ValueError, a value outside JSON's numeric
1722
+ range raises OverflowError, and a raw GObject reference leaking out of an
1723
+ op's result could plausibly hit any of the three depending on where it
1724
+ ends up in the structure.
1725
+
1726
+ If even the fallback write fails (e.g. the response directory itself
1727
+ disappeared underneath us), this logs and returns rather than raising --
1728
+ letting the caller's request go unanswered is the same outcome either
1729
+ way, and raising here would take `serve()`'s loop down with it."""
1730
+ tmp = path + '.tmp'
1731
+ try:
1732
+ with open(tmp, 'w', encoding='utf-8') as fh:
1733
+ json.dump(resp, fh)
1734
+ os.replace(tmp, path)
1735
+ except Exception:
1736
+ try:
1737
+ fallback = {
1738
+ 'id': resp.get('id'),
1739
+ 'ok': False,
1740
+ 'code': 'gimp_op_failed',
1741
+ 'error': 'response not serialisable: %s' % type(resp.get('result')).__name__,
1742
+ }
1743
+ with open(tmp, 'w', encoding='utf-8') as fh:
1744
+ json.dump(fallback, fh)
1745
+ os.replace(tmp, path)
1746
+ except Exception as e:
1747
+ sys.stderr.write('write_response: fallback write also failed for %s: %s\n' % (path, e))
1748
+
1749
+
1750
+ def _is_within(path, directory):
1751
+ """True if `path` resolves to somewhere inside `directory` (both
1752
+ resolved through symlinks). Used to gate a delete driven by an
1753
+ externally-derived path -- a `req_path` is always constructed by this
1754
+ module from `rpc_dir` in practice, but `process_request` also accepts it
1755
+ as a bare argument, so this is the check that keeps a future caller (or a
1756
+ symlink planted in the rpc directory) from ever turning that delete into
1757
+ one outside it.
1758
+
1759
+ `os.path.commonpath` raises ValueError when the two paths don't even
1760
+ share a root (e.g. `C:\\...` vs `D:\\...` on Windows) rather than just
1761
+ returning a path that fails the equality check below -- treated the same
1762
+ as any other "can't confirm this is inside" case: not within."""
1763
+ try:
1764
+ path_real = os.path.realpath(path)
1765
+ dir_real = os.path.realpath(directory)
1766
+ return os.path.commonpath([path_real, dir_real]) == dir_real
1767
+ except (OSError, ValueError):
1768
+ return False
1769
+
1770
+
1771
+ def safe_remove(path):
1772
+ """Delete a file, ignoring "it's already gone" -- every caller here races
1773
+ against nothing but itself, so ENOENT on remove is never a real error."""
1774
+ try:
1775
+ os.remove(path)
1776
+ except OSError:
1777
+ pass
1778
+
1779
+
1780
+ class OpError(Exception):
1781
+ """Raised by an op (or by `process_request` itself, for a request-shape
1782
+ problem) that knows its own error code, bypassing the type-based
1783
+ classification in `classify()` below -- e.g. a raw image file is a
1784
+ ValueError in spirit but must map to gimp_unsupported_file, not
1785
+ invalid_argument."""
1786
+
1787
+ def __init__(self, code, message):
1788
+ super().__init__(message)
1789
+ self.code = code
1790
+
1791
+
1792
+ def classify(exc):
1793
+ """The error `code` a response reports for `exc`. gi-free -- ops.py's
1794
+ GIMP-specific exceptions (raw-file handling, etc.) raise `OpError`
1795
+ directly rather than needing a case here."""
1796
+ if isinstance(exc, OpError):
1797
+ return exc.code
1798
+ if isinstance(exc, FileNotFoundError):
1799
+ return 'file_not_found'
1800
+ if isinstance(exc, ValueError):
1801
+ return 'invalid_argument'
1802
+ return 'gimp_op_failed'
1803
+
1804
+
1805
+ def _log_read_retry(exc, attempt):
1806
+ sys.stderr.write(
1807
+ 'transient %s reading a request (attempt %d): %s\n' % (type(exc).__name__, attempt + 1, exc)
1808
+ )
1809
+
1810
+
1811
+ def process_request(req_path, rpc_dir, dispatch):
1812
+ """Answer one request file. Never raises, and deletes `req_path` on every
1813
+ path once it resolves inside `rpc_dir` (see `_is_within`) -- a bug here
1814
+ must not take down `serve()`'s loop or spin it forever re-reading a file
1815
+ it can never finish with.
1816
+
1817
+ `dispatch(op, args)` is a plain callable returning the op's result or
1818
+ raising on failure; it has no `gi` dependency requirement of its own,
1819
+ which is what makes this function unit-testable with a fake dispatch
1820
+ (see test_lib.py). ops.py passes a dispatch that looks up `op` in its
1821
+ real OPS table.
1822
+
1823
+ The response id is ALWAYS the one embedded in the FILENAME
1824
+ (`req-<id>.json`), never trusted from the request body: a body `id` that
1825
+ isn't an int, or that disagrees with the filename, is rejected with
1826
+ `invalid_argument` rather than used -- handing a non-int id to
1827
+ `response_path` used to raise OUTSIDE this function's own error
1828
+ handling, which meant the request file was never deleted and `serve()`
1829
+ re-read (and re-failed on) it forever."""
1830
+ t0 = time.perf_counter()
1831
+ filename_id = id_from_filename(req_path)
1832
+ try:
1833
+ if filename_id is None:
1834
+ # Not our naming convention at all -- nothing safe to answer.
1835
+ return
1836
+
1837
+ try:
1838
+ req = read_request(req_path, on_retry=_log_read_retry)
1839
+ except json.JSONDecodeError as e:
1840
+ write_response(response_path(rpc_dir, filename_id), {
1841
+ 'id': filename_id, 'ok': False, 'code': 'invalid_argument',
1842
+ 'error': 'malformed request file: %s: %s' % (type(e).__name__, e),
1843
+ })
1844
+ return
1845
+ except OSError as e:
1846
+ # read_request already retried this internally (RESP_READ_RETRY_
1847
+ # ATTEMPTS-equivalent on its side); reaching here means those
1848
+ # retries were exhausted, not that the content is bad -- an
1849
+ # operational failure, not a client error.
1850
+ write_response(response_path(rpc_dir, filename_id), {
1851
+ 'id': filename_id, 'ok': False, 'code': 'gimp_op_failed',
1852
+ 'error': 'could not read request file: %s: %s' % (type(e).__name__, e),
1853
+ })
1854
+ return
1855
+
1856
+ if not isinstance(req, dict):
1857
+ write_response(response_path(rpc_dir, filename_id), {
1858
+ 'id': filename_id, 'ok': False, 'code': 'invalid_argument',
1859
+ 'error': 'request body must be a JSON object',
1860
+ })
1861
+ return
1862
+
1863
+ body_id = req.get('id')
1864
+ if body_id is not None and (
1865
+ not isinstance(body_id, int)
1866
+ or isinstance(body_id, bool) # bool is an int subclass in Python
1867
+ or body_id != filename_id
1868
+ ):
1869
+ write_response(response_path(rpc_dir, filename_id), {
1870
+ 'id': filename_id, 'ok': False, 'code': 'invalid_argument',
1871
+ 'error': 'request id %r does not match its filename id %d' % (body_id, filename_id),
1872
+ })
1873
+ return
1874
+
1875
+ op = req.get('op')
1876
+ args = req.get('args', {})
1877
+ try:
1878
+ if op is None:
1879
+ raise OpError('invalid_argument', 'request is missing "op"')
1880
+ result = dispatch(op, args if isinstance(args, dict) else {})
1881
+ resp = {'id': filename_id, 'ok': True, 'result': result}
1882
+ except Exception as e:
1883
+ resp = {
1884
+ 'id': filename_id, 'ok': False, 'code': classify(e),
1885
+ 'error': '%s: %s' % (type(e).__name__, e), 'trace': traceback.format_exc(),
1886
+ }
1887
+ resp['op_ms'] = round((time.perf_counter() - t0) * 1000, 1)
1888
+ write_response(response_path(rpc_dir, filename_id), resp)
1889
+ finally:
1890
+ if _is_within(req_path, rpc_dir):
1891
+ safe_remove(req_path)
1892
+ else:
1893
+ sys.stderr.write(
1894
+ 'process_request: refusing to remove %s -- it does not resolve inside %s\n'
1895
+ % (req_path, rpc_dir)
1896
+ )
1897
+
1898
+
1899
+ def parse_gimp_version(version_string):
1900
+ """Parse a dotted version string into (major, minor, micro) ints, taking
1901
+ only the leading digits of each part so a pre-release suffix like
1902
+ "3.2.0-RC1" still parses as (3, 2, 0). `Gimp.version()` returns a STRING
1903
+ ("3.2.6"), not a tuple of ints -- measured live on 3.2.6; naively
1904
+ unpacking it as `major, minor, micro = Gimp.version()` unpacks the
1905
+ STRING's characters instead ('3', '.', '2')."""
1906
+ parts = str(version_string).split('.')
1907
+
1908
+ def leading_int(s):
1909
+ m = re.match(r'\d+', s)
1910
+ return int(m.group(0)) if m else 0
1911
+
1912
+ major = leading_int(parts[0]) if len(parts) > 0 else 0
1913
+ minor = leading_int(parts[1]) if len(parts) > 1 else 0
1914
+ micro = leading_int(parts[2]) if len(parts) > 2 else 0
1915
+ return major, minor, micro
1916
+
1917
+
1918
+ # ---- cross-platform process liveness (parent-death check) -------------------
1919
+
1920
+ def is_process_alive(pid):
1921
+ """Cross-platform liveness probe for a pid this process did not spawn
1922
+ (the driver's own pid, checked so `serve()` can exit once its parent is
1923
+ gone rather than becoming an orphan headless GIMP forever).
1924
+
1925
+ POSIX: `os.kill(pid, 0)` sends no signal, only checks whether the pid
1926
+ exists and is signalable; ESRCH means gone.
1927
+
1928
+ Windows: NEVER `os.kill` here -- signal 0 on Windows is CTRL_C_EVENT,
1929
+ which sends a real console-control event to a process GROUP instead of
1930
+ probing existence, and could interrupt an unrelated process sharing this
1931
+ console. Use `OpenProcess(SYNCHRONIZE)` + `WaitForSingleObject` instead:
1932
+ a handle that fails to open means the pid is gone; a handle that opens
1933
+ but whose wait returns immediately signaled means the process already
1934
+ exited (its handle became signaled at exit, even though the pid slot
1935
+ hasn't been fully reclaimed)."""
1936
+ if os.name == 'nt':
1937
+ return _is_process_alive_windows(pid)
1938
+ try:
1939
+ os.kill(pid, 0)
1940
+ return True
1941
+ except ProcessLookupError:
1942
+ return False
1943
+ except OSError:
1944
+ # e.g. EPERM: it exists but we can't signal it -- still alive.
1945
+ return True
1946
+
1947
+
1948
+ def _is_process_alive_windows(pid):
1949
+ import ctypes
1950
+
1951
+ PROCESS_SYNCHRONIZE = 0x00100000
1952
+ WAIT_TIMEOUT = 0x00000102
1953
+
1954
+ kernel32 = ctypes.windll.kernel32
1955
+ handle = kernel32.OpenProcess(PROCESS_SYNCHRONIZE, False, pid)
1956
+ if not handle:
1957
+ return False
1958
+ try:
1959
+ result = kernel32.WaitForSingleObject(handle, 0)
1960
+ return result == WAIT_TIMEOUT # not yet signaled -> still running
1961
+ finally:
1962
+ kernel32.CloseHandle(handle)
1963
+
1964
+
1965
+ # Metadata stripping. `include-exif` / `include-xmp` MUST exist on every format the bridge writes:
1966
+ # stripping explicitly instead of trusting a format's default is pointless if the option can stop
1967
+ # existing (an export-procedure rename, say) with nothing noticing, so a missing one fails loudly.
1968
+ # The rest are set only when present (file-webp-export has no `include-comment`; GIMP 3.2's
1969
+ # file-tiff-export has no `save-geotiff`).
1970
+ METADATA_REQUIRED_OPTIONS = ('include-exif', 'include-xmp')
1971
+ METADATA_OPTIONAL_OPTIONS = ('include-iptc', 'include-thumbnail', 'include-comment')
1972
+
1973
+
1974
+ def metadata_strip_settings(fmt, prop_names):
1975
+ """The export-config options to set False for format `fmt`, given the config's property names.
1976
+ Raises OpError('gimp_op_failed') when a required option is missing."""
1977
+ names = set(prop_names)
1978
+ for required in METADATA_REQUIRED_OPTIONS:
1979
+ if required not in names:
1980
+ raise OpError(
1981
+ 'gimp_op_failed',
1982
+ '%s export config has no %r option to strip metadata with' % (fmt, required),
1983
+ )
1984
+ settings = list(METADATA_REQUIRED_OPTIONS)
1985
+ settings += [o for o in METADATA_OPTIONAL_OPTIONS if o in names]
1986
+ if fmt == 'tiff' and 'save-geotiff' in names:
1987
+ settings.append('save-geotiff')
1988
+ return settings