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