editmamei 1.5.0 → 1.6.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 (48) hide show
  1. package/README.md +17 -1
  2. package/dist/backends/detect-editors.js +79 -0
  3. package/dist/backends/gimp/backend.js +96 -0
  4. package/dist/backends/gimp/bridge/lib.py +1229 -0
  5. package/dist/backends/gimp/bridge/ops.py +1501 -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 +629 -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/server.js +142 -56
  15. package/dist/core/settings.js +25 -0
  16. package/dist/core/tool-activity.js +114 -0
  17. package/dist/core/tool-groups.js +16 -0
  18. package/dist/core/tool-tiers.js +16 -0
  19. package/dist/diagnostics/collect.js +4 -0
  20. package/dist/index.js +5 -1
  21. package/dist/kernel/kernel.js +3 -0
  22. package/dist/kernel/module-lifecycle.js +69 -9
  23. package/dist/license/advisory.js +109 -0
  24. package/dist/license/entitlement.js +34 -6
  25. package/dist/license/polar-client.js +50 -7
  26. package/dist/license/store.js +59 -0
  27. package/dist/modules/gimp/index.js +35 -0
  28. package/dist/skills/editmamei-skill.zip +0 -0
  29. package/dist/telemetry/activity.js +1 -93
  30. package/dist/telemetry/client.js +55 -17
  31. package/dist/telemetry/events.js +5 -0
  32. package/dist/telemetry/outbox.js +43 -18
  33. package/dist/tools/adjustment-tools.js +2 -1
  34. package/dist/tools/gimp-adjustment-tools.js +382 -0
  35. package/dist/tools/gimp-core-tools.js +288 -0
  36. package/dist/tools/gimp-document-tools.js +321 -0
  37. package/dist/tools/gimp-filter-tools.js +93 -0
  38. package/dist/tools/gimp-geometry-tools.js +191 -0
  39. package/dist/tools/gimp-inspect-tools.js +70 -0
  40. package/dist/tools/gimp-mask-tools.js +102 -0
  41. package/dist/tools/gimp-shared.js +51 -0
  42. package/dist/tools/gimp-verify-tools.js +374 -0
  43. package/dist/utils/gimp-path.js +27 -0
  44. package/dist/utils/operation-timeouts.js +16 -0
  45. package/dist/utils/session-log.js +19 -1
  46. package/dist/utils/tool-helpers.js +14 -0
  47. package/dist/version.js +1 -1
  48. package/package.json +1 -1
@@ -0,0 +1,1229 @@
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
+ # The inverse of ADJUST_OPERATIONS, for a ledger record written without a `type` field.
75
+ OPERATION_TYPES = {operation: type_ for type_, operation in ADJUST_OPERATIONS.items()}
76
+
77
+ # Length-typed GEGL properties that must be multiplied by the preview proxy's scale factor
78
+ # when a filter is mirrored onto it (see ops.py's `_mirror_filters`) -- an explicit allow-list
79
+ # per operation, not a heuristic (e.g. "any property named radius"), since a wrong guess here
80
+ # would silently mis-scale a filter that isn't actually spatial. Each entry's scaled value is
81
+ # also clamped to the operation's own pspec minimum (`ops.py` reads that back from the live
82
+ # config) so a small enough source scale never leaves GObject silently keeping the pre-scale
83
+ # value instead of the (invalid, too-small) one we asked for.
84
+ SPATIAL_SCALE_PROPS = {
85
+ 'gegl:shadows-highlights': ('radius',),
86
+ 'gegl:unsharp-mask': ('std-dev',),
87
+ 'gegl:gaussian-blur': ('std-dev-x', 'std-dev-y'),
88
+ }
89
+
90
+ # Integer-valued properties that are scaled by the proxy factor too, but as a ROUNDED count
91
+ # rather than a continuous length -- `noise-reduction`'s `iterations` is a neighbourhood size,
92
+ # not a geometric radius, so scaling it is only an approximation of the full-res effect (unlike
93
+ # the exact scale-equivariance a Gaussian-blur-based radius gets); never below 1 (0 iterations
94
+ # is a no-op filter, which would silently discard the effect entirely on a small enough proxy).
95
+ INT_SPATIAL_SCALE_PROPS = {
96
+ 'gegl:noise-reduction': ('iterations',),
97
+ }
98
+
99
+ HUE_RANGES = ('all', 'red', 'yellow', 'green', 'cyan', 'blue', 'magenta')
100
+ TRANSFER_MODES = ('shadows', 'midtones', 'highlights')
101
+
102
+ # GIMP 3.2.6's file-tiff-export `compression` property is a plain string, not an introspectable
103
+ # enum -- these are the values that measured as accepted (an invalid string is silently ignored
104
+ # by GObject with a stderr warning and the export keeps its prior value, so a bad value here
105
+ # would export successfully with the WRONG compression rather than failing loudly). TIFF tag 259
106
+ # (Compression) values measured for each, on an 8-bit RGB export: none=1, lzw=5, packbits=32773,
107
+ # jpeg=7, adobe_deflate=8.
108
+ #
109
+ # `ccittfax3`/`ccittfax4` (CCITT Group 3/4) are deliberately NOT in this list: they are
110
+ # bilevel-only schemes, and applied to this engine's RGB content they measured as producing a
111
+ # degenerate ~8-byte file with no readable IFD at all -- a silent success reporting a byte count,
112
+ # not an error, for a genuinely broken output file. GIMP's own property still accepts the
113
+ # strings; this engine refuses them anyway rather than exposing an option two of whose seven
114
+ # values always produce a corrupt export.
115
+ TIFF_COMPRESSIONS = ('none', 'lzw', 'packbits', 'jpeg', 'adobe_deflate')
116
+
117
+ MASK_TYPES = ('rectangle', 'ellipse', 'gradient_linear', 'gradient_radial')
118
+
119
+ # Formats `_export_stripped` (ops.py) will write; every export/preview/compare raster save goes
120
+ # through it, and it refuses any other extension outright rather than falling back to a bare,
121
+ # metadata-unaware save.
122
+ EXPORT_FORMATS = {'.jpg': 'jpeg', '.jpeg': 'jpeg', '.png': 'png', '.webp': 'webp',
123
+ '.tif': 'tiff', '.tiff': 'tiff'}
124
+
125
+ BIT_DEPTHS = (8, 16)
126
+
127
+ # A DoS floor for `resize`: bounds a single call's memory/time cost regardless of how large the
128
+ # SOURCE document already is (crop/rotate/flip only ever shrink-or-preserve the existing canvas,
129
+ # so they don't need this; resize is the one op that can grow it arbitrarily from a tiny source).
130
+ MAX_RESIZE_SIDE_PX = 30_000
131
+ MAX_RESIZE_MEGAPIXELS = 250
132
+
133
+ MAX_FEATHER_PX = 1000
134
+
135
+
136
+ def require(args, name):
137
+ """Fetch a required field from an op's `args`, raising ValueError naming it -- the
138
+ classifier maps that to `invalid_argument`. A bare `args[name]` raises KeyError instead,
139
+ which classifies as the much less actionable `gimp_op_failed`."""
140
+ if name not in args or args[name] is None:
141
+ raise ValueError('%s is required' % name)
142
+ return args[name]
143
+
144
+
145
+ def require_bool(args, name):
146
+ """Like `require`, but also rejects anything that isn't a real JSON boolean -- `bool("false")`
147
+ is `True` in Python (any non-empty string is truthy), so a caller that sends the STRING
148
+ "false" for e.g. `visible` would otherwise silently set it True instead of raising."""
149
+ value = require(args, name)
150
+ if not isinstance(value, bool):
151
+ raise ValueError('%s must be a boolean, got %r' % (name, value))
152
+ return value
153
+
154
+
155
+ def validate_range(name, value, lo, hi):
156
+ """Validate a float field is within [lo, hi]. Raises ValueError naming
157
+ `name` (which the bridge's error classifier maps to `invalid_argument`)."""
158
+ value = float(value)
159
+ if not lo <= value <= hi:
160
+ raise ValueError('%s must be within %s..%s' % (name, lo, hi))
161
+ return value
162
+
163
+
164
+ def validate_int_range(name, value, lo, hi):
165
+ value = int(value)
166
+ if not lo <= value <= hi:
167
+ raise ValueError('%s must be within %s..%s' % (name, lo, hi))
168
+ return value
169
+
170
+
171
+ def validate_choice(name, value, choices):
172
+ if value not in choices:
173
+ raise ValueError('%s must be one of %s' % (name, sorted(choices)))
174
+ return value
175
+
176
+
177
+ def validate_region(region, img_width, img_height):
178
+ """A `region` dict must be a rectangle with positive size, entirely within the image --
179
+ partly outside (a negative origin, or one edge past the image bounds) or fully outside both
180
+ raise ValueError naming the field, rather than silently clamping to whatever sliver of it
181
+ happens to overlap (a 1px-wide crop is a confusing result to debug, not a helpful default).
182
+ Returns (x, y, width, height) as ints."""
183
+ x = require(region, 'x')
184
+ y = require(region, 'y')
185
+ width = require(region, 'width')
186
+ height = require(region, 'height')
187
+ x, y, width, height = int(x), int(y), int(width), int(height)
188
+ if width <= 0 or height <= 0:
189
+ raise ValueError('region width and height must be positive')
190
+ if x < 0 or y < 0 or x + width > img_width or y + height > img_height:
191
+ raise ValueError(
192
+ 'region [%d,%d,%d,%d] must lie entirely within the %dx%d image'
193
+ % (x, y, width, height, img_width, img_height)
194
+ )
195
+ return x, y, width, height
196
+
197
+
198
+ def validate_resize_dims(width, height):
199
+ """A DoS floor on `resize`'s target dimensions: each side capped, and the product capped
200
+ separately (a very wide, very short target could pass a per-side check yet still allocate an
201
+ enormous buffer)."""
202
+ if width <= 0 or height <= 0:
203
+ raise ValueError('width and height must be positive')
204
+ if width > MAX_RESIZE_SIDE_PX or height > MAX_RESIZE_SIDE_PX:
205
+ raise ValueError('width and height must each be at most %d px' % MAX_RESIZE_SIDE_PX)
206
+ megapixels = (width * height) / 1_000_000.0
207
+ if megapixels > MAX_RESIZE_MEGAPIXELS:
208
+ raise ValueError('resize target must be at most %d MP' % MAX_RESIZE_MEGAPIXELS)
209
+ return width, height
210
+
211
+
212
+ def validate_feather_px(value):
213
+ return validate_range('feather_px', value, 0.0, MAX_FEATHER_PX)
214
+
215
+
216
+ def pct_to_unit(name, value, lo=-100.0, hi=100.0):
217
+ """User-facing -100..100 (or a narrower lo..hi) percent-like value -> the -1..1 unit
218
+ GEGL/GIMP properties in this family use."""
219
+ return validate_range(name, value, lo, hi) / 100.0
220
+
221
+
222
+ def degrees_to_unit(name, value, lo=-180.0, hi=180.0):
223
+ """User-facing -180..180 degrees -> the -1..1 unit `gimp:hue-saturation`'s `hue` uses."""
224
+ return validate_range(name, value, lo, hi) / 180.0
225
+
226
+
227
+ def resolve_field(args, user_key, defaults, gegl_key, convert):
228
+ """One GEGL-unit field for an `adjust` type: the caller's OWN value (converted from
229
+ user-facing units via `convert`) when `user_key` is present in `args`; otherwise whatever is
230
+ already in `defaults` under `gegl_key` -- the type's hardcoded creation defaults when a
231
+ filter is being CREATED, or the existing live filter's own ledger params when it's being
232
+ RE-EDITED by `filter_id`.
233
+
234
+ This is what makes a re-edit a MERGE rather than a reset: re-editing `{contrast: 50}` on a
235
+ brightness_contrast filter must not silently snap `brightness` back to 0 just because this
236
+ particular call didn't mention it."""
237
+ if user_key in args:
238
+ return convert(args[user_key])
239
+ return defaults[gegl_key]
240
+
241
+
242
+ def build_exposure_params(args, defaults):
243
+ # GEGL's own `exposure` pspec is unbounded (+/- DBL_MAX); +/-10 stops is a bound a real
244
+ # photograph never needs and keeps the field meaningful as a user-facing range.
245
+ return {
246
+ 'exposure': resolve_field(
247
+ args, 'exposure', defaults, 'exposure',
248
+ lambda v: validate_range('exposure', v, -10.0, 10.0),
249
+ ),
250
+ 'black-level': resolve_field(
251
+ args, 'black_level', defaults, 'black-level',
252
+ lambda v: validate_range('black_level', v, -0.1, 0.1),
253
+ ),
254
+ }
255
+
256
+
257
+ def build_brightness_contrast_params(args, defaults):
258
+ return {
259
+ 'brightness': resolve_field(
260
+ args, 'brightness', defaults, 'brightness', lambda v: pct_to_unit('brightness', v)
261
+ ),
262
+ 'contrast': resolve_field(
263
+ args, 'contrast', defaults, 'contrast', lambda v: pct_to_unit('contrast', v)
264
+ ),
265
+ }
266
+
267
+
268
+ def build_hue_saturation_params(args, defaults):
269
+ return {
270
+ 'range': resolve_field(
271
+ args, 'range', defaults, 'range', lambda v: validate_choice('range', v, HUE_RANGES)
272
+ ),
273
+ 'hue': resolve_field(args, 'hue', defaults, 'hue', lambda v: degrees_to_unit('hue', v)),
274
+ 'saturation': resolve_field(
275
+ args, 'saturation', defaults, 'saturation', lambda v: pct_to_unit('saturation', v)
276
+ ),
277
+ 'lightness': resolve_field(
278
+ args, 'lightness', defaults, 'lightness', lambda v: pct_to_unit('lightness', v)
279
+ ),
280
+ }
281
+
282
+
283
+ def build_color_balance_params(args, defaults):
284
+ # One filter targets one range (shadows/midtones/highlights), the same one-filter-per-facet
285
+ # idiom as curves' one-filter-per-channel -- a full three-range grade is three filters.
286
+ return {
287
+ 'range': resolve_field(
288
+ args, 'range', defaults, 'range', lambda v: validate_choice('range', v, TRANSFER_MODES)
289
+ ),
290
+ 'cyan-red': resolve_field(
291
+ args, 'cyan_red', defaults, 'cyan-red', lambda v: pct_to_unit('cyan_red', v)
292
+ ),
293
+ 'magenta-green': resolve_field(
294
+ args, 'magenta_green', defaults, 'magenta-green', lambda v: pct_to_unit('magenta_green', v)
295
+ ),
296
+ 'yellow-blue': resolve_field(
297
+ args, 'yellow_blue', defaults, 'yellow-blue', lambda v: pct_to_unit('yellow_blue', v)
298
+ ),
299
+ 'preserve-luminosity': resolve_field(
300
+ args, 'preserve_luminosity', defaults, 'preserve-luminosity', bool
301
+ ),
302
+ }
303
+
304
+
305
+ def build_color_temperature_params(args, defaults):
306
+ # `from_kelvin` (what the photo currently looks shot at) -> `original-temperature`;
307
+ # `to_kelvin` (the corrected target) -> `intended-temperature`.
308
+ return {
309
+ 'original-temperature': resolve_field(
310
+ args, 'from_kelvin', defaults, 'original-temperature',
311
+ lambda v: validate_range('from_kelvin', v, 1000.0, 12000.0),
312
+ ),
313
+ 'intended-temperature': resolve_field(
314
+ args, 'to_kelvin', defaults, 'intended-temperature',
315
+ lambda v: validate_range('to_kelvin', v, 1000.0, 12000.0),
316
+ ),
317
+ }
318
+
319
+
320
+ def build_shadows_highlights_params(args, defaults):
321
+ return {
322
+ 'shadows': resolve_field(
323
+ args, 'shadows', defaults, 'shadows', lambda v: validate_range('shadows', v, -100.0, 100.0)
324
+ ),
325
+ 'highlights': resolve_field(
326
+ args, 'highlights', defaults, 'highlights',
327
+ lambda v: validate_range('highlights', v, -100.0, 100.0),
328
+ ),
329
+ 'whitepoint': resolve_field(
330
+ args, 'whitepoint', defaults, 'whitepoint',
331
+ lambda v: validate_range('whitepoint', v, -10.0, 10.0),
332
+ ),
333
+ 'radius': resolve_field(
334
+ args, 'radius', defaults, 'radius', lambda v: validate_range('radius', v, 0.1, 1500.0)
335
+ ),
336
+ 'compress': resolve_field(
337
+ args, 'compress', defaults, 'compress', lambda v: validate_range('compress', v, 0.0, 100.0)
338
+ ),
339
+ 'shadows-ccorrect': resolve_field(
340
+ args, 'shadows_ccorrect', defaults, 'shadows-ccorrect',
341
+ lambda v: validate_range('shadows_ccorrect', v, 0.0, 100.0),
342
+ ),
343
+ 'highlights-ccorrect': resolve_field(
344
+ args, 'highlights_ccorrect', defaults, 'highlights-ccorrect',
345
+ lambda v: validate_range('highlights_ccorrect', v, 0.0, 100.0),
346
+ ),
347
+ }
348
+
349
+
350
+ def build_saturation_params(args, defaults):
351
+ return {
352
+ 'scale': resolve_field(
353
+ args, 'scale', defaults, 'scale', lambda v: validate_range('scale', v, 0.0, 10.0)
354
+ )
355
+ }
356
+
357
+
358
+ def build_vibrance_params(args, defaults):
359
+ return {
360
+ 'vibrance': resolve_field(
361
+ args, 'vibrance', defaults, 'vibrance', lambda v: validate_range('vibrance', v, -100.0, 100.0)
362
+ ),
363
+ 'saturation': resolve_field(
364
+ args, 'saturation', defaults, 'saturation', lambda v: validate_range('saturation', v, 0.0, 10.0)
365
+ ),
366
+ }
367
+
368
+
369
+ def build_sharpen_params(args, defaults):
370
+ return {
371
+ 'std-dev': resolve_field(
372
+ args, 'radius', defaults, 'std-dev', lambda v: validate_range('radius', v, 0.0, 1500.0)
373
+ ),
374
+ 'scale': resolve_field(
375
+ args, 'amount', defaults, 'scale', lambda v: validate_range('amount', v, 0.0, 300.0)
376
+ ),
377
+ 'threshold': resolve_field(
378
+ args, 'threshold', defaults, 'threshold', lambda v: validate_range('threshold', v, 0.0, 1.0)
379
+ ),
380
+ }
381
+
382
+
383
+ def build_noise_reduction_params(args, defaults):
384
+ return {
385
+ 'iterations': resolve_field(
386
+ args, 'strength', defaults, 'iterations',
387
+ lambda v: validate_int_range('strength', v, 1, 32),
388
+ )
389
+ }
390
+
391
+
392
+ def build_gaussian_blur_params(args, defaults):
393
+ # One user-facing `radius` drives both axes: a symmetric blur, the only kind the tool exposes.
394
+ # std-dev-x and std-dev-y always hold the same value, so the merge base can read either.
395
+ std_dev = resolve_field(
396
+ args, 'radius', defaults, 'std-dev-x', lambda v: validate_range('radius', v, 0.0, 1500.0)
397
+ )
398
+ return {'std-dev-x': std_dev, 'std-dev-y': std_dev}
399
+
400
+
401
+ def validate_levels(params):
402
+ """Cross-field checks for a resolved levels record (user units, 0-255 levels). The setter
403
+ range-checks each level on its own; these are the relationships it can't see: gamma within
404
+ the tool's 0.1..10 bound (GEGL's own pspec is wider, so an out-of-range value would otherwise
405
+ render), and an input range that isn't empty or inverted."""
406
+ validate_range('gamma', params['gamma'], 0.1, 10.0)
407
+ if not params['in_low'] < params['in_high']:
408
+ raise ValueError(
409
+ 'in_low (%s) must be less than in_high (%s)' % (params['in_low'], params['in_high'])
410
+ )
411
+ return params
412
+
413
+
414
+ ADJUST_PARAM_BUILDERS = {
415
+ 'exposure': build_exposure_params,
416
+ 'brightness_contrast': build_brightness_contrast_params,
417
+ 'hue_saturation': build_hue_saturation_params,
418
+ 'color_balance': build_color_balance_params,
419
+ 'color_temperature': build_color_temperature_params,
420
+ 'shadows_highlights': build_shadows_highlights_params,
421
+ 'saturation': build_saturation_params,
422
+ 'vibrance': build_vibrance_params,
423
+ 'sharpen': build_sharpen_params,
424
+ 'noise_reduction': build_noise_reduction_params,
425
+ 'gaussian_blur': build_gaussian_blur_params,
426
+ }
427
+
428
+ # Creation-time defaults, already in GEGL-property units -- the `defaults` a builder receives
429
+ # when there is no existing filter to re-edit (a fresh `resolve_field` call for every field
430
+ # then falls through to these, since the caller gave none of them).
431
+ ADJUST_CREATE_DEFAULTS = {
432
+ 'exposure': {'exposure': 0.0, 'black-level': 0.0},
433
+ 'brightness_contrast': {'brightness': 0.0, 'contrast': 0.0},
434
+ 'hue_saturation': {'range': 'all', 'hue': 0.0, 'saturation': 0.0, 'lightness': 0.0},
435
+ 'color_balance': {
436
+ 'range': 'midtones', 'cyan-red': 0.0, 'magenta-green': 0.0, 'yellow-blue': 0.0,
437
+ 'preserve-luminosity': True,
438
+ },
439
+ 'color_temperature': {'original-temperature': 6500.0, 'intended-temperature': 6500.0},
440
+ 'shadows_highlights': {
441
+ 'shadows': 0.0, 'highlights': 0.0, 'whitepoint': 0.0, 'radius': 100.0, 'compress': 50.0,
442
+ 'shadows-ccorrect': 100.0, 'highlights-ccorrect': 50.0,
443
+ },
444
+ 'saturation': {'scale': 1.0},
445
+ 'vibrance': {'vibrance': 0.0, 'saturation': 1.0},
446
+ 'sharpen': {'std-dev': 3.0, 'scale': 0.5, 'threshold': 0.0},
447
+ 'noise_reduction': {'iterations': 4},
448
+ 'gaussian_blur': {'std-dev-x': 1.5, 'std-dev-y': 1.5},
449
+ }
450
+
451
+
452
+ def _scaled(factor):
453
+ # Rounded so a GEGL value that went through a /100 or /180 on the way in reads back as the
454
+ # number the caller typed (0.2 * 100 is 20.000000000000004 in binary floating point). A value
455
+ # given with at most 6 decimals round-trips exactly; one with more comes back rounded to 6,
456
+ # a difference of about 1e-8 in GEGL units, far below one 8-bit level.
457
+ return lambda v: round(v * factor, 6)
458
+
459
+
460
+ def _same(v):
461
+ return v
462
+
463
+
464
+ # GEGL-unit ledger params -> the tool's own field names and units, per adjust type: the inverse
465
+ # of each build_*_params above, as (user_key, gegl_key, convert). `gimp_filter op=list` reports
466
+ # these, so a model can pass a listed value straight back on a re-edit. curves/levels already
467
+ # store user units.
468
+ USER_FIELDS = {
469
+ 'exposure': (
470
+ ('exposure', 'exposure', _same),
471
+ ('black_level', 'black-level', _same),
472
+ ),
473
+ 'brightness_contrast': (
474
+ ('brightness', 'brightness', _scaled(100)),
475
+ ('contrast', 'contrast', _scaled(100)),
476
+ ),
477
+ 'hue_saturation': (
478
+ ('range', 'range', _same),
479
+ ('hue', 'hue', _scaled(180)),
480
+ ('saturation', 'saturation', _scaled(100)),
481
+ ('lightness', 'lightness', _scaled(100)),
482
+ ),
483
+ 'color_balance': (
484
+ ('range', 'range', _same),
485
+ ('cyan_red', 'cyan-red', _scaled(100)),
486
+ ('magenta_green', 'magenta-green', _scaled(100)),
487
+ ('yellow_blue', 'yellow-blue', _scaled(100)),
488
+ ('preserve_luminosity', 'preserve-luminosity', _same),
489
+ ),
490
+ 'color_temperature': (
491
+ ('from_kelvin', 'original-temperature', _same),
492
+ ('to_kelvin', 'intended-temperature', _same),
493
+ ),
494
+ 'shadows_highlights': (
495
+ ('shadows', 'shadows', _same),
496
+ ('highlights', 'highlights', _same),
497
+ ('whitepoint', 'whitepoint', _same),
498
+ ('radius', 'radius', _same),
499
+ ('compress', 'compress', _same),
500
+ ('shadows_ccorrect', 'shadows-ccorrect', _same),
501
+ ('highlights_ccorrect', 'highlights-ccorrect', _same),
502
+ ),
503
+ 'saturation': (('scale', 'scale', _same),),
504
+ 'vibrance': (
505
+ ('vibrance', 'vibrance', _same),
506
+ ('saturation', 'saturation', _same),
507
+ ),
508
+ 'sharpen': (
509
+ ('radius', 'std-dev', _same),
510
+ ('amount', 'scale', _same),
511
+ ('threshold', 'threshold', _same),
512
+ ),
513
+ 'noise_reduction': (('strength', 'iterations', _same),),
514
+ 'gaussian_blur': (('radius', 'std-dev-x', _same),),
515
+ }
516
+
517
+ CURVES_USER_FIELDS = ('channel', 'points')
518
+ LEVELS_USER_FIELDS = ('channel', 'in_low', 'in_high', 'gamma', 'out_low', 'out_high')
519
+
520
+
521
+ def user_params(type_, params):
522
+ """A ledger record's `params` in the tool's own field names and units -- what `list` reports
523
+ for a filter this bridge created. `type_` may be None for a record written before `type` was
524
+ stored; the caller passes the type derived from the operation instead. The filter's `mask` is
525
+ reported separately, so it is not repeated here. Unknown types fall back to the raw params
526
+ (minus `mask`) rather than guessing."""
527
+ if type_ == 'curves':
528
+ return {k: params[k] for k in CURVES_USER_FIELDS if k in params}
529
+ if type_ == 'levels':
530
+ return {k: params[k] for k in LEVELS_USER_FIELDS if k in params}
531
+ fields = USER_FIELDS.get(type_)
532
+ if fields is None:
533
+ return {k: v for k, v in params.items() if k != 'mask'}
534
+ return {user: convert(params[gegl]) for user, gegl, convert in fields if gegl in params}
535
+
536
+
537
+ def json_safe(value):
538
+ """A readback property value made JSON-serialisable: plain JSON scalars pass through, lists
539
+ and dicts are walked, and anything else (a Gegl.Color, a path object, bytes) becomes its
540
+ str(). One foreign filter with such a property must not fail `list` for the whole image."""
541
+ if isinstance(value, float) and not math.isfinite(value):
542
+ return str(value) # JSON has no NaN/Infinity; Node's JSON.parse rejects them
543
+ if value is None or isinstance(value, (bool, int, float, str)):
544
+ return value
545
+ if isinstance(value, (list, tuple)):
546
+ return [json_safe(v) for v in value]
547
+ if isinstance(value, dict):
548
+ return {str(k): json_safe(v) for k, v in value.items()}
549
+ return str(value)
550
+
551
+ # The only operations `describe_operation` will probe -- an allow-list, not "any GEGL/GIMP
552
+ # operation name the caller cares to ask about": the probe instantiates a real DrawableFilter,
553
+ # and an unbounded operation name is an unnecessary surface (arbitrary-op instantiation, error
554
+ # text from GIMP's own PDB) for a probe whose only real job is confirming the schema of the
555
+ # operations this engine actually uses.
556
+ ALLOWED_DESCRIBE_OPERATIONS = frozenset(ADJUST_OPERATIONS.values())
557
+
558
+
559
+ # ---- geometry-op masked-filter detection (pure logic; ops.py supplies the live GIMP state) -----
560
+
561
+
562
+ def stale_ledger_names(filters, live_names):
563
+ """Names present in the ledger's `filters` dict that no longer match any LIVE filter name.
564
+ These must be pruned before a geometry op's masked-filter check runs, or a filter someone
565
+ deleted outside `filter op=delete` (the GUI, a foreign tool, a document edited elsewhere)
566
+ would go on blocking rotate/flip/resize forever for a filter that isn't even there."""
567
+ return set(filters) - set(live_names)
568
+
569
+
570
+ def classify_geometry_filters(filters, live_filters):
571
+ """Decide, for every LIVE filter currently on the image, whether it's masked, unmasked, or
572
+ impossible to verify -- the basis for `rotate`/`flip`/`resize`'s refusal check, which iterates
573
+ LIVE filters rather than trusting the ledger alone (a live filter the ledger doesn't know
574
+ about might still be masked; the ledger alone can't say either way).
575
+
576
+ `filters` is the ledger's own {name: {"operation":..., "params": {...}}} map (already pruned
577
+ of stale entries -- see `stale_ledger_names`). `live_filters` is an iterable of
578
+ (name, operation) pairs for every filter actually attached to a layer right now.
579
+
580
+ A live filter counts as OURS only when the ledger has a record under the SAME name AND that
581
+ record's operation matches the live filter's own operation (the same double-check
582
+ `_existing_filter` uses elsewhere, so a name collision with a different operation is never
583
+ mistaken for a match) -- for an ours filter, `masked` is decided by that record's own `mask`
584
+ param. Anything else (no record at all, or a name/operation the ledger doesn't recognise) is
585
+ reported `unverifiable`: it might be masked, and there is no way to tell, so the caller must
586
+ treat it as if it were.
587
+
588
+ Returns (masked_names, unverifiable_names), both lists, in the order `live_filters` was
589
+ given."""
590
+ masked = []
591
+ unverifiable = []
592
+ for name, operation in live_filters:
593
+ rec = filters.get(name)
594
+ if rec and rec.get('operation') == operation:
595
+ if rec.get('params', {}).get('mask'):
596
+ masked.append(name)
597
+ else:
598
+ unverifiable.append(name)
599
+ return masked, unverifiable
600
+
601
+
602
+ def region_to_proxy_px(region, scale):
603
+ """Map a document-pixel `region` dict into proxy-pixel integer bounds at the proxy's
604
+ `scale` factor (0 < scale <= 1). Returns (x, y, width, height); width/height are at least
605
+ 1px so a tiny region never rounds down to an empty crop. Assumes `region` was already
606
+ validated (`validate_region`) against the FULL-RES image -- this only rescales it."""
607
+ x = round(float(region['x']) * scale)
608
+ y = round(float(region['y']) * scale)
609
+ w = max(1, round(float(region['width']) * scale))
610
+ h = max(1, round(float(region['height']) * scale))
611
+ return x, y, w, h
612
+
613
+
614
+ def read_pgm(raw):
615
+ """Decode minimal binary PGM (P5, maxval 255) bytes. Returns (width, height, data).
616
+
617
+ Bounds-checked against `raw` running out before the four header fields
618
+ (magic, width, height, maxval) are found — an empty file or one truncated
619
+ mid-header used to spin forever: `b''.isspace()` is `False`, so the
620
+ "scan until whitespace" loop read `raw[pos:pos+1]` past the end forever,
621
+ each iteration seeing the same empty slice and treating it as "another
622
+ non-whitespace byte" rather than "out of input"."""
623
+ n = len(raw)
624
+ fields, pos = [], 0
625
+ while len(fields) < 4:
626
+ while pos < n and raw[pos:pos + 1].isspace():
627
+ pos += 1
628
+ if pos >= n:
629
+ raise ValueError('truncated PGM header')
630
+ start = pos
631
+ while pos < n and not raw[pos:pos + 1].isspace():
632
+ pos += 1
633
+ fields.append(raw[start:pos])
634
+ if fields[0] != b'P5' or int(fields[3]) != 255:
635
+ raise ValueError('mask must be a binary 8-bit PGM (P5, maxval 255)')
636
+ w, h = int(fields[1]), int(fields[2])
637
+ data = raw[pos + 1:pos + 1 + w * h]
638
+ if len(data) != w * h:
639
+ raise ValueError('truncated PGM')
640
+ return w, h, data
641
+
642
+
643
+ def write_pgm(w, h, data):
644
+ """Encode (width, height, data) as binary PGM (P5, maxval 255) bytes."""
645
+ return b'P5\n%d %d\n255\n' % (w, h) + bytes(data)
646
+
647
+
648
+ def channel_stats(data):
649
+ """mean/median/percentiles/256-bin histogram for one channel's raw byte data."""
650
+ counts = collections.Counter(data)
651
+ bins = [counts.get(v, 0) for v in range(256)]
652
+ n = len(data)
653
+
654
+ def pct(p):
655
+ target, acc = p * n, 0
656
+ for v, c in enumerate(bins):
657
+ acc += c
658
+ if acc >= target:
659
+ return v
660
+ return 255
661
+
662
+ return {
663
+ 'mean': round(sum(v * c for v, c in enumerate(bins)) / n, 3),
664
+ 'median': pct(0.5),
665
+ 'p1': pct(0.01),
666
+ 'p5': pct(0.05),
667
+ 'p95': pct(0.95),
668
+ 'p99': pct(0.99),
669
+ 'bins': bins,
670
+ }
671
+
672
+
673
+ # ---- editmamei-filters ledger (persistent image parasite) -------------------
674
+
675
+ _warned_messages = set()
676
+
677
+
678
+ def _warn_once(message):
679
+ """Log `message` to stderr, but never more than once per distinct
680
+ message for the life of this process — a corrupt or foreign parasite is
681
+ read on every op that touches live filters, and re-logging identically
682
+ on each one would just be noise."""
683
+ if message in _warned_messages:
684
+ return
685
+ _warned_messages.add(message)
686
+ sys.stderr.write('%s\n' % message)
687
+
688
+
689
+ def _decode_ledger_bytes(raw):
690
+ """Normalize `raw` (str, bytes/bytearray, or falsy) to a str, or None if
691
+ it can't be decoded as UTF-8. Bytes arrive here directly from a GIMP
692
+ parasite's raw data — `.decode('utf-8')` on a corrupt/foreign parasite
693
+ used to happen at the CALLER (ops.py), outside any try/except, which
694
+ would take the whole op down instead of degrading to "ledger absent"."""
695
+ if not raw:
696
+ return ''
697
+ if isinstance(raw, (bytes, bytearray)):
698
+ try:
699
+ return raw.decode('utf-8')
700
+ except UnicodeDecodeError:
701
+ return None
702
+ return raw
703
+
704
+
705
+ def _valid_filter_record(record):
706
+ """A ledger filter record must be a dict with a str `operation` and a
707
+ dict `params` — anything else (a stray string, a list, a dict missing
708
+ one of those fields) is dropped as if the entry were never there, rather
709
+ than crashing whatever later reads `record['operation']`."""
710
+ return (
711
+ isinstance(record, dict)
712
+ and isinstance(record.get('operation'), str)
713
+ and isinstance(record.get('params'), dict)
714
+ )
715
+
716
+
717
+ def _validate_filters(filters):
718
+ valid = {}
719
+ dropped = False
720
+ for name, record in filters.items():
721
+ if _valid_filter_record(record):
722
+ valid[name] = record
723
+ else:
724
+ dropped = True
725
+ if dropped:
726
+ _warn_once('editmamei-filters parasite has a malformed filter record; skipping it')
727
+ return valid
728
+
729
+
730
+ def parse_ledger(raw):
731
+ """Parse the parasite's raw data (str, or bytes/bytearray straight from
732
+ `parasite.get_data()`) into (filters_by_name, unknown_top_level,
733
+ raw_filters_by_name).
734
+
735
+ - No data (parasite absent): ({}, {}, {}).
736
+ - Bytes that aren't valid UTF-8, unparsable JSON, or JSON that isn't an
737
+ object: the whole ledger is treated as absent (logged once, not every
738
+ call) rather than raising — a corrupt parasite must degrade to "no
739
+ bridge-applied filters known" (every filter then falls back to
740
+ readback), never take the op down. `ledger_is_undecodable` is how a
741
+ caller distinguishes this case from a genuinely empty ledger before
742
+ deciding whether a rewrite is safe to persist.
743
+ - Versioned vs. legacy is decided by SHAPE, not by the presence of a "v"
744
+ key alone: a dict with an int "v" AND a dict "filters" is versioned;
745
+ anything else (including a legacy document that happens to contain a
746
+ literal key named "v") is the pre-versioning format, where the
747
+ parasite bytes ARE the filters map directly (no wrapper). Treated as
748
+ v1-compatible so files written before the ledger was versioned keep
749
+ working.
750
+ - Versioned + "v" == LEDGER_VERSION: "filters" is the map; any OTHER
751
+ top-level key is returned as `unknown_top_level` so a future writer's
752
+ extra fields round-trip untouched through serialize_ledger.
753
+ - Versioned + "v" != LEDGER_VERSION (in practice: greater, from a future
754
+ writer): every filter is treated as absent — the caller falls back to
755
+ readback for all of them, per the documented contract that we never
756
+ guess at a schema we don't recognize. `unknown_top_level` still
757
+ carries the non-filters keys through, but the caller must NOT persist
758
+ a rewrite in this case (see ops.py's `_ledger_put`) — we don't
759
+ understand the current document well enough to safely overwrite it.
760
+ - Every returned filter record is individually shape-checked (dict, str
761
+ `operation`, dict `params`) before landing in `filters_by_name`; a
762
+ malformed one is dropped as if it were never in the ledger for READ
763
+ purposes (logged once), but it still appears, untouched, in
764
+ `raw_filters_by_name` — a rewrite that starts from the raw map instead
765
+ of the validated one preserves malformed or foreign records verbatim
766
+ rather than silently dropping them the next time this bridge writes
767
+ the ledger.
768
+ """
769
+ text = _decode_ledger_bytes(raw)
770
+ if text is None:
771
+ _warn_once('editmamei-filters parasite is not valid UTF-8; treating as absent')
772
+ return {}, {}, {}
773
+ if not text:
774
+ return {}, {}, {}
775
+ try:
776
+ doc = json.loads(text)
777
+ except (ValueError, TypeError):
778
+ _warn_once('editmamei-filters parasite is not valid JSON; treating as absent')
779
+ return {}, {}, {}
780
+ if not isinstance(doc, dict):
781
+ _warn_once('editmamei-filters parasite is not a JSON object; treating as absent')
782
+ return {}, {}, {}
783
+ versioned = isinstance(doc.get('v'), int) and isinstance(doc.get('filters'), dict)
784
+ if not versioned:
785
+ raw_filters = dict(doc)
786
+ return _validate_filters(raw_filters), {}, raw_filters
787
+ unknown = {k: v for k, v in doc.items() if k not in ('v', 'writer', 'filters')}
788
+ raw_filters = dict(doc['filters'])
789
+ if doc['v'] != LEDGER_VERSION:
790
+ return {}, unknown, raw_filters
791
+ return _validate_filters(raw_filters), unknown, raw_filters
792
+
793
+
794
+ def ledger_is_undecodable(raw):
795
+ """True when the parasite's raw bytes can't be read as a ledger AT ALL —
796
+ not valid UTF-8, not valid JSON, or valid JSON that isn't even an object
797
+ — as opposed to a well-formed document this bridge just doesn't
798
+ recognize every filter in (that's `ledger_is_newer_version`). Callers use
799
+ this to refuse a rewrite entirely rather than overwrite bytes they never
800
+ actually parsed: `parse_ledger`'s "treat as absent" degrade is safe to
801
+ READ from (an op just gets no bridge-applied history), but overwriting
802
+ those same bytes with a fresh `filters: {}` document would permanently
803
+ lose whatever they actually were. Absent (`raw` falsy) is NOT
804
+ undecodable — there is nothing there to lose."""
805
+ text = _decode_ledger_bytes(raw)
806
+ if text is None:
807
+ return True
808
+ if not text:
809
+ return False
810
+ try:
811
+ doc = json.loads(text)
812
+ except (ValueError, TypeError):
813
+ return True
814
+ return not isinstance(doc, dict)
815
+
816
+
817
+ def ledger_is_newer_version(raw):
818
+ """True when the parasite is a versioned document (int "v" + dict
819
+ "filters") whose version is newer than this bridge understands. Callers
820
+ use this to refuse to rewrite a document they can't safely round-trip —
821
+ the filter still applies to the live image either way; only the
822
+ persisted record is skipped. `raw` may be str or bytes/bytearray."""
823
+ text = _decode_ledger_bytes(raw)
824
+ if not text:
825
+ return False
826
+ try:
827
+ doc = json.loads(text)
828
+ except (ValueError, TypeError):
829
+ return False
830
+ if not isinstance(doc, dict):
831
+ return False
832
+ if not (isinstance(doc.get('v'), int) and isinstance(doc.get('filters'), dict)):
833
+ return False
834
+ return doc['v'] > LEDGER_VERSION
835
+
836
+
837
+ def serialize_ledger(filters_by_name, unknown_top_level=None):
838
+ """Serialize filters_by_name as the current-version ledger, preserving any
839
+ unknown_top_level keys carried over from parse_ledger byte-for-byte
840
+ (as JSON values -- not literal bytes, but nothing under them is rewritten)."""
841
+ doc = dict(unknown_top_level or {})
842
+ doc['v'] = LEDGER_VERSION
843
+ doc['writer'] = LEDGER_WRITER
844
+ doc['filters'] = filters_by_name
845
+ return json.dumps(doc)
846
+
847
+
848
+ def merged_ledger_for_write(raw, filters, unknown, removed=None):
849
+ """Compute the bytes to persist as the editmamei-filters parasite, or
850
+ None if the write should be SKIPPED entirely -- the caller's filter still
851
+ applied to the live image either way; only the persisted record is
852
+ skipped in that case (the caller logs it, this function just decides).
853
+
854
+ `raw` is the CURRENT parasite bytes, read fresh right before writing.
855
+ `filters`/`unknown` are the caller's own updated view (e.g. `filters`
856
+ with one new or edited record merged in) of what was read from this same
857
+ `raw` a moment earlier, via `parse_ledger`. `removed`, if given, is an
858
+ iterable of filter NAMES to drop even though `raw` (re-read fresh, and so
859
+ possibly written by someone else since `filters` was derived) still has
860
+ them -- a plain `dict.update` only ever ADDS or OVERWRITES keys present
861
+ in `filters`; it can never express "this name is gone now," which is
862
+ exactly what deleting a filter needs (the deleted name is simply absent
863
+ from `filters`, and used to come back to life because `raw_filters`
864
+ still had a copy of it and nothing ever told the merge to drop it).
865
+
866
+ Returns None (skip the write) when:
867
+ - `raw` is a newer version than this bridge understands
868
+ (`ledger_is_newer_version`) -- writing would silently downgrade a
869
+ future writer's document to this bridge's own schema.
870
+ - `raw` isn't decodable as a ledger at all (`ledger_is_undecodable`) --
871
+ writing would permanently destroy bytes that were never actually
872
+ parsed in the first place.
873
+
874
+ Otherwise, re-parses `raw` for its own `raw_filters_by_name` (which
875
+ preserves a malformed or foreign record verbatim -- see `parse_ledger`),
876
+ drops every name in `removed` from that, and returns the serialized
877
+ ledger as UTF-8 bytes: those raw filters merged with `filters` (`filters`
878
+ wins on a shared key, since it's the caller's own newer view), keeping
879
+ `unknown` as the top-level passthrough fields. This is the ONE place
880
+ that decides whether and how a rewrite happens -- callers (`ops.py`'s
881
+ `_ledger_put`) just hand it bytes in and get bytes-or-None back, with no
882
+ merge or skip logic of their own to keep in sync with this module's."""
883
+ if ledger_is_newer_version(raw) or ledger_is_undecodable(raw):
884
+ return None
885
+ _valid, _existing_unknown, raw_filters = parse_ledger(raw)
886
+ merged = dict(raw_filters)
887
+ for name in (removed or ()):
888
+ merged.pop(name, None)
889
+ merged.update(filters)
890
+ return serialize_ledger(merged, unknown).encode('utf-8')
891
+
892
+
893
+ # ---- request/response transport ---------------------------------------------
894
+
895
+ _REQUEST_NAME_RE = re.compile(r'^req-(\d+)\.json$')
896
+
897
+
898
+ def id_from_filename(path):
899
+ """Recover the numeric id from a `req-<id>.json` path, or None if the
900
+ basename doesn't match that naming convention."""
901
+ match = _REQUEST_NAME_RE.match(os.path.basename(path))
902
+ return int(match.group(1)) if match else None
903
+
904
+
905
+ def response_path(rpc_dir, req_id):
906
+ """Where the response for request `req_id` is written -- ALWAYS derived
907
+ from the id and the rpc directory, never from anything inside the
908
+ request body. The request used to carry its own `resp` path; a bridge
909
+ that trusted it would let a malformed/malicious request steer a write
910
+ anywhere else on disk, so the field is no longer read at all."""
911
+ return os.path.join(rpc_dir, 'resp-%d.json' % req_id)
912
+
913
+
914
+ def list_requests(rpc_dir):
915
+ """`req-<id>.json` names in id order, silently skipping anything that
916
+ doesn't match the naming convention rather than letting a stray file
917
+ crash the sort (a bare int() on a non-numeric name would take the whole
918
+ serve loop down with it)."""
919
+ numbered = []
920
+ for name in os.listdir(rpc_dir):
921
+ if not (name.startswith('req-') and name.endswith('.json')):
922
+ continue
923
+ try:
924
+ req_id = int(name[4:-5])
925
+ except ValueError:
926
+ continue
927
+ numbered.append((req_id, name))
928
+ numbered.sort()
929
+ return [name for _req_id, name in numbered]
930
+
931
+
932
+ def read_request(req_path, attempts=5, delay_s=0.02, on_retry=None):
933
+ """Read + parse a request file. Retries ONLY on OSError (e.g. a
934
+ PermissionError from anti-virus or a file indexer holding a brief lock on
935
+ a just-renamed file on Windows) -- a JSONDecodeError means the content
936
+ itself is bad and is raised immediately; retrying that would just mask a
937
+ genuinely malformed file rather than a transient lock.
938
+
939
+ `on_retry(exc, attempt)`, if given, is called right before each sleep --
940
+ the bridge logs the exception type through it; tests can assert on it
941
+ without capturing stderr."""
942
+ last_err = None
943
+ for attempt in range(attempts):
944
+ try:
945
+ with open(req_path, encoding='utf-8') as fh:
946
+ return json.load(fh)
947
+ except json.JSONDecodeError:
948
+ raise
949
+ except OSError as e:
950
+ last_err = e
951
+ if on_retry:
952
+ on_retry(e, attempt)
953
+ time.sleep(delay_s)
954
+ raise last_err
955
+
956
+
957
+ def write_response(path, resp):
958
+ """Atomic tmp+rename write. Falls back to a minimal, definitely-JSON-
959
+ serializable error response if `resp` itself can't be encoded rather than
960
+ leaving the caller's request hanging forever with no response file at
961
+ all. Catches ANY exception from `json.dump`, not just TypeError -- a
962
+ circular reference raises ValueError, a value outside JSON's numeric
963
+ range raises OverflowError, and a raw GObject reference leaking out of an
964
+ op's result could plausibly hit any of the three depending on where it
965
+ ends up in the structure.
966
+
967
+ If even the fallback write fails (e.g. the response directory itself
968
+ disappeared underneath us), this logs and returns rather than raising --
969
+ letting the caller's request go unanswered is the same outcome either
970
+ way, and raising here would take `serve()`'s loop down with it."""
971
+ tmp = path + '.tmp'
972
+ try:
973
+ with open(tmp, 'w', encoding='utf-8') as fh:
974
+ json.dump(resp, fh)
975
+ os.replace(tmp, path)
976
+ except Exception:
977
+ try:
978
+ fallback = {
979
+ 'id': resp.get('id'),
980
+ 'ok': False,
981
+ 'code': 'gimp_op_failed',
982
+ 'error': 'response not serialisable: %s' % type(resp.get('result')).__name__,
983
+ }
984
+ with open(tmp, 'w', encoding='utf-8') as fh:
985
+ json.dump(fallback, fh)
986
+ os.replace(tmp, path)
987
+ except Exception as e:
988
+ sys.stderr.write('write_response: fallback write also failed for %s: %s\n' % (path, e))
989
+
990
+
991
+ def _is_within(path, directory):
992
+ """True if `path` resolves to somewhere inside `directory` (both
993
+ resolved through symlinks). Used to gate a delete driven by an
994
+ externally-derived path -- a `req_path` is always constructed by this
995
+ module from `rpc_dir` in practice, but `process_request` also accepts it
996
+ as a bare argument, so this is the check that keeps a future caller (or a
997
+ symlink planted in the rpc directory) from ever turning that delete into
998
+ one outside it.
999
+
1000
+ `os.path.commonpath` raises ValueError when the two paths don't even
1001
+ share a root (e.g. `C:\\...` vs `D:\\...` on Windows) rather than just
1002
+ returning a path that fails the equality check below -- treated the same
1003
+ as any other "can't confirm this is inside" case: not within."""
1004
+ try:
1005
+ path_real = os.path.realpath(path)
1006
+ dir_real = os.path.realpath(directory)
1007
+ return os.path.commonpath([path_real, dir_real]) == dir_real
1008
+ except (OSError, ValueError):
1009
+ return False
1010
+
1011
+
1012
+ def safe_remove(path):
1013
+ """Delete a file, ignoring "it's already gone" -- every caller here races
1014
+ against nothing but itself, so ENOENT on remove is never a real error."""
1015
+ try:
1016
+ os.remove(path)
1017
+ except OSError:
1018
+ pass
1019
+
1020
+
1021
+ class OpError(Exception):
1022
+ """Raised by an op (or by `process_request` itself, for a request-shape
1023
+ problem) that knows its own error code, bypassing the type-based
1024
+ classification in `classify()` below -- e.g. a raw image file is a
1025
+ ValueError in spirit but must map to gimp_unsupported_file, not
1026
+ invalid_argument."""
1027
+
1028
+ def __init__(self, code, message):
1029
+ super().__init__(message)
1030
+ self.code = code
1031
+
1032
+
1033
+ def classify(exc):
1034
+ """The error `code` a response reports for `exc`. gi-free -- ops.py's
1035
+ GIMP-specific exceptions (raw-file handling, etc.) raise `OpError`
1036
+ directly rather than needing a case here."""
1037
+ if isinstance(exc, OpError):
1038
+ return exc.code
1039
+ if isinstance(exc, FileNotFoundError):
1040
+ return 'file_not_found'
1041
+ if isinstance(exc, ValueError):
1042
+ return 'invalid_argument'
1043
+ return 'gimp_op_failed'
1044
+
1045
+
1046
+ def _log_read_retry(exc, attempt):
1047
+ sys.stderr.write(
1048
+ 'transient %s reading a request (attempt %d): %s\n' % (type(exc).__name__, attempt + 1, exc)
1049
+ )
1050
+
1051
+
1052
+ def process_request(req_path, rpc_dir, dispatch):
1053
+ """Answer one request file. Never raises, and deletes `req_path` on every
1054
+ path once it resolves inside `rpc_dir` (see `_is_within`) -- a bug here
1055
+ must not take down `serve()`'s loop or spin it forever re-reading a file
1056
+ it can never finish with.
1057
+
1058
+ `dispatch(op, args)` is a plain callable returning the op's result or
1059
+ raising on failure; it has no `gi` dependency requirement of its own,
1060
+ which is what makes this function unit-testable with a fake dispatch
1061
+ (see test_lib.py). ops.py passes a dispatch that looks up `op` in its
1062
+ real OPS table.
1063
+
1064
+ The response id is ALWAYS the one embedded in the FILENAME
1065
+ (`req-<id>.json`), never trusted from the request body: a body `id` that
1066
+ isn't an int, or that disagrees with the filename, is rejected with
1067
+ `invalid_argument` rather than used -- handing a non-int id to
1068
+ `response_path` used to raise OUTSIDE this function's own error
1069
+ handling, which meant the request file was never deleted and `serve()`
1070
+ re-read (and re-failed on) it forever."""
1071
+ t0 = time.perf_counter()
1072
+ filename_id = id_from_filename(req_path)
1073
+ try:
1074
+ if filename_id is None:
1075
+ # Not our naming convention at all -- nothing safe to answer.
1076
+ return
1077
+
1078
+ try:
1079
+ req = read_request(req_path, on_retry=_log_read_retry)
1080
+ except json.JSONDecodeError as e:
1081
+ write_response(response_path(rpc_dir, filename_id), {
1082
+ 'id': filename_id, 'ok': False, 'code': 'invalid_argument',
1083
+ 'error': 'malformed request file: %s: %s' % (type(e).__name__, e),
1084
+ })
1085
+ return
1086
+ except OSError as e:
1087
+ # read_request already retried this internally (RESP_READ_RETRY_
1088
+ # ATTEMPTS-equivalent on its side); reaching here means those
1089
+ # retries were exhausted, not that the content is bad -- an
1090
+ # operational failure, not a client error.
1091
+ write_response(response_path(rpc_dir, filename_id), {
1092
+ 'id': filename_id, 'ok': False, 'code': 'gimp_op_failed',
1093
+ 'error': 'could not read request file: %s: %s' % (type(e).__name__, e),
1094
+ })
1095
+ return
1096
+
1097
+ if not isinstance(req, dict):
1098
+ write_response(response_path(rpc_dir, filename_id), {
1099
+ 'id': filename_id, 'ok': False, 'code': 'invalid_argument',
1100
+ 'error': 'request body must be a JSON object',
1101
+ })
1102
+ return
1103
+
1104
+ body_id = req.get('id')
1105
+ if body_id is not None and (
1106
+ not isinstance(body_id, int)
1107
+ or isinstance(body_id, bool) # bool is an int subclass in Python
1108
+ or body_id != filename_id
1109
+ ):
1110
+ write_response(response_path(rpc_dir, filename_id), {
1111
+ 'id': filename_id, 'ok': False, 'code': 'invalid_argument',
1112
+ 'error': 'request id %r does not match its filename id %d' % (body_id, filename_id),
1113
+ })
1114
+ return
1115
+
1116
+ op = req.get('op')
1117
+ args = req.get('args', {})
1118
+ try:
1119
+ if op is None:
1120
+ raise OpError('invalid_argument', 'request is missing "op"')
1121
+ result = dispatch(op, args if isinstance(args, dict) else {})
1122
+ resp = {'id': filename_id, 'ok': True, 'result': result}
1123
+ except Exception as e:
1124
+ resp = {
1125
+ 'id': filename_id, 'ok': False, 'code': classify(e),
1126
+ 'error': '%s: %s' % (type(e).__name__, e), 'trace': traceback.format_exc(),
1127
+ }
1128
+ resp['op_ms'] = round((time.perf_counter() - t0) * 1000, 1)
1129
+ write_response(response_path(rpc_dir, filename_id), resp)
1130
+ finally:
1131
+ if _is_within(req_path, rpc_dir):
1132
+ safe_remove(req_path)
1133
+ else:
1134
+ sys.stderr.write(
1135
+ 'process_request: refusing to remove %s -- it does not resolve inside %s\n'
1136
+ % (req_path, rpc_dir)
1137
+ )
1138
+
1139
+
1140
+ def parse_gimp_version(version_string):
1141
+ """Parse a dotted version string into (major, minor, micro) ints, taking
1142
+ only the leading digits of each part so a pre-release suffix like
1143
+ "3.2.0-RC1" still parses as (3, 2, 0). `Gimp.version()` returns a STRING
1144
+ ("3.2.6"), not a tuple of ints -- measured live on 3.2.6; naively
1145
+ unpacking it as `major, minor, micro = Gimp.version()` unpacks the
1146
+ STRING's characters instead ('3', '.', '2')."""
1147
+ parts = str(version_string).split('.')
1148
+
1149
+ def leading_int(s):
1150
+ m = re.match(r'\d+', s)
1151
+ return int(m.group(0)) if m else 0
1152
+
1153
+ major = leading_int(parts[0]) if len(parts) > 0 else 0
1154
+ minor = leading_int(parts[1]) if len(parts) > 1 else 0
1155
+ micro = leading_int(parts[2]) if len(parts) > 2 else 0
1156
+ return major, minor, micro
1157
+
1158
+
1159
+ # ---- cross-platform process liveness (parent-death check) -------------------
1160
+
1161
+ def is_process_alive(pid):
1162
+ """Cross-platform liveness probe for a pid this process did not spawn
1163
+ (the driver's own pid, checked so `serve()` can exit once its parent is
1164
+ gone rather than becoming an orphan headless GIMP forever).
1165
+
1166
+ POSIX: `os.kill(pid, 0)` sends no signal, only checks whether the pid
1167
+ exists and is signalable; ESRCH means gone.
1168
+
1169
+ Windows: NEVER `os.kill` here -- signal 0 on Windows is CTRL_C_EVENT,
1170
+ which sends a real console-control event to a process GROUP instead of
1171
+ probing existence, and could interrupt an unrelated process sharing this
1172
+ console. Use `OpenProcess(SYNCHRONIZE)` + `WaitForSingleObject` instead:
1173
+ a handle that fails to open means the pid is gone; a handle that opens
1174
+ but whose wait returns immediately signaled means the process already
1175
+ exited (its handle became signaled at exit, even though the pid slot
1176
+ hasn't been fully reclaimed)."""
1177
+ if os.name == 'nt':
1178
+ return _is_process_alive_windows(pid)
1179
+ try:
1180
+ os.kill(pid, 0)
1181
+ return True
1182
+ except ProcessLookupError:
1183
+ return False
1184
+ except OSError:
1185
+ # e.g. EPERM: it exists but we can't signal it -- still alive.
1186
+ return True
1187
+
1188
+
1189
+ def _is_process_alive_windows(pid):
1190
+ import ctypes
1191
+
1192
+ PROCESS_SYNCHRONIZE = 0x00100000
1193
+ WAIT_TIMEOUT = 0x00000102
1194
+
1195
+ kernel32 = ctypes.windll.kernel32
1196
+ handle = kernel32.OpenProcess(PROCESS_SYNCHRONIZE, False, pid)
1197
+ if not handle:
1198
+ return False
1199
+ try:
1200
+ result = kernel32.WaitForSingleObject(handle, 0)
1201
+ return result == WAIT_TIMEOUT # not yet signaled -> still running
1202
+ finally:
1203
+ kernel32.CloseHandle(handle)
1204
+
1205
+
1206
+ # Metadata stripping. `include-exif` / `include-xmp` MUST exist on every format the bridge writes:
1207
+ # stripping explicitly instead of trusting a format's default is pointless if the option can stop
1208
+ # existing (an export-procedure rename, say) with nothing noticing, so a missing one fails loudly.
1209
+ # The rest are set only when present (file-webp-export has no `include-comment`; GIMP 3.2's
1210
+ # file-tiff-export has no `save-geotiff`).
1211
+ METADATA_REQUIRED_OPTIONS = ('include-exif', 'include-xmp')
1212
+ METADATA_OPTIONAL_OPTIONS = ('include-iptc', 'include-thumbnail', 'include-comment')
1213
+
1214
+
1215
+ def metadata_strip_settings(fmt, prop_names):
1216
+ """The export-config options to set False for format `fmt`, given the config's property names.
1217
+ Raises OpError('gimp_op_failed') when a required option is missing."""
1218
+ names = set(prop_names)
1219
+ for required in METADATA_REQUIRED_OPTIONS:
1220
+ if required not in names:
1221
+ raise OpError(
1222
+ 'gimp_op_failed',
1223
+ '%s export config has no %r option to strip metadata with' % (fmt, required),
1224
+ )
1225
+ settings = list(METADATA_REQUIRED_OPTIONS)
1226
+ settings += [o for o in METADATA_OPTIONAL_OPTIONS if o in names]
1227
+ if fmt == 'tiff' and 'save-geotiff' in names:
1228
+ settings.append('save-geotiff')
1229
+ return settings