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,1501 @@
|
|
|
1
|
+
# Editmamei GIMP bridge: named operations run inside a headless gimp-console session.
|
|
2
|
+
#
|
|
3
|
+
# session.ts spawns `gimp-console` with ONE fixed batch line (see its
|
|
4
|
+
# BATCH_LINE) that calls serve(<session dir>) from this file. serve() never
|
|
5
|
+
# returns until told to, so this python-fu-eval process becomes a request
|
|
6
|
+
# server inside GIMP with full PDB access, and nothing is installed.
|
|
7
|
+
# (`-b -` looks like the obvious route, but gimp-console reads stdin to EOF
|
|
8
|
+
# before running anything, so it can't serve interactively.)
|
|
9
|
+
#
|
|
10
|
+
# Requests are JSON files in <session dir>/rpc. Model-supplied values only
|
|
11
|
+
# ever arrive inside those files and are dispatched through OPS below; they
|
|
12
|
+
# are never evaluated as code. Images live in GIMP's core and are addressed
|
|
13
|
+
# by id.
|
|
14
|
+
#
|
|
15
|
+
# This file is exec'd by the batch line, not imported as a package, so it has
|
|
16
|
+
# no package context of its own — `lib` (the pure, gi-free helpers, unit
|
|
17
|
+
# tested standalone via test_lib.py) is imported by adding this file's own
|
|
18
|
+
# directory to sys.path first.
|
|
19
|
+
|
|
20
|
+
import math
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
import time
|
|
24
|
+
import traceback
|
|
25
|
+
|
|
26
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(os.environ['EM_GIMP_OPS'])))
|
|
27
|
+
import lib # noqa: E402 (must follow the sys.path fix-up above)
|
|
28
|
+
|
|
29
|
+
import gi # noqa: E402
|
|
30
|
+
gi.require_version('Gimp', '3.0') # noqa: E402
|
|
31
|
+
gi.require_version('Gegl', '0.4') # noqa: E402
|
|
32
|
+
from gi.repository import Gimp, Gio, Gegl # noqa: E402
|
|
33
|
+
|
|
34
|
+
CHANNELS = {
|
|
35
|
+
'value': Gimp.HistogramChannel.VALUE,
|
|
36
|
+
'red': Gimp.HistogramChannel.RED,
|
|
37
|
+
'green': Gimp.HistogramChannel.GREEN,
|
|
38
|
+
'blue': Gimp.HistogramChannel.BLUE,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
# Extensions GIMP core has no loader for by default (only with a raw-develop
|
|
42
|
+
# plug-in like darktable/RawTherapee/ART installed). Used only to word the
|
|
43
|
+
# error when a load actually fails -- the load is always attempted first, so
|
|
44
|
+
# an install that DOES have such a plug-in still opens these normally.
|
|
45
|
+
RAW_EXTENSIONS = {'.dng', '.cr2', '.cr3', '.nef', '.arw', '.raf', '.orf', '.rw2', '.pef'}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _image(args):
|
|
49
|
+
image_id = lib.require(args, 'image')
|
|
50
|
+
img = Gimp.Image.get_by_id(int(image_id))
|
|
51
|
+
if img is None or not img.is_valid():
|
|
52
|
+
raise ValueError('no open image with id %s' % image_id)
|
|
53
|
+
return img
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _layer(img, args):
|
|
57
|
+
name = args.get('layer')
|
|
58
|
+
if name:
|
|
59
|
+
layer = img.get_layer_by_name(name) # searches inside layer groups too
|
|
60
|
+
if layer is None:
|
|
61
|
+
raise ValueError('no layer named %r' % name)
|
|
62
|
+
return layer
|
|
63
|
+
selected = img.get_selected_layers()
|
|
64
|
+
return selected[0] if selected else img.get_layers()[0]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _all_layers(img):
|
|
68
|
+
"""Every layer in img, top of stack first, descending into layer groups (a group itself is
|
|
69
|
+
included, then its children). `img.get_layers()` is top-level only, and `_layer` can resolve a
|
|
70
|
+
layer nested inside a group, so every walk over the filter stack goes through this: a filter
|
|
71
|
+
on a nested layer must be listed, mirrored onto the proxy, ledger-tracked, and seen by the
|
|
72
|
+
geometry refusal like any other."""
|
|
73
|
+
out = []
|
|
74
|
+
|
|
75
|
+
def walk(items):
|
|
76
|
+
for item in items:
|
|
77
|
+
out.append(item)
|
|
78
|
+
if item.is_group():
|
|
79
|
+
walk(item.get_children())
|
|
80
|
+
|
|
81
|
+
walk(img.get_layers())
|
|
82
|
+
return out
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _describe(img):
|
|
86
|
+
return {
|
|
87
|
+
'image': img.get_id(),
|
|
88
|
+
'width': img.get_width(),
|
|
89
|
+
'height': img.get_height(),
|
|
90
|
+
'base_type': img.get_base_type().value_nick,
|
|
91
|
+
'precision': img.get_precision().value_nick,
|
|
92
|
+
'layers': [l.get_name() for l in img.get_layers()],
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _existing_filter(img, args, operation):
|
|
97
|
+
"""Look up filter_id AMONG img's own layers -- not just by a global id
|
|
98
|
+
lookup, which would happily hand back a filter that belongs to some
|
|
99
|
+
other open image (or one already closed) and let a re-edit silently
|
|
100
|
+
touch the wrong document."""
|
|
101
|
+
filter_id = int(args['filter_id'])
|
|
102
|
+
for layer in _all_layers(img):
|
|
103
|
+
for f in layer.get_filters():
|
|
104
|
+
if f.get_id() == filter_id:
|
|
105
|
+
if f.get_operation_name() != operation:
|
|
106
|
+
raise ValueError(
|
|
107
|
+
'filter %s is %s, not %s' % (filter_id, f.get_operation_name(), operation)
|
|
108
|
+
)
|
|
109
|
+
return f
|
|
110
|
+
raise ValueError('no filter with id %s on image %s' % (filter_id, img.get_id()))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _channel(args):
|
|
114
|
+
ch = args.get('channel', 'value')
|
|
115
|
+
if ch not in CHANNELS:
|
|
116
|
+
raise ValueError('channel must be one of %s' % sorted(CHANNELS))
|
|
117
|
+
return CHANNELS[ch]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _level(v, name):
|
|
121
|
+
v = float(v)
|
|
122
|
+
if not 0 <= v <= 255:
|
|
123
|
+
raise ValueError('%s must be within 0..255' % name)
|
|
124
|
+
return v / 255.0
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _composite(img):
|
|
128
|
+
"""A full-resolution flattened duplicate of img. Caller deletes it. Renders every live
|
|
129
|
+
filter at full size: ~2 s per 24 MP with two curves on an 8-thread machine."""
|
|
130
|
+
dup = img.duplicate()
|
|
131
|
+
return dup, dup.flatten()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# Preview proxies, keyed by image id: a filter-free downscale of the document made once, onto
|
|
135
|
+
# which the document's live filters are re-applied per preview. Rendering filters on ~0.7 MP
|
|
136
|
+
# instead of 24 MP is what gets preview under a second; every route that renders from the
|
|
137
|
+
# full-size document (flatten, scale, get_thumbnail's projection) costs 1.4-4.5 s after an
|
|
138
|
+
# edit. Exact for per-pixel filters (curves, levels); a spatial filter would need its radius
|
|
139
|
+
# scaled. Every op in this file only ever changes layer pixels via a non-destructive filter
|
|
140
|
+
# (never bakes), so the proxy's filter-free base never goes stale.
|
|
141
|
+
PROXIES = {}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _proxy(img, max_px):
|
|
145
|
+
key = (img.get_id(), max_px)
|
|
146
|
+
proxy = PROXIES.get(key)
|
|
147
|
+
if proxy is None or not proxy.is_valid():
|
|
148
|
+
proxy = img.duplicate()
|
|
149
|
+
for layer in _all_layers(proxy):
|
|
150
|
+
for f in layer.get_filters():
|
|
151
|
+
f.delete()
|
|
152
|
+
w, h = proxy.get_width(), proxy.get_height()
|
|
153
|
+
scale = min(1.0, float(max_px) / max(w, h))
|
|
154
|
+
if scale < 1.0:
|
|
155
|
+
proxy.scale(max(1, round(w * scale)), max(1, round(h * scale)))
|
|
156
|
+
PROXIES[key] = proxy
|
|
157
|
+
return proxy
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _mirror_filters(src_img, dst_img):
|
|
161
|
+
"""Re-create src's live filters, in stack order, on the matching layers of dst.
|
|
162
|
+
|
|
163
|
+
Filters this bridge applied are rebuilt from the ledger, not from readback: after
|
|
164
|
+
an XCF reload, libgimp's config for gimp:curves / gimp:levels reports only the `value`
|
|
165
|
+
channel, so a readback copy of a red or blue curve comes out as identity (measured: the
|
|
166
|
+
reopened document rendered R-B 25.4, a readback-mirrored proxy 10.6).
|
|
167
|
+
|
|
168
|
+
dst is normally smaller than src (a preview proxy): every length-typed property named in
|
|
169
|
+
`lib.SPATIAL_SCALE_PROPS` for the filter's operation is multiplied by dst/src's width ratio
|
|
170
|
+
after the ledger's own params are applied, so a spatial filter's radius still reads in SRC's
|
|
171
|
+
pixel units rather than rendering as if it were `radius` proxy-px wide. Integer neighbourhood
|
|
172
|
+
sizes in `lib.INT_SPATIAL_SCALE_PROPS` (currently just noise-reduction's `iterations`) are
|
|
173
|
+
scaled the same way but rounded, floored at 1 -- an approximation of the full-res effect, not
|
|
174
|
+
an exact one, since a neighbourhood count doesn't scale as cleanly as a Gaussian radius does.
|
|
175
|
+
Either way, the scaled value is clamped to the property's own pspec minimum before being set
|
|
176
|
+
-- GObject silently KEEPS the property's prior value (the unscaled one `dst_cfg` already has
|
|
177
|
+
from the loop above, not the intended small one) when asked to set something below its
|
|
178
|
+
minimum, so without this clamp a small enough proxy would render as if the property were
|
|
179
|
+
never scaled down at all. This is an approximation (see `op_preview`'s `proxy` flag) -- exact
|
|
180
|
+
for everything else, since every other property here is scale-invariant (a hue shift, a
|
|
181
|
+
level, a percentage)."""
|
|
182
|
+
filters, _unknown = _ledger_get(src_img)
|
|
183
|
+
src_w = src_img.get_width()
|
|
184
|
+
scale = (dst_img.get_width() / src_w) if src_w else 1.0
|
|
185
|
+
# dst is a duplicate of src (see `_proxy`), so both walks yield the same layers in the same order.
|
|
186
|
+
for src, dst in zip(_all_layers(src_img), _all_layers(dst_img)):
|
|
187
|
+
for f in reversed(src.get_filters()): # get_filters() is top-first
|
|
188
|
+
g = Gimp.DrawableFilter.new(dst, f.get_operation_name(), f.get_name())
|
|
189
|
+
src_cfg, dst_cfg = f.get_config(), g.get_config()
|
|
190
|
+
for p in src_cfg.list_properties():
|
|
191
|
+
dst_cfg.set_property(p.name, src_cfg.get_property(p.name))
|
|
192
|
+
rec = filters.get(f.get_name())
|
|
193
|
+
mask = None
|
|
194
|
+
if rec and rec['operation'] == f.get_operation_name():
|
|
195
|
+
SETTERS[rec['operation']](dst_cfg, rec['params'])
|
|
196
|
+
mask = rec['params'].get('mask')
|
|
197
|
+
operation = rec['operation']
|
|
198
|
+
pspecs = {p.name: p for p in dst_cfg.list_properties()}
|
|
199
|
+
for prop_name in lib.SPATIAL_SCALE_PROPS.get(operation, ()):
|
|
200
|
+
scaled = dst_cfg.get_property(prop_name) * scale
|
|
201
|
+
minimum = getattr(pspecs.get(prop_name), 'minimum', None)
|
|
202
|
+
if minimum is not None and scaled < minimum:
|
|
203
|
+
scaled = minimum
|
|
204
|
+
dst_cfg.set_property(prop_name, scaled)
|
|
205
|
+
for prop_name in lib.INT_SPATIAL_SCALE_PROPS.get(operation, ()):
|
|
206
|
+
scaled = max(1, round(dst_cfg.get_property(prop_name) * scale))
|
|
207
|
+
minimum = getattr(pspecs.get(prop_name), 'minimum', None)
|
|
208
|
+
if minimum is not None and scaled < minimum:
|
|
209
|
+
scaled = int(minimum)
|
|
210
|
+
dst_cfg.set_property(prop_name, scaled)
|
|
211
|
+
g.set_opacity(f.get_opacity())
|
|
212
|
+
g.set_blend_mode(f.get_blend_mode())
|
|
213
|
+
_append_masked(dst_img, dst, g, mask)
|
|
214
|
+
g.set_visible(f.get_visible())
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _drop_proxies(image_id):
|
|
218
|
+
for key in [k for k in PROXIES if k[0] == image_id]:
|
|
219
|
+
proxy = PROXIES.pop(key)
|
|
220
|
+
if proxy.is_valid():
|
|
221
|
+
proxy.delete()
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _curve_points(curve):
|
|
225
|
+
pts = []
|
|
226
|
+
for i in range(curve.get_n_points()):
|
|
227
|
+
x, y = curve.get_point(i)
|
|
228
|
+
pts.append([round(x * 255, 2), round(y * 255, 2)])
|
|
229
|
+
return pts
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# What the bridge applied, per filter name, as JSON in a persistent image parasite. It travels
|
|
233
|
+
# inside the XCF, so it survives close/reopen; it is the source of truth for readback and the
|
|
234
|
+
# preview proxy wherever libgimp's own readback is lossy (see _mirror_filters). Schema, version
|
|
235
|
+
# handling, the merge, and the "don't rewrite a document I don't understand" decision all live in
|
|
236
|
+
# lib.parse_ledger / lib.merged_ledger_for_write.
|
|
237
|
+
META = 'editmamei-filters'
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _ledger_get(img):
|
|
241
|
+
"""Hands the parasite's raw BYTES to lib.parse_ledger rather than
|
|
242
|
+
decoding here first: a corrupt/foreign parasite that isn't valid UTF-8
|
|
243
|
+
used to raise straight out of a bare `.decode('utf-8')`, outside any
|
|
244
|
+
try/except, taking down whatever op called this.
|
|
245
|
+
|
|
246
|
+
Returns (filters, unknown_top_level) -- the validated view. A caller
|
|
247
|
+
about to write the ledger back doesn't need the raw (pre-validation) map
|
|
248
|
+
itself: `_ledger_put` re-reads the parasite and re-derives it, via
|
|
249
|
+
`lib.merged_ledger_for_write`, at the moment it actually writes."""
|
|
250
|
+
p = img.get_parasite(META)
|
|
251
|
+
if not p:
|
|
252
|
+
return {}, {}
|
|
253
|
+
filters, unknown, _raw_filters = lib.parse_ledger(bytes(p.get_data()))
|
|
254
|
+
return filters, unknown
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _ledger_put(img, filters, unknown, removed=None):
|
|
258
|
+
"""Persist filters/unknown as the ledger parasite. The merge (preserving
|
|
259
|
+
any malformed/foreign record verbatim) and the decision whether writing
|
|
260
|
+
is even safe (a newer-version or undecodable existing parasite) both
|
|
261
|
+
live in `lib.merged_ledger_for_write` -- this function just reads the
|
|
262
|
+
current parasite, calls it, and writes back whatever it returns, if
|
|
263
|
+
anything. `None` means the write was skipped: the filter this call is
|
|
264
|
+
for was still applied to the live image either way; only the persisted
|
|
265
|
+
record is, and list_filters/preview/histogram fall back to readback for
|
|
266
|
+
it exactly as they would for any other foreign filter.
|
|
267
|
+
|
|
268
|
+
`removed`, when given, names filters to drop even though the freshly
|
|
269
|
+
re-read parasite still has them -- see `lib.merged_ledger_for_write`'s
|
|
270
|
+
own docstring for why a plain merge can't express a deletion by itself."""
|
|
271
|
+
existing = img.get_parasite(META)
|
|
272
|
+
existing_raw = bytes(existing.get_data()) if existing else b''
|
|
273
|
+
merged = lib.merged_ledger_for_write(existing_raw, filters, unknown, removed=removed)
|
|
274
|
+
if merged is None:
|
|
275
|
+
sys.stderr.write(
|
|
276
|
+
'editmamei-filters parasite on image %d was not rewritten (see '
|
|
277
|
+
'lib.merged_ledger_for_write); this filter still applied to the live image\n'
|
|
278
|
+
% img.get_id()
|
|
279
|
+
)
|
|
280
|
+
return
|
|
281
|
+
img.attach_parasite(Gimp.Parasite.new(META, Gimp.PARASITE_PERSISTENT, merged))
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _unique_name(img, base):
|
|
285
|
+
taken = {f.get_name() for layer in _all_layers(img) for f in layer.get_filters()}
|
|
286
|
+
name, n = base, 2
|
|
287
|
+
while name in taken:
|
|
288
|
+
name, n = '%s %d' % (base, n), n + 1
|
|
289
|
+
return name
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _set_curves(cfg, params):
|
|
293
|
+
curve = Gimp.Curve.new()
|
|
294
|
+
curve.clear_points()
|
|
295
|
+
for x, y in params['points']:
|
|
296
|
+
curve.add_point(_level(x, 'x'), _level(y, 'y'))
|
|
297
|
+
cfg.set_property('trc', Gimp.TRCType.PERCEPTUAL)
|
|
298
|
+
cfg.set_property('channel', _channel(params))
|
|
299
|
+
cfg.set_property('curve', curve)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _set_levels(cfg, params):
|
|
303
|
+
cfg.set_property('trc', Gimp.TRCType.PERCEPTUAL)
|
|
304
|
+
cfg.set_property('linear', False)
|
|
305
|
+
cfg.set_property('channel', _channel(params))
|
|
306
|
+
cfg.set_property('low-input', _level(params['in_low'], 'in_low'))
|
|
307
|
+
cfg.set_property('high-input', _level(params['in_high'], 'in_high'))
|
|
308
|
+
cfg.set_property('gamma', params['gamma'])
|
|
309
|
+
cfg.set_property('low-output', _level(params['out_low'], 'out_low'))
|
|
310
|
+
cfg.set_property('high-output', _level(params['out_high'], 'out_high'))
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# GimpHueRange / GimpTransferMode enum members, keyed by the plain strings
|
|
314
|
+
# `lib.build_hue_saturation_params` / `lib.build_color_balance_params` validate against --
|
|
315
|
+
# those two functions are gi-free (unit-tested without GIMP), so the mapping to the real `gi`
|
|
316
|
+
# enum objects has to live here instead.
|
|
317
|
+
_HUE_RANGE_ENUM = {
|
|
318
|
+
'all': Gimp.HueRange.ALL,
|
|
319
|
+
'red': Gimp.HueRange.RED,
|
|
320
|
+
'yellow': Gimp.HueRange.YELLOW,
|
|
321
|
+
'green': Gimp.HueRange.GREEN,
|
|
322
|
+
'cyan': Gimp.HueRange.CYAN,
|
|
323
|
+
'blue': Gimp.HueRange.BLUE,
|
|
324
|
+
'magenta': Gimp.HueRange.MAGENTA,
|
|
325
|
+
}
|
|
326
|
+
_TRANSFER_MODE_ENUM = {
|
|
327
|
+
'shadows': Gimp.TransferMode.SHADOWS,
|
|
328
|
+
'midtones': Gimp.TransferMode.MIDTONES,
|
|
329
|
+
'highlights': Gimp.TransferMode.HIGHLIGHTS,
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _set_exposure(cfg, params):
|
|
334
|
+
cfg.set_property('exposure', params['exposure'])
|
|
335
|
+
cfg.set_property('black-level', params['black-level'])
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _set_brightness_contrast(cfg, params):
|
|
339
|
+
cfg.set_property('brightness', params['brightness'])
|
|
340
|
+
cfg.set_property('contrast', params['contrast'])
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _set_hue_saturation(cfg, params):
|
|
344
|
+
cfg.set_property('range', _HUE_RANGE_ENUM[params['range']])
|
|
345
|
+
cfg.set_property('hue', params['hue'])
|
|
346
|
+
cfg.set_property('saturation', params['saturation'])
|
|
347
|
+
cfg.set_property('lightness', params['lightness'])
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _set_color_balance(cfg, params):
|
|
351
|
+
cfg.set_property('range', _TRANSFER_MODE_ENUM[params['range']])
|
|
352
|
+
cfg.set_property('cyan-red', params['cyan-red'])
|
|
353
|
+
cfg.set_property('magenta-green', params['magenta-green'])
|
|
354
|
+
cfg.set_property('yellow-blue', params['yellow-blue'])
|
|
355
|
+
cfg.set_property('preserve-luminosity', params['preserve-luminosity'])
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _set_color_temperature(cfg, params):
|
|
359
|
+
cfg.set_property('original-temperature', params['original-temperature'])
|
|
360
|
+
cfg.set_property('intended-temperature', params['intended-temperature'])
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _set_shadows_highlights(cfg, params):
|
|
364
|
+
for key in (
|
|
365
|
+
'shadows', 'highlights', 'whitepoint', 'radius', 'compress',
|
|
366
|
+
'shadows-ccorrect', 'highlights-ccorrect',
|
|
367
|
+
):
|
|
368
|
+
cfg.set_property(key, params[key])
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _set_saturation(cfg, params):
|
|
372
|
+
cfg.set_property('scale', params['scale'])
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _set_vibrance(cfg, params):
|
|
376
|
+
cfg.set_property('vibrance', params['vibrance'])
|
|
377
|
+
cfg.set_property('saturation', params['saturation'])
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _set_sharpen(cfg, params):
|
|
381
|
+
cfg.set_property('std-dev', params['std-dev'])
|
|
382
|
+
cfg.set_property('scale', params['scale'])
|
|
383
|
+
cfg.set_property('threshold', params['threshold'])
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _set_noise_reduction(cfg, params):
|
|
387
|
+
cfg.set_property('iterations', params['iterations'])
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _set_gaussian_blur(cfg, params):
|
|
391
|
+
cfg.set_property('std-dev-x', params['std-dev-x'])
|
|
392
|
+
cfg.set_property('std-dev-y', params['std-dev-y'])
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
SETTERS = {
|
|
396
|
+
'gimp:curves': _set_curves,
|
|
397
|
+
'gimp:levels': _set_levels,
|
|
398
|
+
'gegl:exposure': _set_exposure,
|
|
399
|
+
'gimp:brightness-contrast': _set_brightness_contrast,
|
|
400
|
+
'gimp:hue-saturation': _set_hue_saturation,
|
|
401
|
+
'gimp:color-balance': _set_color_balance,
|
|
402
|
+
'gegl:color-temperature': _set_color_temperature,
|
|
403
|
+
'gegl:shadows-highlights': _set_shadows_highlights,
|
|
404
|
+
'gegl:saturation': _set_saturation,
|
|
405
|
+
'gegl:vibrance': _set_vibrance,
|
|
406
|
+
'gegl:unsharp-mask': _set_sharpen,
|
|
407
|
+
'gegl:noise-reduction': _set_noise_reduction,
|
|
408
|
+
'gegl:gaussian-blur': _set_gaussian_blur,
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _channel_by_name(img, name):
|
|
413
|
+
for ch in img.get_channels():
|
|
414
|
+
if ch.get_name() == name:
|
|
415
|
+
return ch
|
|
416
|
+
raise ValueError('no mask channel named %r (create one with create_mask)' % name)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _append_masked(img, layer, f, mask):
|
|
420
|
+
"""Append f, confined to channel `mask` if given. A filter appended while a selection is
|
|
421
|
+
active keeps that selection as its own mask, which persists after the selection is
|
|
422
|
+
cleared and through XCF, so no extra layer or pixel copy is needed.
|
|
423
|
+
|
|
424
|
+
When `mask` is falsy, this explicitly clears any active selection FIRST -- defence in depth
|
|
425
|
+
against a stray selection left active by an earlier op (e.g. `create_mask` used to leave its
|
|
426
|
+
own channel selected; that bug is fixed at the source too, but an unmasked filter must never
|
|
427
|
+
silently inherit whatever happens to be selected regardless)."""
|
|
428
|
+
if mask:
|
|
429
|
+
img.select_item(Gimp.ChannelOps.REPLACE, _channel_by_name(img, mask))
|
|
430
|
+
else:
|
|
431
|
+
Gimp.Selection.none(img)
|
|
432
|
+
try:
|
|
433
|
+
layer.append_filter(f)
|
|
434
|
+
finally:
|
|
435
|
+
if mask:
|
|
436
|
+
Gimp.Selection.none(img)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _apply_filter(img, args, operation, params, default_name, type_=None):
|
|
440
|
+
"""Create (or, with filter_id, update in place) a filter and record it in the ledger.
|
|
441
|
+
A new filter may be confined to a mask channel (`mask`); a re-edit keeps the mask the
|
|
442
|
+
filter was created with. `type_` is the user-facing adjust `type` (e.g. "shadows_highlights")
|
|
443
|
+
recorded alongside `operation` (the GEGL op name) so a filter listing can report what the
|
|
444
|
+
model asked for, not just the GEGL internals."""
|
|
445
|
+
filters, unknown = _ledger_get(img)
|
|
446
|
+
if args.get('filter_id') is not None:
|
|
447
|
+
f = _existing_filter(img, args, operation)
|
|
448
|
+
if args.get('mask'):
|
|
449
|
+
raise ValueError('mask is fixed when the filter is created; add a new filter to change it')
|
|
450
|
+
params['mask'] = filters.get(f.get_name(), {}).get('params', {}).get('mask')
|
|
451
|
+
SETTERS[operation](f.get_config(), params)
|
|
452
|
+
f.update()
|
|
453
|
+
else:
|
|
454
|
+
layer = _layer(img, args)
|
|
455
|
+
f = Gimp.DrawableFilter.new(layer, operation, _unique_name(img, args.get('name', default_name)))
|
|
456
|
+
SETTERS[operation](f.get_config(), params)
|
|
457
|
+
params['mask'] = args.get('mask')
|
|
458
|
+
_append_masked(img, layer, f, params['mask'])
|
|
459
|
+
filters[f.get_name()] = {'operation': operation, 'type': type_, 'params': params}
|
|
460
|
+
_ledger_put(img, filters, unknown)
|
|
461
|
+
return {'filter_id': f.get_id(), 'name': f.get_name(), 'type': type_, 'mask': params['mask']}
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
# ---- operations -------------------------------------------------------------------------
|
|
465
|
+
|
|
466
|
+
def op_ping(args):
|
|
467
|
+
major, minor, micro = lib.parse_gimp_version(Gimp.version())
|
|
468
|
+
return {'major': major, 'minor': minor, 'micro': micro,
|
|
469
|
+
'images': [i.get_id() for i in Gimp.get_images()]}
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
# `precision` open-time promotion, using the enum names GIMP 3 actually exposes (verified live).
|
|
473
|
+
# u16/float-non-linear is the "perceptual-encoded" bit depth banding-prone tone moves need
|
|
474
|
+
# (measured live: ~130ms to convert_precision a 24 MP image -- negligible next to the rest of
|
|
475
|
+
# `open`'s cost).
|
|
476
|
+
_PRECISION_ENUM = {
|
|
477
|
+
'16': Gimp.Precision.U16_NON_LINEAR,
|
|
478
|
+
'32': Gimp.Precision.FLOAT_NON_LINEAR,
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def op_open(args):
|
|
483
|
+
path = lib.require(args, 'path')
|
|
484
|
+
if not os.path.exists(path):
|
|
485
|
+
raise FileNotFoundError('no file at %s' % path)
|
|
486
|
+
precision = args.get('precision', 'keep')
|
|
487
|
+
if precision not in ('keep', '16', '32'):
|
|
488
|
+
raise ValueError("precision must be one of 'keep', '16', '32'")
|
|
489
|
+
ext = os.path.splitext(path)[1].lower()
|
|
490
|
+
try:
|
|
491
|
+
img = Gimp.file_load(Gimp.RunMode.NONINTERACTIVE, Gio.File.new_for_path(path))
|
|
492
|
+
except Exception as e:
|
|
493
|
+
# Attempt the load first rather than refusing by extension alone: an
|
|
494
|
+
# install with a raw-develop plug-in (darktable/RawTherapee/ART) can
|
|
495
|
+
# open these directly. Only when the load actually fails AND the
|
|
496
|
+
# extension is one we know GIMP core can't read natively do we
|
|
497
|
+
# reframe it as gimp_unsupported_file with an actionable hint.
|
|
498
|
+
if ext in RAW_EXTENSIONS:
|
|
499
|
+
raise lib.OpError(
|
|
500
|
+
'gimp_unsupported_file',
|
|
501
|
+
'GIMP could not open this %s file (no raw loader by default); install a '
|
|
502
|
+
'raw-develop plug-in (darktable, RawTherapee, or ART) for GIMP, or develop it '
|
|
503
|
+
'externally first and open the resulting JPEG/TIFF. (%s)' % (ext, e),
|
|
504
|
+
)
|
|
505
|
+
raise
|
|
506
|
+
# Everything from here on runs against a real, open `img` -- ANY exception in this block
|
|
507
|
+
# (precision conversion, ledger pruning, or building the default proxy) must free it before
|
|
508
|
+
# propagating, or a failure partway through leaks an open image nothing can ever reach again
|
|
509
|
+
# (not just a precision-conversion failure specifically, which is all an earlier, narrower
|
|
510
|
+
# version of this guard covered).
|
|
511
|
+
try:
|
|
512
|
+
# XCF stores the selection, so a file saved from the GUI can arrive with one active. Every
|
|
513
|
+
# op here works on the whole canvas, and a transform with a selection active moves only
|
|
514
|
+
# the selected pixels (leaving a floating selection), so it is cleared on the way in.
|
|
515
|
+
Gimp.Selection.none(img)
|
|
516
|
+
if precision != 'keep':
|
|
517
|
+
# `convert_precision` does NOT raise on failure -- verified live that it returns
|
|
518
|
+
# plain `False` (GIMP logs a "Calling error" to stderr, e.g. "must not be of type
|
|
519
|
+
# 'indexed'", but nothing propagates to Python), so a bare call here would silently
|
|
520
|
+
# report `ok: true` with the ORIGINAL precision still in effect, misleading the
|
|
521
|
+
# caller into thinking their promotion applied when it didn't. Checking the return
|
|
522
|
+
# value turns that into a real, catchable failure.
|
|
523
|
+
if not img.convert_precision(_PRECISION_ENUM[precision]):
|
|
524
|
+
raise lib.OpError(
|
|
525
|
+
'gimp_op_failed',
|
|
526
|
+
"precision promotion to %r failed for this image (base_type=%s); GIMP "
|
|
527
|
+
"requires RGB or grayscale for precision conversion, not indexed"
|
|
528
|
+
% (precision, img.get_base_type().value_nick),
|
|
529
|
+
)
|
|
530
|
+
# Reconciled here, not just at the start of every geometry op's own check: a `.xcf` opened
|
|
531
|
+
# from disk can carry ledger records for filters that no longer exist in it for any number
|
|
532
|
+
# of reasons outside this bridge's control (edited by the GUI, hand-edited, a partial
|
|
533
|
+
# write) -- pruning now means staleness never has a chance to accumulate across a session.
|
|
534
|
+
_prune_stale_ledger_records(img)
|
|
535
|
+
_proxy(img, 1024) # build the default preview proxy now so the first preview is fast too
|
|
536
|
+
except Exception:
|
|
537
|
+
img.delete()
|
|
538
|
+
raise
|
|
539
|
+
return _describe(img)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _existing_ledger_params(img, args, operation):
|
|
543
|
+
"""The existing filter's own ledger `params`, when `args` names a `filter_id` for a
|
|
544
|
+
bridge-applied filter of this exact operation -- the merge base a re-edit's builder resolves
|
|
545
|
+
unspecified fields against. None on create.
|
|
546
|
+
|
|
547
|
+
A re-edit of a filter this bridge did NOT create is refused: its current values can't be read
|
|
548
|
+
back exactly (libgimp's readback is lossy, and there is no ledger record to merge from), so a
|
|
549
|
+
partial re-edit would reset every unmentioned field to the creation default, and recording the
|
|
550
|
+
result as ours would drop whatever mask the filter carries from the geometry refusal and the
|
|
551
|
+
preview proxy."""
|
|
552
|
+
if args.get('filter_id') is None:
|
|
553
|
+
return None
|
|
554
|
+
f = _existing_filter(img, args, operation)
|
|
555
|
+
filters, _unknown = _ledger_get(img)
|
|
556
|
+
rec = filters.get(f.get_name())
|
|
557
|
+
if rec and rec.get('operation') == operation:
|
|
558
|
+
return rec['params']
|
|
559
|
+
parasite = img.get_parasite(META)
|
|
560
|
+
if parasite and lib.ledger_is_newer_version(bytes(parasite.get_data())):
|
|
561
|
+
raise ValueError(
|
|
562
|
+
'filter %s cannot be re-edited: this document was saved by a newer Editmamei, whose '
|
|
563
|
+
'filter records this version cannot read. Re-edit it with that version, or delete and '
|
|
564
|
+
're-create the filter.' % f.get_id()
|
|
565
|
+
)
|
|
566
|
+
raise ValueError(
|
|
567
|
+
'filter %s was not created by Editmamei; its current values cannot be read back exactly. '
|
|
568
|
+
'Delete it and re-create it, or edit it in the GIMP GUI.' % f.get_id()
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def op_curves(args):
|
|
573
|
+
"""One gimp:curves filter per channel: a single filter carries one channel's curve
|
|
574
|
+
(setting red then blue on one filter renders only blue). A re-edit that omits `channel` or
|
|
575
|
+
`points` keeps the existing filter's own value for that field (the same merge-not-reset
|
|
576
|
+
contract every other adjust type gets) rather than silently resetting `channel` to 'value' or
|
|
577
|
+
requiring `points` to be resent every time."""
|
|
578
|
+
img = _image(args)
|
|
579
|
+
existing = _existing_ledger_params(img, args, 'gimp:curves')
|
|
580
|
+
channel = args.get('channel', existing['channel'] if existing else 'value')
|
|
581
|
+
if 'points' in args and args['points'] is not None:
|
|
582
|
+
points = [[float(x), float(y)] for x, y in args['points']]
|
|
583
|
+
if len(points) < 2:
|
|
584
|
+
raise ValueError('points needs at least two [x, y] pairs')
|
|
585
|
+
elif existing is not None:
|
|
586
|
+
points = existing['points']
|
|
587
|
+
else:
|
|
588
|
+
raise ValueError('points is required')
|
|
589
|
+
params = {'channel': channel, 'points': points}
|
|
590
|
+
return _apply_filter(img, args, 'gimp:curves', params, 'Curves', type_='curves')
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def op_levels(args):
|
|
594
|
+
"""Re-edit merges: any of channel/in_low/in_high/gamma/out_low/out_high omitted from a
|
|
595
|
+
re-edit keeps the existing filter's own value rather than resetting to the create-time
|
|
596
|
+
default (0/255/1.0/0/255)."""
|
|
597
|
+
img = _image(args)
|
|
598
|
+
existing = _existing_ledger_params(img, args, 'gimp:levels')
|
|
599
|
+
defaults = existing or {
|
|
600
|
+
'channel': 'value', 'in_low': 0.0, 'in_high': 255.0, 'gamma': 1.0,
|
|
601
|
+
'out_low': 0.0, 'out_high': 255.0,
|
|
602
|
+
}
|
|
603
|
+
params = lib.validate_levels({
|
|
604
|
+
'channel': args.get('channel', defaults['channel']),
|
|
605
|
+
'in_low': float(args.get('in_low', defaults['in_low'])),
|
|
606
|
+
'in_high': float(args.get('in_high', defaults['in_high'])),
|
|
607
|
+
'gamma': float(args.get('gamma', defaults['gamma'])),
|
|
608
|
+
'out_low': float(args.get('out_low', defaults['out_low'])),
|
|
609
|
+
'out_high': float(args.get('out_high', defaults['out_high'])),
|
|
610
|
+
})
|
|
611
|
+
return _apply_filter(img, args, 'gimp:levels', params, 'Levels', type_='levels')
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def op_adjust(args):
|
|
615
|
+
"""The single discriminated adjustment op: `type` picks the GEGL/GIMP operation
|
|
616
|
+
(`lib.ADJUST_OPERATIONS`) and a pure, gi-free param builder (`lib.ADJUST_PARAM_BUILDERS`)
|
|
617
|
+
that validates every range and returns GEGL-property-named params. curves/levels are folded
|
|
618
|
+
in here too so ONE dispatch handles every adjust type, but keep their own richer argument
|
|
619
|
+
shapes (a points list; per-side 0-255 levels) rather than forcing them through the generic
|
|
620
|
+
builders.
|
|
621
|
+
|
|
622
|
+
A re-edit (`filter_id` given) MERGES: the builder's `defaults` argument is the existing
|
|
623
|
+
filter's own ledger params rather than this type's hardcoded creation defaults, so a re-edit
|
|
624
|
+
that only mentions one field (e.g. `{contrast: 50}`) keeps every other field (e.g.
|
|
625
|
+
`brightness`) exactly as it was, instead of silently resetting it."""
|
|
626
|
+
type_ = args.get('type')
|
|
627
|
+
if type_ == 'curves':
|
|
628
|
+
return op_curves(args)
|
|
629
|
+
if type_ == 'levels':
|
|
630
|
+
return op_levels(args)
|
|
631
|
+
builder = lib.ADJUST_PARAM_BUILDERS.get(type_)
|
|
632
|
+
if builder is None:
|
|
633
|
+
raise ValueError('type must be one of %s' % sorted(lib.ADJUST_OPERATIONS))
|
|
634
|
+
img = _image(args)
|
|
635
|
+
operation = lib.ADJUST_OPERATIONS[type_]
|
|
636
|
+
defaults = _existing_ledger_params(img, args, operation) or lib.ADJUST_CREATE_DEFAULTS[type_]
|
|
637
|
+
params = builder(args, defaults)
|
|
638
|
+
default_name = type_.replace('_', ' ').title()
|
|
639
|
+
return _apply_filter(img, args, operation, params, default_name, type_=type_)
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def op_list_filters(args):
|
|
643
|
+
"""Bridge-applied filters report the ledger record (`source: editmamei`) with `params` in the
|
|
644
|
+
adjust tool's own field names and units (`lib.user_params`), so a listed value can be passed
|
|
645
|
+
straight back on a re-edit. Anything else reports libgimp's readback (`source: readback`):
|
|
646
|
+
raw GEGL property names and units, lossy for per-channel curves, with any value JSON can't
|
|
647
|
+
carry (a Gegl.Color, say) stringified."""
|
|
648
|
+
img = _image(args)
|
|
649
|
+
filters, _unknown = _ledger_get(img)
|
|
650
|
+
out = []
|
|
651
|
+
for layer in _all_layers(img):
|
|
652
|
+
for f in layer.get_filters():
|
|
653
|
+
rec = filters.get(f.get_name())
|
|
654
|
+
if rec and rec['operation'] == f.get_operation_name():
|
|
655
|
+
source = 'editmamei'
|
|
656
|
+
type_ = rec.get('type') or lib.OPERATION_TYPES.get(rec['operation'])
|
|
657
|
+
mask = rec['params'].get('mask')
|
|
658
|
+
params = lib.user_params(type_, rec['params'])
|
|
659
|
+
else:
|
|
660
|
+
cfg, params, source, type_, mask = f.get_config(), {}, 'readback', None, None
|
|
661
|
+
for p in cfg.list_properties():
|
|
662
|
+
v = cfg.get_property(p.name)
|
|
663
|
+
if isinstance(v, Gimp.Curve):
|
|
664
|
+
v = _curve_points(v)
|
|
665
|
+
elif hasattr(v, 'value_nick'):
|
|
666
|
+
v = v.value_nick
|
|
667
|
+
params[p.name] = lib.json_safe(v)
|
|
668
|
+
out.append({'layer': layer.get_name(), 'filter_id': f.get_id(), 'name': f.get_name(),
|
|
669
|
+
'operation': f.get_operation_name(), 'type': type_, 'visible': f.get_visible(),
|
|
670
|
+
'source': source, 'mask': mask, 'params': params})
|
|
671
|
+
return {'filters': out}
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _find_filter(img, filter_id):
|
|
675
|
+
"""Locate a filter by id AMONG img's own layers (same reasoning as `_existing_filter`: a
|
|
676
|
+
global filter-id lookup could hand back one belonging to a different, possibly closed,
|
|
677
|
+
image)."""
|
|
678
|
+
for layer in _all_layers(img):
|
|
679
|
+
for f in layer.get_filters():
|
|
680
|
+
if f.get_id() == filter_id:
|
|
681
|
+
return layer, f
|
|
682
|
+
raise ValueError('no filter with id %s on image %s' % (filter_id, img.get_id()))
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def op_filter(args):
|
|
686
|
+
"""Stack management: `op` in list | set_visibility | delete. There is deliberately no
|
|
687
|
+
`reorder` -- `Gimp.DrawableFilter` exposes only delete/set_visible/update, and the PDB has no
|
|
688
|
+
raise/lower-filter procedure (verified live, GIMP 3.2.6); emulating it means deleting and
|
|
689
|
+
re-appending every filter above the moved one, which changes their ids and can't restore
|
|
690
|
+
masks on filters this bridge didn't create."""
|
|
691
|
+
img = _image(args)
|
|
692
|
+
fop = args.get('op')
|
|
693
|
+
if fop == 'list':
|
|
694
|
+
return op_list_filters(args)
|
|
695
|
+
if fop == 'set_visibility':
|
|
696
|
+
filter_id = int(lib.require(args, 'filter_id'))
|
|
697
|
+
_layer, f = _find_filter(img, filter_id)
|
|
698
|
+
visible = lib.require_bool(args, 'visible')
|
|
699
|
+
f.set_visible(visible)
|
|
700
|
+
return {'filter_id': f.get_id(), 'visible': visible}
|
|
701
|
+
if fop == 'delete':
|
|
702
|
+
filter_id = int(lib.require(args, 'filter_id'))
|
|
703
|
+
layer, f = _find_filter(img, filter_id)
|
|
704
|
+
name = f.get_name()
|
|
705
|
+
f.delete()
|
|
706
|
+
filters, unknown = _ledger_get(img)
|
|
707
|
+
filters.pop(name, None)
|
|
708
|
+
# `removed={name}` is what actually keeps it gone -- see `_ledger_put`/
|
|
709
|
+
# `lib.merged_ledger_for_write`: popping it from THIS in-memory dict alone doesn't stop a
|
|
710
|
+
# stale on-disk copy from surviving the merge and resurrecting the record.
|
|
711
|
+
_ledger_put(img, filters, unknown, removed={name})
|
|
712
|
+
return {'filter_id': filter_id, 'name': name, 'deleted': True}
|
|
713
|
+
if fop == 'reorder':
|
|
714
|
+
raise ValueError(
|
|
715
|
+
'filter reorder is not supported: GIMP has no reorder primitive for drawable '
|
|
716
|
+
'filters in this beta -- delete and re-create in the desired order instead'
|
|
717
|
+
)
|
|
718
|
+
raise ValueError('op must be one of list, set_visibility, delete (got %r)' % fop)
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
# ---- geometry -----------------------------------------------------------------------------
|
|
722
|
+
#
|
|
723
|
+
# Verified live (GIMP 3.2.6): unmasked filters, named mask CHANNELS, and the editmamei-filters
|
|
724
|
+
# ledger parasite all survive every op below -- `Image.crop`, `Item.transform_rotate`,
|
|
725
|
+
# `Image.flip`, and `Image.scale` all mutate the existing Image object in place rather than
|
|
726
|
+
# replacing it, so a parasite attached to the image is untouched, and layers/channels resize and
|
|
727
|
+
# reposition along with their drawable exactly as GIMP's own crop/rotate/flip tools would.
|
|
728
|
+
#
|
|
729
|
+
# A FILTER's OWN mask is a different story, and NOT what the paragraph above covers. Appending a
|
|
730
|
+
# filter while a selection is active bakes that selection in as a fixed internal snapshot at
|
|
731
|
+
# that moment -- `Gimp.DrawableFilter` exposes no `get_mask`/`set_mask` of any kind (verified
|
|
732
|
+
# live: not in its method list at all), so nothing on the Python API can re-point an EXISTING
|
|
733
|
+
# filter's mask at a channel's new content after a transform, no matter what the channel's own
|
|
734
|
+
# data ends up looking like. Measured live, per op:
|
|
735
|
+
# - crop: the filter's own confinement survives and stays pixel-aligned with the surviving
|
|
736
|
+
# content (its internal mask is offset/cropped along with everything else).
|
|
737
|
+
# - rotate/flip: the filter's own confinement does NOT move -- it stays pinned to the
|
|
738
|
+
# ORIGINAL, pre-transform pixel positions while the layer/channel content underneath it
|
|
739
|
+
# rotates or flips, so the rendered result is silently wrong (misaligned) even though the
|
|
740
|
+
# channel itself, and the ledger's `mask` field, both look fine.
|
|
741
|
+
# - resize: the filter's own confinement does not scale either; measured as the filter
|
|
742
|
+
# appearing to have NO effect at all afterward (an entirely blank mask), not merely
|
|
743
|
+
# misaligned.
|
|
744
|
+
# rotate/flip/resize therefore REFUSE outright (`invalid_argument`) when the image has any filter
|
|
745
|
+
# whose ledger record names a mask, rather than silently rendering a masked edit in the wrong
|
|
746
|
+
# place -- the same "refuse rather than silently corrupt" contract as `filter op=reorder`. crop
|
|
747
|
+
# never refuses for this reason, since it verified correct.
|
|
748
|
+
|
|
749
|
+
def _live_filter_names(img):
|
|
750
|
+
return {f.get_name() for layer in _all_layers(img) for f in layer.get_filters()}
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def _prune_stale_ledger_records(img):
|
|
754
|
+
"""Drop ledger records whose name no longer matches any LIVE filter -- the filter behind that
|
|
755
|
+
record is simply gone (deleted outside `filter op=delete`, or a document edited by the GUI or
|
|
756
|
+
another tool), and a stale record left in place would otherwise go on looking like a real,
|
|
757
|
+
still-masked filter to `_classify_geometry_filters` forever. Called at the START of every
|
|
758
|
+
geometry op's masked-filter check, and once at `open` time, so staleness never has a chance to
|
|
759
|
+
accumulate silently. Returns the (possibly pruned) filters dict; writes the ledger back only
|
|
760
|
+
if something was actually pruned."""
|
|
761
|
+
filters, unknown = _ledger_get(img)
|
|
762
|
+
stale = lib.stale_ledger_names(filters, _live_filter_names(img))
|
|
763
|
+
if stale:
|
|
764
|
+
for name in stale:
|
|
765
|
+
filters.pop(name, None)
|
|
766
|
+
_ledger_put(img, filters, unknown, removed=stale)
|
|
767
|
+
return filters
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _classify_geometry_filters(img):
|
|
771
|
+
"""(masked_names, unverifiable_names) for every LIVE filter on `img` right now -- the
|
|
772
|
+
ledger-pruned, gi-free classification lives in `lib.classify_geometry_filters`; this just
|
|
773
|
+
supplies it the live GIMP state (every filter's own name + operation) instead of trusting the
|
|
774
|
+
ledger alone, per the settled decision that a live filter the ledger doesn't recognise (never
|
|
775
|
+
ledgered at all, e.g. because its own ledger write was skipped -- see
|
|
776
|
+
`lib.merged_ledger_for_write` -- or one whose name/operation the ledger has no matching record
|
|
777
|
+
for) must be treated as POSSIBLY masked, not silently assumed safe."""
|
|
778
|
+
filters = _prune_stale_ledger_records(img)
|
|
779
|
+
live = [(f.get_name(), f.get_operation_name()) for layer in _all_layers(img) for f in layer.get_filters()]
|
|
780
|
+
return lib.classify_geometry_filters(filters, live)
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def _refuse_if_masked_filters(img, op_name):
|
|
784
|
+
masked, unverifiable = _classify_geometry_filters(img)
|
|
785
|
+
if unverifiable:
|
|
786
|
+
raise ValueError(
|
|
787
|
+
'%s cannot proceed: filter(s) %s were not created by Editmamei (no matching ledger '
|
|
788
|
+
'record for their name and operation), so whether they are masked cannot be checked. '
|
|
789
|
+
'Delete them first, or make this geometry change before adding them.'
|
|
790
|
+
% (op_name, ', '.join(repr(n) for n in sorted(unverifiable)))
|
|
791
|
+
)
|
|
792
|
+
if masked:
|
|
793
|
+
raise ValueError(
|
|
794
|
+
'%s would misalign the masked adjustment(s) %s: a filter\'s mask cannot move with '
|
|
795
|
+
'this transform. Rotate, flip or resize before adding masked adjustments, or delete '
|
|
796
|
+
'those filters first and re-create them afterwards. Crop is unaffected.'
|
|
797
|
+
% (op_name, ', '.join(repr(n) for n in sorted(masked)))
|
|
798
|
+
)
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def op_crop(args):
|
|
802
|
+
img = _image(args)
|
|
803
|
+
left, top = int(lib.require(args, 'left')), int(lib.require(args, 'top'))
|
|
804
|
+
width, height = int(lib.require(args, 'width')), int(lib.require(args, 'height'))
|
|
805
|
+
# `Image.crop(width, height, left, top)` is a raw resize-the-canvas primitive underneath --
|
|
806
|
+
# it happily accepts a rectangle that's partly or fully outside the existing image (left/top
|
|
807
|
+
# negative, or left+width/top+height past the current edges), silently padding the new canvas
|
|
808
|
+
# with blank space rather than erroring. This bridge's `crop` is meant to be a bounds-checked
|
|
809
|
+
# sub-rectangle of what's already there (the same contract `preview`'s `region` and
|
|
810
|
+
# `histogram`/`compare`'s regions already enforce), so `left`/`top`/`width`/`height` are
|
|
811
|
+
# validated against the CURRENT image bounds first (`invalid_argument` if not), same as any
|
|
812
|
+
# other region. The DoS floor `resize` validates its own target against is applied too --
|
|
813
|
+
# belt-and-braces, since a bounds-checked rectangle is already capped by the source image's own
|
|
814
|
+
# size, but this keeps the two geometry ops' size ceiling identical rather than assuming that.
|
|
815
|
+
lib.validate_region({'x': left, 'y': top, 'width': width, 'height': height}, img.get_width(), img.get_height())
|
|
816
|
+
width, height = lib.validate_resize_dims(width, height)
|
|
817
|
+
Gimp.Selection.none(img) # see op_open: geometry always applies to the whole canvas
|
|
818
|
+
img.crop(width, height, left, top)
|
|
819
|
+
_drop_proxies(img.get_id())
|
|
820
|
+
return {'width': img.get_width(), 'height': img.get_height()}
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def op_resize(args):
|
|
824
|
+
"""Scale the image (and every layer/channel in it) -- Photoshop's "Image Size", not "Canvas
|
|
825
|
+
Size" (that's `crop`). `width`/`height` given together stretch to exactly that box;
|
|
826
|
+
given alone, the other side is derived to keep aspect; `long_edge` scales so the longer side
|
|
827
|
+
lands there."""
|
|
828
|
+
img = _image(args)
|
|
829
|
+
_refuse_if_masked_filters(img, 'resize')
|
|
830
|
+
width, height, long_edge = args.get('width'), args.get('height'), args.get('long_edge')
|
|
831
|
+
w0, h0 = img.get_width(), img.get_height()
|
|
832
|
+
if long_edge is not None:
|
|
833
|
+
long_edge = int(long_edge)
|
|
834
|
+
if long_edge <= 0:
|
|
835
|
+
raise ValueError('long_edge must be positive')
|
|
836
|
+
scale = float(long_edge) / max(w0, h0)
|
|
837
|
+
width, height = max(1, round(w0 * scale)), max(1, round(h0 * scale))
|
|
838
|
+
elif width is not None and height is not None:
|
|
839
|
+
width, height = int(width), int(height)
|
|
840
|
+
elif width is not None:
|
|
841
|
+
width = int(width)
|
|
842
|
+
height = max(1, round(h0 * (width / float(w0))))
|
|
843
|
+
elif height is not None:
|
|
844
|
+
height = int(height)
|
|
845
|
+
width = max(1, round(w0 * (height / float(h0))))
|
|
846
|
+
else:
|
|
847
|
+
raise ValueError('resize needs one of width, height, or long_edge')
|
|
848
|
+
width, height = lib.validate_resize_dims(width, height)
|
|
849
|
+
Gimp.Selection.none(img) # see op_open: geometry always applies to the whole canvas
|
|
850
|
+
img.scale(width, height)
|
|
851
|
+
_drop_proxies(img.get_id())
|
|
852
|
+
return {'width': img.get_width(), 'height': img.get_height()}
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def op_rotate(args):
|
|
856
|
+
"""Arbitrary-degree rotate (straighten): `Image.rotate` only offers the three 90-degree
|
|
857
|
+
multiples, so every angle goes through `Item.transform_rotate` instead. `expand` resizes the
|
|
858
|
+
canvas to the rotated layers' new bounds (`Image.resize_to_layers`); without it the canvas
|
|
859
|
+
stays put and rotated content can fall outside it, same as a Photoshop free-transform without
|
|
860
|
+
"reveal all".
|
|
861
|
+
|
|
862
|
+
`transform_rotate`'s second argument is `auto_center` -- confusingly, passing True means
|
|
863
|
+
"IGNORE the center_x/center_y given and use this item's own bounds' center instead," not
|
|
864
|
+
"yes, use the center I computed." That must be False here: every layer AND every named mask
|
|
865
|
+
channel has to rotate around the exact SAME point (the image's own center) to stay aligned
|
|
866
|
+
with each other, and `auto_center=True` would rotate each one around its own individual
|
|
867
|
+
center instead, which only happens to look right when there is nothing else to line up
|
|
868
|
+
against. Refuses outright when a masked filter is present -- see this section's own comment
|
|
869
|
+
for why rotating the channel isn't enough to keep such a filter's rendering aligned."""
|
|
870
|
+
img = _image(args)
|
|
871
|
+
_refuse_if_masked_filters(img, 'rotate')
|
|
872
|
+
degrees = float(lib.require(args, 'degrees'))
|
|
873
|
+
# With a selection active, transform_rotate moves only the selected pixels and leaves a
|
|
874
|
+
# floating selection behind (see op_open).
|
|
875
|
+
Gimp.Selection.none(img)
|
|
876
|
+
expand = bool(args.get('expand', False))
|
|
877
|
+
cx, cy = img.get_width() / 2.0, img.get_height() / 2.0
|
|
878
|
+
angle = math.radians(degrees)
|
|
879
|
+
for layer in img.get_layers():
|
|
880
|
+
layer.transform_rotate(angle, False, cx, cy)
|
|
881
|
+
for channel in img.get_channels():
|
|
882
|
+
channel.transform_rotate(angle, False, cx, cy)
|
|
883
|
+
if expand:
|
|
884
|
+
img.resize_to_layers()
|
|
885
|
+
_drop_proxies(img.get_id())
|
|
886
|
+
return {'width': img.get_width(), 'height': img.get_height(), 'degrees': degrees}
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
_FLIP_ORIENTATIONS = {
|
|
890
|
+
'horizontal': Gimp.OrientationType.HORIZONTAL,
|
|
891
|
+
'vertical': Gimp.OrientationType.VERTICAL,
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
def op_flip(args):
|
|
896
|
+
"""Refuses outright when a masked filter is present -- see the geometry section's own comment
|
|
897
|
+
for why flipping the channel isn't enough to keep such a filter's rendering aligned."""
|
|
898
|
+
orientation = args.get('orientation')
|
|
899
|
+
if orientation not in _FLIP_ORIENTATIONS:
|
|
900
|
+
raise ValueError('orientation must be one of %s' % sorted(_FLIP_ORIENTATIONS))
|
|
901
|
+
img = _image(args)
|
|
902
|
+
_refuse_if_masked_filters(img, 'flip')
|
|
903
|
+
Gimp.Selection.none(img) # see op_open: geometry always applies to the whole canvas
|
|
904
|
+
img.flip(_FLIP_ORIENTATIONS[orientation])
|
|
905
|
+
_drop_proxies(img.get_id())
|
|
906
|
+
return {'width': img.get_width(), 'height': img.get_height()}
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
def _proxy_render(img, max_px):
|
|
910
|
+
"""A flattened, downscaled render of img with its live filters: the proxy plus mirrored
|
|
911
|
+
filters. Caller deletes it."""
|
|
912
|
+
dup = _proxy(img, max_px).duplicate()
|
|
913
|
+
try:
|
|
914
|
+
_mirror_filters(img, dup)
|
|
915
|
+
dup.flatten()
|
|
916
|
+
except Exception:
|
|
917
|
+
dup.delete()
|
|
918
|
+
raise
|
|
919
|
+
return dup
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
def _region_full_res(img, region):
|
|
923
|
+
"""Full-res duplicate, cropped to `region`, flattened. Caller deletes it. A plain
|
|
924
|
+
`Image.duplicate()` copies each layer's real live DrawableFilters (not just pixels), so
|
|
925
|
+
rendering this way is exact -- the filter runs at native resolution over the cropped area
|
|
926
|
+
BEFORE any later downscale, unlike the preview proxy (which shrinks the base first and then
|
|
927
|
+
has to approximate a spatial filter's radius, see `_mirror_filters`).
|
|
928
|
+
|
|
929
|
+
`region` is validated against `img`'s FULL-RES bounds before anything is duplicated (partly
|
|
930
|
+
or fully outside raises `invalid_argument` rather than silently clamping to whatever sliver
|
|
931
|
+
overlaps); the duplicate itself is torn down on ANY failure after that, not just the size
|
|
932
|
+
check, so a crop/flatten error can never leak an orphaned image."""
|
|
933
|
+
x, y, w, h = lib.validate_region(region, img.get_width(), img.get_height())
|
|
934
|
+
dup = img.duplicate()
|
|
935
|
+
try:
|
|
936
|
+
dup.crop(w, h, x, y)
|
|
937
|
+
layer = dup.flatten()
|
|
938
|
+
except Exception:
|
|
939
|
+
dup.delete()
|
|
940
|
+
raise
|
|
941
|
+
return dup, layer
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
def _scale_to_max(dup, max_px):
|
|
945
|
+
w, h = dup.get_width(), dup.get_height()
|
|
946
|
+
scale = min(1.0, float(max_px) / max(w, h))
|
|
947
|
+
if scale < 1.0:
|
|
948
|
+
dup.scale(max(1, round(w * scale)), max(1, round(h * scale)))
|
|
949
|
+
return dup
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def op_preview(args):
|
|
953
|
+
"""Proxy render with live filters, by default; `region` renders a full-res crop instead (see
|
|
954
|
+
`_region_full_res`), which is exact for spatial filters rather than approximate. `proxy` in
|
|
955
|
+
the result says which route was used, so the tool layer can flag a spatial filter's preview
|
|
956
|
+
as approximate only when it actually is.
|
|
957
|
+
|
|
958
|
+
Every preview leaves the bridge through `_export_stripped` -- the SAME metadata-stripping,
|
|
959
|
+
format-refusing writer `export` uses -- because a preview routinely goes to a cloud model and
|
|
960
|
+
a source photo's EXIF/GPS must never ride along on that path either."""
|
|
961
|
+
img = _image(args)
|
|
962
|
+
max_px = lib.validate_max_px(int(args.get('max_px', 1024)))
|
|
963
|
+
region = args.get('region')
|
|
964
|
+
out_path = lib.require(args, 'out_path')
|
|
965
|
+
if region:
|
|
966
|
+
dup, _layer = _region_full_res(img, region)
|
|
967
|
+
try:
|
|
968
|
+
_scale_to_max(dup, max_px)
|
|
969
|
+
_export_stripped(dup, out_path)
|
|
970
|
+
return {'path': out_path, 'width': dup.get_width(), 'height': dup.get_height(),
|
|
971
|
+
'proxy': False}
|
|
972
|
+
finally:
|
|
973
|
+
dup.delete()
|
|
974
|
+
dup = _proxy_render(img, max_px)
|
|
975
|
+
try:
|
|
976
|
+
_export_stripped(dup, out_path)
|
|
977
|
+
return {'path': out_path, 'width': dup.get_width(), 'height': dup.get_height(),
|
|
978
|
+
'proxy': True}
|
|
979
|
+
finally:
|
|
980
|
+
dup.delete()
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
def _channel_stats_from(dup, layer, channels):
|
|
984
|
+
w, h = dup.get_width(), dup.get_height()
|
|
985
|
+
buf = layer.get_buffer()
|
|
986
|
+
rect = Gegl.Rectangle.new(0, 0, w, h)
|
|
987
|
+
rgb = buf.get(rect, 1.0, "R'G'B' u8", Gegl.AbyssPolicy.NONE) if set(channels) - {'luminance'} else None
|
|
988
|
+
lum = buf.get(rect, 1.0, "Y' u8", Gegl.AbyssPolicy.NONE) if 'luminance' in channels else None
|
|
989
|
+
out = {}
|
|
990
|
+
for ch in channels:
|
|
991
|
+
out[ch] = lib.channel_stats(lum if ch == 'luminance' else rgb[('red', 'green', 'blue').index(ch)::3])
|
|
992
|
+
return w, h, out
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def op_histogram(args):
|
|
996
|
+
"""Stats for several channels from ONE render. Default (whole image): the 1024 px preview
|
|
997
|
+
render (~0.3 s), or the full-resolution composite with exact=True (~3.7 s for 24 MP with
|
|
998
|
+
filters). `region`: computed on the (mirrored) proxy when the region maps to at least
|
|
999
|
+
`lib.MIN_PROXY_REGION_PX` on a side there, else a full-res crop -- same "too few proxy
|
|
1000
|
+
samples to mean anything" reasoning as `lib.MIN_PROXY_REGION_PX`'s own docstring; exact=True
|
|
1001
|
+
always forces the full-res crop regardless of size."""
|
|
1002
|
+
img = _image(args)
|
|
1003
|
+
channels = args.get('channels') or list(lib.HIST_CHANNELS)
|
|
1004
|
+
bad = [c for c in channels if c not in lib.HIST_CHANNELS]
|
|
1005
|
+
if bad:
|
|
1006
|
+
raise ValueError('channels must be drawn from %s, got %s' % (list(lib.HIST_CHANNELS), bad))
|
|
1007
|
+
exact = bool(args.get('exact', False))
|
|
1008
|
+
region = args.get('region')
|
|
1009
|
+
if region:
|
|
1010
|
+
lib.validate_region(region, img.get_width(), img.get_height())
|
|
1011
|
+
|
|
1012
|
+
if region and not exact:
|
|
1013
|
+
img_w = img.get_width()
|
|
1014
|
+
scale = min(1.0, 1024.0 / max(img_w, img.get_height())) if img_w else 1.0
|
|
1015
|
+
px, py, pw, ph = lib.region_to_proxy_px(region, scale)
|
|
1016
|
+
if pw >= lib.MIN_PROXY_REGION_PX and ph >= lib.MIN_PROXY_REGION_PX:
|
|
1017
|
+
dup = _proxy_render(img, 1024)
|
|
1018
|
+
try:
|
|
1019
|
+
px = min(px, max(0, dup.get_width() - 1))
|
|
1020
|
+
py = min(py, max(0, dup.get_height() - 1))
|
|
1021
|
+
pw = min(pw, dup.get_width() - px)
|
|
1022
|
+
ph = min(ph, dup.get_height() - py)
|
|
1023
|
+
dup.crop(pw, ph, px, py)
|
|
1024
|
+
layer = dup.flatten()
|
|
1025
|
+
w, h, out = _channel_stats_from(dup, layer, channels)
|
|
1026
|
+
finally:
|
|
1027
|
+
dup.delete()
|
|
1028
|
+
return {'exact': False, 'width': w, 'height': h, 'pixels': w * h, 'channels': out}
|
|
1029
|
+
region = dict(region) # fall through to the full-res path below
|
|
1030
|
+
exact = True
|
|
1031
|
+
|
|
1032
|
+
if region:
|
|
1033
|
+
dup, layer = _region_full_res(img, region)
|
|
1034
|
+
elif exact:
|
|
1035
|
+
dup, layer = _composite(img)
|
|
1036
|
+
else:
|
|
1037
|
+
dup = _proxy_render(img, 1024)
|
|
1038
|
+
layer = dup.get_layers()[0]
|
|
1039
|
+
try:
|
|
1040
|
+
w, h, out = _channel_stats_from(dup, layer, channels)
|
|
1041
|
+
finally:
|
|
1042
|
+
dup.delete()
|
|
1043
|
+
return {'exact': exact, 'width': w, 'height': h, 'pixels': w * h, 'channels': out}
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
def op_compare(args):
|
|
1047
|
+
"""`mode: 'before_after'` -- proxy base (no filters) vs proxy+filters (both the existing
|
|
1048
|
+
preview-proxy machinery already builds), per-channel stat deltas, optional paired preview
|
|
1049
|
+
JPEGs. `mode: 'regions'` -- the same per-channel stats for two rects, no delta (the caller
|
|
1050
|
+
compares)."""
|
|
1051
|
+
img = _image(args)
|
|
1052
|
+
mode = args.get('mode')
|
|
1053
|
+
channels = args.get('channels') or list(lib.HIST_CHANNELS)
|
|
1054
|
+
bad = [c for c in channels if c not in lib.HIST_CHANNELS]
|
|
1055
|
+
if bad:
|
|
1056
|
+
raise ValueError('channels must be drawn from %s, got %s' % (list(lib.HIST_CHANNELS), bad))
|
|
1057
|
+
|
|
1058
|
+
if mode == 'before_after':
|
|
1059
|
+
region = args.get('region')
|
|
1060
|
+
if region:
|
|
1061
|
+
lib.validate_region(region, img.get_width(), img.get_height())
|
|
1062
|
+
max_px = lib.validate_max_px(int(args.get('max_px', 1024)))
|
|
1063
|
+
# Both duplicates' CREATION now lives inside this try (it used to happen before the try
|
|
1064
|
+
# started), and each is set to None first: if `_proxy_render` (building `after`) raised
|
|
1065
|
+
# right after `base` was already a real duplicate, the old code's `finally` never even
|
|
1066
|
+
# started running, so `base` leaked. Guarding each delete on "was it actually assigned"
|
|
1067
|
+
# lets either half's construction fail without leaking the other.
|
|
1068
|
+
base = None
|
|
1069
|
+
after = None
|
|
1070
|
+
try:
|
|
1071
|
+
base = _proxy(img, max_px).duplicate()
|
|
1072
|
+
after = _proxy_render(img, max_px)
|
|
1073
|
+
if region:
|
|
1074
|
+
img_w = img.get_width()
|
|
1075
|
+
scale = min(1.0, float(max_px) / max(img_w, img.get_height())) if img_w else 1.0
|
|
1076
|
+
px, py, pw, ph = lib.region_to_proxy_px(region, scale)
|
|
1077
|
+
for dup in (base, after):
|
|
1078
|
+
x = min(px, max(0, dup.get_width() - 1))
|
|
1079
|
+
y = min(py, max(0, dup.get_height() - 1))
|
|
1080
|
+
w = min(pw, dup.get_width() - x)
|
|
1081
|
+
h = min(ph, dup.get_height() - y)
|
|
1082
|
+
dup.crop(w, h, x, y)
|
|
1083
|
+
base_layer, after_layer = base.flatten(), after.flatten()
|
|
1084
|
+
_bw, _bh, before_stats = _channel_stats_from(base, base_layer, channels)
|
|
1085
|
+
_aw, _ah, after_stats = _channel_stats_from(after, after_layer, channels)
|
|
1086
|
+
deltas = {}
|
|
1087
|
+
for ch in channels:
|
|
1088
|
+
b, a = before_stats[ch], after_stats[ch]
|
|
1089
|
+
deltas[ch] = {
|
|
1090
|
+
k: round(a[k] - b[k], 3) for k in ('mean', 'median', 'p1', 'p5', 'p95', 'p99')
|
|
1091
|
+
}
|
|
1092
|
+
result = {'before': before_stats, 'after': after_stats, 'delta': deltas, 'proxy': True}
|
|
1093
|
+
before_path, after_path = args.get('before_path'), args.get('after_path')
|
|
1094
|
+
if before_path:
|
|
1095
|
+
_export_stripped(base, before_path)
|
|
1096
|
+
result['before_path'] = before_path
|
|
1097
|
+
if after_path:
|
|
1098
|
+
_export_stripped(after, after_path)
|
|
1099
|
+
result['after_path'] = after_path
|
|
1100
|
+
return result
|
|
1101
|
+
finally:
|
|
1102
|
+
if base is not None:
|
|
1103
|
+
base.delete()
|
|
1104
|
+
if after is not None:
|
|
1105
|
+
after.delete()
|
|
1106
|
+
|
|
1107
|
+
if mode == 'regions':
|
|
1108
|
+
region_a, region_b = args.get('region_a'), args.get('region_b')
|
|
1109
|
+
if not region_a or not region_b:
|
|
1110
|
+
raise ValueError('regions mode needs both region_a and region_b')
|
|
1111
|
+
dup_a, layer_a = _region_full_res(img, region_a)
|
|
1112
|
+
try:
|
|
1113
|
+
_wa, _ha, stats_a = _channel_stats_from(dup_a, layer_a, channels)
|
|
1114
|
+
finally:
|
|
1115
|
+
dup_a.delete()
|
|
1116
|
+
dup_b, layer_b = _region_full_res(img, region_b)
|
|
1117
|
+
try:
|
|
1118
|
+
_wb, _hb, stats_b = _channel_stats_from(dup_b, layer_b, channels)
|
|
1119
|
+
finally:
|
|
1120
|
+
dup_b.delete()
|
|
1121
|
+
return {'region_a': stats_a, 'region_b': stats_b, 'proxy': False}
|
|
1122
|
+
|
|
1123
|
+
raise ValueError("mode must be one of 'before_after', 'regions'")
|
|
1124
|
+
|
|
1125
|
+
|
|
1126
|
+
def _strip_metadata(cfg, fmt):
|
|
1127
|
+
"""Switch off every metadata-carrying option on an export config. Which options must exist,
|
|
1128
|
+
and which are set only when present, is decided by `lib.metadata_strip_settings` (gi-free, so
|
|
1129
|
+
the fail-loud path for a missing required option is unit-tested)."""
|
|
1130
|
+
prop_names = {p.name for p in cfg.list_properties()}
|
|
1131
|
+
for name in lib.metadata_strip_settings(fmt, prop_names):
|
|
1132
|
+
cfg.set_property(name, False)
|
|
1133
|
+
|
|
1134
|
+
|
|
1135
|
+
def _export_stripped(dup, path, options=None):
|
|
1136
|
+
"""The ONE place any flattened raster copy leaves the bridge -- `export`'s own flat branch,
|
|
1137
|
+
every `preview` (whole-image or region), and `compare`'s before/after outputs all go through
|
|
1138
|
+
this, so there is exactly one spot that decides how metadata is handled and what formats are
|
|
1139
|
+
even reachable, instead of N call sites that could each get it slightly differently (the
|
|
1140
|
+
original bug: `preview`/`compare` called a bare `Gimp.file_save`, which has no
|
|
1141
|
+
metadata-stripping properties to set AT ALL, while `export`'s own catch-all branch for an
|
|
1142
|
+
unrecognised extension did the exact same bare, unstripped save).
|
|
1143
|
+
|
|
1144
|
+
Refuses any extension outside jpg/jpeg/png/webp/tif/tiff outright (`invalid_argument`) --
|
|
1145
|
+
there is no metadata-safe fallback for a format this bridge doesn't know the export
|
|
1146
|
+
procedure's properties for, so silently falling back to an unstripped save is not an option.
|
|
1147
|
+
|
|
1148
|
+
Metadata: EXIF/XMP/IPTC/thumbnail/comment are always stripped via the format's own export
|
|
1149
|
+
config (verified live that GIMP's own defaults do NOT strip a source image's EXIF/GPS through
|
|
1150
|
+
`file-jpeg-export`, so this cannot be left to the exporter's defaults). The image's attached
|
|
1151
|
+
`Gimp.Metadata` is also cleared on `dup` itself before saving, belt-and-braces alongside the
|
|
1152
|
+
config flags (which are the mechanism actually verified to control what lands in the file).
|
|
1153
|
+
|
|
1154
|
+
Bit depth: png/tiff accept `bit_depth: 8 | 16` (default 8); jpeg/webp are always 8-bit. Either
|
|
1155
|
+
way `dup`'s precision is converted to match BEFORE saving -- after a 16/32-bit `precision`
|
|
1156
|
+
promotion at open time, the flattened duplicate would otherwise still carry that higher bit
|
|
1157
|
+
depth into an export that never asked for it."""
|
|
1158
|
+
options = options or {}
|
|
1159
|
+
ext = os.path.splitext(path)[1].lower()
|
|
1160
|
+
fmt = lib.EXPORT_FORMATS.get(ext)
|
|
1161
|
+
if fmt is None:
|
|
1162
|
+
raise ValueError(
|
|
1163
|
+
'export path must end in .jpg, .jpeg, .png, .webp, .tif, or .tiff (got %r)' % ext
|
|
1164
|
+
)
|
|
1165
|
+
if fmt in ('png', 'tiff'):
|
|
1166
|
+
bit_depth = lib.validate_choice('bit_depth', options.get('bit_depth', 8), lib.BIT_DEPTHS)
|
|
1167
|
+
else:
|
|
1168
|
+
bit_depth = 8
|
|
1169
|
+
target_precision = (
|
|
1170
|
+
Gimp.Precision.U16_NON_LINEAR if bit_depth == 16 else Gimp.Precision.U8_NON_LINEAR
|
|
1171
|
+
)
|
|
1172
|
+
if dup.get_precision() != target_precision:
|
|
1173
|
+
dup.convert_precision(target_precision)
|
|
1174
|
+
try:
|
|
1175
|
+
dup.set_metadata(Gimp.Metadata.new())
|
|
1176
|
+
except Exception:
|
|
1177
|
+
pass
|
|
1178
|
+
|
|
1179
|
+
pdb = Gimp.get_pdb()
|
|
1180
|
+
proc_name = {
|
|
1181
|
+
'jpeg': 'file-jpeg-export', 'png': 'file-png-export',
|
|
1182
|
+
'webp': 'file-webp-export', 'tiff': 'file-tiff-export',
|
|
1183
|
+
}[fmt]
|
|
1184
|
+
proc = pdb.lookup_procedure(proc_name)
|
|
1185
|
+
cfg = proc.create_config()
|
|
1186
|
+
if fmt == 'jpeg':
|
|
1187
|
+
cfg.set_property('quality', lib.pct_to_unit('quality', options.get('quality', 90), 1.0, 100.0))
|
|
1188
|
+
elif fmt == 'png':
|
|
1189
|
+
cfg.set_property(
|
|
1190
|
+
'compression', lib.validate_int_range('compression', options.get('compression', 3), 0, 9)
|
|
1191
|
+
)
|
|
1192
|
+
elif fmt == 'webp':
|
|
1193
|
+
cfg.set_property(
|
|
1194
|
+
'quality', lib.validate_range('quality', options.get('quality', 90), 0.0, 100.0)
|
|
1195
|
+
)
|
|
1196
|
+
cfg.set_property('lossless', bool(options.get('lossless', False)))
|
|
1197
|
+
elif fmt == 'tiff':
|
|
1198
|
+
cfg.set_property(
|
|
1199
|
+
'compression',
|
|
1200
|
+
lib.validate_choice('compression', options.get('compression', 'none'), lib.TIFF_COMPRESSIONS),
|
|
1201
|
+
)
|
|
1202
|
+
cfg.set_property('image', dup)
|
|
1203
|
+
cfg.set_property('file', Gio.File.new_for_path(path))
|
|
1204
|
+
_strip_metadata(cfg, fmt)
|
|
1205
|
+
# A failed write does not raise: run() returns an EXECUTION_ERROR status with GIMP's message
|
|
1206
|
+
# as the second value (verified live, GIMP 3.2.6, writing into a missing directory).
|
|
1207
|
+
result = proc.run(cfg)
|
|
1208
|
+
if result.index(0) != Gimp.PDBStatusType.SUCCESS:
|
|
1209
|
+
name = os.path.basename(path)
|
|
1210
|
+
detail = result.index(1) if result.length() > 1 else result.index(0).value_nick
|
|
1211
|
+
# GIMP's message quotes the full path ("Could not open '<path>' for writing"); keep just
|
|
1212
|
+
# the file name, as every other message here does.
|
|
1213
|
+
detail = str(detail)
|
|
1214
|
+
for spelling in sorted({Gio.File.new_for_path(path).get_path() or path, path}, key=len, reverse=True):
|
|
1215
|
+
detail = detail.replace(spelling, name)
|
|
1216
|
+
raise lib.OpError('gimp_op_failed', 'could not write %s: %s' % (name, detail))
|
|
1217
|
+
|
|
1218
|
+
|
|
1219
|
+
def op_export(args):
|
|
1220
|
+
"""Writes path by extension: .xcf saves the live document, anything else exports a flattened
|
|
1221
|
+
copy via `_export_stripped` (metadata stripped; jpeg quality 1-100 default 90; png compression
|
|
1222
|
+
0-9 default 3 -- GIMP's own default of 9 measured far slower for negligible size gain; webp
|
|
1223
|
+
quality 0-100 default 90 + lossless; tiff compression, default 'none'; png/tiff bit_depth 8 or
|
|
1224
|
+
16, default 8).
|
|
1225
|
+
|
|
1226
|
+
Flat exports flatten a duplicate first and save that. Handing the live document to
|
|
1227
|
+
file_save lets the exporter render the filters itself, which measured 9.3 s for a 24 MP
|
|
1228
|
+
JPEG with three curves; flatten (3.0 s) + save of the flat copy (1.0 s) gives the same pixels."""
|
|
1229
|
+
img = _image(args)
|
|
1230
|
+
path = lib.require(args, 'path')
|
|
1231
|
+
if path.lower().endswith('.xcf'):
|
|
1232
|
+
# file_save reports a failed write by returning False, not by raising (verified live).
|
|
1233
|
+
if not Gimp.file_save(Gimp.RunMode.NONINTERACTIVE, img, Gio.File.new_for_path(path), None):
|
|
1234
|
+
raise lib.OpError(
|
|
1235
|
+
'gimp_op_failed',
|
|
1236
|
+
'could not save %s (check that the folder exists and is writable)'
|
|
1237
|
+
% os.path.basename(path),
|
|
1238
|
+
)
|
|
1239
|
+
else:
|
|
1240
|
+
dup, _flat = _composite(img)
|
|
1241
|
+
try:
|
|
1242
|
+
_export_stripped(dup, path, args)
|
|
1243
|
+
finally:
|
|
1244
|
+
dup.delete()
|
|
1245
|
+
return {'path': path, 'bytes': os.path.getsize(path)}
|
|
1246
|
+
|
|
1247
|
+
|
|
1248
|
+
def _replace_named_channel(img, name, w, h):
|
|
1249
|
+
"""Remove any existing channel called `name` and insert a fresh, black-filled one of the
|
|
1250
|
+
image's own size -- `op_create_mask`'s replace-by-name semantics."""
|
|
1251
|
+
for existing in img.get_channels():
|
|
1252
|
+
if existing.get_name() == name:
|
|
1253
|
+
img.remove_channel(existing)
|
|
1254
|
+
ch = Gimp.Channel.new(img, name, w, h, 50.0, Gegl.Color.new('black'))
|
|
1255
|
+
img.insert_channel(ch, None, 0)
|
|
1256
|
+
return ch
|
|
1257
|
+
|
|
1258
|
+
|
|
1259
|
+
def _channel_coverage(ch, w, h):
|
|
1260
|
+
# "Y' u8" (perceptual/non-linear), not "Y u8" (linear light): the channel's own storage is
|
|
1261
|
+
# non-linear (matching the image precision), and reading it back through the LINEAR format
|
|
1262
|
+
# applies a real, non-trivial conversion, not an identity one -- verified live, a gradient
|
|
1263
|
+
# painted as an even perceptual ramp (0..255 in file/mask terms) reads back compressed
|
|
1264
|
+
# toward black through 'Y u8' (e.g. the geometric midpoint reads ~55, not ~128), while "Y' u8"
|
|
1265
|
+
# reproduces the ramp exactly. Every mask/selection buffer read or write in this file uses
|
|
1266
|
+
# the primed, perceptual format for the same reason.
|
|
1267
|
+
data = ch.get_buffer().get(Gegl.Rectangle.new(0, 0, w, h), 1.0, "Y' u8", Gegl.AbyssPolicy.NONE)
|
|
1268
|
+
selected = sum(1 for b in data if b >= 128)
|
|
1269
|
+
return selected, round(selected / len(data), 4)
|
|
1270
|
+
|
|
1271
|
+
|
|
1272
|
+
def _mask_name_in_use(img, name):
|
|
1273
|
+
"""True if any bridge-applied filter's ledger record currently uses `name` as its mask --
|
|
1274
|
+
the check `create_mask` refuses on, so replacing a same-named channel can never silently
|
|
1275
|
+
change what an EXISTING filter is confined to (a filter's mask, once created, is meant to be
|
|
1276
|
+
fixed -- see `_apply_filter`'s own re-edit rule). A name nothing yet references is still free
|
|
1277
|
+
to reuse or replace, which is what lets a mask be iterated on before it's ever attached to a
|
|
1278
|
+
filter."""
|
|
1279
|
+
filters, _unknown = _ledger_get(img)
|
|
1280
|
+
return any(rec.get('params', {}).get('mask') == name for rec in filters.values())
|
|
1281
|
+
|
|
1282
|
+
|
|
1283
|
+
def op_create_mask(args):
|
|
1284
|
+
"""Geometric mask -> a named channel (replacing any same-named channel not already in use as
|
|
1285
|
+
a filter's mask -- see `_mask_name_in_use`), leaving the selection cleared afterward.
|
|
1286
|
+
|
|
1287
|
+
rectangle/ellipse: `Image.select_rectangle`/`select_ellipse`, optional `Selection.invert`,
|
|
1288
|
+
optional `Selection.feather`, then the resulting selection's pixels are copied into the
|
|
1289
|
+
channel.
|
|
1290
|
+
|
|
1291
|
+
gradient_linear/gradient_radial: `gegl:linear-gradient`/`gegl:radial-gradient` are NOT usable
|
|
1292
|
+
as drawable filters (verified live: their pspecs return None, `DrawableFilter.new` fails) --
|
|
1293
|
+
the working route is painting a black-to-white ramp directly into the channel with
|
|
1294
|
+
`Drawable.edit_gradient_fill` (verified live). `Gimp.context_push`/`context_pop` bracket every
|
|
1295
|
+
context change (foreground/background/gradient/blend space) so the session's ambient context
|
|
1296
|
+
-- which the user's own GUI or a later call might depend on -- is restored exactly regardless
|
|
1297
|
+
of how this function exits, rather than manually saving and restoring each property by hand.
|
|
1298
|
+
The gradient blend color space is explicitly set to RGB_PERCEPTUAL, but verified live that this
|
|
1299
|
+
has NO effect on a Channel fill's actual output either way: forcing RGB_PERCEPTUAL, forcing
|
|
1300
|
+
RGB_LINEAR, and leaving the context at whatever it already was all produced byte-identical
|
|
1301
|
+
output (an exact linear ramp -- x=128 reads 128) once read back correctly. What DOES control
|
|
1302
|
+
the crossing point is the buffer format every reader of this channel (`_channel_coverage`,
|
|
1303
|
+
and the selection copy above) uses -- `"Y' u8"` (perceptual/gamma-encoded, matching the
|
|
1304
|
+
channel's own storage), not `'Y u8'` (linear light, which compresses the readback toward black,
|
|
1305
|
+
e.g. the geometric midpoint reading ~55 instead of ~128; this is what an earlier, incorrect
|
|
1306
|
+
version of this comment blamed on the blend color space instead). RGB_PERCEPTUAL is kept set
|
|
1307
|
+
here anyway as the explicit, correct-if-it-ever-starts-mattering choice for a caller who thinks
|
|
1308
|
+
in 0-255 terms, not because it changes anything measured today.
|
|
1309
|
+
|
|
1310
|
+
`feather_px` has no effect here (a gradient is already a continuous ramp, nothing to feather);
|
|
1311
|
+
`invert` swaps which end is black vs white."""
|
|
1312
|
+
img = _image(args)
|
|
1313
|
+
type_ = args.get('type')
|
|
1314
|
+
if type_ not in lib.MASK_TYPES:
|
|
1315
|
+
raise ValueError('type must be one of %s' % sorted(lib.MASK_TYPES))
|
|
1316
|
+
name = args.get('name', 'Mask')
|
|
1317
|
+
if _mask_name_in_use(img, name):
|
|
1318
|
+
raise ValueError(
|
|
1319
|
+
'mask %r is already used by an existing filter; delete that filter or use a '
|
|
1320
|
+
'different name' % name
|
|
1321
|
+
)
|
|
1322
|
+
invert = bool(args.get('invert', False))
|
|
1323
|
+
feather_px = lib.validate_feather_px(args.get('feather_px', 0))
|
|
1324
|
+
w, h = img.get_width(), img.get_height()
|
|
1325
|
+
|
|
1326
|
+
if type_ in ('rectangle', 'ellipse'):
|
|
1327
|
+
x, y = float(lib.require(args, 'x')), float(lib.require(args, 'y'))
|
|
1328
|
+
width, height = float(lib.require(args, 'width')), float(lib.require(args, 'height'))
|
|
1329
|
+
if type_ == 'rectangle':
|
|
1330
|
+
img.select_rectangle(Gimp.ChannelOps.REPLACE, x, y, width, height)
|
|
1331
|
+
else:
|
|
1332
|
+
img.select_ellipse(Gimp.ChannelOps.REPLACE, x, y, width, height)
|
|
1333
|
+
if invert:
|
|
1334
|
+
Gimp.Selection.invert(img)
|
|
1335
|
+
if feather_px > 0:
|
|
1336
|
+
Gimp.Selection.feather(img, feather_px)
|
|
1337
|
+
ch = _replace_named_channel(img, name, w, h)
|
|
1338
|
+
# "Y' u8" (perceptual), not "Y u8" (linear) -- see `_channel_coverage`'s comment. A hard
|
|
1339
|
+
# selection is 0/255 either way, but a FEATHERED one has a real falloff whose shape this
|
|
1340
|
+
# format choice controls.
|
|
1341
|
+
sel_data = img.get_selection().get_buffer().get(
|
|
1342
|
+
Gegl.Rectangle.new(0, 0, w, h), 1.0, "Y' u8", Gegl.AbyssPolicy.NONE
|
|
1343
|
+
)
|
|
1344
|
+
ch_buf = ch.get_buffer()
|
|
1345
|
+
ch_buf.set(Gegl.Rectangle.new(0, 0, w, h), "Y' u8", sel_data)
|
|
1346
|
+
ch_buf.flush()
|
|
1347
|
+
ch.update(0, 0, w, h)
|
|
1348
|
+
else:
|
|
1349
|
+
ch = _replace_named_channel(img, name, w, h)
|
|
1350
|
+
black, white = ('white', 'black') if invert else ('black', 'white')
|
|
1351
|
+
# A gradient fill paints `start_color` at (x1,y1) and `end_color` at (x2,y2). For
|
|
1352
|
+
# gradient_linear those points are the caller's own start/end -- black-at-x1 ramps
|
|
1353
|
+
# toward white-at-x2 exactly as given (verified live). For gradient_radial (x1,y1) is
|
|
1354
|
+
# always the CENTER and (x2,y2) the radius edge, and the intuitive reading of a radial
|
|
1355
|
+
# mask is a spotlight -- full effect (white) at the center, fading to none (black) at
|
|
1356
|
+
# the edge -- so the two colors are swapped relative to the linear case.
|
|
1357
|
+
if type_ == 'gradient_linear':
|
|
1358
|
+
start_color, end_color = black, white
|
|
1359
|
+
x1, y1 = float(args.get('x1', 0)), float(args.get('y1', 0))
|
|
1360
|
+
x2, y2 = float(args.get('x2', w)), float(args.get('y2', 0))
|
|
1361
|
+
gtype = Gimp.GradientType.LINEAR
|
|
1362
|
+
else:
|
|
1363
|
+
start_color, end_color = white, black
|
|
1364
|
+
cx, cy = float(args.get('cx', w / 2.0)), float(args.get('cy', h / 2.0))
|
|
1365
|
+
radius = float(args.get('radius', min(w, h) / 2.0))
|
|
1366
|
+
x1, y1, x2, y2 = cx, cy, cx + radius, cy
|
|
1367
|
+
gtype = Gimp.GradientType.RADIAL
|
|
1368
|
+
Gimp.context_push()
|
|
1369
|
+
try:
|
|
1370
|
+
Gimp.context_set_foreground(Gegl.Color.new(start_color))
|
|
1371
|
+
Gimp.context_set_background(Gegl.Color.new(end_color))
|
|
1372
|
+
Gimp.context_set_gradient_fg_bg_rgb()
|
|
1373
|
+
Gimp.context_set_gradient_blend_color_space(Gimp.GradientBlendColorSpace.RGB_PERCEPTUAL)
|
|
1374
|
+
ch.edit_gradient_fill(gtype, 0.0, False, 1, 0, False, x1, y1, x2, y2)
|
|
1375
|
+
finally:
|
|
1376
|
+
Gimp.context_pop()
|
|
1377
|
+
Gimp.Selection.none(img) # a mask is referenced by NAME at filter-creation time, not by
|
|
1378
|
+
# staying the active selection -- leaving it selected let the NEXT unmasked filter silently
|
|
1379
|
+
# inherit it (`_append_masked` also clears defensively, but the fix belongs here too).
|
|
1380
|
+
_drop_proxies(img.get_id())
|
|
1381
|
+
selected, fraction = _channel_coverage(ch, w, h)
|
|
1382
|
+
return {'channel': name, 'selected_pixels': selected, 'fraction': fraction}
|
|
1383
|
+
|
|
1384
|
+
|
|
1385
|
+
def op_describe_operation(args):
|
|
1386
|
+
"""Probe: `list_properties()` (name, type, min, max, default) for a named GEGL/GIMP
|
|
1387
|
+
operation, via a transient `DrawableFilter` on a throwaway 1x1 image that is never applied
|
|
1388
|
+
or rendered. This is what the GEGL-schema golden test snapshots per operation, so a GIMP
|
|
1389
|
+
upgrade that changes a property's range or default fails a test instead of silently shipping
|
|
1390
|
+
an adjust type whose validated range no longer matches the real op."""
|
|
1391
|
+
operation = lib.require(args, 'operation')
|
|
1392
|
+
if operation not in lib.ALLOWED_DESCRIBE_OPERATIONS:
|
|
1393
|
+
raise ValueError('operation must be one of %s' % sorted(lib.ALLOWED_DESCRIBE_OPERATIONS))
|
|
1394
|
+
img = Gimp.Image.new(1, 1, Gimp.ImageBaseType.RGB)
|
|
1395
|
+
try:
|
|
1396
|
+
layer = Gimp.Layer.new(img, 'probe', 1, 1, Gimp.ImageType.RGB_IMAGE, 100.0, Gimp.LayerMode.NORMAL)
|
|
1397
|
+
img.insert_layer(layer, None, 0)
|
|
1398
|
+
try:
|
|
1399
|
+
f = Gimp.DrawableFilter.new(layer, operation, operation)
|
|
1400
|
+
except Exception as e:
|
|
1401
|
+
raise ValueError('no such GEGL/GIMP operation %r (%s)' % (operation, e))
|
|
1402
|
+
cfg = f.get_config()
|
|
1403
|
+
props = []
|
|
1404
|
+
for p in cfg.list_properties():
|
|
1405
|
+
try:
|
|
1406
|
+
default = cfg.get_property(p.name)
|
|
1407
|
+
if isinstance(default, Gimp.Curve):
|
|
1408
|
+
# A bare repr() of a GimpCurve embeds its object pointer address, which
|
|
1409
|
+
# differs every run and would make the schema golden fail on every diff
|
|
1410
|
+
# regardless of any real drift; its points are the stable, meaningful summary.
|
|
1411
|
+
default = _curve_points(default)
|
|
1412
|
+
elif hasattr(default, 'value_nick'):
|
|
1413
|
+
default = default.value_nick
|
|
1414
|
+
elif not isinstance(default, (int, float, str, bool, type(None))):
|
|
1415
|
+
default = str(default)
|
|
1416
|
+
except Exception:
|
|
1417
|
+
default = None
|
|
1418
|
+
props.append({
|
|
1419
|
+
'name': p.name,
|
|
1420
|
+
'type': p.value_type.name if hasattr(p.value_type, 'name') else str(p.value_type),
|
|
1421
|
+
'minimum': getattr(p, 'minimum', None),
|
|
1422
|
+
'maximum': getattr(p, 'maximum', None),
|
|
1423
|
+
'default': default,
|
|
1424
|
+
})
|
|
1425
|
+
f.delete()
|
|
1426
|
+
return {'operation': operation, 'properties': props}
|
|
1427
|
+
finally:
|
|
1428
|
+
img.delete()
|
|
1429
|
+
|
|
1430
|
+
|
|
1431
|
+
def op_select_none(args):
|
|
1432
|
+
Gimp.Selection.none(_image(args))
|
|
1433
|
+
return {'selection': 'none'}
|
|
1434
|
+
|
|
1435
|
+
|
|
1436
|
+
def op_close(args):
|
|
1437
|
+
img = _image(args)
|
|
1438
|
+
_drop_proxies(img.get_id())
|
|
1439
|
+
img.delete()
|
|
1440
|
+
return {'closed': int(args['image'])}
|
|
1441
|
+
|
|
1442
|
+
|
|
1443
|
+
OPS = {
|
|
1444
|
+
'ping': op_ping, 'open': op_open, 'curves': op_curves, 'levels': op_levels,
|
|
1445
|
+
'adjust': op_adjust, 'filter': op_filter,
|
|
1446
|
+
'list_filters': op_list_filters, 'preview': op_preview, 'histogram': op_histogram,
|
|
1447
|
+
'compare': op_compare, 'export': op_export, 'close': op_close,
|
|
1448
|
+
'crop': op_crop, 'resize': op_resize, 'rotate': op_rotate, 'flip': op_flip,
|
|
1449
|
+
'create_mask': op_create_mask, 'describe_operation': op_describe_operation,
|
|
1450
|
+
'select_none': op_select_none,
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
|
|
1454
|
+
def _dispatch(op, args):
|
|
1455
|
+
"""The real OPS-table lookup `lib.process_request` calls for each
|
|
1456
|
+
request. Raising `lib.OpError('invalid_argument', ...)` for an unknown op
|
|
1457
|
+
(rather than a bare KeyError) is what makes process_request's own
|
|
1458
|
+
classification report it as invalid_argument, not gimp_op_failed."""
|
|
1459
|
+
fn = OPS.get(op)
|
|
1460
|
+
if fn is None:
|
|
1461
|
+
raise lib.OpError('invalid_argument', 'unknown op %r' % op)
|
|
1462
|
+
return fn(args)
|
|
1463
|
+
|
|
1464
|
+
|
|
1465
|
+
def serve(session_dir, poll_s=0.005, parent_check_s=1.0):
|
|
1466
|
+
"""Handle rpc/req-<id>.json in id order until rpc/shutdown appears, or
|
|
1467
|
+
the parent process (EM_GIMP_PARENT_PID, checked about once a second) is
|
|
1468
|
+
gone -- a dead driver otherwise leaves this headless GIMP running
|
|
1469
|
+
forever with nothing left to ever write rpc/shutdown. (Absent entirely
|
|
1470
|
+
for a Flatpak-launched GIMP, whose pid namespace can't see the host
|
|
1471
|
+
parent anyway -- see session.ts's env-building for that decision.)"""
|
|
1472
|
+
rpc = os.path.join(session_dir, 'rpc')
|
|
1473
|
+
with open(os.path.join(rpc, 'ready.tmp'), 'w') as fh:
|
|
1474
|
+
fh.write(str(os.getpid()))
|
|
1475
|
+
os.replace(os.path.join(rpc, 'ready.tmp'), os.path.join(rpc, 'ready'))
|
|
1476
|
+
|
|
1477
|
+
parent_pid_raw = os.environ.get('EM_GIMP_PARENT_PID')
|
|
1478
|
+
parent_pid = int(parent_pid_raw) if parent_pid_raw else None
|
|
1479
|
+
last_parent_check = time.time()
|
|
1480
|
+
|
|
1481
|
+
while not os.path.exists(os.path.join(rpc, 'shutdown')):
|
|
1482
|
+
now = time.time()
|
|
1483
|
+
if parent_pid is not None and now - last_parent_check >= parent_check_s:
|
|
1484
|
+
last_parent_check = now
|
|
1485
|
+
if not lib.is_process_alive(parent_pid):
|
|
1486
|
+
sys.stderr.write('parent process %d is gone; exiting\n' % parent_pid)
|
|
1487
|
+
return
|
|
1488
|
+
names = lib.list_requests(rpc)
|
|
1489
|
+
if not names:
|
|
1490
|
+
time.sleep(poll_s)
|
|
1491
|
+
continue
|
|
1492
|
+
for name in names:
|
|
1493
|
+
try:
|
|
1494
|
+
lib.process_request(os.path.join(rpc, name), rpc, _dispatch)
|
|
1495
|
+
except Exception:
|
|
1496
|
+
# process_request is designed to never raise (see its own
|
|
1497
|
+
# docstring) -- this is belt-and-suspenders so a bug in that
|
|
1498
|
+
# contract takes down one request, not the whole session.
|
|
1499
|
+
sys.stderr.write(
|
|
1500
|
+
'process_request raised for %s:\n%s\n' % (name, traceback.format_exc())
|
|
1501
|
+
)
|