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.
- package/README.md +22 -1
- package/dist/backends/detect-editors.js +79 -0
- package/dist/backends/gimp/backend.js +99 -0
- package/dist/backends/gimp/bridge/lib.py +1988 -0
- package/dist/backends/gimp/bridge/ops.py +3120 -0
- package/dist/backends/gimp/detect.js +86 -0
- package/dist/backends/gimp/errors.js +21 -0
- package/dist/backends/gimp/launcher.js +6 -0
- package/dist/backends/gimp/session.js +632 -0
- package/dist/bin/editmamei-core-darwin-arm64 +0 -0
- package/dist/bin/editmamei-core-darwin-x64 +0 -0
- package/dist/bin/editmamei-core-win-x64.exe +0 -0
- package/dist/cli/config.js +28 -0
- package/dist/core/preview-privacy.js +54 -0
- package/dist/core/server.js +98 -49
- package/dist/core/settings.js +25 -0
- package/dist/core/tool-activity.js +122 -0
- package/dist/core/tool-groups.js +24 -0
- package/dist/core/tool-tiers.js +24 -0
- package/dist/diagnostics/collect.js +4 -0
- package/dist/index.js +5 -1
- package/dist/kernel/kernel.js +3 -0
- package/dist/modules/gimp/index.js +43 -0
- package/dist/skills/editmamei-skill.zip +0 -0
- package/dist/telemetry/activity.js +1 -93
- package/dist/tools/adjustment-tools.js +2 -1
- package/dist/tools/gimp-adjustment-tools.js +385 -0
- package/dist/tools/gimp-checkpoint-tools.js +541 -0
- package/dist/tools/gimp-compose-tools.js +399 -0
- package/dist/tools/gimp-core-tools.js +302 -0
- package/dist/tools/gimp-document-tools.js +321 -0
- package/dist/tools/gimp-effect-tools.js +267 -0
- package/dist/tools/gimp-filter-tools.js +94 -0
- package/dist/tools/gimp-geometry-tools.js +223 -0
- package/dist/tools/gimp-inspect-tools.js +157 -0
- package/dist/tools/gimp-layer-tools.js +385 -0
- package/dist/tools/gimp-mask-tools.js +102 -0
- package/dist/tools/gimp-shared.js +58 -0
- package/dist/tools/gimp-verify-tools.js +409 -0
- package/dist/utils/gimp-path.js +27 -0
- package/dist/utils/operation-timeouts.js +24 -0
- package/dist/utils/session-log.js +19 -1
- package/dist/utils/tool-helpers.js +14 -0
- package/dist/utils/validate.js +11 -0
- package/dist/version.js +1 -1
- 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
|