editmamei 1.5.1 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -1
- package/dist/backends/detect-editors.js +79 -0
- package/dist/backends/gimp/backend.js +99 -0
- package/dist/backends/gimp/bridge/lib.py +1988 -0
- package/dist/backends/gimp/bridge/ops.py +3120 -0
- package/dist/backends/gimp/detect.js +86 -0
- package/dist/backends/gimp/errors.js +21 -0
- package/dist/backends/gimp/launcher.js +6 -0
- package/dist/backends/gimp/session.js +632 -0
- package/dist/bin/editmamei-core-darwin-arm64 +0 -0
- package/dist/bin/editmamei-core-darwin-x64 +0 -0
- package/dist/bin/editmamei-core-win-x64.exe +0 -0
- package/dist/cli/config.js +28 -0
- package/dist/core/preview-privacy.js +54 -0
- package/dist/core/server.js +98 -49
- package/dist/core/settings.js +25 -0
- package/dist/core/tool-activity.js +122 -0
- package/dist/core/tool-groups.js +24 -0
- package/dist/core/tool-tiers.js +24 -0
- package/dist/diagnostics/collect.js +4 -0
- package/dist/index.js +5 -1
- package/dist/kernel/kernel.js +3 -0
- package/dist/modules/gimp/index.js +43 -0
- package/dist/skills/editmamei-skill.zip +0 -0
- package/dist/telemetry/activity.js +1 -93
- package/dist/tools/adjustment-tools.js +2 -1
- package/dist/tools/gimp-adjustment-tools.js +385 -0
- package/dist/tools/gimp-checkpoint-tools.js +541 -0
- package/dist/tools/gimp-compose-tools.js +399 -0
- package/dist/tools/gimp-core-tools.js +302 -0
- package/dist/tools/gimp-document-tools.js +321 -0
- package/dist/tools/gimp-effect-tools.js +267 -0
- package/dist/tools/gimp-filter-tools.js +94 -0
- package/dist/tools/gimp-geometry-tools.js +223 -0
- package/dist/tools/gimp-inspect-tools.js +157 -0
- package/dist/tools/gimp-layer-tools.js +385 -0
- package/dist/tools/gimp-mask-tools.js +102 -0
- package/dist/tools/gimp-shared.js +58 -0
- package/dist/tools/gimp-verify-tools.js +409 -0
- package/dist/utils/gimp-path.js +27 -0
- package/dist/utils/operation-timeouts.js +24 -0
- package/dist/utils/session-log.js +19 -1
- package/dist/utils/tool-helpers.js +14 -0
- package/dist/utils/validate.js +11 -0
- package/dist/version.js +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,3120 @@
|
|
|
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
|
+
# A preview proxy (see PROXIES below) is an internal, filter-free duplicate this bridge made
|
|
54
|
+
# for itself -- never one a caller could legitimately know the id of (no op ever returns one),
|
|
55
|
+
# so a request naming one is refused the same way an unknown id would be, not treated as a real
|
|
56
|
+
# open document.
|
|
57
|
+
if any(img.get_id() == proxy.get_id() for proxy in PROXIES.values()):
|
|
58
|
+
raise ValueError('no open image with id %s' % image_id)
|
|
59
|
+
return img
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _owning_image(item):
|
|
63
|
+
"""`item`'s own image, or None -- a plain wrapper around `Item.get_image()` so every id-based
|
|
64
|
+
membership check below (`_layer`, `_resolve_parent_group`) reads the same way, and so a layer
|
|
65
|
+
that somehow reports no image at all (should not happen for one just returned by GIMP, but
|
|
66
|
+
never trusted blindly) degrades to "not found" rather than an AttributeError."""
|
|
67
|
+
return item.get_image() if item is not None else None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _layer(img, args):
|
|
71
|
+
"""Resolve the layer `args` addresses: `layer_id` (canonical -- GIMP allows duplicate layer
|
|
72
|
+
names, so an id is the only handle that always tells two apart; the id `gimp_inspect`'s layer
|
|
73
|
+
tree and `gimp_layer`'s own results report) takes priority over `layer` (name, searched inside
|
|
74
|
+
groups too); neither given falls back to the selected layer, or the topmost layer if none is
|
|
75
|
+
selected. `layer_id` is checked for IMAGE MEMBERSHIP the same way `_existing_filter` checks a
|
|
76
|
+
filter_id -- a global id lookup could otherwise hand back a layer belonging to a different
|
|
77
|
+
(possibly closed) image and let an op silently touch the wrong document."""
|
|
78
|
+
layer_id = args.get('layer_id')
|
|
79
|
+
if layer_id is not None:
|
|
80
|
+
layer = Gimp.Layer.get_by_id(int(layer_id))
|
|
81
|
+
owner = _owning_image(layer)
|
|
82
|
+
if layer is None or owner is None or owner.get_id() != img.get_id():
|
|
83
|
+
raise ValueError('no layer with id %s on image %s' % (layer_id, img.get_id()))
|
|
84
|
+
return layer
|
|
85
|
+
name = args.get('layer')
|
|
86
|
+
if name:
|
|
87
|
+
layer = img.get_layer_by_name(name) # searches inside layer groups too
|
|
88
|
+
if layer is None:
|
|
89
|
+
raise ValueError('no layer named %r' % name)
|
|
90
|
+
return layer
|
|
91
|
+
selected = img.get_selected_layers()
|
|
92
|
+
return selected[0] if selected else img.get_layers()[0]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _all_layers(img):
|
|
96
|
+
"""Every layer in img, top of stack first, descending into layer groups (a group itself is
|
|
97
|
+
included, then its children). `img.get_layers()` is top-level only, and `_layer` can resolve a
|
|
98
|
+
layer nested inside a group, so every walk over the filter stack goes through this: a filter
|
|
99
|
+
on a nested layer must be listed, mirrored onto the proxy, ledger-tracked, and seen by the
|
|
100
|
+
geometry refusal like any other.
|
|
101
|
+
|
|
102
|
+
Walked with an EXPLICIT stack, the same reasoning (and the same shape) as `_build_layer_tree`'s
|
|
103
|
+
own walk: a pathological chain of nested single-child groups would otherwise risk Python's OWN
|
|
104
|
+
recursion limit, not just some cap this function itself imposes (it imposes none -- every
|
|
105
|
+
caller here matters exactly because it doesn't). A group's children are pushed in REVERSE order
|
|
106
|
+
so popping (LIFO) still visits top-of-stack-first -- positional proxy mirroring and other
|
|
107
|
+
callers depend on that exact order, not just on the same set of layers."""
|
|
108
|
+
out = []
|
|
109
|
+
stack = list(reversed(img.get_layers()))
|
|
110
|
+
while stack:
|
|
111
|
+
item = stack.pop()
|
|
112
|
+
out.append(item)
|
|
113
|
+
if item.is_group():
|
|
114
|
+
stack.extend(reversed(item.get_children()))
|
|
115
|
+
return out
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _describe(img):
|
|
119
|
+
return {
|
|
120
|
+
'image': img.get_id(),
|
|
121
|
+
'width': img.get_width(),
|
|
122
|
+
'height': img.get_height(),
|
|
123
|
+
'base_type': img.get_base_type().value_nick,
|
|
124
|
+
'precision': img.get_precision().value_nick,
|
|
125
|
+
'layers': [l.get_name() for l in img.get_layers()],
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _existing_filter(img, args, operation):
|
|
130
|
+
"""Look up filter_id AMONG img's own layers -- not just by a global id
|
|
131
|
+
lookup, which would happily hand back a filter that belongs to some
|
|
132
|
+
other open image (or one already closed) and let a re-edit silently
|
|
133
|
+
touch the wrong document."""
|
|
134
|
+
filter_id = int(args['filter_id'])
|
|
135
|
+
for layer in _all_layers(img):
|
|
136
|
+
for f in layer.get_filters():
|
|
137
|
+
if f.get_id() == filter_id:
|
|
138
|
+
if f.get_operation_name() != operation:
|
|
139
|
+
# Phrased with the tool's own `type` names (lib.OPERATION_TYPES, the merged
|
|
140
|
+
# adjust+effect inverse), not the raw GEGL op strings -- a caller sent
|
|
141
|
+
# `type: 'motion_blur'` or similar, not `gegl:motion-blur-linear`, so the
|
|
142
|
+
# refusal should name what they actually typed. Falls back to the raw
|
|
143
|
+
# operation name for anything not in that table (there is none today, but a
|
|
144
|
+
# future op added to one table and not the other must not raise here instead
|
|
145
|
+
# of just showing an unmapped name).
|
|
146
|
+
actual = lib.OPERATION_TYPES.get(f.get_operation_name(), f.get_operation_name())
|
|
147
|
+
expected = lib.OPERATION_TYPES.get(operation, operation)
|
|
148
|
+
raise ValueError('filter %s is %s, not %s' % (filter_id, actual, expected))
|
|
149
|
+
return f
|
|
150
|
+
raise ValueError('no filter with id %s on image %s' % (filter_id, img.get_id()))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _channel(args):
|
|
154
|
+
ch = args.get('channel', 'value')
|
|
155
|
+
if ch not in CHANNELS:
|
|
156
|
+
raise ValueError('channel must be one of %s' % sorted(CHANNELS))
|
|
157
|
+
return CHANNELS[ch]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _level(v, name):
|
|
161
|
+
v = float(v)
|
|
162
|
+
if not 0 <= v <= 255:
|
|
163
|
+
raise ValueError('%s must be within 0..255' % name)
|
|
164
|
+
return v / 255.0
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _composite(img):
|
|
168
|
+
"""A full-resolution flattened duplicate of img. Caller deletes it. Renders every live
|
|
169
|
+
filter at full size: ~2 s per 24 MP with two curves on an 8-thread machine."""
|
|
170
|
+
dup = img.duplicate()
|
|
171
|
+
return dup, dup.flatten()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# Preview proxies, keyed by (image id, max_px): a filter-free downscale of the document made once,
|
|
175
|
+
# onto which the document's live filters are re-applied per preview. Rendering filters on ~0.7 MP
|
|
176
|
+
# instead of 24 MP is what gets preview under a second; every route that renders from the
|
|
177
|
+
# full-size document (flatten, scale, get_thumbnail's projection) costs 1.4-4.5 s after an
|
|
178
|
+
# edit. Exact for per-pixel filters (curves, levels); a spatial filter would need its radius
|
|
179
|
+
# scaled.
|
|
180
|
+
#
|
|
181
|
+
# The cache is correct ONLY because every op that changes pixels, layer structure, canvas size, or
|
|
182
|
+
# image mode ends with `_drop_proxies(img.get_id())`, not only the ops that add or edit a live
|
|
183
|
+
# filter.
|
|
184
|
+
# Layer create/create_group/delete/duplicate/move/reorder/set (opacity/mode/visible), merge_down,
|
|
185
|
+
# flatten, and bake all change the document's real pixels or its layer tree, so a proxy built
|
|
186
|
+
# before any of them would render stale content -- or worse, `_mirror_filters` zips the proxy's own
|
|
187
|
+
# layer list against the live document's POSITIONALLY, so a structural drift between them (a layer
|
|
188
|
+
# added, removed, or reordered on one side but not the other) would mis-attach a filter to the
|
|
189
|
+
# wrong layer rather than merely rendering an out-of-date preview; see that function's own guard.
|
|
190
|
+
# `create_document`/`place_image` (new layer tree), `canvas` (canvas size), and
|
|
191
|
+
# `convert_image_mode` (base type) all fall under the same rule. `select` is the one layer op that
|
|
192
|
+
# changes neither pixels nor structure and is deliberately exempt.
|
|
193
|
+
PROXIES = {}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _proxy(img, max_px):
|
|
197
|
+
key = (img.get_id(), max_px)
|
|
198
|
+
proxy = PROXIES.get(key)
|
|
199
|
+
if proxy is None or not proxy.is_valid():
|
|
200
|
+
proxy = img.duplicate()
|
|
201
|
+
for layer in _all_layers(proxy):
|
|
202
|
+
for f in layer.get_filters():
|
|
203
|
+
f.delete()
|
|
204
|
+
w, h = proxy.get_width(), proxy.get_height()
|
|
205
|
+
scale = min(1.0, float(max_px) / max(w, h))
|
|
206
|
+
if scale < 1.0:
|
|
207
|
+
proxy.scale(max(1, round(w * scale)), max(1, round(h * scale)))
|
|
208
|
+
PROXIES[key] = proxy
|
|
209
|
+
return proxy
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _mirror_filters(src_img, dst_img):
|
|
213
|
+
"""Re-create src's live filters, in stack order, on the matching layers of dst.
|
|
214
|
+
|
|
215
|
+
Filters this bridge applied are rebuilt from the ledger, not from readback: after
|
|
216
|
+
an XCF reload, libgimp's config for gimp:curves / gimp:levels reports only the `value`
|
|
217
|
+
channel, so a readback copy of a red or blue curve comes out as identity (measured: the
|
|
218
|
+
reopened document rendered R-B 25.4, a readback-mirrored proxy 10.6).
|
|
219
|
+
|
|
220
|
+
dst is normally smaller than src (a preview proxy): every length-typed property named in
|
|
221
|
+
`lib.SPATIAL_SCALE_PROPS` for the filter's operation is multiplied by dst/src's width ratio
|
|
222
|
+
after the ledger's own params are applied, so a spatial filter's radius still reads in SRC's
|
|
223
|
+
pixel units rather than rendering as if it were `radius` proxy-px wide. Integer neighbourhood
|
|
224
|
+
sizes in `lib.INT_SPATIAL_SCALE_PROPS` (currently just noise-reduction's `iterations`) are
|
|
225
|
+
scaled the same way but rounded, floored at 1 -- an approximation of the full-res effect, not
|
|
226
|
+
an exact one, since a neighbourhood count doesn't scale as cleanly as a Gaussian radius does.
|
|
227
|
+
Either way, the scaled value is clamped to the property's own pspec minimum before being set
|
|
228
|
+
-- GObject silently KEEPS the property's prior value (the unscaled one `dst_cfg` already has
|
|
229
|
+
from the loop above, not the intended small one) when asked to set something below its
|
|
230
|
+
minimum, so without this clamp a small enough proxy would render as if the property were
|
|
231
|
+
never scaled down at all. This is an approximation (see `op_preview`'s `proxy` flag) -- exact
|
|
232
|
+
for everything else, since every other property here is scale-invariant (a hue shift, a
|
|
233
|
+
level, a percentage).
|
|
234
|
+
|
|
235
|
+
Invisible filters (toggled off via `filter op=set_visibility`) are skipped entirely, without
|
|
236
|
+
even attempting to re-create them -- they contribute nothing to the render either way. A
|
|
237
|
+
filter GIMP refuses to attach non-destructively (`_append_masked`'s own guard, the same
|
|
238
|
+
silent-failure class it exists to catch) is likewise skipped here rather than raised: unlike
|
|
239
|
+
`_apply_filter`'s CREATE path, where that refusal must be a hard error (nothing has been
|
|
240
|
+
ledgered yet, so failing loudly is free), a proxy render already renders every OTHER live
|
|
241
|
+
filter and is allowed to approximate by leaving one out rather than failing the whole preview/
|
|
242
|
+
histogram/compare call. Returns the list of filter names that could not be mirrored (empty
|
|
243
|
+
when every visible filter mirrored cleanly), which callers surface as `unmirrored_filters` in
|
|
244
|
+
their own result."""
|
|
245
|
+
filters, _unknown = _ledger_get(src_img)
|
|
246
|
+
src_w = src_img.get_width()
|
|
247
|
+
scale = (dst_img.get_width() / src_w) if src_w else 1.0
|
|
248
|
+
unmirrored = []
|
|
249
|
+
# dst is a duplicate of src (see `_proxy`), so both walks yield the same layers in the same
|
|
250
|
+
# order -- PROVIDED the proxy-drop discipline above held. Checked explicitly rather than
|
|
251
|
+
# trusted: a future structural op that forgets `_drop_proxies` would otherwise silently zip a
|
|
252
|
+
# stale proxy's layer list against the live document's and mis-attach a filter to the wrong
|
|
253
|
+
# layer instead of failing loudly. Comparing plain lengths alone would miss a COUNT-preserving
|
|
254
|
+
# drift (e.g. two layers swapped by a reorder) -- the (name, is_group) shape at every position
|
|
255
|
+
# is compared too, so a mismatch at any single slot is caught, not just a size change.
|
|
256
|
+
src_layers, dst_layers = _all_layers(src_img), _all_layers(dst_img)
|
|
257
|
+
src_shape = [(l.get_name(), l.is_group()) for l in src_layers]
|
|
258
|
+
dst_shape = [(l.get_name(), l.is_group()) for l in dst_layers]
|
|
259
|
+
if src_shape != dst_shape:
|
|
260
|
+
raise lib.OpError(
|
|
261
|
+
'gimp_op_failed',
|
|
262
|
+
'internal error: the preview proxy has drifted from the document\'s layer structure -- '
|
|
263
|
+
'a structural change did not drop the proxy cache',
|
|
264
|
+
)
|
|
265
|
+
for src, dst in zip(src_layers, dst_layers):
|
|
266
|
+
for f in reversed(src.get_filters()): # get_filters() is top-first
|
|
267
|
+
if not f.get_visible():
|
|
268
|
+
continue
|
|
269
|
+
g = Gimp.DrawableFilter.new(dst, f.get_operation_name(), f.get_name())
|
|
270
|
+
src_cfg, dst_cfg = f.get_config(), g.get_config()
|
|
271
|
+
for p in src_cfg.list_properties():
|
|
272
|
+
dst_cfg.set_property(p.name, src_cfg.get_property(p.name))
|
|
273
|
+
rec = filters.get(f.get_name())
|
|
274
|
+
mask = None
|
|
275
|
+
if rec and rec['operation'] == f.get_operation_name():
|
|
276
|
+
SETTERS[rec['operation']](dst_cfg, rec['params'])
|
|
277
|
+
mask = rec['params'].get('mask')
|
|
278
|
+
operation = rec['operation']
|
|
279
|
+
pspecs = {p.name: p for p in dst_cfg.list_properties()}
|
|
280
|
+
for prop_name in lib.SPATIAL_SCALE_PROPS.get(operation, ()):
|
|
281
|
+
scaled = dst_cfg.get_property(prop_name) * scale
|
|
282
|
+
minimum = getattr(pspecs.get(prop_name), 'minimum', None)
|
|
283
|
+
if minimum is not None and scaled < minimum:
|
|
284
|
+
scaled = minimum
|
|
285
|
+
dst_cfg.set_property(prop_name, scaled)
|
|
286
|
+
for prop_name in lib.INT_SPATIAL_SCALE_PROPS.get(operation, ()):
|
|
287
|
+
scaled = max(1, round(dst_cfg.get_property(prop_name) * scale))
|
|
288
|
+
minimum = getattr(pspecs.get(prop_name), 'minimum', None)
|
|
289
|
+
if minimum is not None and scaled < minimum:
|
|
290
|
+
scaled = int(minimum)
|
|
291
|
+
dst_cfg.set_property(prop_name, scaled)
|
|
292
|
+
g.set_opacity(f.get_opacity())
|
|
293
|
+
g.set_blend_mode(f.get_blend_mode())
|
|
294
|
+
try:
|
|
295
|
+
_append_masked(dst_img, dst, g, mask)
|
|
296
|
+
except lib.OpError:
|
|
297
|
+
unmirrored.append(f.get_name())
|
|
298
|
+
continue
|
|
299
|
+
g.set_visible(True)
|
|
300
|
+
return unmirrored
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _drop_proxies(image_id):
|
|
304
|
+
for key in [k for k in PROXIES if k[0] == image_id]:
|
|
305
|
+
proxy = PROXIES.pop(key)
|
|
306
|
+
if proxy.is_valid():
|
|
307
|
+
proxy.delete()
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _curve_points(curve):
|
|
311
|
+
pts = []
|
|
312
|
+
for i in range(curve.get_n_points()):
|
|
313
|
+
x, y = curve.get_point(i)
|
|
314
|
+
pts.append([round(x * 255, 2), round(y * 255, 2)])
|
|
315
|
+
return pts
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
# What the bridge applied, per filter name, as JSON in a persistent image parasite. It travels
|
|
319
|
+
# inside the XCF, so it survives close/reopen; it is the source of truth for readback and the
|
|
320
|
+
# preview proxy wherever libgimp's own readback is lossy (see _mirror_filters). Schema, version
|
|
321
|
+
# handling, the merge, and the "don't rewrite a document I don't understand" decision all live in
|
|
322
|
+
# lib.parse_ledger / lib.merged_ledger_for_write.
|
|
323
|
+
META = 'editmamei-filters'
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _ledger_get(img):
|
|
327
|
+
"""Hands the parasite's raw BYTES to lib.parse_ledger rather than
|
|
328
|
+
decoding here first: a corrupt/foreign parasite that isn't valid UTF-8
|
|
329
|
+
used to raise straight out of a bare `.decode('utf-8')`, outside any
|
|
330
|
+
try/except, taking down whatever op called this.
|
|
331
|
+
|
|
332
|
+
Returns (filters, unknown_top_level) -- the validated view. A caller
|
|
333
|
+
about to write the ledger back doesn't need the raw (pre-validation) map
|
|
334
|
+
itself: `_ledger_put` re-reads the parasite and re-derives it, via
|
|
335
|
+
`lib.merged_ledger_for_write`, at the moment it actually writes."""
|
|
336
|
+
p = img.get_parasite(META)
|
|
337
|
+
if not p:
|
|
338
|
+
return {}, {}
|
|
339
|
+
filters, unknown, _raw_filters = lib.parse_ledger(bytes(p.get_data()))
|
|
340
|
+
return filters, unknown
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _ledger_put(img, filters, unknown, removed=None):
|
|
344
|
+
"""Persist filters/unknown as the ledger parasite. The merge (preserving
|
|
345
|
+
any malformed/foreign record verbatim) and the decision whether writing
|
|
346
|
+
is even safe (a newer-version or undecodable existing parasite) both
|
|
347
|
+
live in `lib.merged_ledger_for_write` -- this function just reads the
|
|
348
|
+
current parasite, calls it, and writes back whatever it returns, if
|
|
349
|
+
anything. `None` means the write was skipped: the filter this call is
|
|
350
|
+
for was still applied to the live image either way; only the persisted
|
|
351
|
+
record is, and list_filters/preview/histogram fall back to readback for
|
|
352
|
+
it exactly as they would for any other foreign filter.
|
|
353
|
+
|
|
354
|
+
`removed`, when given, names filters to drop even though the freshly
|
|
355
|
+
re-read parasite still has them -- see `lib.merged_ledger_for_write`'s
|
|
356
|
+
own docstring for why a plain merge can't express a deletion by itself."""
|
|
357
|
+
existing = img.get_parasite(META)
|
|
358
|
+
existing_raw = bytes(existing.get_data()) if existing else b''
|
|
359
|
+
merged = lib.merged_ledger_for_write(existing_raw, filters, unknown, removed=removed)
|
|
360
|
+
if merged is None:
|
|
361
|
+
sys.stderr.write(
|
|
362
|
+
'editmamei-filters parasite on image %d was not rewritten (see '
|
|
363
|
+
'lib.merged_ledger_for_write); this filter still applied to the live image\n'
|
|
364
|
+
% img.get_id()
|
|
365
|
+
)
|
|
366
|
+
return
|
|
367
|
+
img.attach_parasite(Gimp.Parasite.new(META, Gimp.PARASITE_PERSISTENT, merged))
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _unique_name(img, base):
|
|
371
|
+
taken = {f.get_name() for layer in _all_layers(img) for f in layer.get_filters()}
|
|
372
|
+
return lib.unique_name(taken, base)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _unique_layer_name(img, base, exclude=None):
|
|
376
|
+
"""Like `_unique_name`, but over LAYER names (`gimp_layer`'s create/create_group/duplicate/
|
|
377
|
+
set(name=...) naming) rather than filter names. `exclude`, when given, is a layer whose own
|
|
378
|
+
current name never counts as "taken" -- renaming a layer to the name it already has must not
|
|
379
|
+
force an unwanted suffix onto itself."""
|
|
380
|
+
taken = {
|
|
381
|
+
l.get_name() for l in _all_layers(img) if exclude is None or l.get_id() != exclude.get_id()
|
|
382
|
+
}
|
|
383
|
+
return lib.unique_name(taken, base)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _set_curves(cfg, params):
|
|
387
|
+
curve = Gimp.Curve.new()
|
|
388
|
+
curve.clear_points()
|
|
389
|
+
for x, y in params['points']:
|
|
390
|
+
curve.add_point(_level(x, 'x'), _level(y, 'y'))
|
|
391
|
+
cfg.set_property('trc', Gimp.TRCType.PERCEPTUAL)
|
|
392
|
+
cfg.set_property('channel', _channel(params))
|
|
393
|
+
cfg.set_property('curve', curve)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _set_levels(cfg, params):
|
|
397
|
+
cfg.set_property('trc', Gimp.TRCType.PERCEPTUAL)
|
|
398
|
+
cfg.set_property('linear', False)
|
|
399
|
+
cfg.set_property('channel', _channel(params))
|
|
400
|
+
cfg.set_property('low-input', _level(params['in_low'], 'in_low'))
|
|
401
|
+
cfg.set_property('high-input', _level(params['in_high'], 'in_high'))
|
|
402
|
+
cfg.set_property('gamma', params['gamma'])
|
|
403
|
+
cfg.set_property('low-output', _level(params['out_low'], 'out_low'))
|
|
404
|
+
cfg.set_property('high-output', _level(params['out_high'], 'out_high'))
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
# GimpHueRange / GimpTransferMode enum members, keyed by the plain strings
|
|
408
|
+
# `lib.build_hue_saturation_params` / `lib.build_color_balance_params` validate against --
|
|
409
|
+
# those two functions are gi-free (unit-tested without GIMP), so the mapping to the real `gi`
|
|
410
|
+
# enum objects has to live here instead.
|
|
411
|
+
_HUE_RANGE_ENUM = {
|
|
412
|
+
'all': Gimp.HueRange.ALL,
|
|
413
|
+
'red': Gimp.HueRange.RED,
|
|
414
|
+
'yellow': Gimp.HueRange.YELLOW,
|
|
415
|
+
'green': Gimp.HueRange.GREEN,
|
|
416
|
+
'cyan': Gimp.HueRange.CYAN,
|
|
417
|
+
'blue': Gimp.HueRange.BLUE,
|
|
418
|
+
'magenta': Gimp.HueRange.MAGENTA,
|
|
419
|
+
}
|
|
420
|
+
_TRANSFER_MODE_ENUM = {
|
|
421
|
+
'shadows': Gimp.TransferMode.SHADOWS,
|
|
422
|
+
'midtones': Gimp.TransferMode.MIDTONES,
|
|
423
|
+
'highlights': Gimp.TransferMode.HIGHLIGHTS,
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _set_exposure(cfg, params):
|
|
428
|
+
cfg.set_property('exposure', params['exposure'])
|
|
429
|
+
cfg.set_property('black-level', params['black-level'])
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _set_brightness_contrast(cfg, params):
|
|
433
|
+
cfg.set_property('brightness', params['brightness'])
|
|
434
|
+
cfg.set_property('contrast', params['contrast'])
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _set_hue_saturation(cfg, params):
|
|
438
|
+
cfg.set_property('range', _HUE_RANGE_ENUM[params['range']])
|
|
439
|
+
cfg.set_property('hue', params['hue'])
|
|
440
|
+
cfg.set_property('saturation', params['saturation'])
|
|
441
|
+
cfg.set_property('lightness', params['lightness'])
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _set_color_balance(cfg, params):
|
|
445
|
+
cfg.set_property('range', _TRANSFER_MODE_ENUM[params['range']])
|
|
446
|
+
cfg.set_property('cyan-red', params['cyan-red'])
|
|
447
|
+
cfg.set_property('magenta-green', params['magenta-green'])
|
|
448
|
+
cfg.set_property('yellow-blue', params['yellow-blue'])
|
|
449
|
+
cfg.set_property('preserve-luminosity', params['preserve-luminosity'])
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _set_color_temperature(cfg, params):
|
|
453
|
+
cfg.set_property('original-temperature', params['original-temperature'])
|
|
454
|
+
cfg.set_property('intended-temperature', params['intended-temperature'])
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _set_shadows_highlights(cfg, params):
|
|
458
|
+
for key in (
|
|
459
|
+
'shadows', 'highlights', 'whitepoint', 'radius', 'compress',
|
|
460
|
+
'shadows-ccorrect', 'highlights-ccorrect',
|
|
461
|
+
):
|
|
462
|
+
cfg.set_property(key, params[key])
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _set_saturation(cfg, params):
|
|
466
|
+
cfg.set_property('scale', params['scale'])
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _set_vibrance(cfg, params):
|
|
470
|
+
cfg.set_property('vibrance', params['vibrance'])
|
|
471
|
+
cfg.set_property('saturation', params['saturation'])
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _set_sharpen(cfg, params):
|
|
475
|
+
cfg.set_property('std-dev', params['std-dev'])
|
|
476
|
+
cfg.set_property('scale', params['scale'])
|
|
477
|
+
cfg.set_property('threshold', params['threshold'])
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _set_noise_reduction(cfg, params):
|
|
481
|
+
cfg.set_property('iterations', params['iterations'])
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _set_gaussian_blur(cfg, params):
|
|
485
|
+
cfg.set_property('std-dev-x', params['std-dev-x'])
|
|
486
|
+
cfg.set_property('std-dev-y', params['std-dev-y'])
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
# ---- gimp_add_effect setters (lib.EFFECT_OPERATIONS' parallel table to the adjust ones
|
|
490
|
+
# above) -- SETTERS itself is one shared dict below: an operation name is an operation name
|
|
491
|
+
# regardless of which tool (gimp_add_adjustment's `adjust` or gimp_add_effect's `effect`) created
|
|
492
|
+
# the filter, and _apply_filter/_mirror_filters dispatch on operation alone.
|
|
493
|
+
|
|
494
|
+
def _set_vignette(cfg, params):
|
|
495
|
+
cfg.set_property('radius', params['radius'])
|
|
496
|
+
cfg.set_property('softness', params['softness'])
|
|
497
|
+
cfg.set_property('gamma', params['gamma'])
|
|
498
|
+
cfg.set_property('x', params['x'])
|
|
499
|
+
cfg.set_property('y', params['y'])
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _set_black_white(cfg, params):
|
|
503
|
+
cfg.set_property('red', params['red'])
|
|
504
|
+
cfg.set_property('green', params['green'])
|
|
505
|
+
cfg.set_property('blue', params['blue'])
|
|
506
|
+
cfg.set_property('preserve-luminosity', params['preserve-luminosity'])
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _set_motion_blur(cfg, params):
|
|
510
|
+
cfg.set_property('length', params['length'])
|
|
511
|
+
cfg.set_property('angle', params['angle'])
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _set_lens_blur(cfg, params):
|
|
515
|
+
# gegl:focus-blur, not gegl:lens-blur -- see lib.build_lens_blur_params' own comment.
|
|
516
|
+
#
|
|
517
|
+
# Two properties are forced here, not part of `params` and not user-configurable:
|
|
518
|
+
# - `radius` (distinct from `blur-radius`) sizes a circular IN-FOCUS zone that stays
|
|
519
|
+
# perfectly sharp regardless of `blur-radius` -- verified live: at its own default (0.75),
|
|
520
|
+
# the center ~75% of the image never blurs at all, which is a tilt-shift/depth-of-field
|
|
521
|
+
# effect, not the uniform "lens blur" this tool advertises. Forced to 0 so the blur applies
|
|
522
|
+
# uniformly across the whole layer instead.
|
|
523
|
+
# - `blur-type` defaults to 'gaussian', which would make this a second, redundant
|
|
524
|
+
# gaussian_blur and leave `highlight_factor` inert. Forced to 'lens' -- verified live, that
|
|
525
|
+
# mode DOES attach as a non-destructive filter (the 'aux'-pad restriction that sank
|
|
526
|
+
# gegl:lens-blur does not apply inside this meta-operation) and produces a real bokeh
|
|
527
|
+
# highlight boost.
|
|
528
|
+
cfg.set_property('radius', 0.0)
|
|
529
|
+
cfg.set_property('blur-type', 'lens')
|
|
530
|
+
cfg.set_property('blur-radius', params['blur-radius'])
|
|
531
|
+
cfg.set_property('highlight-factor', params['highlight-factor'])
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _set_add_noise(cfg, params):
|
|
535
|
+
# `independent=False`: what "add grain" means to a photographer is monochrome noise (the
|
|
536
|
+
# same random value added to R, G, and B at a given pixel, like real film grain or sensor
|
|
537
|
+
# noise's luminance component) -- GEGL's own default (`independent=True`) instead draws THREE
|
|
538
|
+
# separate random values per pixel, one per channel, which reads as colour speckle/confetti
|
|
539
|
+
# rather than grain. Not part of `params`, not user-configurable.
|
|
540
|
+
cfg.set_property('independent', False)
|
|
541
|
+
cfg.set_property('red', params['red'])
|
|
542
|
+
cfg.set_property('green', params['green'])
|
|
543
|
+
cfg.set_property('blue', params['blue'])
|
|
544
|
+
cfg.set_property('alpha', params['alpha'])
|
|
545
|
+
cfg.set_property('seed', params['seed'])
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _set_drop_shadow(cfg, params):
|
|
549
|
+
# The rendered shadow is clipped to the layer's own bounds -- verified live, a large
|
|
550
|
+
# offset/radius does not grow the layer or canvas (measured at the schema maxima and in
|
|
551
|
+
# combination: no size change), it just gets cut off at the edge like any other
|
|
552
|
+
# DrawableFilter here, so no separate cap against "ballooning" is needed.
|
|
553
|
+
cfg.set_property('x', params['x'])
|
|
554
|
+
cfg.set_property('y', params['y'])
|
|
555
|
+
cfg.set_property('radius', params['radius'])
|
|
556
|
+
cfg.set_property('opacity', params['opacity'])
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
SETTERS = {
|
|
560
|
+
'gimp:curves': _set_curves,
|
|
561
|
+
'gimp:levels': _set_levels,
|
|
562
|
+
'gegl:exposure': _set_exposure,
|
|
563
|
+
'gimp:brightness-contrast': _set_brightness_contrast,
|
|
564
|
+
'gimp:hue-saturation': _set_hue_saturation,
|
|
565
|
+
'gimp:color-balance': _set_color_balance,
|
|
566
|
+
'gegl:color-temperature': _set_color_temperature,
|
|
567
|
+
'gegl:shadows-highlights': _set_shadows_highlights,
|
|
568
|
+
'gegl:saturation': _set_saturation,
|
|
569
|
+
'gegl:vibrance': _set_vibrance,
|
|
570
|
+
'gegl:unsharp-mask': _set_sharpen,
|
|
571
|
+
'gegl:noise-reduction': _set_noise_reduction,
|
|
572
|
+
'gegl:gaussian-blur': _set_gaussian_blur,
|
|
573
|
+
'gegl:vignette': _set_vignette,
|
|
574
|
+
'gegl:mono-mixer': _set_black_white,
|
|
575
|
+
'gegl:motion-blur-linear': _set_motion_blur,
|
|
576
|
+
'gegl:focus-blur': _set_lens_blur,
|
|
577
|
+
'gegl:noise-rgb': _set_add_noise,
|
|
578
|
+
'gegl:dropshadow': _set_drop_shadow,
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _channel_by_name(img, name):
|
|
583
|
+
for ch in img.get_channels():
|
|
584
|
+
if ch.get_name() == name:
|
|
585
|
+
return ch
|
|
586
|
+
raise ValueError('no mask channel named %r (create one with create_mask)' % name)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _append_masked(img, layer, f, mask):
|
|
590
|
+
"""Append f, confined to channel `mask` if given. A filter appended while a selection is
|
|
591
|
+
active keeps that selection as its own mask, which persists after the selection is
|
|
592
|
+
cleared and through XCF, so no extra layer or pixel copy is needed.
|
|
593
|
+
|
|
594
|
+
When `mask` is falsy, this explicitly clears any active selection FIRST -- defence in depth
|
|
595
|
+
against a stray selection left active by an earlier op (e.g. `create_mask` used to leave its
|
|
596
|
+
own channel selected; that bug is fixed at the source too, but an unmasked filter must never
|
|
597
|
+
silently inherit whatever happens to be selected regardless).
|
|
598
|
+
|
|
599
|
+
Verifies the filter actually attached before returning. `Gimp.Drawable.append_filter`
|
|
600
|
+
(`gimp-drawable-append-filter` in the PDB) can refuse an operation outright -- verified live
|
|
601
|
+
for `gegl:lens-blur` ("effects with an 'aux' pad cannot be applied non-destructively") -- and
|
|
602
|
+
does so SILENTLY on the Python side: a GIMP-Error goes to stderr, but `append_filter` itself
|
|
603
|
+
raises nothing and returns `None`, the same as a successful call, with the DrawableFilter's
|
|
604
|
+
own id still perfectly valid. Left unchecked, the caller (`_apply_filter`) would ledger a
|
|
605
|
+
phantom filter: `filter_id` returned as if it worked, the image silently rendering with no
|
|
606
|
+
effect at all. Raised here, before `_apply_filter` ever writes the ledger record, so a
|
|
607
|
+
refused attach never gets persisted as one -- `_mirror_filters` (the other caller) catches
|
|
608
|
+
this same exception instead of letting it propagate, since a proxy render is allowed to skip
|
|
609
|
+
a filter it can't mirror rather than fail outright (see its own doc comment). `f.delete()`
|
|
610
|
+
runs before raising either way, so a refused filter that never actually attached to anything
|
|
611
|
+
doesn't linger as an orphaned DrawableFilter object."""
|
|
612
|
+
if mask:
|
|
613
|
+
img.select_item(Gimp.ChannelOps.REPLACE, _channel_by_name(img, mask))
|
|
614
|
+
else:
|
|
615
|
+
Gimp.Selection.none(img)
|
|
616
|
+
try:
|
|
617
|
+
layer.append_filter(f)
|
|
618
|
+
if f.get_id() not in [x.get_id() for x in layer.get_filters()]:
|
|
619
|
+
# The operation name is captured BEFORE delete() -- a deleted DrawableFilter is not
|
|
620
|
+
# guaranteed to answer get_operation_name() usefully afterward -- and delete() itself
|
|
621
|
+
# is wrapped so that if IT raises for some unrelated reason, that exception can never
|
|
622
|
+
# replace (mask) the actionable OpError below with a confusing, unrelated traceback.
|
|
623
|
+
operation_name = f.get_operation_name()
|
|
624
|
+
try:
|
|
625
|
+
f.delete()
|
|
626
|
+
except Exception:
|
|
627
|
+
pass
|
|
628
|
+
raise lib.OpError(
|
|
629
|
+
'gimp_op_failed',
|
|
630
|
+
'GIMP refused to attach %s as a live filter (some GEGL operations with an '
|
|
631
|
+
'auxiliary input cannot be applied non-destructively)' % operation_name
|
|
632
|
+
)
|
|
633
|
+
finally:
|
|
634
|
+
if mask:
|
|
635
|
+
Gimp.Selection.none(img)
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _apply_filter(img, args, operation, params, default_name, type_=None):
|
|
639
|
+
"""Create (or, with filter_id, update in place) a filter and record it in the ledger.
|
|
640
|
+
A new filter may be confined to a mask channel (`mask`); a re-edit keeps the mask the
|
|
641
|
+
filter was created with. `type_` is the user-facing adjust `type` (e.g. "shadows_highlights")
|
|
642
|
+
recorded alongside `operation` (the GEGL op name) so a filter listing can report what the
|
|
643
|
+
model asked for, not just the GEGL internals."""
|
|
644
|
+
filters, unknown = _ledger_get(img)
|
|
645
|
+
if args.get('filter_id') is not None:
|
|
646
|
+
f = _existing_filter(img, args, operation)
|
|
647
|
+
if args.get('mask'):
|
|
648
|
+
raise ValueError('mask is fixed when the filter is created; add a new filter to change it')
|
|
649
|
+
params['mask'] = filters.get(f.get_name(), {}).get('params', {}).get('mask')
|
|
650
|
+
SETTERS[operation](f.get_config(), params)
|
|
651
|
+
f.update()
|
|
652
|
+
else:
|
|
653
|
+
layer = _layer(img, args)
|
|
654
|
+
f = Gimp.DrawableFilter.new(layer, operation, _unique_name(img, args.get('name', default_name)))
|
|
655
|
+
SETTERS[operation](f.get_config(), params)
|
|
656
|
+
params['mask'] = args.get('mask')
|
|
657
|
+
_append_masked(img, layer, f, params['mask'])
|
|
658
|
+
filters[f.get_name()] = {'operation': operation, 'type': type_, 'params': params}
|
|
659
|
+
_ledger_put(img, filters, unknown)
|
|
660
|
+
return {'filter_id': f.get_id(), 'name': f.get_name(), 'type': type_, 'mask': params['mask']}
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
# ---- operations -------------------------------------------------------------------------
|
|
664
|
+
|
|
665
|
+
def op_ping(args):
|
|
666
|
+
major, minor, micro = lib.parse_gimp_version(Gimp.version())
|
|
667
|
+
return {'major': major, 'minor': minor, 'micro': micro,
|
|
668
|
+
'images': [i.get_id() for i in Gimp.get_images()]}
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
# `precision` open-time promotion, using the enum names GIMP 3 actually exposes (verified live).
|
|
672
|
+
# u16/float-non-linear is the "perceptual-encoded" bit depth banding-prone tone moves need
|
|
673
|
+
# (measured live: ~130ms to convert_precision a 24 MP image -- negligible next to the rest of
|
|
674
|
+
# `open`'s cost).
|
|
675
|
+
_PRECISION_ENUM = {
|
|
676
|
+
'16': Gimp.Precision.U16_NON_LINEAR,
|
|
677
|
+
'32': Gimp.Precision.FLOAT_NON_LINEAR,
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def op_open(args):
|
|
682
|
+
path = lib.require(args, 'path')
|
|
683
|
+
if not os.path.exists(path):
|
|
684
|
+
raise FileNotFoundError('no file at %s' % path)
|
|
685
|
+
precision = args.get('precision', 'keep')
|
|
686
|
+
if precision not in ('keep', '16', '32'):
|
|
687
|
+
raise ValueError("precision must be one of 'keep', '16', '32'")
|
|
688
|
+
ext = os.path.splitext(path)[1].lower()
|
|
689
|
+
try:
|
|
690
|
+
img = Gimp.file_load(Gimp.RunMode.NONINTERACTIVE, Gio.File.new_for_path(path))
|
|
691
|
+
except Exception as e:
|
|
692
|
+
# Attempt the load first rather than refusing by extension alone: an
|
|
693
|
+
# install with a raw-develop plug-in (darktable/RawTherapee/ART) can
|
|
694
|
+
# open these directly. Only when the load actually fails AND the
|
|
695
|
+
# extension is one we know GIMP core can't read natively do we
|
|
696
|
+
# reframe it as gimp_unsupported_file with an actionable hint.
|
|
697
|
+
if ext in RAW_EXTENSIONS:
|
|
698
|
+
raise lib.OpError(
|
|
699
|
+
'gimp_unsupported_file',
|
|
700
|
+
'GIMP could not open this %s file (no raw loader by default); install a '
|
|
701
|
+
'raw-develop plug-in (darktable, RawTherapee, or ART) for GIMP, or develop it '
|
|
702
|
+
'externally first and open the resulting JPEG/TIFF. (%s)' % (ext, e),
|
|
703
|
+
)
|
|
704
|
+
raise
|
|
705
|
+
# Everything from here on runs against a real, open `img` -- ANY exception in this block
|
|
706
|
+
# (precision conversion, ledger pruning, or building the default proxy) must free it before
|
|
707
|
+
# propagating, or a failure partway through leaks an open image nothing can ever reach again
|
|
708
|
+
# (not just a precision-conversion failure specifically, which is all an earlier, narrower
|
|
709
|
+
# version of this guard covered).
|
|
710
|
+
try:
|
|
711
|
+
# XCF stores the selection, so a file saved from the GUI can arrive with one active. Every
|
|
712
|
+
# op here works on the whole canvas, and a transform with a selection active moves only
|
|
713
|
+
# the selected pixels (leaving a floating selection), so it is cleared on the way in.
|
|
714
|
+
Gimp.Selection.none(img)
|
|
715
|
+
if precision != 'keep':
|
|
716
|
+
# `convert_precision` does NOT raise on failure -- verified live that it returns
|
|
717
|
+
# plain `False` (GIMP logs a "Calling error" to stderr, e.g. "must not be of type
|
|
718
|
+
# 'indexed'", but nothing propagates to Python), so a bare call here would silently
|
|
719
|
+
# report `ok: true` with the ORIGINAL precision still in effect, misleading the
|
|
720
|
+
# caller into thinking their promotion applied when it didn't. Checking the return
|
|
721
|
+
# value turns that into a real, catchable failure.
|
|
722
|
+
if not img.convert_precision(_PRECISION_ENUM[precision]):
|
|
723
|
+
raise lib.OpError(
|
|
724
|
+
'gimp_op_failed',
|
|
725
|
+
"precision promotion to %r failed for this image (base_type=%s); GIMP "
|
|
726
|
+
"requires RGB or grayscale for precision conversion, not indexed"
|
|
727
|
+
% (precision, img.get_base_type().value_nick),
|
|
728
|
+
)
|
|
729
|
+
# Reconciled here, not just at the start of every geometry op's own check: a `.xcf` opened
|
|
730
|
+
# from disk can carry ledger records for filters that no longer exist in it for any number
|
|
731
|
+
# of reasons outside this bridge's control (edited by the GUI, hand-edited, a partial
|
|
732
|
+
# write) -- pruning now means staleness never has a chance to accumulate across a session.
|
|
733
|
+
_prune_stale_ledger_records(img)
|
|
734
|
+
_proxy(img, 1024) # build the default preview proxy now so the first preview is fast too
|
|
735
|
+
except Exception:
|
|
736
|
+
img.delete()
|
|
737
|
+
raise
|
|
738
|
+
return _describe(img)
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def _existing_ledger_params(img, args, operation):
|
|
742
|
+
"""The existing filter's own ledger `params`, when `args` names a `filter_id` for a
|
|
743
|
+
bridge-applied filter of this exact operation -- the merge base a re-edit's builder resolves
|
|
744
|
+
unspecified fields against. None on create.
|
|
745
|
+
|
|
746
|
+
A re-edit of a filter this bridge did NOT create is refused: its current values can't be read
|
|
747
|
+
back exactly (libgimp's readback is lossy, and there is no ledger record to merge from), so a
|
|
748
|
+
partial re-edit would reset every unmentioned field to the creation default, and recording the
|
|
749
|
+
result as ours would drop whatever mask the filter carries from the geometry refusal and the
|
|
750
|
+
preview proxy."""
|
|
751
|
+
if args.get('filter_id') is None:
|
|
752
|
+
return None
|
|
753
|
+
f = _existing_filter(img, args, operation)
|
|
754
|
+
filters, _unknown = _ledger_get(img)
|
|
755
|
+
rec = filters.get(f.get_name())
|
|
756
|
+
if rec and rec.get('operation') == operation:
|
|
757
|
+
return rec['params']
|
|
758
|
+
parasite = img.get_parasite(META)
|
|
759
|
+
if parasite and lib.ledger_is_newer_version(bytes(parasite.get_data())):
|
|
760
|
+
raise ValueError(
|
|
761
|
+
'filter %s cannot be re-edited: this document was saved by a newer Editmamei, whose '
|
|
762
|
+
'filter records this version cannot read. Re-edit it with that version, or delete and '
|
|
763
|
+
're-create the filter.' % f.get_id()
|
|
764
|
+
)
|
|
765
|
+
raise ValueError(
|
|
766
|
+
'filter %s was not created by Editmamei; its current values cannot be read back exactly. '
|
|
767
|
+
'Delete it and re-create it, or edit it in the GIMP GUI.' % f.get_id()
|
|
768
|
+
)
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def op_curves(args):
|
|
772
|
+
"""One gimp:curves filter per channel: a single filter carries one channel's curve
|
|
773
|
+
(setting red then blue on one filter renders only blue). A re-edit that omits `channel` or
|
|
774
|
+
`points` keeps the existing filter's own value for that field (the same merge-not-reset
|
|
775
|
+
contract every other adjust type gets) rather than silently resetting `channel` to 'value' or
|
|
776
|
+
requiring `points` to be resent every time."""
|
|
777
|
+
img = _image(args)
|
|
778
|
+
existing = _existing_ledger_params(img, args, 'gimp:curves')
|
|
779
|
+
channel = args.get('channel', existing['channel'] if existing else 'value')
|
|
780
|
+
if 'points' in args and args['points'] is not None:
|
|
781
|
+
points = [[float(x), float(y)] for x, y in args['points']]
|
|
782
|
+
if len(points) < 2:
|
|
783
|
+
raise ValueError('points needs at least two [x, y] pairs')
|
|
784
|
+
elif existing is not None:
|
|
785
|
+
points = existing['points']
|
|
786
|
+
else:
|
|
787
|
+
raise ValueError('points is required')
|
|
788
|
+
params = {'channel': channel, 'points': points}
|
|
789
|
+
return _apply_filter(img, args, 'gimp:curves', params, 'Curves', type_='curves')
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def op_levels(args):
|
|
793
|
+
"""Re-edit merges: any of channel/in_low/in_high/gamma/out_low/out_high omitted from a
|
|
794
|
+
re-edit keeps the existing filter's own value rather than resetting to the create-time
|
|
795
|
+
default (0/255/1.0/0/255)."""
|
|
796
|
+
img = _image(args)
|
|
797
|
+
existing = _existing_ledger_params(img, args, 'gimp:levels')
|
|
798
|
+
defaults = existing or {
|
|
799
|
+
'channel': 'value', 'in_low': 0.0, 'in_high': 255.0, 'gamma': 1.0,
|
|
800
|
+
'out_low': 0.0, 'out_high': 255.0,
|
|
801
|
+
}
|
|
802
|
+
params = lib.validate_levels({
|
|
803
|
+
'channel': args.get('channel', defaults['channel']),
|
|
804
|
+
'in_low': float(args.get('in_low', defaults['in_low'])),
|
|
805
|
+
'in_high': float(args.get('in_high', defaults['in_high'])),
|
|
806
|
+
'gamma': float(args.get('gamma', defaults['gamma'])),
|
|
807
|
+
'out_low': float(args.get('out_low', defaults['out_low'])),
|
|
808
|
+
'out_high': float(args.get('out_high', defaults['out_high'])),
|
|
809
|
+
})
|
|
810
|
+
return _apply_filter(img, args, 'gimp:levels', params, 'Levels', type_='levels')
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def op_adjust(args):
|
|
814
|
+
"""The single discriminated adjustment op: `type` picks the GEGL/GIMP operation
|
|
815
|
+
(`lib.ADJUST_OPERATIONS`) and a pure, gi-free param builder (`lib.ADJUST_PARAM_BUILDERS`)
|
|
816
|
+
that validates every range and returns GEGL-property-named params. curves/levels are folded
|
|
817
|
+
in here too so ONE dispatch handles every adjust type, but keep their own richer argument
|
|
818
|
+
shapes (a points list; per-side 0-255 levels) rather than forcing them through the generic
|
|
819
|
+
builders.
|
|
820
|
+
|
|
821
|
+
A re-edit (`filter_id` given) MERGES: the builder's `defaults` argument is the existing
|
|
822
|
+
filter's own ledger params rather than this type's hardcoded creation defaults, so a re-edit
|
|
823
|
+
that only mentions one field (e.g. `{contrast: 50}`) keeps every other field (e.g.
|
|
824
|
+
`brightness`) exactly as it was, instead of silently resetting it."""
|
|
825
|
+
type_ = args.get('type')
|
|
826
|
+
if type_ in lib.ADJUST_OPERATIONS: # curves and levels included
|
|
827
|
+
lib.reject_foreign_fields(type_, args)
|
|
828
|
+
if type_ == 'curves':
|
|
829
|
+
return op_curves(args)
|
|
830
|
+
if type_ == 'levels':
|
|
831
|
+
return op_levels(args)
|
|
832
|
+
builder = lib.ADJUST_PARAM_BUILDERS.get(type_)
|
|
833
|
+
if builder is None:
|
|
834
|
+
raise ValueError('type must be one of %s' % sorted(lib.ADJUST_OPERATIONS))
|
|
835
|
+
img = _image(args)
|
|
836
|
+
operation = lib.ADJUST_OPERATIONS[type_]
|
|
837
|
+
defaults = _existing_ledger_params(img, args, operation) or lib.ADJUST_CREATE_DEFAULTS[type_]
|
|
838
|
+
params = builder(args, defaults)
|
|
839
|
+
default_name = type_.replace('_', ' ').title()
|
|
840
|
+
return _apply_filter(img, args, operation, params, default_name, type_=type_)
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def _filter_record(filters, layer, f):
|
|
844
|
+
"""One filter's `op_list_filters`-shaped record: `filters` is the ledger's own {name: {...}}
|
|
845
|
+
map (`_ledger_get`'s first return value). Bridge-applied filters report the ledger record
|
|
846
|
+
(`source: editmamei`) with `params` in the adjust tool's own field names and units
|
|
847
|
+
(`lib.user_params`), so a listed value can be passed straight back on a re-edit. Anything else
|
|
848
|
+
reports libgimp's readback (`source: readback`): raw GEGL property names and units, lossy for
|
|
849
|
+
per-channel curves, with any value JSON can't carry (a Gegl.Color, say) stringified. Shared by
|
|
850
|
+
`op_list_filters` (every filter on the image) and `op_describe`'s `what='filter'` (one, by id)."""
|
|
851
|
+
rec = filters.get(f.get_name())
|
|
852
|
+
if rec and rec['operation'] == f.get_operation_name():
|
|
853
|
+
source = 'editmamei'
|
|
854
|
+
type_ = rec.get('type') or lib.OPERATION_TYPES.get(rec['operation'])
|
|
855
|
+
mask = rec['params'].get('mask')
|
|
856
|
+
params = lib.user_params(type_, rec['params'])
|
|
857
|
+
else:
|
|
858
|
+
cfg, params, source, type_, mask = f.get_config(), {}, 'readback', None, None
|
|
859
|
+
for p in cfg.list_properties():
|
|
860
|
+
v = cfg.get_property(p.name)
|
|
861
|
+
if isinstance(v, Gimp.Curve):
|
|
862
|
+
v = _curve_points(v)
|
|
863
|
+
elif hasattr(v, 'value_nick'):
|
|
864
|
+
v = v.value_nick
|
|
865
|
+
params[p.name] = lib.json_safe(v)
|
|
866
|
+
return {'layer': layer.get_name(), 'layer_id': layer.get_id(), 'filter_id': f.get_id(),
|
|
867
|
+
'name': f.get_name(), 'operation': f.get_operation_name(), 'type': type_,
|
|
868
|
+
'visible': f.get_visible(), 'source': source, 'mask': mask, 'params': params}
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def op_list_filters(args):
|
|
872
|
+
img = _image(args)
|
|
873
|
+
filters, _unknown = _ledger_get(img)
|
|
874
|
+
out = []
|
|
875
|
+
for layer in _all_layers(img):
|
|
876
|
+
for f in layer.get_filters():
|
|
877
|
+
out.append(_filter_record(filters, layer, f))
|
|
878
|
+
return {'filters': out}
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def _find_filter(img, filter_id):
|
|
882
|
+
"""Locate a filter by id AMONG img's own layers (same reasoning as `_existing_filter`: a
|
|
883
|
+
global filter-id lookup could hand back one belonging to a different, possibly closed,
|
|
884
|
+
image)."""
|
|
885
|
+
for layer in _all_layers(img):
|
|
886
|
+
for f in layer.get_filters():
|
|
887
|
+
if f.get_id() == filter_id:
|
|
888
|
+
return layer, f
|
|
889
|
+
raise ValueError('no filter with id %s on image %s' % (filter_id, img.get_id()))
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def op_effect(args):
|
|
893
|
+
"""`gimp_add_effect`: create (or, with `filter_id`, re-edit in place) one of the allow-listed
|
|
894
|
+
GEGL effect filters (`lib.EFFECT_OPERATIONS`) -- the same merge/mask/ledger machinery
|
|
895
|
+
`op_adjust` uses, but reading its own `type` against the EFFECT_* tables
|
|
896
|
+
(`lib.EFFECT_PARAM_BUILDERS`/`EFFECT_CREATE_DEFAULTS`) rather than `op_adjust`'s ADJUST_*
|
|
897
|
+
ones. Dispatched through the shared `_apply_filter`, so the ledger, mask confinement,
|
|
898
|
+
merge-on-re-edit, geometry refusals and proxy mirroring all come free, unchanged.
|
|
899
|
+
gimp_add_effect is its own tool (tier 'dev'), a sibling of gimp_add_adjustment, rather than a
|
|
900
|
+
new op on gimp_filter (already 'community') -- tool-tiers.ts classifies per TOOL, not per op,
|
|
901
|
+
so a new op on an already-shipping tool would have skipped the tier gate entirely."""
|
|
902
|
+
effect_type = args.get('type')
|
|
903
|
+
builder = lib.EFFECT_PARAM_BUILDERS.get(effect_type)
|
|
904
|
+
if builder is None:
|
|
905
|
+
raise ValueError('type must be one of %s' % sorted(lib.EFFECT_OPERATIONS))
|
|
906
|
+
lib.reject_foreign_fields(effect_type, args)
|
|
907
|
+
img = _image(args)
|
|
908
|
+
operation = lib.EFFECT_OPERATIONS[effect_type]
|
|
909
|
+
defaults = _existing_ledger_params(img, args, operation) or lib.EFFECT_CREATE_DEFAULTS[effect_type]
|
|
910
|
+
params = builder(args, defaults)
|
|
911
|
+
default_name = effect_type.replace('_', ' ').title()
|
|
912
|
+
return _apply_filter(img, args, operation, params, default_name, type_=effect_type)
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
def op_filter(args):
|
|
916
|
+
"""Stack management: `op` in list | set_visibility | delete. There is deliberately no
|
|
917
|
+
`reorder` -- `Gimp.DrawableFilter` exposes only delete/set_visible/update, and the PDB has no
|
|
918
|
+
raise/lower-filter procedure (verified live, GIMP 3.2.6); emulating it means deleting and
|
|
919
|
+
re-appending every filter above the moved one, which changes their ids and can't restore
|
|
920
|
+
masks on filters this bridge didn't create."""
|
|
921
|
+
img = _image(args)
|
|
922
|
+
fop = args.get('op')
|
|
923
|
+
if fop == 'list':
|
|
924
|
+
return op_list_filters(args)
|
|
925
|
+
if fop == 'set_visibility':
|
|
926
|
+
filter_id = int(lib.require(args, 'filter_id'))
|
|
927
|
+
_layer, f = _find_filter(img, filter_id)
|
|
928
|
+
visible = lib.require_bool(args, 'visible')
|
|
929
|
+
f.set_visible(visible)
|
|
930
|
+
return {'filter_id': f.get_id(), 'visible': visible}
|
|
931
|
+
if fop == 'delete':
|
|
932
|
+
filter_id = int(lib.require(args, 'filter_id'))
|
|
933
|
+
layer, f = _find_filter(img, filter_id)
|
|
934
|
+
name = f.get_name()
|
|
935
|
+
f.delete()
|
|
936
|
+
filters, unknown = _ledger_get(img)
|
|
937
|
+
filters.pop(name, None)
|
|
938
|
+
# `removed={name}` is what actually keeps it gone -- see `_ledger_put`/
|
|
939
|
+
# `lib.merged_ledger_for_write`: popping it from THIS in-memory dict alone doesn't stop a
|
|
940
|
+
# stale on-disk copy from surviving the merge and resurrecting the record.
|
|
941
|
+
_ledger_put(img, filters, unknown, removed={name})
|
|
942
|
+
return {'filter_id': filter_id, 'name': name, 'deleted': True}
|
|
943
|
+
if fop == 'reorder':
|
|
944
|
+
raise ValueError(
|
|
945
|
+
'filter reorder is not supported: GIMP has no reorder primitive for drawable '
|
|
946
|
+
'filters in this beta -- delete and re-create in the desired order instead'
|
|
947
|
+
)
|
|
948
|
+
raise ValueError('op must be one of list, set_visibility, delete (got %r)' % fop)
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
DESCRIBE_TARGETS = ('document', 'layers', 'channels', 'filter')
|
|
952
|
+
|
|
953
|
+
# A hard cap on how many layer-tree nodes `describe`'s `document`/`layers` targets will build,
|
|
954
|
+
# counting every node in the WHOLE tree (top-level and every descendant), not just top-level --
|
|
955
|
+
# an output-size bound, and it is what makes `_build_layer_tree`'s walk itself safe: it stops
|
|
956
|
+
# outright once this many nodes have been visited, so neither a very wide document (many layers)
|
|
957
|
+
# nor a very deep one (many nested groups) can produce unbounded output. `truncated: true` in the
|
|
958
|
+
# result says the cap was hit; a group visited right at the cut-off may be missing some of its own
|
|
959
|
+
# children, since the walk simply stops rather than finishing that group first.
|
|
960
|
+
MAX_DESCRIBE_LAYER_NODES = 2000
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def _layer_node_shallow(layer):
|
|
964
|
+
"""One layer's own fields for a describe-by-id layer tree node, with `children` left as an
|
|
965
|
+
empty list -- `_build_layer_tree` fills it in as it walks. Id is canonical (unlike `_layer`'s
|
|
966
|
+
name-based lookup, which can't tell two same-named layers apart -- GIMP allows duplicate
|
|
967
|
+
names). `get_offsets()` returns (ok, x, y); `ok` is False only in a genuinely invalid-item
|
|
968
|
+
case that shouldn't arise for a layer this walk just visited via `get_layers()`/
|
|
969
|
+
`get_children()`, but is still checked rather than trusted blindly -- offsets fall back to
|
|
970
|
+
null rather than reporting a wrong position."""
|
|
971
|
+
ok, off_x, off_y = layer.get_offsets()
|
|
972
|
+
return {
|
|
973
|
+
'layer_id': layer.get_id(),
|
|
974
|
+
'name': layer.get_name(),
|
|
975
|
+
'opacity': layer.get_opacity(),
|
|
976
|
+
'mode': layer.get_mode().value_nick,
|
|
977
|
+
'visible': layer.get_visible(),
|
|
978
|
+
'offsets': {'x': off_x, 'y': off_y} if ok else {'x': None, 'y': None},
|
|
979
|
+
'has_alpha': layer.has_alpha(),
|
|
980
|
+
'is_group': layer.is_group(),
|
|
981
|
+
'is_text_layer': layer.is_text_layer(),
|
|
982
|
+
'children': [],
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
def _build_layer_tree(top_layers, max_nodes=MAX_DESCRIBE_LAYER_NODES):
|
|
987
|
+
"""The nested layer tree for `top_layers` (an image's own `get_layers()`, top of stack first),
|
|
988
|
+
walked with an EXPLICIT stack rather than one recursive call per nesting level -- a
|
|
989
|
+
pathological chain of nested single-child groups would otherwise risk Python's OWN recursion
|
|
990
|
+
limit, not just some output-size limit of this op's choosing. Capped at `max_nodes` total
|
|
991
|
+
nodes across the whole tree. Returns (nodes, truncated, total_nodes) -- `total_nodes` is simply
|
|
992
|
+
the walk's own `seen` counter, so it costs nothing extra to report; it is what a truncated
|
|
993
|
+
response is actually counted from, distinct from `top_level_count` (the caller's own
|
|
994
|
+
`len(top_layers)`, always the TRUE top-level count even when the walk stopped before reaching
|
|
995
|
+
every top-level sibling).
|
|
996
|
+
|
|
997
|
+
Each stack entry is (layer, parent_id); a group's children are pushed in REVERSE order so
|
|
998
|
+
popping (LIFO) still visits them top-of-stack-first, the same order `_all_layers` already
|
|
999
|
+
relies on elsewhere. A layer's own node dict is built and recorded (`by_id`) the moment it is
|
|
1000
|
+
popped, and immediately appended into its parent's `children` list (or the top-level list) --
|
|
1001
|
+
safe because a layer is always popped strictly after its own parent (the parent's children
|
|
1002
|
+
are only ever pushed once the parent itself has already been popped and recorded), so the
|
|
1003
|
+
parent's node dict is guaranteed to already exist. This one pass is enough; no separate
|
|
1004
|
+
bottom-up assembly pass is needed."""
|
|
1005
|
+
truncated = False
|
|
1006
|
+
top = []
|
|
1007
|
+
by_id = {}
|
|
1008
|
+
stack = [(layer, None) for layer in reversed(top_layers)]
|
|
1009
|
+
seen = 0
|
|
1010
|
+
while stack:
|
|
1011
|
+
if seen >= max_nodes:
|
|
1012
|
+
truncated = True
|
|
1013
|
+
break
|
|
1014
|
+
layer, parent_id = stack.pop()
|
|
1015
|
+
seen += 1
|
|
1016
|
+
node = _layer_node_shallow(layer)
|
|
1017
|
+
by_id[layer.get_id()] = node
|
|
1018
|
+
if parent_id is None:
|
|
1019
|
+
top.append(node)
|
|
1020
|
+
else:
|
|
1021
|
+
by_id[parent_id]['children'].append(node)
|
|
1022
|
+
if layer.is_group():
|
|
1023
|
+
for child in reversed(layer.get_children()):
|
|
1024
|
+
stack.append((child, layer.get_id()))
|
|
1025
|
+
return top, truncated, seen
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
def _channels_summary(img):
|
|
1029
|
+
"""Every named channel on img, by id and name only -- `document`'s cheap channel listing.
|
|
1030
|
+
Coverage (`_channel_coverage`) reads a channel's full pixel buffer, so it is computed only for
|
|
1031
|
+
`what='channels'` (`_channels_described`), never bundled into `document`'s broader, cheaper
|
|
1032
|
+
read."""
|
|
1033
|
+
return [{'channel_id': ch.get_id(), 'name': ch.get_name()} for ch in img.get_channels()]
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
# `_channels_described` checks this after every channel it reads, and stops once that many seconds
|
|
1037
|
+
# have passed, rather than run gimp_inspect's own dispatch budget out on a document with many named
|
|
1038
|
+
# channels (`operation-timeouts.ts`). A plain module global, not a function default, so a test
|
|
1039
|
+
# fixture can reassign it directly (`test_set_channels_deadline`, fixtures/test_ops.py) and force
|
|
1040
|
+
# the stop without needing dozens of real channels.
|
|
1041
|
+
CHANNELS_DESCRIBE_DEADLINE_S = 15.0
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
def _channels_described(img):
|
|
1045
|
+
"""Every named channel on img, with its coverage (`_channel_coverage`) -- the same stat
|
|
1046
|
+
`op_create_mask` returns for the one it just built. `what='channels'`-only; see
|
|
1047
|
+
`_channels_summary`'s own docstring for why `document` doesn't compute this.
|
|
1048
|
+
|
|
1049
|
+
Reads at least the first channel unconditionally, then checks `CHANNELS_DESCRIBE_DEADLINE_S`
|
|
1050
|
+
after each one read: once elapsed time reaches it, stops and returns early. Returns
|
|
1051
|
+
{'channels', 'truncated', 'channels_skipped'} -- `channels` is whatever was read before
|
|
1052
|
+
stopping, `truncated` is whether any named channel was left unread, and `channels_skipped`
|
|
1053
|
+
counts them."""
|
|
1054
|
+
w, h = img.get_width(), img.get_height()
|
|
1055
|
+
channels = img.get_channels()
|
|
1056
|
+
out = []
|
|
1057
|
+
start = time.time()
|
|
1058
|
+
for ch in channels:
|
|
1059
|
+
selected, fraction = _channel_coverage(ch, w, h)
|
|
1060
|
+
out.append({
|
|
1061
|
+
'channel_id': ch.get_id(), 'name': ch.get_name(),
|
|
1062
|
+
'selected_pixels': selected, 'fraction': fraction,
|
|
1063
|
+
})
|
|
1064
|
+
if time.time() - start >= CHANNELS_DESCRIBE_DEADLINE_S:
|
|
1065
|
+
break
|
|
1066
|
+
return {
|
|
1067
|
+
'channels': out,
|
|
1068
|
+
'truncated': len(out) < len(channels),
|
|
1069
|
+
'channels_skipped': len(channels) - len(out),
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
def op_describe(args):
|
|
1074
|
+
"""`gimp_inspect`'s describe-by-id bridge op: `what` in document | layers | channels | filter
|
|
1075
|
+
(`documents` -- every open image's id -- stays on `op_ping`, unchanged; the tool layer never
|
|
1076
|
+
routes it here). `document` bundles dims/base_type/precision/resolution with the layer tree
|
|
1077
|
+
and a cheap by-id/name channel listing in one call; `layers` returns just the tree (capped and
|
|
1078
|
+
flagged, see `_build_layer_tree`/`MAX_DESCRIBE_LAYER_NODES`); `channels` returns the same
|
|
1079
|
+
channels WITH coverage (`_channels_described`), the one part `document` deliberately leaves
|
|
1080
|
+
out since it reads full pixel buffers -- and stops after its own time budget on a document with
|
|
1081
|
+
many named channels, returning whatever it already read (see `_channels_described`). `filter`
|
|
1082
|
+
reports one filter by id, in the exact shape `op_list_filters` reports it in (`_filter_record`,
|
|
1083
|
+
via `_find_filter` so a filter_id from a different or closed image is never mistaken for a
|
|
1084
|
+
match). `document`/`layers` also report `top_level_count` (the image's real top-level layer
|
|
1085
|
+
count, from `get_layers()` directly) and `total_nodes` (how many nodes THIS response carries)
|
|
1086
|
+
alongside `truncated`."""
|
|
1087
|
+
what = args.get('what')
|
|
1088
|
+
if what not in DESCRIBE_TARGETS:
|
|
1089
|
+
raise ValueError('what must be one of %s' % ', '.join(DESCRIBE_TARGETS))
|
|
1090
|
+
img = _image(args)
|
|
1091
|
+
if what == 'filter':
|
|
1092
|
+
filter_id = int(lib.require(args, 'filter_id'))
|
|
1093
|
+
layer, f = _find_filter(img, filter_id)
|
|
1094
|
+
filters, _unknown = _ledger_get(img)
|
|
1095
|
+
return _filter_record(filters, layer, f)
|
|
1096
|
+
if what == 'layers':
|
|
1097
|
+
top_layers = img.get_layers()
|
|
1098
|
+
layers, truncated, total_nodes = _build_layer_tree(top_layers)
|
|
1099
|
+
return {
|
|
1100
|
+
'layers': layers,
|
|
1101
|
+
'truncated': truncated,
|
|
1102
|
+
'top_level_count': len(top_layers),
|
|
1103
|
+
'total_nodes': total_nodes,
|
|
1104
|
+
}
|
|
1105
|
+
if what == 'channels':
|
|
1106
|
+
return _channels_described(img)
|
|
1107
|
+
ok, xres, yres = img.get_resolution()
|
|
1108
|
+
top_layers = img.get_layers()
|
|
1109
|
+
layers, truncated, total_nodes = _build_layer_tree(top_layers)
|
|
1110
|
+
return {
|
|
1111
|
+
'image': img.get_id(),
|
|
1112
|
+
'width': img.get_width(),
|
|
1113
|
+
'height': img.get_height(),
|
|
1114
|
+
'base_type': img.get_base_type().value_nick,
|
|
1115
|
+
'precision': img.get_precision().value_nick,
|
|
1116
|
+
'resolution': {'x': xres, 'y': yres} if ok else {'x': None, 'y': None},
|
|
1117
|
+
'layers': layers,
|
|
1118
|
+
'truncated': truncated,
|
|
1119
|
+
'top_level_count': len(top_layers),
|
|
1120
|
+
'total_nodes': total_nodes,
|
|
1121
|
+
'channels': _channels_summary(img),
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
|
|
1125
|
+
# ---- geometry -----------------------------------------------------------------------------
|
|
1126
|
+
#
|
|
1127
|
+
# Verified live (GIMP 3.2.6): unmasked filters, named mask CHANNELS, and the editmamei-filters
|
|
1128
|
+
# ledger parasite all survive every op below -- `Image.crop`, `Item.transform_rotate`,
|
|
1129
|
+
# `Image.flip`, and `Image.scale` all mutate the existing Image object in place rather than
|
|
1130
|
+
# replacing it, so a parasite attached to the image is untouched, and layers/channels resize and
|
|
1131
|
+
# reposition along with their drawable exactly as GIMP's own crop/rotate/flip tools would.
|
|
1132
|
+
#
|
|
1133
|
+
# A FILTER's OWN mask is a different story, and NOT what the paragraph above covers. Appending a
|
|
1134
|
+
# filter while a selection is active bakes that selection in as a fixed internal snapshot at
|
|
1135
|
+
# that moment -- `Gimp.DrawableFilter` exposes no `get_mask`/`set_mask` of any kind (verified
|
|
1136
|
+
# live: not in its method list at all), so nothing on the Python API can re-point an EXISTING
|
|
1137
|
+
# filter's mask at a channel's new content after a transform, no matter what the channel's own
|
|
1138
|
+
# data ends up looking like. Measured live, per op:
|
|
1139
|
+
# - crop: the filter's own confinement survives and stays pixel-aligned with the surviving
|
|
1140
|
+
# content (its internal mask is offset/cropped along with everything else).
|
|
1141
|
+
# - rotate/flip: the filter's own confinement does NOT move -- it stays pinned to the
|
|
1142
|
+
# ORIGINAL, pre-transform pixel positions while the layer/channel content underneath it
|
|
1143
|
+
# rotates or flips, so the rendered result is silently wrong (misaligned) even though the
|
|
1144
|
+
# channel itself, and the ledger's `mask` field, both look fine.
|
|
1145
|
+
# - resize: the filter's own confinement does not scale either; measured as the filter
|
|
1146
|
+
# appearing to have NO effect at all afterward (an entirely blank mask), not merely
|
|
1147
|
+
# misaligned.
|
|
1148
|
+
# rotate/flip/resize therefore REFUSE outright (`invalid_argument`) when the image has any filter
|
|
1149
|
+
# whose ledger record names a mask, rather than silently rendering a masked edit in the wrong
|
|
1150
|
+
# place -- the same "refuse rather than silently corrupt" contract as `filter op=reorder`. crop
|
|
1151
|
+
# never refuses for this reason, since it verified correct.
|
|
1152
|
+
#
|
|
1153
|
+
# gimp_add_effect's own vignette/motion_blur/drop_shadow filters get a SEPARATE tracking
|
|
1154
|
+
# mechanism, below (`_snapshot_effect_transform`/`_apply_planned_effect_transform`), that keeps
|
|
1155
|
+
# their params locked to the content through flip/rotate(right angles only)/resize -- see lib.py's
|
|
1156
|
+
# "geometry transforms for direction/position-dependent EFFECT params" comment for the full
|
|
1157
|
+
# design. crop needs none of this: vignette's center is already a fraction of its LAYER's own
|
|
1158
|
+
# extent, so cropping the layer naturally re-centres it on the new, smaller frame -- intended
|
|
1159
|
+
# behaviour (the same way Lightroom's post-crop vignette re-centres), not a gap in tracking.
|
|
1160
|
+
|
|
1161
|
+
def _live_filter_names(img):
|
|
1162
|
+
return {f.get_name() for layer in _all_layers(img) for f in layer.get_filters()}
|
|
1163
|
+
|
|
1164
|
+
|
|
1165
|
+
def _prune_stale_ledger_records(img):
|
|
1166
|
+
"""Drop ledger records whose name no longer matches any LIVE filter -- the filter behind that
|
|
1167
|
+
record is simply gone (deleted outside `filter op=delete`, or a document edited by the GUI or
|
|
1168
|
+
another tool), and a stale record left in place would otherwise go on looking like a real,
|
|
1169
|
+
still-masked filter to `_classify_geometry_filters` forever. Called at the START of every
|
|
1170
|
+
geometry op's masked-filter check, and once at `open` time, so staleness never has a chance to
|
|
1171
|
+
accumulate silently. Returns the (possibly pruned) filters dict; writes the ledger back only
|
|
1172
|
+
if something was actually pruned."""
|
|
1173
|
+
filters, unknown = _ledger_get(img)
|
|
1174
|
+
stale = lib.stale_ledger_names(filters, _live_filter_names(img))
|
|
1175
|
+
if stale:
|
|
1176
|
+
for name in stale:
|
|
1177
|
+
filters.pop(name, None)
|
|
1178
|
+
_ledger_put(img, filters, unknown, removed=stale)
|
|
1179
|
+
return filters
|
|
1180
|
+
|
|
1181
|
+
|
|
1182
|
+
def _classify_geometry_filters(img):
|
|
1183
|
+
"""(masked_names, unverifiable_names) for every LIVE filter on `img` right now -- the
|
|
1184
|
+
ledger-pruned, gi-free classification lives in `lib.classify_geometry_filters`; this just
|
|
1185
|
+
supplies it the live GIMP state (every filter's own name + operation) instead of trusting the
|
|
1186
|
+
ledger alone, per the settled decision that a live filter the ledger doesn't recognise (never
|
|
1187
|
+
ledgered at all, e.g. because its own ledger write was skipped -- see
|
|
1188
|
+
`lib.merged_ledger_for_write` -- or one whose name/operation the ledger has no matching record
|
|
1189
|
+
for) must be treated as POSSIBLY masked, not silently assumed safe."""
|
|
1190
|
+
filters = _prune_stale_ledger_records(img)
|
|
1191
|
+
live = [(f.get_name(), f.get_operation_name()) for layer in _all_layers(img) for f in layer.get_filters()]
|
|
1192
|
+
return lib.classify_geometry_filters(filters, live)
|
|
1193
|
+
|
|
1194
|
+
|
|
1195
|
+
def _refuse_if_masked_filters(img, op_name, before_hint='Rotate, flip or resize'):
|
|
1196
|
+
"""`before_hint` names the alternative ordering this op's own refusal message suggests --
|
|
1197
|
+
'Rotate, flip or resize' for resize/rotate/flip, 'Extend the canvas' for `gimp_canvas` (see its
|
|
1198
|
+
own call site) -- so the advice matches the op that actually refused."""
|
|
1199
|
+
masked, unverifiable = _classify_geometry_filters(img)
|
|
1200
|
+
if unverifiable:
|
|
1201
|
+
# `sorted(set(...))`, not `sorted(...)`: classify_geometry_filters reports a duplicate
|
|
1202
|
+
# live filter NAME once per occurrence (so a caller counting what it iterated still gets
|
|
1203
|
+
# a consistent count), but the refusal message only needs to name it once.
|
|
1204
|
+
raise ValueError(
|
|
1205
|
+
'%s cannot proceed: filter(s) %s were not created by Editmamei (no matching ledger '
|
|
1206
|
+
'record for their name and operation), so whether they are masked cannot be checked. '
|
|
1207
|
+
'Delete them first, or make this geometry change before adding them.'
|
|
1208
|
+
% (op_name, ', '.join(repr(n) for n in sorted(set(unverifiable))))
|
|
1209
|
+
)
|
|
1210
|
+
if masked:
|
|
1211
|
+
raise ValueError(
|
|
1212
|
+
'%s would misalign the masked filter(s) %s: a filter\'s mask cannot move with '
|
|
1213
|
+
'this transform. %s before adding masked filters, or delete '
|
|
1214
|
+
'those filters first and re-create them afterwards. Crop is unaffected.'
|
|
1215
|
+
% (op_name, ', '.join(repr(n) for n in sorted(set(masked))), before_hint)
|
|
1216
|
+
)
|
|
1217
|
+
|
|
1218
|
+
|
|
1219
|
+
# Operations whose params are position/direction-dependent under rotation -- an arbitrary
|
|
1220
|
+
# (non-right-angle) rotation is refused outright while any LIVE, ledgered filter using one of
|
|
1221
|
+
# these is present (see lib.py's own "ONLY exact cases are supported" comment for why).
|
|
1222
|
+
ROTATE_DEPENDENT_OPERATIONS = frozenset({'gegl:vignette', 'gegl:motion-blur-linear', 'gegl:dropshadow'})
|
|
1223
|
+
|
|
1224
|
+
|
|
1225
|
+
def _refuse_if_non_right_angle_with_tracked_effects(img, degrees, op_name):
|
|
1226
|
+
if lib.is_right_angle_degrees(degrees):
|
|
1227
|
+
return
|
|
1228
|
+
filters, _unknown = _ledger_get(img)
|
|
1229
|
+
live_names = _live_filter_names(img)
|
|
1230
|
+
tracked = sorted(
|
|
1231
|
+
name for name, rec in filters.items()
|
|
1232
|
+
if name in live_names and rec.get('operation') in ROTATE_DEPENDENT_OPERATIONS
|
|
1233
|
+
)
|
|
1234
|
+
if tracked:
|
|
1235
|
+
raise ValueError(
|
|
1236
|
+
'%s cannot use an arbitrary angle (%.4g°) while effect filter(s) %s are present: '
|
|
1237
|
+
'only an exact 0/90/180/270-degree rotation keeps them locked to the content. Rotate '
|
|
1238
|
+
'at a right angle instead, or delete these filters first and re-add them afterwards.'
|
|
1239
|
+
% (op_name, degrees, ', '.join(repr(n) for n in tracked))
|
|
1240
|
+
)
|
|
1241
|
+
|
|
1242
|
+
|
|
1243
|
+
def _snapshot_effect_transform(img, op_name, transform_fn):
|
|
1244
|
+
"""Precompute every ledgered effect filter's new params from a SNAPSHOT of the current
|
|
1245
|
+
ledger, validate each against this bridge's own field ranges, and raise -- refusing `op_name`
|
|
1246
|
+
outright -- if anything would land out of range, ALL BEFORE anything is mutated. Working from
|
|
1247
|
+
a snapshot (rather than re-reading `filters` mid-loop, which a naive loop could otherwise
|
|
1248
|
+
transform twice over if two live filters were ever ledgered under the same name) is what makes
|
|
1249
|
+
this safe to call before the geometry op has committed to anything.
|
|
1250
|
+
|
|
1251
|
+
`transform_fn(operation, params, layer)` is called once per live, ledgered filter still
|
|
1252
|
+
present -- `layer` is that filter's OWNING layer, queried for its CURRENT (pre-mutation)
|
|
1253
|
+
dimensions where the transform needs them (only rotate's vignette case does; flip and resize
|
|
1254
|
+
ignore it). Returns {filter_name: (operation, new_params)} for every filter transform_fn
|
|
1255
|
+
actually changed -- callers apply these with `_apply_planned_effect_transform` AFTER the
|
|
1256
|
+
geometry mutation itself. A filter transform_fn has nothing to change for (black_white,
|
|
1257
|
+
add_noise, and every gimp_add_adjustment type -- out of scope for this table, see lib.py's own
|
|
1258
|
+
comment) comes back with the SAME values (by `==`) and is simply absent from the result."""
|
|
1259
|
+
filters, _unknown = _ledger_get(img)
|
|
1260
|
+
if not filters:
|
|
1261
|
+
return {}
|
|
1262
|
+
snapshot = {name: (rec['operation'], dict(rec['params'])) for name, rec in filters.items()}
|
|
1263
|
+
planned = {}
|
|
1264
|
+
for layer in _all_layers(img):
|
|
1265
|
+
for f in layer.get_filters():
|
|
1266
|
+
entry = snapshot.get(f.get_name())
|
|
1267
|
+
if entry is None or entry[0] != f.get_operation_name():
|
|
1268
|
+
continue
|
|
1269
|
+
operation, params = entry
|
|
1270
|
+
new_params = transform_fn(operation, params, layer)
|
|
1271
|
+
if new_params == params:
|
|
1272
|
+
continue
|
|
1273
|
+
lib.validate_effect_transform(op_name, operation, f.get_name(), new_params)
|
|
1274
|
+
planned[f.get_name()] = (operation, new_params)
|
|
1275
|
+
return planned
|
|
1276
|
+
|
|
1277
|
+
|
|
1278
|
+
def _apply_planned_effect_transform(img, planned):
|
|
1279
|
+
"""Push each planned (operation, new_params) -- already validated by
|
|
1280
|
+
`_snapshot_effect_transform` -- into the live GEGL config and the ledger record. Called AFTER
|
|
1281
|
+
the geometry mutation.
|
|
1282
|
+
|
|
1283
|
+
A live update failure for one filter does not stop the others. The ledger must always match
|
|
1284
|
+
what actually renders, so a failure never leaves the ledger record at the new (unapplied)
|
|
1285
|
+
params: this instead tries to restore the filter's OLD params live (best effort -- if that
|
|
1286
|
+
ALSO fails, the filter is simply left wherever the failed attempt left it) and keeps the ledger
|
|
1287
|
+
record at the OLD params either way. The filter's name is collected and returned so the caller
|
|
1288
|
+
can report it (`effect_update_failures`) -- a silent partial failure here would otherwise look
|
|
1289
|
+
identical to a filter the geometry op never touched at all."""
|
|
1290
|
+
if not planned:
|
|
1291
|
+
return []
|
|
1292
|
+
failures = []
|
|
1293
|
+
filters, unknown = _ledger_get(img)
|
|
1294
|
+
for layer in _all_layers(img):
|
|
1295
|
+
for f in layer.get_filters():
|
|
1296
|
+
entry = planned.get(f.get_name())
|
|
1297
|
+
if entry is None:
|
|
1298
|
+
continue
|
|
1299
|
+
operation, new_params = entry
|
|
1300
|
+
rec = filters.get(f.get_name())
|
|
1301
|
+
old_params = rec['params'] if rec is not None else None
|
|
1302
|
+
try:
|
|
1303
|
+
SETTERS[operation](f.get_config(), new_params)
|
|
1304
|
+
f.update()
|
|
1305
|
+
except Exception as e:
|
|
1306
|
+
failures.append(f.get_name())
|
|
1307
|
+
sys.stderr.write(
|
|
1308
|
+
'geometry transform: live update failed for filter %r (%s): %s -- '
|
|
1309
|
+
'restoring its old params\n' % (f.get_name(), operation, e)
|
|
1310
|
+
)
|
|
1311
|
+
if old_params is not None:
|
|
1312
|
+
try:
|
|
1313
|
+
SETTERS[operation](f.get_config(), old_params)
|
|
1314
|
+
f.update()
|
|
1315
|
+
except Exception as restore_error:
|
|
1316
|
+
sys.stderr.write(
|
|
1317
|
+
'geometry transform: restoring filter %r also failed: %s\n'
|
|
1318
|
+
% (f.get_name(), restore_error)
|
|
1319
|
+
)
|
|
1320
|
+
continue # ledger record stays at old_params -- never advances to new_params
|
|
1321
|
+
if rec is not None:
|
|
1322
|
+
rec['params'] = new_params
|
|
1323
|
+
_ledger_put(img, filters, unknown)
|
|
1324
|
+
return failures
|
|
1325
|
+
|
|
1326
|
+
|
|
1327
|
+
def op_crop(args):
|
|
1328
|
+
img = _image(args)
|
|
1329
|
+
left, top = int(lib.require(args, 'left')), int(lib.require(args, 'top'))
|
|
1330
|
+
width, height = int(lib.require(args, 'width')), int(lib.require(args, 'height'))
|
|
1331
|
+
# `Image.crop(width, height, left, top)` is a raw resize-the-canvas primitive underneath --
|
|
1332
|
+
# it happily accepts a rectangle that's partly or fully outside the existing image (left/top
|
|
1333
|
+
# negative, or left+width/top+height past the current edges), silently padding the new canvas
|
|
1334
|
+
# with blank space rather than erroring. This bridge's `crop` is meant to be a bounds-checked
|
|
1335
|
+
# sub-rectangle of what's already there (the same contract `preview`'s `region` and
|
|
1336
|
+
# `histogram`/`compare`'s regions already enforce), so `left`/`top`/`width`/`height` are
|
|
1337
|
+
# validated against the CURRENT image bounds first (`invalid_argument` if not), same as any
|
|
1338
|
+
# other region. The DoS floor `resize` validates its own target against is applied too --
|
|
1339
|
+
# belt-and-braces, since a bounds-checked rectangle is already capped by the source image's own
|
|
1340
|
+
# size, but this keeps the two geometry ops' size ceiling identical rather than assuming that.
|
|
1341
|
+
lib.validate_region({'x': left, 'y': top, 'width': width, 'height': height}, img.get_width(), img.get_height())
|
|
1342
|
+
width, height = lib.validate_resize_dims(width, height)
|
|
1343
|
+
Gimp.Selection.none(img) # see op_open: geometry always applies to the whole canvas
|
|
1344
|
+
img.crop(width, height, left, top)
|
|
1345
|
+
_drop_proxies(img.get_id())
|
|
1346
|
+
return {'width': img.get_width(), 'height': img.get_height()}
|
|
1347
|
+
|
|
1348
|
+
|
|
1349
|
+
def op_resize(args):
|
|
1350
|
+
"""Scale the image (and every layer/channel in it) -- Photoshop's "Image Size", not "Canvas
|
|
1351
|
+
Size" (that's `crop`). `width`/`height` given together stretch to exactly that box;
|
|
1352
|
+
given alone, the other side is derived to keep aspect; `long_edge` scales so the longer side
|
|
1353
|
+
lands there."""
|
|
1354
|
+
img = _image(args)
|
|
1355
|
+
_refuse_if_masked_filters(img, 'resize')
|
|
1356
|
+
width, height, long_edge = args.get('width'), args.get('height'), args.get('long_edge')
|
|
1357
|
+
w0, h0 = img.get_width(), img.get_height()
|
|
1358
|
+
if long_edge is not None:
|
|
1359
|
+
long_edge = int(long_edge)
|
|
1360
|
+
if long_edge <= 0:
|
|
1361
|
+
raise ValueError('long_edge must be positive')
|
|
1362
|
+
scale = float(long_edge) / max(w0, h0)
|
|
1363
|
+
width, height = max(1, round(w0 * scale)), max(1, round(h0 * scale))
|
|
1364
|
+
elif width is not None and height is not None:
|
|
1365
|
+
width, height = int(width), int(height)
|
|
1366
|
+
elif width is not None:
|
|
1367
|
+
width = int(width)
|
|
1368
|
+
height = max(1, round(h0 * (width / float(w0))))
|
|
1369
|
+
elif height is not None:
|
|
1370
|
+
height = int(height)
|
|
1371
|
+
width = max(1, round(w0 * (height / float(h0))))
|
|
1372
|
+
else:
|
|
1373
|
+
raise ValueError('resize needs one of width, height, or long_edge')
|
|
1374
|
+
width, height = lib.validate_resize_dims(width, height)
|
|
1375
|
+
scale_x, scale_y = width / float(w0), height / float(h0)
|
|
1376
|
+
planned = _snapshot_effect_transform(
|
|
1377
|
+
img, 'resize',
|
|
1378
|
+
lambda operation, params, layer: lib.resize_effect_params(operation, params, scale_x, scale_y),
|
|
1379
|
+
)
|
|
1380
|
+
Gimp.Selection.none(img) # see op_open: geometry always applies to the whole canvas
|
|
1381
|
+
img.scale(width, height)
|
|
1382
|
+
failures = _apply_planned_effect_transform(img, planned)
|
|
1383
|
+
_drop_proxies(img.get_id())
|
|
1384
|
+
result = {'width': img.get_width(), 'height': img.get_height()}
|
|
1385
|
+
if failures:
|
|
1386
|
+
result['effect_update_failures'] = failures
|
|
1387
|
+
return result
|
|
1388
|
+
|
|
1389
|
+
|
|
1390
|
+
def op_rotate(args):
|
|
1391
|
+
"""Arbitrary-degree rotate (straighten): `Image.rotate` only offers the three 90-degree
|
|
1392
|
+
multiples, so every angle goes through `Item.transform_rotate` instead. `expand` resizes the
|
|
1393
|
+
canvas to the rotated layers' new bounds (`Image.resize_to_layers`); without it the canvas
|
|
1394
|
+
stays put and rotated content can fall outside it, same as a Photoshop free-transform without
|
|
1395
|
+
"reveal all". Any angle is accepted -- UNLESS a position/direction-dependent effect filter
|
|
1396
|
+
(vignette, motion_blur, drop_shadow) is present, in which case only an exact 0/90/180/270
|
|
1397
|
+
degrees is allowed (`_refuse_if_non_right_angle_with_tracked_effects`; see lib.py's own "ONLY
|
|
1398
|
+
exact cases are supported" comment for why).
|
|
1399
|
+
|
|
1400
|
+
`transform_rotate`'s second argument is `auto_center` -- confusingly, passing True means
|
|
1401
|
+
"IGNORE the center_x/center_y given and use this item's own bounds' center instead," not
|
|
1402
|
+
"yes, use the center I computed." That must be False here: every layer AND every named mask
|
|
1403
|
+
channel has to rotate around the exact SAME point (the image's own center) to stay aligned
|
|
1404
|
+
with each other, and `auto_center=True` would rotate each one around its own individual
|
|
1405
|
+
center instead, which only happens to look right when there is nothing else to line up
|
|
1406
|
+
against. Refuses outright when a masked filter is present -- see this section's own comment
|
|
1407
|
+
for why rotating the channel isn't enough to keep such a filter's rendering aligned."""
|
|
1408
|
+
img = _image(args)
|
|
1409
|
+
_refuse_if_masked_filters(img, 'rotate')
|
|
1410
|
+
degrees = float(lib.require(args, 'degrees'))
|
|
1411
|
+
expand = bool(args.get('expand', False))
|
|
1412
|
+
_refuse_if_non_right_angle_with_tracked_effects(img, degrees, 'rotate')
|
|
1413
|
+
# Every affected filter's new params are computed and range-validated from each owning
|
|
1414
|
+
# layer's CURRENT (pre-rotation) dimensions before anything below mutates the image -- see
|
|
1415
|
+
# `_snapshot_effect_transform`'s own doc comment.
|
|
1416
|
+
planned = _snapshot_effect_transform(
|
|
1417
|
+
img, 'rotate',
|
|
1418
|
+
lambda operation, params, layer: lib.rotate_effect_params(
|
|
1419
|
+
operation, params, degrees, layer.get_width(), layer.get_height()
|
|
1420
|
+
),
|
|
1421
|
+
)
|
|
1422
|
+
# With a selection active, transform_rotate moves only the selected pixels and leaves a
|
|
1423
|
+
# floating selection behind (see op_open).
|
|
1424
|
+
Gimp.Selection.none(img)
|
|
1425
|
+
old_w, old_h = img.get_width(), img.get_height()
|
|
1426
|
+
cx, cy = old_w / 2.0, old_h / 2.0
|
|
1427
|
+
angle = math.radians(degrees)
|
|
1428
|
+
for layer in img.get_layers():
|
|
1429
|
+
layer.transform_rotate(angle, False, cx, cy)
|
|
1430
|
+
for channel in img.get_channels():
|
|
1431
|
+
channel.transform_rotate(angle, False, cx, cy)
|
|
1432
|
+
if expand:
|
|
1433
|
+
img.resize_to_layers()
|
|
1434
|
+
failures = _apply_planned_effect_transform(img, planned)
|
|
1435
|
+
_drop_proxies(img.get_id())
|
|
1436
|
+
result = {'width': img.get_width(), 'height': img.get_height(), 'degrees': degrees}
|
|
1437
|
+
if failures:
|
|
1438
|
+
result['effect_update_failures'] = failures
|
|
1439
|
+
return result
|
|
1440
|
+
|
|
1441
|
+
|
|
1442
|
+
_FLIP_ORIENTATIONS = {
|
|
1443
|
+
'horizontal': Gimp.OrientationType.HORIZONTAL,
|
|
1444
|
+
'vertical': Gimp.OrientationType.VERTICAL,
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
|
|
1448
|
+
def op_flip(args):
|
|
1449
|
+
"""Refuses outright when a masked filter is present -- see the geometry section's own comment
|
|
1450
|
+
for why flipping the channel isn't enough to keep such a filter's rendering aligned."""
|
|
1451
|
+
orientation = args.get('orientation')
|
|
1452
|
+
if orientation not in _FLIP_ORIENTATIONS:
|
|
1453
|
+
raise ValueError('orientation must be one of %s' % sorted(_FLIP_ORIENTATIONS))
|
|
1454
|
+
img = _image(args)
|
|
1455
|
+
_refuse_if_masked_filters(img, 'flip')
|
|
1456
|
+
planned = _snapshot_effect_transform(
|
|
1457
|
+
img, 'flip',
|
|
1458
|
+
lambda operation, params, layer: lib.flip_effect_params(operation, params, orientation),
|
|
1459
|
+
)
|
|
1460
|
+
Gimp.Selection.none(img) # see op_open: geometry always applies to the whole canvas
|
|
1461
|
+
img.flip(_FLIP_ORIENTATIONS[orientation])
|
|
1462
|
+
failures = _apply_planned_effect_transform(img, planned)
|
|
1463
|
+
_drop_proxies(img.get_id())
|
|
1464
|
+
result = {'width': img.get_width(), 'height': img.get_height()}
|
|
1465
|
+
if failures:
|
|
1466
|
+
result['effect_update_failures'] = failures
|
|
1467
|
+
return result
|
|
1468
|
+
|
|
1469
|
+
|
|
1470
|
+
def _proxy_render(img, max_px):
|
|
1471
|
+
"""A flattened, downscaled render of img with its live filters: the proxy plus mirrored
|
|
1472
|
+
filters. Caller deletes it. Returns (dup, unmirrored_filters) -- the second element is
|
|
1473
|
+
`_mirror_filters`'s own return value, surfaced by every caller's result as
|
|
1474
|
+
`unmirrored_filters`."""
|
|
1475
|
+
dup = _proxy(img, max_px).duplicate()
|
|
1476
|
+
try:
|
|
1477
|
+
unmirrored = _mirror_filters(img, dup)
|
|
1478
|
+
dup.flatten()
|
|
1479
|
+
except Exception:
|
|
1480
|
+
dup.delete()
|
|
1481
|
+
raise
|
|
1482
|
+
return dup, unmirrored
|
|
1483
|
+
|
|
1484
|
+
|
|
1485
|
+
def _region_full_res(img, region):
|
|
1486
|
+
"""Full-res duplicate, cropped to `region`, flattened. Caller deletes it. A plain
|
|
1487
|
+
`Image.duplicate()` copies each layer's real live DrawableFilters (not just pixels), so
|
|
1488
|
+
rendering this way is exact -- the filter runs at native resolution over the cropped area
|
|
1489
|
+
BEFORE any later downscale, unlike the preview proxy (which shrinks the base first and then
|
|
1490
|
+
has to approximate a spatial filter's radius, see `_mirror_filters`).
|
|
1491
|
+
|
|
1492
|
+
`region` is validated against `img`'s FULL-RES bounds before anything is duplicated (partly
|
|
1493
|
+
or fully outside raises `invalid_argument` rather than silently clamping to whatever sliver
|
|
1494
|
+
overlaps); the duplicate itself is torn down on ANY failure after that, not just the size
|
|
1495
|
+
check, so a crop/flatten error can never leak an orphaned image."""
|
|
1496
|
+
x, y, w, h = lib.validate_region(region, img.get_width(), img.get_height())
|
|
1497
|
+
dup = img.duplicate()
|
|
1498
|
+
try:
|
|
1499
|
+
dup.crop(w, h, x, y)
|
|
1500
|
+
layer = dup.flatten()
|
|
1501
|
+
except Exception:
|
|
1502
|
+
dup.delete()
|
|
1503
|
+
raise
|
|
1504
|
+
return dup, layer
|
|
1505
|
+
|
|
1506
|
+
|
|
1507
|
+
def _scale_to_max(dup, max_px):
|
|
1508
|
+
w, h = dup.get_width(), dup.get_height()
|
|
1509
|
+
scale = min(1.0, float(max_px) / max(w, h))
|
|
1510
|
+
if scale < 1.0:
|
|
1511
|
+
dup.scale(max(1, round(w * scale)), max(1, round(h * scale)))
|
|
1512
|
+
return dup
|
|
1513
|
+
|
|
1514
|
+
|
|
1515
|
+
def op_preview(args):
|
|
1516
|
+
"""Proxy render with live filters, by default; `region` renders a full-res crop instead (see
|
|
1517
|
+
`_region_full_res`), which is exact for spatial filters rather than approximate. `proxy` in
|
|
1518
|
+
the result says which route was used, so the tool layer can flag a spatial filter's preview
|
|
1519
|
+
as approximate only when it actually is.
|
|
1520
|
+
|
|
1521
|
+
Every preview leaves the bridge through `_export_stripped` -- the SAME metadata-stripping,
|
|
1522
|
+
format-refusing writer `export` uses -- because a preview routinely goes to a cloud model and
|
|
1523
|
+
a source photo's EXIF/GPS must never ride along on that path either."""
|
|
1524
|
+
img = _image(args)
|
|
1525
|
+
max_px = lib.validate_max_px(int(args.get('max_px', 1024)))
|
|
1526
|
+
region = args.get('region')
|
|
1527
|
+
out_path = lib.require(args, 'out_path')
|
|
1528
|
+
if region:
|
|
1529
|
+
dup, _layer = _region_full_res(img, region)
|
|
1530
|
+
try:
|
|
1531
|
+
_scale_to_max(dup, max_px)
|
|
1532
|
+
_export_stripped(dup, out_path)
|
|
1533
|
+
return {'path': out_path, 'width': dup.get_width(), 'height': dup.get_height(),
|
|
1534
|
+
'proxy': False}
|
|
1535
|
+
finally:
|
|
1536
|
+
dup.delete()
|
|
1537
|
+
dup, unmirrored = _proxy_render(img, max_px)
|
|
1538
|
+
try:
|
|
1539
|
+
_export_stripped(dup, out_path)
|
|
1540
|
+
return {'path': out_path, 'width': dup.get_width(), 'height': dup.get_height(),
|
|
1541
|
+
'proxy': True, 'unmirrored_filters': unmirrored}
|
|
1542
|
+
finally:
|
|
1543
|
+
dup.delete()
|
|
1544
|
+
|
|
1545
|
+
|
|
1546
|
+
def _channel_stats_from(dup, layer, channels):
|
|
1547
|
+
w, h = dup.get_width(), dup.get_height()
|
|
1548
|
+
buf = layer.get_buffer()
|
|
1549
|
+
rect = Gegl.Rectangle.new(0, 0, w, h)
|
|
1550
|
+
rgb = buf.get(rect, 1.0, "R'G'B' u8", Gegl.AbyssPolicy.NONE) if set(channels) - {'luminance'} else None
|
|
1551
|
+
lum = buf.get(rect, 1.0, "Y' u8", Gegl.AbyssPolicy.NONE) if 'luminance' in channels else None
|
|
1552
|
+
out = {}
|
|
1553
|
+
for ch in channels:
|
|
1554
|
+
out[ch] = lib.channel_stats(lum if ch == 'luminance' else rgb[('red', 'green', 'blue').index(ch)::3])
|
|
1555
|
+
return w, h, out
|
|
1556
|
+
|
|
1557
|
+
|
|
1558
|
+
def op_histogram(args):
|
|
1559
|
+
"""Stats for several channels from ONE render. Default (whole image): the 1024 px preview
|
|
1560
|
+
render (~0.3 s), or the full-resolution composite with exact=True (~3.7 s for 24 MP with
|
|
1561
|
+
filters). `region`: computed on the (mirrored) proxy when the region maps to at least
|
|
1562
|
+
`lib.MIN_PROXY_REGION_PX` on a side there, else a full-res crop -- same "too few proxy
|
|
1563
|
+
samples to mean anything" reasoning as `lib.MIN_PROXY_REGION_PX`'s own docstring; exact=True
|
|
1564
|
+
always forces the full-res crop regardless of size."""
|
|
1565
|
+
img = _image(args)
|
|
1566
|
+
channels = args.get('channels') or list(lib.HIST_CHANNELS)
|
|
1567
|
+
bad = [c for c in channels if c not in lib.HIST_CHANNELS]
|
|
1568
|
+
if bad:
|
|
1569
|
+
raise ValueError('channels must be drawn from %s, got %s' % (list(lib.HIST_CHANNELS), bad))
|
|
1570
|
+
exact = bool(args.get('exact', False))
|
|
1571
|
+
region = args.get('region')
|
|
1572
|
+
if region:
|
|
1573
|
+
lib.validate_region(region, img.get_width(), img.get_height())
|
|
1574
|
+
|
|
1575
|
+
if region and not exact:
|
|
1576
|
+
img_w = img.get_width()
|
|
1577
|
+
scale = min(1.0, 1024.0 / max(img_w, img.get_height())) if img_w else 1.0
|
|
1578
|
+
px, py, pw, ph = lib.region_to_proxy_px(region, scale)
|
|
1579
|
+
if pw >= lib.MIN_PROXY_REGION_PX and ph >= lib.MIN_PROXY_REGION_PX:
|
|
1580
|
+
dup, unmirrored = _proxy_render(img, 1024)
|
|
1581
|
+
try:
|
|
1582
|
+
px = min(px, max(0, dup.get_width() - 1))
|
|
1583
|
+
py = min(py, max(0, dup.get_height() - 1))
|
|
1584
|
+
pw = min(pw, dup.get_width() - px)
|
|
1585
|
+
ph = min(ph, dup.get_height() - py)
|
|
1586
|
+
dup.crop(pw, ph, px, py)
|
|
1587
|
+
layer = dup.flatten()
|
|
1588
|
+
w, h, out = _channel_stats_from(dup, layer, channels)
|
|
1589
|
+
finally:
|
|
1590
|
+
dup.delete()
|
|
1591
|
+
return {'exact': False, 'width': w, 'height': h, 'pixels': w * h, 'channels': out,
|
|
1592
|
+
'unmirrored_filters': unmirrored}
|
|
1593
|
+
region = dict(region) # fall through to the full-res path below
|
|
1594
|
+
exact = True
|
|
1595
|
+
|
|
1596
|
+
unmirrored = []
|
|
1597
|
+
if region:
|
|
1598
|
+
dup, layer = _region_full_res(img, region)
|
|
1599
|
+
elif exact:
|
|
1600
|
+
dup, layer = _composite(img)
|
|
1601
|
+
else:
|
|
1602
|
+
dup, unmirrored = _proxy_render(img, 1024)
|
|
1603
|
+
layer = dup.get_layers()[0]
|
|
1604
|
+
try:
|
|
1605
|
+
w, h, out = _channel_stats_from(dup, layer, channels)
|
|
1606
|
+
finally:
|
|
1607
|
+
dup.delete()
|
|
1608
|
+
result = {'exact': exact, 'width': w, 'height': h, 'pixels': w * h, 'channels': out}
|
|
1609
|
+
if not region and not exact:
|
|
1610
|
+
result['unmirrored_filters'] = unmirrored
|
|
1611
|
+
return result
|
|
1612
|
+
|
|
1613
|
+
|
|
1614
|
+
def op_compare(args):
|
|
1615
|
+
"""`mode: 'before_after'` -- proxy base (no filters) vs proxy+filters (both the existing
|
|
1616
|
+
preview-proxy machinery already builds), per-channel stat deltas, optional paired preview
|
|
1617
|
+
JPEGs. `mode: 'regions'` -- the same per-channel stats for two rects, no delta (the caller
|
|
1618
|
+
compares)."""
|
|
1619
|
+
img = _image(args)
|
|
1620
|
+
mode = args.get('mode')
|
|
1621
|
+
channels = args.get('channels') or list(lib.HIST_CHANNELS)
|
|
1622
|
+
bad = [c for c in channels if c not in lib.HIST_CHANNELS]
|
|
1623
|
+
if bad:
|
|
1624
|
+
raise ValueError('channels must be drawn from %s, got %s' % (list(lib.HIST_CHANNELS), bad))
|
|
1625
|
+
|
|
1626
|
+
if mode == 'before_after':
|
|
1627
|
+
region = args.get('region')
|
|
1628
|
+
if region:
|
|
1629
|
+
lib.validate_region(region, img.get_width(), img.get_height())
|
|
1630
|
+
max_px = lib.validate_max_px(int(args.get('max_px', 1024)))
|
|
1631
|
+
# Both duplicates' CREATION now lives inside this try (it used to happen before the try
|
|
1632
|
+
# started), and each is set to None first: if `_proxy_render` (building `after`) raised
|
|
1633
|
+
# right after `base` was already a real duplicate, the old code's `finally` never even
|
|
1634
|
+
# started running, so `base` leaked. Guarding each delete on "was it actually assigned"
|
|
1635
|
+
# lets either half's construction fail without leaking the other.
|
|
1636
|
+
base = None
|
|
1637
|
+
after = None
|
|
1638
|
+
try:
|
|
1639
|
+
base = _proxy(img, max_px).duplicate()
|
|
1640
|
+
after, unmirrored = _proxy_render(img, max_px)
|
|
1641
|
+
if region:
|
|
1642
|
+
img_w = img.get_width()
|
|
1643
|
+
scale = min(1.0, float(max_px) / max(img_w, img.get_height())) if img_w else 1.0
|
|
1644
|
+
px, py, pw, ph = lib.region_to_proxy_px(region, scale)
|
|
1645
|
+
for dup in (base, after):
|
|
1646
|
+
x = min(px, max(0, dup.get_width() - 1))
|
|
1647
|
+
y = min(py, max(0, dup.get_height() - 1))
|
|
1648
|
+
w = min(pw, dup.get_width() - x)
|
|
1649
|
+
h = min(ph, dup.get_height() - y)
|
|
1650
|
+
dup.crop(w, h, x, y)
|
|
1651
|
+
base_layer, after_layer = base.flatten(), after.flatten()
|
|
1652
|
+
_bw, _bh, before_stats = _channel_stats_from(base, base_layer, channels)
|
|
1653
|
+
_aw, _ah, after_stats = _channel_stats_from(after, after_layer, channels)
|
|
1654
|
+
deltas = {}
|
|
1655
|
+
for ch in channels:
|
|
1656
|
+
b, a = before_stats[ch], after_stats[ch]
|
|
1657
|
+
deltas[ch] = {
|
|
1658
|
+
k: round(a[k] - b[k], 3) for k in ('mean', 'median', 'p1', 'p5', 'p95', 'p99')
|
|
1659
|
+
}
|
|
1660
|
+
result = {'before': before_stats, 'after': after_stats, 'delta': deltas, 'proxy': True,
|
|
1661
|
+
'unmirrored_filters': unmirrored}
|
|
1662
|
+
before_path, after_path = args.get('before_path'), args.get('after_path')
|
|
1663
|
+
if before_path:
|
|
1664
|
+
_export_stripped(base, before_path)
|
|
1665
|
+
result['before_path'] = before_path
|
|
1666
|
+
if after_path:
|
|
1667
|
+
_export_stripped(after, after_path)
|
|
1668
|
+
result['after_path'] = after_path
|
|
1669
|
+
return result
|
|
1670
|
+
finally:
|
|
1671
|
+
if base is not None:
|
|
1672
|
+
base.delete()
|
|
1673
|
+
if after is not None:
|
|
1674
|
+
after.delete()
|
|
1675
|
+
|
|
1676
|
+
if mode == 'regions':
|
|
1677
|
+
region_a, region_b = args.get('region_a'), args.get('region_b')
|
|
1678
|
+
if not region_a or not region_b:
|
|
1679
|
+
raise ValueError('regions mode needs both region_a and region_b')
|
|
1680
|
+
dup_a, layer_a = _region_full_res(img, region_a)
|
|
1681
|
+
try:
|
|
1682
|
+
_wa, _ha, stats_a = _channel_stats_from(dup_a, layer_a, channels)
|
|
1683
|
+
finally:
|
|
1684
|
+
dup_a.delete()
|
|
1685
|
+
dup_b, layer_b = _region_full_res(img, region_b)
|
|
1686
|
+
try:
|
|
1687
|
+
_wb, _hb, stats_b = _channel_stats_from(dup_b, layer_b, channels)
|
|
1688
|
+
finally:
|
|
1689
|
+
dup_b.delete()
|
|
1690
|
+
return {'region_a': stats_a, 'region_b': stats_b, 'proxy': False}
|
|
1691
|
+
|
|
1692
|
+
raise ValueError("mode must be one of 'before_after', 'regions'")
|
|
1693
|
+
|
|
1694
|
+
|
|
1695
|
+
def _strip_metadata(cfg, fmt):
|
|
1696
|
+
"""Switch off every metadata-carrying option on an export config. Which options must exist,
|
|
1697
|
+
and which are set only when present, is decided by `lib.metadata_strip_settings` (gi-free, so
|
|
1698
|
+
the fail-loud path for a missing required option is unit-tested)."""
|
|
1699
|
+
prop_names = {p.name for p in cfg.list_properties()}
|
|
1700
|
+
for name in lib.metadata_strip_settings(fmt, prop_names):
|
|
1701
|
+
cfg.set_property(name, False)
|
|
1702
|
+
|
|
1703
|
+
|
|
1704
|
+
def _export_stripped(dup, path, options=None):
|
|
1705
|
+
"""The ONE place any flattened raster copy leaves the bridge -- `export`'s own flat branch,
|
|
1706
|
+
every `preview` (whole-image or region), and `compare`'s before/after outputs all go through
|
|
1707
|
+
this, so there is exactly one spot that decides how metadata is handled and what formats are
|
|
1708
|
+
even reachable, instead of N call sites that could each get it slightly differently (the
|
|
1709
|
+
original bug: `preview`/`compare` called a bare `Gimp.file_save`, which has no
|
|
1710
|
+
metadata-stripping properties to set AT ALL, while `export`'s own catch-all branch for an
|
|
1711
|
+
unrecognised extension did the exact same bare, unstripped save).
|
|
1712
|
+
|
|
1713
|
+
Refuses any extension outside jpg/jpeg/png/webp/tif/tiff outright (`invalid_argument`) --
|
|
1714
|
+
there is no metadata-safe fallback for a format this bridge doesn't know the export
|
|
1715
|
+
procedure's properties for, so silently falling back to an unstripped save is not an option.
|
|
1716
|
+
|
|
1717
|
+
Metadata: EXIF/XMP/IPTC/thumbnail/comment are always stripped via the format's own export
|
|
1718
|
+
config (verified live that GIMP's own defaults do NOT strip a source image's EXIF/GPS through
|
|
1719
|
+
`file-jpeg-export`, so this cannot be left to the exporter's defaults). The image's attached
|
|
1720
|
+
`Gimp.Metadata` is also cleared on `dup` itself before saving, belt-and-braces alongside the
|
|
1721
|
+
config flags (which are the mechanism actually verified to control what lands in the file).
|
|
1722
|
+
|
|
1723
|
+
Bit depth: png/tiff accept `bit_depth: 8 | 16` (default 8); jpeg/webp are always 8-bit. Either
|
|
1724
|
+
way `dup`'s precision is converted to match BEFORE saving -- after a 16/32-bit `precision`
|
|
1725
|
+
promotion at open time, the flattened duplicate would otherwise still carry that higher bit
|
|
1726
|
+
depth into an export that never asked for it."""
|
|
1727
|
+
options = options or {}
|
|
1728
|
+
ext = os.path.splitext(path)[1].lower()
|
|
1729
|
+
fmt = lib.EXPORT_FORMATS.get(ext)
|
|
1730
|
+
if fmt is None:
|
|
1731
|
+
raise ValueError(
|
|
1732
|
+
'export path must end in .jpg, .jpeg, .png, .webp, .tif, or .tiff (got %r)' % ext
|
|
1733
|
+
)
|
|
1734
|
+
if fmt in ('png', 'tiff'):
|
|
1735
|
+
bit_depth = lib.validate_choice('bit_depth', options.get('bit_depth', 8), lib.BIT_DEPTHS)
|
|
1736
|
+
else:
|
|
1737
|
+
bit_depth = 8
|
|
1738
|
+
target_precision = (
|
|
1739
|
+
Gimp.Precision.U16_NON_LINEAR if bit_depth == 16 else Gimp.Precision.U8_NON_LINEAR
|
|
1740
|
+
)
|
|
1741
|
+
if dup.get_precision() != target_precision:
|
|
1742
|
+
dup.convert_precision(target_precision)
|
|
1743
|
+
try:
|
|
1744
|
+
dup.set_metadata(Gimp.Metadata.new())
|
|
1745
|
+
except Exception:
|
|
1746
|
+
pass
|
|
1747
|
+
|
|
1748
|
+
pdb = Gimp.get_pdb()
|
|
1749
|
+
proc_name = {
|
|
1750
|
+
'jpeg': 'file-jpeg-export', 'png': 'file-png-export',
|
|
1751
|
+
'webp': 'file-webp-export', 'tiff': 'file-tiff-export',
|
|
1752
|
+
}[fmt]
|
|
1753
|
+
proc = pdb.lookup_procedure(proc_name)
|
|
1754
|
+
cfg = proc.create_config()
|
|
1755
|
+
if fmt == 'jpeg':
|
|
1756
|
+
cfg.set_property('quality', lib.pct_to_unit('quality', options.get('quality', 90), 1.0, 100.0))
|
|
1757
|
+
elif fmt == 'png':
|
|
1758
|
+
cfg.set_property(
|
|
1759
|
+
'compression', lib.validate_int_range('compression', options.get('compression', 3), 0, 9)
|
|
1760
|
+
)
|
|
1761
|
+
elif fmt == 'webp':
|
|
1762
|
+
cfg.set_property(
|
|
1763
|
+
'quality', lib.validate_range('quality', options.get('quality', 90), 0.0, 100.0)
|
|
1764
|
+
)
|
|
1765
|
+
cfg.set_property('lossless', bool(options.get('lossless', False)))
|
|
1766
|
+
elif fmt == 'tiff':
|
|
1767
|
+
cfg.set_property(
|
|
1768
|
+
'compression',
|
|
1769
|
+
lib.validate_choice('compression', options.get('compression', 'none'), lib.TIFF_COMPRESSIONS),
|
|
1770
|
+
)
|
|
1771
|
+
cfg.set_property('image', dup)
|
|
1772
|
+
cfg.set_property('file', Gio.File.new_for_path(path))
|
|
1773
|
+
_strip_metadata(cfg, fmt)
|
|
1774
|
+
# A failed write does not raise: run() returns an EXECUTION_ERROR status with GIMP's message
|
|
1775
|
+
# as the second value (verified live, GIMP 3.2.6, writing into a missing directory).
|
|
1776
|
+
result = proc.run(cfg)
|
|
1777
|
+
if result.index(0) != Gimp.PDBStatusType.SUCCESS:
|
|
1778
|
+
name = os.path.basename(path)
|
|
1779
|
+
detail = result.index(1) if result.length() > 1 else result.index(0).value_nick
|
|
1780
|
+
# GIMP's message quotes the full path ("Could not open '<path>' for writing"); keep just
|
|
1781
|
+
# the file name, as every other message here does.
|
|
1782
|
+
detail = str(detail)
|
|
1783
|
+
for spelling in sorted({Gio.File.new_for_path(path).get_path() or path, path}, key=len, reverse=True):
|
|
1784
|
+
detail = detail.replace(spelling, name)
|
|
1785
|
+
raise lib.OpError('gimp_op_failed', 'could not write %s: %s' % (name, detail))
|
|
1786
|
+
|
|
1787
|
+
|
|
1788
|
+
def op_export(args):
|
|
1789
|
+
"""Writes path by extension: .xcf saves the live document, anything else exports a flattened
|
|
1790
|
+
copy via `_export_stripped` (metadata stripped; jpeg quality 1-100 default 90; png compression
|
|
1791
|
+
0-9 default 3 -- GIMP's own default of 9 measured far slower for negligible size gain; webp
|
|
1792
|
+
quality 0-100 default 90 + lossless; tiff compression, default 'none'; png/tiff bit_depth 8 or
|
|
1793
|
+
16, default 8).
|
|
1794
|
+
|
|
1795
|
+
Flat exports flatten a duplicate first and save that. Handing the live document to
|
|
1796
|
+
file_save lets the exporter render the filters itself, which measured 9.3 s for a 24 MP
|
|
1797
|
+
JPEG with three curves; flatten (3.0 s) + save of the flat copy (1.0 s) gives the same pixels."""
|
|
1798
|
+
img = _image(args)
|
|
1799
|
+
path = lib.require(args, 'path')
|
|
1800
|
+
if path.lower().endswith('.xcf'):
|
|
1801
|
+
# file_save reports a failed write by returning False, not by raising (verified live).
|
|
1802
|
+
if not Gimp.file_save(Gimp.RunMode.NONINTERACTIVE, img, Gio.File.new_for_path(path), None):
|
|
1803
|
+
raise lib.OpError(
|
|
1804
|
+
'gimp_op_failed',
|
|
1805
|
+
'could not save %s (check that the folder exists and is writable)'
|
|
1806
|
+
% os.path.basename(path),
|
|
1807
|
+
)
|
|
1808
|
+
else:
|
|
1809
|
+
dup, _flat = _composite(img)
|
|
1810
|
+
try:
|
|
1811
|
+
_export_stripped(dup, path, args)
|
|
1812
|
+
finally:
|
|
1813
|
+
dup.delete()
|
|
1814
|
+
return {'path': path, 'bytes': os.path.getsize(path)}
|
|
1815
|
+
|
|
1816
|
+
|
|
1817
|
+
def _replace_named_channel(img, name, w, h):
|
|
1818
|
+
"""Remove any existing channel called `name` and insert a fresh, black-filled one of the
|
|
1819
|
+
image's own size -- `op_create_mask`'s replace-by-name semantics."""
|
|
1820
|
+
for existing in img.get_channels():
|
|
1821
|
+
if existing.get_name() == name:
|
|
1822
|
+
img.remove_channel(existing)
|
|
1823
|
+
ch = Gimp.Channel.new(img, name, w, h, 50.0, Gegl.Color.new('black'))
|
|
1824
|
+
img.insert_channel(ch, None, 0)
|
|
1825
|
+
return ch
|
|
1826
|
+
|
|
1827
|
+
|
|
1828
|
+
def _channel_coverage(ch, w, h):
|
|
1829
|
+
# "Y' u8" (perceptual/non-linear), not "Y u8" (linear light): the channel's own storage is
|
|
1830
|
+
# non-linear (matching the image precision), and reading it back through the LINEAR format
|
|
1831
|
+
# applies a real, non-trivial conversion, not an identity one -- verified live, a gradient
|
|
1832
|
+
# painted as an even perceptual ramp (0..255 in file/mask terms) reads back compressed
|
|
1833
|
+
# toward black through 'Y u8' (e.g. the geometric midpoint reads ~55, not ~128), while "Y' u8"
|
|
1834
|
+
# reproduces the ramp exactly. Every mask/selection buffer read or write in this file uses
|
|
1835
|
+
# the primed, perceptual format for the same reason.
|
|
1836
|
+
data = ch.get_buffer().get(Gegl.Rectangle.new(0, 0, w, h), 1.0, "Y' u8", Gegl.AbyssPolicy.NONE)
|
|
1837
|
+
# `bytes.translate(None, delete)` runs the byte-by-byte pass in C rather than the interpreter
|
|
1838
|
+
# loop a `sum(1 for b in data if b >= 128)` generator pays per byte. `delete` names the bytes to
|
|
1839
|
+
# DROP, so deleting every byte below 128 (0..127) leaves exactly the >=128 ones behind: len() of
|
|
1840
|
+
# what's left IS the selected count directly, no subtraction needed. Same result as the
|
|
1841
|
+
# generator, measured live (GIMP 3.2.6, ~24MP) at ~120ms/channel -- what keeps `describe`'s
|
|
1842
|
+
# `channels` target, which reads this per NAMED channel rather than just one, fast enough to
|
|
1843
|
+
# stay inside its own time budget (`_channels_described`'s `CHANNELS_DESCRIBE_DEADLINE_S`).
|
|
1844
|
+
selected = len(data.translate(None, bytes(range(128))))
|
|
1845
|
+
return selected, round(selected / len(data), 4)
|
|
1846
|
+
|
|
1847
|
+
|
|
1848
|
+
def _mask_name_in_use(img, name):
|
|
1849
|
+
"""True if any bridge-applied filter's ledger record currently uses `name` as its mask --
|
|
1850
|
+
the check `create_mask` refuses on, so replacing a same-named channel can never silently
|
|
1851
|
+
change what an EXISTING filter is confined to (a filter's mask, once created, is meant to be
|
|
1852
|
+
fixed -- see `_apply_filter`'s own re-edit rule). A name nothing yet references is still free
|
|
1853
|
+
to reuse or replace, which is what lets a mask be iterated on before it's ever attached to a
|
|
1854
|
+
filter."""
|
|
1855
|
+
filters, _unknown = _ledger_get(img)
|
|
1856
|
+
return any(rec.get('params', {}).get('mask') == name for rec in filters.values())
|
|
1857
|
+
|
|
1858
|
+
|
|
1859
|
+
def op_create_mask(args):
|
|
1860
|
+
"""Geometric mask -> a named channel (replacing any same-named channel not already in use as
|
|
1861
|
+
a filter's mask -- see `_mask_name_in_use`), leaving the selection cleared afterward.
|
|
1862
|
+
|
|
1863
|
+
rectangle/ellipse: `Image.select_rectangle`/`select_ellipse`, optional `Selection.invert`,
|
|
1864
|
+
optional `Selection.feather`, then the resulting selection's pixels are copied into the
|
|
1865
|
+
channel.
|
|
1866
|
+
|
|
1867
|
+
gradient_linear/gradient_radial: `gegl:linear-gradient`/`gegl:radial-gradient` are NOT usable
|
|
1868
|
+
as drawable filters (verified live: their pspecs return None, `DrawableFilter.new` fails) --
|
|
1869
|
+
the working route is painting a black-to-white ramp directly into the channel with
|
|
1870
|
+
`Drawable.edit_gradient_fill` (verified live). `Gimp.context_push`/`context_pop` bracket every
|
|
1871
|
+
context change (foreground/background/gradient/blend space) so the session's ambient context
|
|
1872
|
+
-- which the user's own GUI or a later call might depend on -- is restored exactly regardless
|
|
1873
|
+
of how this function exits, rather than manually saving and restoring each property by hand.
|
|
1874
|
+
The gradient blend color space is explicitly set to RGB_PERCEPTUAL, but verified live that this
|
|
1875
|
+
has NO effect on a Channel fill's actual output either way: forcing RGB_PERCEPTUAL, forcing
|
|
1876
|
+
RGB_LINEAR, and leaving the context at whatever it already was all produced byte-identical
|
|
1877
|
+
output (an exact linear ramp -- x=128 reads 128) once read back correctly. What DOES control
|
|
1878
|
+
the crossing point is the buffer format every reader of this channel (`_channel_coverage`,
|
|
1879
|
+
and the selection copy above) uses -- `"Y' u8"` (perceptual/gamma-encoded, matching the
|
|
1880
|
+
channel's own storage), not `'Y u8'` (linear light, which compresses the readback toward black,
|
|
1881
|
+
e.g. the geometric midpoint reading ~55 instead of ~128; this is what an earlier, incorrect
|
|
1882
|
+
version of this comment blamed on the blend color space instead). RGB_PERCEPTUAL is kept set
|
|
1883
|
+
here anyway as the explicit, correct-if-it-ever-starts-mattering choice for a caller who thinks
|
|
1884
|
+
in 0-255 terms, not because it changes anything measured today.
|
|
1885
|
+
|
|
1886
|
+
`feather_px` has no effect here (a gradient is already a continuous ramp, nothing to feather);
|
|
1887
|
+
`invert` swaps which end is black vs white."""
|
|
1888
|
+
img = _image(args)
|
|
1889
|
+
type_ = args.get('type')
|
|
1890
|
+
if type_ not in lib.MASK_TYPES:
|
|
1891
|
+
raise ValueError('type must be one of %s' % sorted(lib.MASK_TYPES))
|
|
1892
|
+
name = args.get('name', 'Mask')
|
|
1893
|
+
if _mask_name_in_use(img, name):
|
|
1894
|
+
raise ValueError(
|
|
1895
|
+
'mask %r is already used by an existing filter; delete that filter or use a '
|
|
1896
|
+
'different name' % name
|
|
1897
|
+
)
|
|
1898
|
+
invert = bool(args.get('invert', False))
|
|
1899
|
+
feather_px = lib.validate_feather_px(args.get('feather_px', 0))
|
|
1900
|
+
w, h = img.get_width(), img.get_height()
|
|
1901
|
+
|
|
1902
|
+
if type_ in ('rectangle', 'ellipse'):
|
|
1903
|
+
x, y = float(lib.require(args, 'x')), float(lib.require(args, 'y'))
|
|
1904
|
+
width, height = float(lib.require(args, 'width')), float(lib.require(args, 'height'))
|
|
1905
|
+
if type_ == 'rectangle':
|
|
1906
|
+
img.select_rectangle(Gimp.ChannelOps.REPLACE, x, y, width, height)
|
|
1907
|
+
else:
|
|
1908
|
+
img.select_ellipse(Gimp.ChannelOps.REPLACE, x, y, width, height)
|
|
1909
|
+
if invert:
|
|
1910
|
+
Gimp.Selection.invert(img)
|
|
1911
|
+
if feather_px > 0:
|
|
1912
|
+
Gimp.Selection.feather(img, feather_px)
|
|
1913
|
+
ch = _replace_named_channel(img, name, w, h)
|
|
1914
|
+
# "Y' u8" (perceptual), not "Y u8" (linear) -- see `_channel_coverage`'s comment. A hard
|
|
1915
|
+
# selection is 0/255 either way, but a FEATHERED one has a real falloff whose shape this
|
|
1916
|
+
# format choice controls.
|
|
1917
|
+
sel_data = img.get_selection().get_buffer().get(
|
|
1918
|
+
Gegl.Rectangle.new(0, 0, w, h), 1.0, "Y' u8", Gegl.AbyssPolicy.NONE
|
|
1919
|
+
)
|
|
1920
|
+
ch_buf = ch.get_buffer()
|
|
1921
|
+
ch_buf.set(Gegl.Rectangle.new(0, 0, w, h), "Y' u8", sel_data)
|
|
1922
|
+
ch_buf.flush()
|
|
1923
|
+
ch.update(0, 0, w, h)
|
|
1924
|
+
else:
|
|
1925
|
+
ch = _replace_named_channel(img, name, w, h)
|
|
1926
|
+
black, white = ('white', 'black') if invert else ('black', 'white')
|
|
1927
|
+
# A gradient fill paints `start_color` at (x1,y1) and `end_color` at (x2,y2). For
|
|
1928
|
+
# gradient_linear those points are the caller's own start/end -- black-at-x1 ramps
|
|
1929
|
+
# toward white-at-x2 exactly as given (verified live). For gradient_radial (x1,y1) is
|
|
1930
|
+
# always the CENTER and (x2,y2) the radius edge, and the intuitive reading of a radial
|
|
1931
|
+
# mask is a spotlight -- full effect (white) at the center, fading to none (black) at
|
|
1932
|
+
# the edge -- so the two colors are swapped relative to the linear case.
|
|
1933
|
+
if type_ == 'gradient_linear':
|
|
1934
|
+
start_color, end_color = black, white
|
|
1935
|
+
x1, y1 = float(args.get('x1', 0)), float(args.get('y1', 0))
|
|
1936
|
+
x2, y2 = float(args.get('x2', w)), float(args.get('y2', 0))
|
|
1937
|
+
gtype = Gimp.GradientType.LINEAR
|
|
1938
|
+
else:
|
|
1939
|
+
start_color, end_color = white, black
|
|
1940
|
+
cx, cy = float(args.get('cx', w / 2.0)), float(args.get('cy', h / 2.0))
|
|
1941
|
+
radius = float(args.get('radius', min(w, h) / 2.0))
|
|
1942
|
+
x1, y1, x2, y2 = cx, cy, cx + radius, cy
|
|
1943
|
+
gtype = Gimp.GradientType.RADIAL
|
|
1944
|
+
Gimp.context_push()
|
|
1945
|
+
try:
|
|
1946
|
+
Gimp.context_set_foreground(Gegl.Color.new(start_color))
|
|
1947
|
+
Gimp.context_set_background(Gegl.Color.new(end_color))
|
|
1948
|
+
Gimp.context_set_gradient_fg_bg_rgb()
|
|
1949
|
+
Gimp.context_set_gradient_blend_color_space(Gimp.GradientBlendColorSpace.RGB_PERCEPTUAL)
|
|
1950
|
+
ch.edit_gradient_fill(gtype, 0.0, False, 1, 0, False, x1, y1, x2, y2)
|
|
1951
|
+
finally:
|
|
1952
|
+
Gimp.context_pop()
|
|
1953
|
+
Gimp.Selection.none(img) # a mask is referenced by NAME at filter-creation time, not by
|
|
1954
|
+
# staying the active selection -- leaving it selected let the NEXT unmasked filter silently
|
|
1955
|
+
# inherit it (`_append_masked` also clears defensively, but the fix belongs here too).
|
|
1956
|
+
_drop_proxies(img.get_id())
|
|
1957
|
+
selected, fraction = _channel_coverage(ch, w, h)
|
|
1958
|
+
return {'channel': name, 'selected_pixels': selected, 'fraction': fraction}
|
|
1959
|
+
|
|
1960
|
+
|
|
1961
|
+
def op_describe_operation(args):
|
|
1962
|
+
"""Probe: `list_properties()` (name, type, min, max, default) for a named GEGL/GIMP
|
|
1963
|
+
operation, via a transient `DrawableFilter` on a throwaway 1x1 image that is never applied
|
|
1964
|
+
or rendered. This is what the GEGL-schema golden test snapshots per operation, so a GIMP
|
|
1965
|
+
upgrade that changes a property's range or default fails a test instead of silently shipping
|
|
1966
|
+
an adjust type whose validated range no longer matches the real op."""
|
|
1967
|
+
operation = lib.require(args, 'operation')
|
|
1968
|
+
if operation not in lib.ALLOWED_DESCRIBE_OPERATIONS:
|
|
1969
|
+
raise ValueError('operation must be one of %s' % sorted(lib.ALLOWED_DESCRIBE_OPERATIONS))
|
|
1970
|
+
img = Gimp.Image.new(1, 1, Gimp.ImageBaseType.RGB)
|
|
1971
|
+
try:
|
|
1972
|
+
layer = Gimp.Layer.new(img, 'probe', 1, 1, Gimp.ImageType.RGB_IMAGE, 100.0, Gimp.LayerMode.NORMAL)
|
|
1973
|
+
img.insert_layer(layer, None, 0)
|
|
1974
|
+
try:
|
|
1975
|
+
f = Gimp.DrawableFilter.new(layer, operation, operation)
|
|
1976
|
+
except Exception as e:
|
|
1977
|
+
raise ValueError('no such GEGL/GIMP operation %r (%s)' % (operation, e))
|
|
1978
|
+
cfg = f.get_config()
|
|
1979
|
+
props = []
|
|
1980
|
+
for p in cfg.list_properties():
|
|
1981
|
+
try:
|
|
1982
|
+
default = cfg.get_property(p.name)
|
|
1983
|
+
if isinstance(default, Gimp.Curve):
|
|
1984
|
+
# A bare repr() of a GimpCurve embeds its object pointer address, which
|
|
1985
|
+
# differs every run and would make the schema golden fail on every diff
|
|
1986
|
+
# regardless of any real drift; its points are the stable, meaningful summary.
|
|
1987
|
+
default = _curve_points(default)
|
|
1988
|
+
elif isinstance(default, Gegl.Color):
|
|
1989
|
+
# Same reasoning as GimpCurve above -- a bare repr() of a GeglColor (e.g.
|
|
1990
|
+
# vignette's/dropshadow's `color`) embeds two object pointer addresses that
|
|
1991
|
+
# differ every run. `get_rgba()` is the stable, meaningful summary.
|
|
1992
|
+
default = list(default.get_rgba())
|
|
1993
|
+
elif hasattr(default, 'value_nick'):
|
|
1994
|
+
default = default.value_nick
|
|
1995
|
+
elif not isinstance(default, (int, float, str, bool, type(None))):
|
|
1996
|
+
default = str(default)
|
|
1997
|
+
except Exception:
|
|
1998
|
+
default = None
|
|
1999
|
+
props.append({
|
|
2000
|
+
'name': p.name,
|
|
2001
|
+
'type': p.value_type.name if hasattr(p.value_type, 'name') else str(p.value_type),
|
|
2002
|
+
'minimum': getattr(p, 'minimum', None),
|
|
2003
|
+
'maximum': getattr(p, 'maximum', None),
|
|
2004
|
+
'default': default,
|
|
2005
|
+
})
|
|
2006
|
+
f.delete()
|
|
2007
|
+
return {'operation': operation, 'properties': props}
|
|
2008
|
+
finally:
|
|
2009
|
+
img.delete()
|
|
2010
|
+
|
|
2011
|
+
|
|
2012
|
+
# ---- layer management (gimp_layer) ---------------------------------------------------------
|
|
2013
|
+
#
|
|
2014
|
+
# `create_group` + `reorder`'s `parent_group` cover everything a dedicated group tool would --
|
|
2015
|
+
# there is no separate group tool.
|
|
2016
|
+
#
|
|
2017
|
+
# Verified live (GIMP 3.2.6), the assumptions this section is built on:
|
|
2018
|
+
# - `Gimp.GroupLayer.new(image, name)` + `Image.insert_layer(layer, parent, position)` works for
|
|
2019
|
+
# both a plain layer and a group, nested arbitrarily deep; `Image.reorder_item(item, parent,
|
|
2020
|
+
# position)` moves an existing item (including into/out of a group) the same way. Duplicating a
|
|
2021
|
+
# group (`layer.copy()`) renames every descendant itself, uniquely, as `<name> #<n>` (the
|
|
2022
|
+
# ORIGINAL keeps its own name) -- verified across repeated and nested duplicates, so this
|
|
2023
|
+
# section never has to rename a duplicated group's own children itself.
|
|
2024
|
+
# - `layer.copy()` returns a new, unattached layer whose filters carry the SAME NAMES as the
|
|
2025
|
+
# source's -- and `DrawableFilter.set_name` does NOT exist on this GIMP build (verified: not in
|
|
2026
|
+
# its method list at all), so a copy of a layer with an Editmamei filter cannot be renamed. The
|
|
2027
|
+
# ledger is keyed by filter name (`_apply_filter`), so leaving the collision in place would
|
|
2028
|
+
# mean a later re-edit of EITHER copy silently rewrites the other's ledger record. `duplicate`
|
|
2029
|
+
# therefore refuses outright when the source (or, for a group, any descendant) carries an
|
|
2030
|
+
# Editmamei-ledgered filter.
|
|
2031
|
+
# - `Item.set_offsets(x, y)` is an ABSOLUTE move, not a delta -- verified live (two successive
|
|
2032
|
+
# calls land the layer exactly where each one asked, not summed). Called on a GROUP layer, it
|
|
2033
|
+
# moves every descendant by the same delta too (verified live: a child's own reported offset
|
|
2034
|
+
# shifts by exactly the same amount as the group's) -- `move` needs no special-casing for a
|
|
2035
|
+
# group target.
|
|
2036
|
+
# - A masked filter's confinement does NOT travel with `set_offsets`: verified live with a
|
|
2037
|
+
# brightness-contrast filter masked to the left half of a layer, moved by set_offsets -- the
|
|
2038
|
+
# darkened region did not reappear anywhere in the layer's new position (its mask stayed
|
|
2039
|
+
# pinned to the OLD canvas coordinates, the same "confinement is a fixed snapshot" physics
|
|
2040
|
+
# `_refuse_if_masked_filters`'s own comment documents for rotate/flip). `move` therefore
|
|
2041
|
+
# refuses under the same conditions, scoped to the moved layer's own subtree AND its containing
|
|
2042
|
+
# groups (an untouched OTHER, unrelated layer's masked filter is unaffected by this layer
|
|
2043
|
+
# moving) -- see `_refuse_if_masked_filters_on`'s own comment for why duplicate filter NAMES
|
|
2044
|
+
# are classified over the whole image even though the refusal itself stays scoped.
|
|
2045
|
+
# - `Image.merge_down(layer, merge_type)` merges INTO THE FIRST VISIBLE LAYER BELOW -- verified
|
|
2046
|
+
# live that a HIDDEN layer directly beneath the one being merged is skipped entirely and left
|
|
2047
|
+
# untouched, not merged and not removed. Refuses (returns None, no Python exception -- GIMP
|
|
2048
|
+
# logs "Cannot merge down to a layer group." / "There is no visible layer to merge down to." to
|
|
2049
|
+
# its own console) when that target would be a group, or when there is no visible layer below
|
|
2050
|
+
# at all; both are checked here first for a clean, specific error instead of a generic failure.
|
|
2051
|
+
# - `Image.merge_down` and `Image.flatten()` both render each source layer's live filters (masked
|
|
2052
|
+
# ones included) into real pixels BEFORE compositing -- verified live: a masked
|
|
2053
|
+
# brightness-contrast filter's confined darkening survived merge_down exactly, on a top-level
|
|
2054
|
+
# pair and on a pair nested inside a group. Both consume every live filter on every layer they
|
|
2055
|
+
# touch (verified: a layer being merged INTO, which had its own separate live filter, ends up
|
|
2056
|
+
# with `get_filters() == []` afterward too, not just the layer merged away) -- no masked-filter
|
|
2057
|
+
# refusal is needed for either, only a ledger prune afterward.
|
|
2058
|
+
# - `Image.flatten()` DISCARDS every hidden layer outright (verified live: a hidden layer's own
|
|
2059
|
+
# content never reaches the flattened composite, and the layer itself is simply gone
|
|
2060
|
+
# afterward, text layers included) rather than compositing it in -- `flatten` therefore refuses
|
|
2061
|
+
# by default when any layer is hidden, listing them, unless `discard_hidden: true` is given.
|
|
2062
|
+
# Always drops alpha (verified: `has_alpha()` is False on the result even when the only layer
|
|
2063
|
+
# had one) -- surfaced in the result and the tool description, not hidden.
|
|
2064
|
+
# - A VISIBLE text layer merges/flattens into an ordinary raster layer with no error (verified);
|
|
2065
|
+
# a HIDDEN one is simply discarded like any other hidden layer, never rasterized at all, so
|
|
2066
|
+
# `rasterized_text` only counts visible text layers.
|
|
2067
|
+
# - `Drawable.merge_filters()` (gimp_bake) does NOT rasterize a text layer -- verified live:
|
|
2068
|
+
# `is_text_layer()` still reads True after baking away its filter. Bake's result has no
|
|
2069
|
+
# `rasterized_text` field for this reason; the tool description just says so.
|
|
2070
|
+
# - A filter CAN be attached to a group layer (verified live: `DrawableFilter.new` and
|
|
2071
|
+
# `append_filter` both succeed on one) -- `gimp_add_adjustment` does not refuse it. `gimp_bake`
|
|
2072
|
+
# cannot bake one (`merge_filters()` errors on a group, see `_bake_one`), so `op=bake all: true`
|
|
2073
|
+
# reports any such group under `skipped_groups_with_filters` rather than silently ignoring it.
|
|
2074
|
+
|
|
2075
|
+
|
|
2076
|
+
def _layer_subtree(layer):
|
|
2077
|
+
"""`layer` itself, plus every descendant if it is a group -- the scope a structural op on
|
|
2078
|
+
`layer` actually reaches (deleting, duplicating, or moving a group takes its children with
|
|
2079
|
+
it). A plain (non-group) layer's subtree is just itself."""
|
|
2080
|
+
out = [layer]
|
|
2081
|
+
|
|
2082
|
+
def walk(items):
|
|
2083
|
+
for item in items:
|
|
2084
|
+
out.append(item)
|
|
2085
|
+
if item.is_group():
|
|
2086
|
+
walk(item.get_children())
|
|
2087
|
+
|
|
2088
|
+
if layer.is_group():
|
|
2089
|
+
walk(layer.get_children())
|
|
2090
|
+
return out
|
|
2091
|
+
|
|
2092
|
+
|
|
2093
|
+
def _ledgered_filter_names_on(img, layers):
|
|
2094
|
+
"""Names of live filters on `layers` (see `_layer_subtree`) that match an Editmamei ledger
|
|
2095
|
+
record -- the ones `duplicate` cannot safely carry onto a copy (see its own comment above)."""
|
|
2096
|
+
filters, _unknown = _ledger_get(img)
|
|
2097
|
+
out = []
|
|
2098
|
+
for layer in layers:
|
|
2099
|
+
for f in layer.get_filters():
|
|
2100
|
+
rec = filters.get(f.get_name())
|
|
2101
|
+
if rec and rec.get('operation') == f.get_operation_name():
|
|
2102
|
+
out.append(f.get_name())
|
|
2103
|
+
return out
|
|
2104
|
+
|
|
2105
|
+
|
|
2106
|
+
def _layer_ancestors(layer):
|
|
2107
|
+
"""Every GROUP layer between `layer` and the top of the stack, immediate parent first -- the
|
|
2108
|
+
complement of `_layer_subtree` (which walks DOWN into descendants, never up). A masked filter
|
|
2109
|
+
on one of these confines to that group's own rendered composite, which shifts under it when a
|
|
2110
|
+
layer inside the group moves, exactly the same misalignment a masked filter directly on the
|
|
2111
|
+
moving layer would suffer."""
|
|
2112
|
+
out = []
|
|
2113
|
+
parent = layer.get_parent()
|
|
2114
|
+
while parent is not None:
|
|
2115
|
+
out.append(parent)
|
|
2116
|
+
parent = parent.get_parent()
|
|
2117
|
+
return out
|
|
2118
|
+
|
|
2119
|
+
|
|
2120
|
+
def _refuse_if_masked_filters_on(img, op_name, layer):
|
|
2121
|
+
"""The same refuse-rather-than-corrupt check as `_refuse_if_masked_filters`, scoped to `layer`
|
|
2122
|
+
itself, its own subtree (`_layer_subtree` -- moving a group takes its children with it), and
|
|
2123
|
+
its containing groups (`_layer_ancestors`) -- moving `layer` cannot misalign a masked filter
|
|
2124
|
+
that lives on some OTHER, unrelated part of the image.
|
|
2125
|
+
|
|
2126
|
+
Duplicate-name classification runs over the WHOLE image's live filters, not just this scope: a
|
|
2127
|
+
name is ambiguous the moment it appears on more than one live filter ANYWHERE in the image,
|
|
2128
|
+
since the ledger's {name: record} shape can only ever describe one of them -- a masked filter
|
|
2129
|
+
elsewhere hiding behind an unmasked one's ledger record in this scope (or the reverse) would
|
|
2130
|
+
otherwise slip through unnoticed. The refusal itself still only fires for a name that actually
|
|
2131
|
+
appears in `layer`'s own scope, so a masked filter on a completely unrelated layer never blocks
|
|
2132
|
+
this move."""
|
|
2133
|
+
scope = set(_layer_subtree(layer)) | set(_layer_ancestors(layer))
|
|
2134
|
+
scoped_names = {f.get_name() for l in scope for f in l.get_filters()}
|
|
2135
|
+
filters = _prune_stale_ledger_records(img)
|
|
2136
|
+
whole_image_live = [
|
|
2137
|
+
(f.get_name(), f.get_operation_name()) for l in _all_layers(img) for f in l.get_filters()
|
|
2138
|
+
]
|
|
2139
|
+
masked, unverifiable = lib.classify_geometry_filters(filters, whole_image_live)
|
|
2140
|
+
masked = sorted(set(n for n in masked if n in scoped_names))
|
|
2141
|
+
unverifiable = sorted(set(n for n in unverifiable if n in scoped_names))
|
|
2142
|
+
if unverifiable:
|
|
2143
|
+
# An in-scope name lands here for one of two different reasons, which need different
|
|
2144
|
+
# advice: no matching ledger record at all (a genuinely foreign filter -- delete IT), or a
|
|
2145
|
+
# name carried by more than one live filter in the image. The ledger is keyed by name, so
|
|
2146
|
+
# in the second case it cannot say which copy is Editmamei's; the advice names every layer
|
|
2147
|
+
# carrying the name and leaves the choice to the caller. `name_counts` -- not
|
|
2148
|
+
# `lib.classify_geometry_filters`'s own return, which doesn't distinguish the two -- is what
|
|
2149
|
+
# tells them apart.
|
|
2150
|
+
name_counts = {}
|
|
2151
|
+
for live_name, _operation in whole_image_live:
|
|
2152
|
+
name_counts[live_name] = name_counts.get(live_name, 0) + 1
|
|
2153
|
+
duplicated = sorted(n for n in unverifiable if name_counts[n] > 1)
|
|
2154
|
+
unrecognized = sorted(n for n in unverifiable if name_counts[n] <= 1)
|
|
2155
|
+
reasons = []
|
|
2156
|
+
if unrecognized:
|
|
2157
|
+
reasons.append(
|
|
2158
|
+
'filter(s) %s on this layer, or a group containing it, were not created by '
|
|
2159
|
+
'Editmamei (no matching ledger record for their name and operation), so whether '
|
|
2160
|
+
'they are masked cannot be checked -- delete them first, or move the layer before '
|
|
2161
|
+
'adding them'
|
|
2162
|
+
% ', '.join(repr(n) for n in unrecognized)
|
|
2163
|
+
)
|
|
2164
|
+
if duplicated:
|
|
2165
|
+
dup_descriptions = []
|
|
2166
|
+
for name in duplicated:
|
|
2167
|
+
carriers = sorted({
|
|
2168
|
+
l.get_name() for l in _all_layers(img) for f in l.get_filters()
|
|
2169
|
+
if f.get_name() == name
|
|
2170
|
+
})
|
|
2171
|
+
dup_descriptions.append(
|
|
2172
|
+
'%r (on layer(s) %s)' % (name, ', '.join(repr(c) for c in carriers))
|
|
2173
|
+
)
|
|
2174
|
+
reasons.append(
|
|
2175
|
+
'the name(s) %s are carried by more than one live filter, so which copy was '
|
|
2176
|
+
'created by Editmamei cannot be told apart -- rename or delete whichever copy was '
|
|
2177
|
+
'not created by Editmamei, then retry'
|
|
2178
|
+
% ', '.join(dup_descriptions)
|
|
2179
|
+
)
|
|
2180
|
+
raise ValueError('%s cannot proceed: %s.' % (op_name, '; '.join(reasons)))
|
|
2181
|
+
if masked:
|
|
2182
|
+
raise ValueError(
|
|
2183
|
+
'%s would misalign the masked adjustment(s) %s: a filter\'s mask does not travel with '
|
|
2184
|
+
'content that moves beneath it, whether the filter is on this layer or a group that '
|
|
2185
|
+
'contains it. Move the layer before adding masked adjustments, or delete those filters '
|
|
2186
|
+
'first and re-create them afterwards.'
|
|
2187
|
+
% (op_name, ', '.join(repr(n) for n in masked))
|
|
2188
|
+
)
|
|
2189
|
+
|
|
2190
|
+
|
|
2191
|
+
def _resolve_parent_group(img, args):
|
|
2192
|
+
"""`args['parent_group']` (a layer_id) resolved to a group layer belonging to `img`, or None
|
|
2193
|
+
for top-level (the key absent, or explicitly null). Membership- and group-checked the same way
|
|
2194
|
+
`_layer`'s own `layer_id` path is -- a stray id, one from another image, or one that names an
|
|
2195
|
+
ordinary layer, is refused rather than silently misinserted."""
|
|
2196
|
+
parent_id = args.get('parent_group')
|
|
2197
|
+
if parent_id is None:
|
|
2198
|
+
return None
|
|
2199
|
+
parent = Gimp.Layer.get_by_id(int(parent_id))
|
|
2200
|
+
owner = _owning_image(parent)
|
|
2201
|
+
if parent is None or owner is None or owner.get_id() != img.get_id():
|
|
2202
|
+
raise ValueError('no layer with id %s on image %s' % (parent_id, img.get_id()))
|
|
2203
|
+
if not parent.is_group():
|
|
2204
|
+
raise ValueError('parent_group %s is not a group layer' % parent_id)
|
|
2205
|
+
return parent
|
|
2206
|
+
|
|
2207
|
+
|
|
2208
|
+
def _assert_layer_attached(img, layer, what):
|
|
2209
|
+
"""Some GIMP calls return normally having silently done nothing at all rather than raising on
|
|
2210
|
+
failure (`convert_precision`, `op_open`'s own comment) -- so every insertion this section
|
|
2211
|
+
relies on is verified the same defensive way: confirm the layer is actually reachable by
|
|
2212
|
+
walking `img`'s own live layer tree (`_all_layers`, not just trusting `Item.get_image()`'s own
|
|
2213
|
+
metadata) before reporting success."""
|
|
2214
|
+
if not any(l.get_id() == layer.get_id() for l in _all_layers(img)):
|
|
2215
|
+
raise lib.OpError('gimp_op_failed', '%s did not attach to the image' % what)
|
|
2216
|
+
|
|
2217
|
+
|
|
2218
|
+
def _discard_layer(img, layer):
|
|
2219
|
+
"""Best-effort cleanup for a layer this op created or inserted but must not keep, on any
|
|
2220
|
+
failure path from that point on (never let a half-finished op leave an orphaned GIMP object
|
|
2221
|
+
behind): remove it from the image if it got that far, then delete the underlying object
|
|
2222
|
+
outright. Verified live that `Item.delete()` works both on a layer that was never inserted at
|
|
2223
|
+
all and on one already removed -- so this is safe to call unconditionally. Swallows its own
|
|
2224
|
+
failures: this runs while another exception is already propagating, and cleanup failing must
|
|
2225
|
+
never replace the real error."""
|
|
2226
|
+
owner = _owning_image(layer)
|
|
2227
|
+
if owner is not None and owner.get_id() == img.get_id():
|
|
2228
|
+
try:
|
|
2229
|
+
img.remove_layer(layer)
|
|
2230
|
+
except Exception:
|
|
2231
|
+
pass
|
|
2232
|
+
try:
|
|
2233
|
+
layer.delete()
|
|
2234
|
+
except Exception:
|
|
2235
|
+
pass
|
|
2236
|
+
|
|
2237
|
+
|
|
2238
|
+
def _sibling_index(layer, parent):
|
|
2239
|
+
"""`layer`'s own index among its current siblings (`parent`'s children, or the image's
|
|
2240
|
+
top-level layers when `parent` is None) -- compared by id, never by GObject identity (each
|
|
2241
|
+
`get_children()`/`get_layers()` call can hand back a fresh Python wrapper for the same
|
|
2242
|
+
underlying item)."""
|
|
2243
|
+
siblings = list(parent.get_children()) if parent is not None else list(_owning_image(layer).get_layers())
|
|
2244
|
+
return next((i for i, s in enumerate(siblings) if s.get_id() == layer.get_id()), None)
|
|
2245
|
+
|
|
2246
|
+
|
|
2247
|
+
def _validated_position(args):
|
|
2248
|
+
position = int(args.get('position', 0))
|
|
2249
|
+
if position < 0:
|
|
2250
|
+
raise ValueError('position must be >= 0')
|
|
2251
|
+
return position
|
|
2252
|
+
|
|
2253
|
+
|
|
2254
|
+
def _validated_move_offset(img, x, y):
|
|
2255
|
+
"""A DoS floor on `move`'s target offset, the same spirit as `lib.validate_resize_dims`'s own
|
|
2256
|
+
bound on `resize`: nothing stops a layer from being moved far outside the canvas on its own,
|
|
2257
|
+
but combined with a later merge_down/flatten/resize_to_layers an arbitrarily distant offset
|
|
2258
|
+
could blow up the working canvas size. Bounded to within `lib.MAX_RESIZE_SIDE_PX` of either
|
|
2259
|
+
edge of the CURRENT canvas, not an absolute limit -- generous enough for any real placement,
|
|
2260
|
+
tight enough to keep a single call's downstream cost bounded."""
|
|
2261
|
+
lo_x, hi_x = -lib.MAX_RESIZE_SIDE_PX, img.get_width() + lib.MAX_RESIZE_SIDE_PX
|
|
2262
|
+
lo_y, hi_y = -lib.MAX_RESIZE_SIDE_PX, img.get_height() + lib.MAX_RESIZE_SIDE_PX
|
|
2263
|
+
if not lo_x <= x <= hi_x:
|
|
2264
|
+
raise ValueError('x must be within %d..%d for this %dpx-wide canvas' % (lo_x, hi_x, img.get_width()))
|
|
2265
|
+
if not lo_y <= y <= hi_y:
|
|
2266
|
+
raise ValueError('y must be within %d..%d for this %dpx-tall canvas' % (lo_y, hi_y, img.get_height()))
|
|
2267
|
+
|
|
2268
|
+
|
|
2269
|
+
def _union_bbox(a, b):
|
|
2270
|
+
"""The bounding box `Image.merge_down`'s EXPAND_AS_NECESSARY would produce for merging `a`
|
|
2271
|
+
into `b` -- the union of their two offset rectangles. Checked against the same DoS floor
|
|
2272
|
+
`resize` uses (`lib.validate_resize_dims`) BEFORE the merge runs: two layers offset far apart
|
|
2273
|
+
could otherwise expand the result to an enormous canvas."""
|
|
2274
|
+
_ok_a, ax, ay = a.get_offsets()
|
|
2275
|
+
_ok_b, bx, by = b.get_offsets()
|
|
2276
|
+
aw, ah = a.get_width(), a.get_height()
|
|
2277
|
+
bw, bh = b.get_width(), b.get_height()
|
|
2278
|
+
left, top = min(ax, bx), min(ay, by)
|
|
2279
|
+
right, bottom = max(ax + aw, bx + bw), max(ay + ah, by + bh)
|
|
2280
|
+
return right - left, bottom - top
|
|
2281
|
+
|
|
2282
|
+
|
|
2283
|
+
def _merge_down_target(siblings, idx):
|
|
2284
|
+
"""The layer `merge_down` will actually merge `siblings[idx]` into: the first VISIBLE layer
|
|
2285
|
+
below it in the same parent/level -- verified live that GIMP silently skips a hidden layer in
|
|
2286
|
+
between and merges into the next visible one instead, leaving the hidden layer untouched.
|
|
2287
|
+
Returns None if there is no visible layer below."""
|
|
2288
|
+
for candidate in siblings[idx + 1:]:
|
|
2289
|
+
if candidate.get_visible():
|
|
2290
|
+
return candidate
|
|
2291
|
+
return None
|
|
2292
|
+
|
|
2293
|
+
|
|
2294
|
+
# Base types a NEW layer can be given a matching `Gimp.ImageType` for -- everywhere a fresh layer
|
|
2295
|
+
# might need to be added (gimp_layer op=create, a gimp_canvas fill backdrop) reads this SAME dict
|
|
2296
|
+
# rather than repeating the RGB/GRAY-only assumption independently, so the two call sites can
|
|
2297
|
+
# never drift apart on which base types qualify.
|
|
2298
|
+
_LAYER_CAPABLE_BASE_TYPES = {
|
|
2299
|
+
Gimp.ImageBaseType.RGB: Gimp.ImageType.RGBA_IMAGE,
|
|
2300
|
+
Gimp.ImageBaseType.GRAY: Gimp.ImageType.GRAYA_IMAGE,
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
|
|
2304
|
+
def _layer_type_for(img):
|
|
2305
|
+
"""The `Gimp.ImageType` a new layer should use so it matches `img`'s own color model instead
|
|
2306
|
+
of relying on GIMP's own silent coercion (verified live: an RGBA layer inserted into a
|
|
2307
|
+
grayscale image is quietly converted to GRAYA on insert -- this asks for the right type up
|
|
2308
|
+
front rather than lean on that)."""
|
|
2309
|
+
base = img.get_base_type()
|
|
2310
|
+
layer_type = _LAYER_CAPABLE_BASE_TYPES.get(base)
|
|
2311
|
+
if layer_type is None:
|
|
2312
|
+
raise ValueError(
|
|
2313
|
+
'gimp_layer op=create does not support %s images -- only RGB and grayscale' % base.value_nick
|
|
2314
|
+
)
|
|
2315
|
+
return layer_type
|
|
2316
|
+
|
|
2317
|
+
|
|
2318
|
+
# `Gimp.FillType` has no BLACK member (verified live: WHITE, TRANSPARENT, FOREGROUND, BACKGROUND,
|
|
2319
|
+
# PATTERN, CIELAB_MIDDLE_GRAY only) -- FOREGROUND/BACKGROUND are whatever color the session's
|
|
2320
|
+
# ambient context happens to hold, not reliably black, so a black fill is done by pushing the
|
|
2321
|
+
# context, setting the foreground to an explicit black, filling with FOREGROUND, and popping the
|
|
2322
|
+
# context back -- the same push/pop bracket `op_create_mask` already uses around its own context
|
|
2323
|
+
# changes. Module-level so a typo'd enum member here fails at import time, not on the first call
|
|
2324
|
+
# that happens to hit it.
|
|
2325
|
+
_LAYER_FILL_TYPES = {'white': Gimp.FillType.WHITE, 'transparent': Gimp.FillType.TRANSPARENT}
|
|
2326
|
+
|
|
2327
|
+
|
|
2328
|
+
def _fill_new_layer(layer, fill):
|
|
2329
|
+
# `Drawable.fill()` reports failure by returning False rather than raising (the same "calling
|
|
2330
|
+
# error returns False" class `op_open`'s own `convert_precision` comment documents, verified
|
|
2331
|
+
# live for this call too) -- checked here so a failed fill is never silently reported as
|
|
2332
|
+
# success with the layer left at whatever undefined content `Layer.new` gave it.
|
|
2333
|
+
if fill == 'black':
|
|
2334
|
+
Gimp.context_push()
|
|
2335
|
+
try:
|
|
2336
|
+
Gimp.context_set_foreground(Gegl.Color.new('black'))
|
|
2337
|
+
ok = layer.fill(Gimp.FillType.FOREGROUND)
|
|
2338
|
+
finally:
|
|
2339
|
+
Gimp.context_pop()
|
|
2340
|
+
else:
|
|
2341
|
+
ok = layer.fill(_LAYER_FILL_TYPES[fill])
|
|
2342
|
+
if not ok:
|
|
2343
|
+
raise lib.OpError('gimp_op_failed', 'GIMP could not fill the new layer with %s' % fill)
|
|
2344
|
+
|
|
2345
|
+
|
|
2346
|
+
def _op_layer_create(img, args):
|
|
2347
|
+
# Every field is validated BEFORE any GIMP mutation runs, and the whole insert+fill sequence
|
|
2348
|
+
# is one try/finally: a failure partway through must still drop the proxy cache (something may
|
|
2349
|
+
# already have changed) and must not leave an orphaned, half-inserted layer object behind.
|
|
2350
|
+
name = _unique_layer_name(img, args.get('name') or 'Layer')
|
|
2351
|
+
width = int(args['width']) if args.get('width') is not None else img.get_width()
|
|
2352
|
+
height = int(args['height']) if args.get('height') is not None else img.get_height()
|
|
2353
|
+
width, height = lib.validate_resize_dims(width, height)
|
|
2354
|
+
fill = lib.validate_choice('fill', args.get('fill', 'transparent'), lib.LAYER_FILLS)
|
|
2355
|
+
parent = _resolve_parent_group(img, args)
|
|
2356
|
+
position = _validated_position(args)
|
|
2357
|
+
layer_type = _layer_type_for(img)
|
|
2358
|
+
layer = Gimp.Layer.new(img, name, width, height, layer_type, 100.0, Gimp.LayerMode.NORMAL)
|
|
2359
|
+
try:
|
|
2360
|
+
img.insert_layer(layer, parent, position)
|
|
2361
|
+
_assert_layer_attached(img, layer, 'the new layer')
|
|
2362
|
+
_fill_new_layer(layer, fill)
|
|
2363
|
+
except Exception:
|
|
2364
|
+
_discard_layer(img, layer)
|
|
2365
|
+
raise
|
|
2366
|
+
finally:
|
|
2367
|
+
_drop_proxies(img.get_id())
|
|
2368
|
+
return {'layer_id': layer.get_id(), 'name': layer.get_name(), 'is_group': False}
|
|
2369
|
+
|
|
2370
|
+
|
|
2371
|
+
def _op_layer_create_group(img, args):
|
|
2372
|
+
name = _unique_layer_name(img, args.get('name') or 'Group')
|
|
2373
|
+
parent = _resolve_parent_group(img, args)
|
|
2374
|
+
position = _validated_position(args)
|
|
2375
|
+
group = Gimp.GroupLayer.new(img, name)
|
|
2376
|
+
try:
|
|
2377
|
+
img.insert_layer(group, parent, position)
|
|
2378
|
+
_assert_layer_attached(img, group, 'the new group')
|
|
2379
|
+
except Exception:
|
|
2380
|
+
_discard_layer(img, group)
|
|
2381
|
+
raise
|
|
2382
|
+
finally:
|
|
2383
|
+
_drop_proxies(img.get_id())
|
|
2384
|
+
return {'layer_id': group.get_id(), 'name': group.get_name(), 'is_group': True}
|
|
2385
|
+
|
|
2386
|
+
|
|
2387
|
+
def _op_layer_delete(img, args):
|
|
2388
|
+
layer = _layer(img, args)
|
|
2389
|
+
subtree = _layer_subtree(layer)
|
|
2390
|
+
removed_drawables = sum(1 for l in subtree if not l.is_group())
|
|
2391
|
+
total_drawables = sum(1 for l in _all_layers(img) if not l.is_group())
|
|
2392
|
+
if total_drawables - removed_drawables <= 0:
|
|
2393
|
+
raise ValueError(
|
|
2394
|
+
'cannot delete %r: it is (or contains) every remaining layer in this image -- an '
|
|
2395
|
+
'image needs at least one' % layer.get_name()
|
|
2396
|
+
)
|
|
2397
|
+
layer_id, name = layer.get_id(), layer.get_name()
|
|
2398
|
+
try:
|
|
2399
|
+
img.remove_layer(layer)
|
|
2400
|
+
if any(l.get_id() == layer_id for l in _all_layers(img)):
|
|
2401
|
+
raise lib.OpError('gimp_op_failed', 'GIMP did not remove this layer')
|
|
2402
|
+
finally:
|
|
2403
|
+
_prune_stale_ledger_records(img)
|
|
2404
|
+
_drop_proxies(img.get_id())
|
|
2405
|
+
return {'layer_id': layer_id, 'name': name, 'deleted': True}
|
|
2406
|
+
|
|
2407
|
+
|
|
2408
|
+
def _op_layer_duplicate(img, args):
|
|
2409
|
+
layer = _layer(img, args)
|
|
2410
|
+
ledgered = _ledgered_filter_names_on(img, _layer_subtree(layer))
|
|
2411
|
+
if ledgered:
|
|
2412
|
+
raise ValueError(
|
|
2413
|
+
'cannot duplicate %r: it carries Editmamei filter(s) %s. GIMP has no way to rename a '
|
|
2414
|
+
"copied filter in this build (DrawableFilter.set_name does not exist), and the ledger "
|
|
2415
|
+
"is keyed by filter name, so the copy's filter would silently rewrite the original's "
|
|
2416
|
+
'own record the next time either is re-edited. Bake it first (gimp_bake) or delete it, '
|
|
2417
|
+
'then duplicate.' % (layer.get_name(), ', '.join(repr(n) for n in sorted(set(ledgered))))
|
|
2418
|
+
)
|
|
2419
|
+
copy = layer.copy()
|
|
2420
|
+
if copy is None:
|
|
2421
|
+
raise lib.OpError('gimp_op_failed', 'GIMP could not duplicate this layer')
|
|
2422
|
+
try:
|
|
2423
|
+
copy.set_name(_unique_layer_name(img, layer.get_name()))
|
|
2424
|
+
parent = layer.get_parent()
|
|
2425
|
+
position = _sibling_index(layer, parent)
|
|
2426
|
+
img.insert_layer(copy, parent, position if position is not None else 0)
|
|
2427
|
+
_assert_layer_attached(img, copy, 'the duplicated layer')
|
|
2428
|
+
except Exception:
|
|
2429
|
+
_discard_layer(img, copy)
|
|
2430
|
+
raise
|
|
2431
|
+
finally:
|
|
2432
|
+
_drop_proxies(img.get_id())
|
|
2433
|
+
return {'layer_id': copy.get_id(), 'name': copy.get_name(), 'is_group': copy.is_group()}
|
|
2434
|
+
|
|
2435
|
+
|
|
2436
|
+
def _op_layer_select(img, args):
|
|
2437
|
+
# The one sub-op that changes neither pixels nor structure -- deliberately does NOT drop
|
|
2438
|
+
# proxies (see the PROXIES cache's own comment).
|
|
2439
|
+
layer = _layer(img, args)
|
|
2440
|
+
img.set_selected_layers([layer])
|
|
2441
|
+
return {'layer_id': layer.get_id(), 'name': layer.get_name()}
|
|
2442
|
+
|
|
2443
|
+
|
|
2444
|
+
def _op_layer_set(img, args):
|
|
2445
|
+
layer = _layer(img, args)
|
|
2446
|
+
# Every field is validated FIRST, before any setter runs at all: a later field failing
|
|
2447
|
+
# validation must never leave an earlier one already applied with nothing to show for it (no
|
|
2448
|
+
# proxy drop, no error).
|
|
2449
|
+
to_apply = []
|
|
2450
|
+
if args.get('opacity') is not None:
|
|
2451
|
+
to_apply.append(('opacity', lib.validate_range('opacity', args['opacity'], 0.0, 100.0)))
|
|
2452
|
+
if args.get('mode') is not None:
|
|
2453
|
+
to_apply.append(('mode', lib.validate_layer_mode(args['mode'])))
|
|
2454
|
+
if 'visible' in args and args['visible'] is not None:
|
|
2455
|
+
to_apply.append(('visible', lib.require_bool(args, 'visible')))
|
|
2456
|
+
if args.get('name') is not None:
|
|
2457
|
+
new_name = str(args['name']).strip()
|
|
2458
|
+
if not new_name:
|
|
2459
|
+
raise ValueError('name must not be empty')
|
|
2460
|
+
to_apply.append(('name', _unique_layer_name(img, new_name, exclude=layer)))
|
|
2461
|
+
if not to_apply:
|
|
2462
|
+
raise ValueError('set needs at least one of opacity, mode, visible, name')
|
|
2463
|
+
|
|
2464
|
+
changed = {}
|
|
2465
|
+
try:
|
|
2466
|
+
for field, value in to_apply:
|
|
2467
|
+
if field == 'opacity':
|
|
2468
|
+
layer.set_opacity(value)
|
|
2469
|
+
elif field == 'mode':
|
|
2470
|
+
layer.set_mode(getattr(Gimp.LayerMode, lib.LAYER_MODES[value]))
|
|
2471
|
+
elif field == 'visible':
|
|
2472
|
+
layer.set_visible(value)
|
|
2473
|
+
else:
|
|
2474
|
+
layer.set_name(value)
|
|
2475
|
+
changed[field] = value
|
|
2476
|
+
finally:
|
|
2477
|
+
if changed:
|
|
2478
|
+
_drop_proxies(img.get_id())
|
|
2479
|
+
return {'layer_id': layer.get_id(), 'name': layer.get_name(), **changed}
|
|
2480
|
+
|
|
2481
|
+
|
|
2482
|
+
def _op_layer_move(img, args):
|
|
2483
|
+
layer = _layer(img, args)
|
|
2484
|
+
_refuse_if_masked_filters_on(img, 'move', layer)
|
|
2485
|
+
x, y = int(lib.require(args, 'x')), int(lib.require(args, 'y'))
|
|
2486
|
+
_validated_move_offset(img, x, y)
|
|
2487
|
+
try:
|
|
2488
|
+
layer.set_offsets(x, y)
|
|
2489
|
+
finally:
|
|
2490
|
+
_drop_proxies(img.get_id())
|
|
2491
|
+
ok, off_x, off_y = layer.get_offsets()
|
|
2492
|
+
return {'layer_id': layer.get_id(), 'x': off_x if ok else None, 'y': off_y if ok else None}
|
|
2493
|
+
|
|
2494
|
+
|
|
2495
|
+
def _op_layer_reorder(img, args):
|
|
2496
|
+
layer = _layer(img, args)
|
|
2497
|
+
parent = _resolve_parent_group(img, args) if 'parent_group' in args else layer.get_parent()
|
|
2498
|
+
# GIMP itself only logs a "Gimp-Core-CRITICAL" console assertion for this (verified live) --
|
|
2499
|
+
# it does not raise -- so it is checked here explicitly rather than left to that assertion plus
|
|
2500
|
+
# the post-hoc `_sibling_index` check below to catch it.
|
|
2501
|
+
if parent is not None and any(s.get_id() == parent.get_id() for s in _layer_subtree(layer)):
|
|
2502
|
+
raise ValueError('parent_group cannot be the layer itself, or one of its own descendants')
|
|
2503
|
+
position = _validated_position(args)
|
|
2504
|
+
try:
|
|
2505
|
+
img.reorder_item(layer, parent, position)
|
|
2506
|
+
if _sibling_index(layer, parent) is None:
|
|
2507
|
+
raise lib.OpError('gimp_op_failed', 'GIMP did not reorder this layer')
|
|
2508
|
+
finally:
|
|
2509
|
+
_drop_proxies(img.get_id())
|
|
2510
|
+
return {'layer_id': layer.get_id(), 'parent_group': parent.get_id() if parent is not None else None}
|
|
2511
|
+
|
|
2512
|
+
|
|
2513
|
+
def _op_layer_merge_down(img, args):
|
|
2514
|
+
layer = _layer(img, args)
|
|
2515
|
+
if layer.is_group():
|
|
2516
|
+
raise ValueError(
|
|
2517
|
+
'merge_down does not accept a group layer -- move its children out first, or use '
|
|
2518
|
+
'flatten to collapse the whole image'
|
|
2519
|
+
)
|
|
2520
|
+
# Verified live: GIMP's own merge_down silently fails (returns None) when the SOURCE layer's
|
|
2521
|
+
# own visibility is off, regardless of the target -- checked explicitly, right after the group
|
|
2522
|
+
# check above, so the refusal names the real reason instead of surfacing as a generic
|
|
2523
|
+
# gimp_op_failed. An ancestor group being hidden does not trigger this: verified live, GIMP
|
|
2524
|
+
# merges two VISIBLE siblings inside a hidden group without complaint, since the merge only
|
|
2525
|
+
# ever touches the two layers directly involved.
|
|
2526
|
+
if not layer.get_visible():
|
|
2527
|
+
raise ValueError(
|
|
2528
|
+
'merge_down refuses on the HIDDEN layer %r -- GIMP cannot merge a hidden layer down. '
|
|
2529
|
+
'Make it visible first, or use gimp_layer op=delete to drop just that layer instead.'
|
|
2530
|
+
% layer.get_name()
|
|
2531
|
+
)
|
|
2532
|
+
parent = layer.get_parent()
|
|
2533
|
+
siblings = list(parent.get_children()) if parent is not None else list(img.get_layers())
|
|
2534
|
+
idx = next((i for i, s in enumerate(siblings) if s.get_id() == layer.get_id()), None)
|
|
2535
|
+
target = _merge_down_target(siblings, idx) if idx is not None else None
|
|
2536
|
+
if target is None:
|
|
2537
|
+
raise ValueError(
|
|
2538
|
+
'there is no VISIBLE layer below %r to merge into (a hidden layer in between is '
|
|
2539
|
+
'skipped, not merged, so it is never a valid target)' % layer.get_name()
|
|
2540
|
+
)
|
|
2541
|
+
if target.is_group():
|
|
2542
|
+
raise ValueError(
|
|
2543
|
+
'cannot merge %r into %r: GIMP cannot merge a layer into a group'
|
|
2544
|
+
% (layer.get_name(), target.get_name())
|
|
2545
|
+
)
|
|
2546
|
+
lib.validate_resize_dims(*_union_bbox(layer, target))
|
|
2547
|
+
rasterized_text = layer.is_text_layer() or target.is_text_layer()
|
|
2548
|
+
try:
|
|
2549
|
+
merged = img.merge_down(layer, Gimp.MergeType.EXPAND_AS_NECESSARY)
|
|
2550
|
+
if merged is None:
|
|
2551
|
+
raise lib.OpError('gimp_op_failed', 'GIMP could not merge this layer down')
|
|
2552
|
+
finally:
|
|
2553
|
+
_prune_stale_ledger_records(img)
|
|
2554
|
+
_drop_proxies(img.get_id())
|
|
2555
|
+
return {'layer_id': merged.get_id(), 'name': merged.get_name(), 'rasterized_text': rasterized_text}
|
|
2556
|
+
|
|
2557
|
+
|
|
2558
|
+
def _effectively_visible(layer):
|
|
2559
|
+
"""Whether `layer` actually renders: its own visible flag AND every ANCESTOR group's own
|
|
2560
|
+
visible flag. `Item.get_visible()` reflects only that one item's own checkbox -- a visible
|
|
2561
|
+
child of a hidden group renders as invisible (and flatten discards it) exactly like a directly
|
|
2562
|
+
hidden layer would."""
|
|
2563
|
+
return layer.get_visible() and all(a.get_visible() for a in _layer_ancestors(layer))
|
|
2564
|
+
|
|
2565
|
+
|
|
2566
|
+
def _op_layer_flatten(img, args):
|
|
2567
|
+
all_layers = _all_layers(img)
|
|
2568
|
+
hidden = [l for l in all_layers if not _effectively_visible(l)]
|
|
2569
|
+
discard_hidden = lib.optional_bool(args, 'discard_hidden')
|
|
2570
|
+
if hidden and not discard_hidden:
|
|
2571
|
+
raise ValueError(
|
|
2572
|
+
'flatten would discard %d hidden layer(s) (%s) -- GIMP drops a hidden layer entirely '
|
|
2573
|
+
'rather than compositing it in (a layer inside a hidden group counts as hidden too, '
|
|
2574
|
+
'even when its own visibility is on). Pass discard_hidden: true to proceed, or make '
|
|
2575
|
+
'them visible first.' % (len(hidden), ', '.join(repr(l.get_name()) for l in hidden))
|
|
2576
|
+
)
|
|
2577
|
+
# Every LAYER hidden -- not just every leaf's content. A visible GROUP whose own content is
|
|
2578
|
+
# all hidden (or which has no children at all) never trips this: the group itself is not
|
|
2579
|
+
# hidden, and verified live, `Image.flatten()` handles that case fine on its own, producing a
|
|
2580
|
+
# single background-colored layer rather than failing -- so nothing extra is needed here for
|
|
2581
|
+
# it.
|
|
2582
|
+
if len(hidden) == len(all_layers):
|
|
2583
|
+
raise ValueError('cannot flatten: every layer is hidden, and flatten needs at least one visible layer')
|
|
2584
|
+
rasterized_text = any(l.is_text_layer() and _effectively_visible(l) for l in all_layers)
|
|
2585
|
+
discarded_hidden_layers = [{'layer_id': l.get_id(), 'name': l.get_name()} for l in hidden]
|
|
2586
|
+
try:
|
|
2587
|
+
flat = img.flatten()
|
|
2588
|
+
if flat is None:
|
|
2589
|
+
raise lib.OpError('gimp_op_failed', 'GIMP could not flatten this image')
|
|
2590
|
+
finally:
|
|
2591
|
+
_prune_stale_ledger_records(img)
|
|
2592
|
+
_drop_proxies(img.get_id())
|
|
2593
|
+
return {
|
|
2594
|
+
'layer_id': flat.get_id(), 'name': flat.get_name(),
|
|
2595
|
+
'rasterized_text': rasterized_text, 'has_alpha': flat.has_alpha(),
|
|
2596
|
+
'discarded_hidden_layers': discarded_hidden_layers,
|
|
2597
|
+
}
|
|
2598
|
+
|
|
2599
|
+
|
|
2600
|
+
LAYER_OPS = {
|
|
2601
|
+
'create': _op_layer_create,
|
|
2602
|
+
'create_group': _op_layer_create_group,
|
|
2603
|
+
'delete': _op_layer_delete,
|
|
2604
|
+
'duplicate': _op_layer_duplicate,
|
|
2605
|
+
'select': _op_layer_select,
|
|
2606
|
+
'set': _op_layer_set,
|
|
2607
|
+
'move': _op_layer_move,
|
|
2608
|
+
'reorder': _op_layer_reorder,
|
|
2609
|
+
'merge_down': _op_layer_merge_down,
|
|
2610
|
+
'flatten': _op_layer_flatten,
|
|
2611
|
+
}
|
|
2612
|
+
|
|
2613
|
+
|
|
2614
|
+
def op_layer(args):
|
|
2615
|
+
img = _image(args)
|
|
2616
|
+
lop = args.get('op')
|
|
2617
|
+
fn = LAYER_OPS.get(lop)
|
|
2618
|
+
if fn is None:
|
|
2619
|
+
raise ValueError('op must be one of %s' % sorted(LAYER_OPS))
|
|
2620
|
+
return fn(img, args)
|
|
2621
|
+
|
|
2622
|
+
|
|
2623
|
+
# ---- gimp_bake --------------------------------------------------------------------------------
|
|
2624
|
+
|
|
2625
|
+
|
|
2626
|
+
def _bake_one(layer):
|
|
2627
|
+
"""Bake every live filter on `layer` into its own pixels via `Drawable.merge_filters()` --
|
|
2628
|
+
probed live (GIMP 3.2.6) and confirmed correct even for a masked filter (its confinement
|
|
2629
|
+
survives into the baked pixels exactly), and confirmed NOT to rasterize a text layer (it stays
|
|
2630
|
+
a text layer, filters gone). Returns False (a no-op, not an error) for a layer with no filters.
|
|
2631
|
+
Raises for a group layer: verified live that GIMP accepts the call but silently does nothing (a
|
|
2632
|
+
console \"Calling error ... cannot be modified because it is a group item\", no Python
|
|
2633
|
+
exception, no filters removed) -- the same "returned without raising, but didn't do it" failure
|
|
2634
|
+
class `_assert_layer_attached`'s own comment names, so this is refused up front rather than
|
|
2635
|
+
reported as a silent success."""
|
|
2636
|
+
if layer.is_group():
|
|
2637
|
+
raise ValueError(
|
|
2638
|
+
'layer %r is a group; bake targets individual layers, not groups -- GIMP has no way to '
|
|
2639
|
+
'merge filters on a group item' % layer.get_name()
|
|
2640
|
+
)
|
|
2641
|
+
if not layer.get_filters():
|
|
2642
|
+
return False
|
|
2643
|
+
layer.merge_filters()
|
|
2644
|
+
if layer.get_filters():
|
|
2645
|
+
raise lib.OpError('gimp_op_failed', 'GIMP did not bake every filter on layer %r' % layer.get_name())
|
|
2646
|
+
return True
|
|
2647
|
+
|
|
2648
|
+
|
|
2649
|
+
def op_bake(args):
|
|
2650
|
+
"""`layer_id`/`layer` bakes one layer (defaulting to the selected/topmost layer, same as any
|
|
2651
|
+
other `_layer` call, when neither is given); `all: true` bakes every non-group layer on the
|
|
2652
|
+
image that has any live filters. A group layer can legitimately carry its own filter (verified
|
|
2653
|
+
live), but GIMP cannot bake one -- `all: true` reports any such group under
|
|
2654
|
+
`skipped_groups_with_filters` rather than silently ignoring it; a single-target `bake` on a
|
|
2655
|
+
group is refused outright (`_bake_one`). Either way, baked filters' ledger records are pruned
|
|
2656
|
+
(`_prune_stale_ledger_records`, the same generic staleness sweep every other structural op
|
|
2657
|
+
relies on) and the proxy cache is dropped -- both in a `finally`, so a failure partway through
|
|
2658
|
+
`all: true` still leaves the ledger and proxy consistent with whatever DID get baked. Baking a
|
|
2659
|
+
layer's masked filter also clears it from the next geometry op's refusal check for free:
|
|
2660
|
+
`_refuse_if_masked_filters`/`_refuse_if_masked_filters_on` only ever look at LIVE filters, and
|
|
2661
|
+
baking leaves none behind."""
|
|
2662
|
+
img = _image(args)
|
|
2663
|
+
if lib.optional_bool(args, 'all'):
|
|
2664
|
+
baked = []
|
|
2665
|
+
skipped_groups_with_filters = []
|
|
2666
|
+
try:
|
|
2667
|
+
for layer in _all_layers(img):
|
|
2668
|
+
if layer.is_group():
|
|
2669
|
+
if layer.get_filters():
|
|
2670
|
+
skipped_groups_with_filters.append(
|
|
2671
|
+
{'layer_id': layer.get_id(), 'name': layer.get_name()}
|
|
2672
|
+
)
|
|
2673
|
+
continue
|
|
2674
|
+
if not layer.get_filters():
|
|
2675
|
+
continue
|
|
2676
|
+
_bake_one(layer)
|
|
2677
|
+
baked.append({'layer_id': layer.get_id(), 'name': layer.get_name()})
|
|
2678
|
+
finally:
|
|
2679
|
+
_prune_stale_ledger_records(img)
|
|
2680
|
+
_drop_proxies(img.get_id())
|
|
2681
|
+
return {'baked_layers': baked, 'skipped_groups_with_filters': skipped_groups_with_filters}
|
|
2682
|
+
layer = _layer(img, args)
|
|
2683
|
+
try:
|
|
2684
|
+
baked = _bake_one(layer)
|
|
2685
|
+
finally:
|
|
2686
|
+
_prune_stale_ledger_records(img)
|
|
2687
|
+
_drop_proxies(img.get_id())
|
|
2688
|
+
return {'layer_id': layer.get_id(), 'name': layer.get_name(), 'baked': baked}
|
|
2689
|
+
|
|
2690
|
+
|
|
2691
|
+
# ---- document composition (gimp_create_document / gimp_place_image / gimp_canvas /
|
|
2692
|
+
# gimp_convert_image_mode) -----------------------------------------------------------------
|
|
2693
|
+
#
|
|
2694
|
+
# Verified live (GIMP 3.2.6):
|
|
2695
|
+
# - `Gimp.Image.resize(width, height, offx, offy)` is the canvas-resize primitive `op_crop`'s
|
|
2696
|
+
# own `Image.crop` already wraps for the shrink direction; called directly here for the grow
|
|
2697
|
+
# direction it also supports. It repositions every existing layer by ADDING (offx, offy) to
|
|
2698
|
+
# that layer's own current offset -- layer CONTENT and size are untouched, only where it sits
|
|
2699
|
+
# in the (now larger) canvas moves.
|
|
2700
|
+
# - That repositioning is exactly the "layer moves, its own pixels don't" case
|
|
2701
|
+
# `_refuse_if_masked_filters`'s own comment already documents for rotate/flip/resize: a masked
|
|
2702
|
+
# filter's confinement is a fixed snapshot that does NOT travel with a moving layer (verified
|
|
2703
|
+
# live with a rectangle-masked brightness-contrast filter on the right half of a layer --
|
|
2704
|
+
# after growing the canvas on the left, the darkened region stayed at the OLD absolute pixel
|
|
2705
|
+
# positions, not the layer's new ones) -- `gimp_canvas` therefore refuses under the same
|
|
2706
|
+
# conditions `resize`/`rotate`/`flip` do. An UNMASKED effect filter (vignette, motion_blur,
|
|
2707
|
+
# drop_shadow) is unaffected: its own params key off the LAYER's own unchanged extent, never
|
|
2708
|
+
# the image/canvas, so no transform-tracking table entry is needed here the way rotate/resize
|
|
2709
|
+
# need one for those.
|
|
2710
|
+
# - `Gimp.FillType` has no BLACK (see `_fill_new_layer`'s own comment); a hex fill goes through
|
|
2711
|
+
# the same context-push/foreground/pop bracket.
|
|
2712
|
+
# - `Gimp.file_load_layer(run_mode, image, file)` returns an UNATTACHED layer (an explicit
|
|
2713
|
+
# `image.insert_layer` is still required -- verified live: the loaded layer is not a member of
|
|
2714
|
+
# `image.get_layers()` until that call, even though `layer.get_image()` already answers
|
|
2715
|
+
# non-None before it) already converted to `image`'s own base type at LOAD time, before that
|
|
2716
|
+
# insert -- verified both directions (a grayscale source loaded into an RGB target reports
|
|
2717
|
+
# `rgb-image` even before insert; an RGB source into a grayscale target reports `gray-image`
|
|
2718
|
+
# after). Loading into a HIGHER-precision (16/32-bit) target succeeds with no error either.
|
|
2719
|
+
# Placing a file carrying its own metadata (EXIF/XMP) does not attach anything to the TARGET
|
|
2720
|
+
# image's own metadata -- verified with a real GPS-bearing source. Given a MULTI-layer source
|
|
2721
|
+
# (e.g. a `.xcf`), only ONE layer comes back -- GIMP's own choice, not a flattened composite of
|
|
2722
|
+
# the whole file -- and that ONE layer's own LIVE FILTERS come with it (verified live: a
|
|
2723
|
+
# brightness-contrast filter on the source's layer is present on the loaded, still-unattached
|
|
2724
|
+
# layer too, `Drawable.merge_filters()` bakes it there cleanly even before insert, and a
|
|
2725
|
+
# SAME-NAMED filter already on the target's own, unrelated layer is provably untouched by the
|
|
2726
|
+
# bake) -- `op_place_image` always bakes before inserting, so a placed layer is never a
|
|
2727
|
+
# ledger-name-collision risk the way `gimp_layer op=duplicate` guards against for a copy.
|
|
2728
|
+
# - Inserting a NEW layer changes the image's own SELECTED layer to it -- verified live (a layer
|
|
2729
|
+
# explicitly selected beforehand is no longer selected once a different layer is inserted) --
|
|
2730
|
+
# which is why a failure after `insert_layer` restores the pre-call selection explicitly rather
|
|
2731
|
+
# than assuming discarding the newly-inserted layer alone leaves everything as it was.
|
|
2732
|
+
# - `Item.scale(width, height, local_origin)` resizes just that one layer; called here while its
|
|
2733
|
+
# offset is still whatever `file_load_layer` gave it (verified: local_origin's own effect on
|
|
2734
|
+
# the resulting offset only matters when the pre-scale offset is non-zero), and the caller's
|
|
2735
|
+
# `x`/`y` are applied afterward via the same absolute `set_offsets` `gimp_layer op=move` uses --
|
|
2736
|
+
# so the requested position is always exact regardless of local_origin's own math. Both
|
|
2737
|
+
# `Drawable.scale()` and `Drawable.fill()` report failure by returning `False` rather than
|
|
2738
|
+
# raising (the same class `op_open`'s own `convert_precision` comment documents) -- checked
|
|
2739
|
+
# everywhere this file calls either, including the pre-existing `_fill_new_layer` shared with
|
|
2740
|
+
# `gimp_layer op=create`.
|
|
2741
|
+
# - `Gimp.Image.convert_rgb()` / `convert_grayscale()` exist; calling one when the image is
|
|
2742
|
+
# ALREADY that base type is the same "calling error returns False, doesn't raise" class
|
|
2743
|
+
# `op_open`'s own `convert_precision` comment documents, which is why `op_convert_image_mode`
|
|
2744
|
+
# checks the current base type itself first rather than relying on that return value to tell
|
|
2745
|
+
# the two cases apart.
|
|
2746
|
+
# - `fill: transparent`'s "no fill layer" is only genuinely transparent in a LIVE, layered
|
|
2747
|
+
# document (e.g. saved via `gimp_save_xcf`) -- verified live that both `Image.flatten()`
|
|
2748
|
+
# (`gimp_layer op=flatten`) and `_composite`'s own flatten (every `gimp_export`) fill any
|
|
2749
|
+
# transparent area with GIMP's ambient BACKGROUND context color (white, unmodified here) and
|
|
2750
|
+
# drop alpha entirely, not with transparency. A PNG exported WITHOUT going through that flatten
|
|
2751
|
+
# path (this bridge always does, via `_composite`) would keep the padding genuinely
|
|
2752
|
+
# transparent, but that route is not one `gimp_export` takes -- the tool description says so.
|
|
2753
|
+
|
|
2754
|
+
|
|
2755
|
+
def op_create_document(args):
|
|
2756
|
+
precision = args.get('precision')
|
|
2757
|
+
if precision is not None and precision not in ('16', '32'):
|
|
2758
|
+
raise ValueError("precision must be one of '16', '32'")
|
|
2759
|
+
# The megapixel ceiling is precision-aware (`lib.DOCUMENT_MEGAPIXEL_CAP`) -- a 16/32-bit
|
|
2760
|
+
# request costs 2x/4x the memory of the same pixel count at 8-bit, so it gets a
|
|
2761
|
+
# correspondingly lower cap rather than the plain 8-bit-assuming `validate_resize_dims`.
|
|
2762
|
+
width, height = lib.validate_document_dims(
|
|
2763
|
+
int(lib.require(args, 'width')), int(lib.require(args, 'height')), precision or '8'
|
|
2764
|
+
)
|
|
2765
|
+
fill = lib.validate_choice('fill', args.get('fill', 'white'), lib.LAYER_FILLS)
|
|
2766
|
+
mode = lib.validate_choice('mode', args.get('mode', 'rgb'), lib.IMAGE_MODES)
|
|
2767
|
+
name = args.get('name') or 'Background'
|
|
2768
|
+
base_type = Gimp.ImageBaseType.RGB if mode == 'rgb' else Gimp.ImageBaseType.GRAY
|
|
2769
|
+
img = Gimp.Image.new(width, height, base_type)
|
|
2770
|
+
try:
|
|
2771
|
+
if precision is not None:
|
|
2772
|
+
if not img.convert_precision(_PRECISION_ENUM[precision]):
|
|
2773
|
+
raise lib.OpError(
|
|
2774
|
+
'gimp_op_failed',
|
|
2775
|
+
'precision promotion to %r failed for a new %s image' % (precision, mode),
|
|
2776
|
+
)
|
|
2777
|
+
layer = Gimp.Layer.new(img, name, width, height, _layer_type_for(img), 100.0, Gimp.LayerMode.NORMAL)
|
|
2778
|
+
img.insert_layer(layer, None, 0)
|
|
2779
|
+
_assert_layer_attached(img, layer, 'the background layer')
|
|
2780
|
+
_fill_new_layer(layer, fill)
|
|
2781
|
+
Gimp.Selection.none(img)
|
|
2782
|
+
_proxy(img, 1024) # see op_open: build the default preview proxy up front too
|
|
2783
|
+
except Exception:
|
|
2784
|
+
img.delete()
|
|
2785
|
+
raise
|
|
2786
|
+
return _describe(img)
|
|
2787
|
+
|
|
2788
|
+
|
|
2789
|
+
def _path_free_detail(detail):
|
|
2790
|
+
"""A GIMP- or Python-supplied error detail, made safe to surface -- DROPPED ENTIRELY (not
|
|
2791
|
+
redacted in place) when it contains anything that looks like a filesystem path (a `/` or `\\`
|
|
2792
|
+
separator) or a `file:` URI scheme. Unlike `_export_stripped`'s rewrite, which only ever has to
|
|
2793
|
+
erase the ONE exact path spelling this bridge itself passed in, a detail string coming back
|
|
2794
|
+
from GIMP or a raised Python exception could quote the caller's path in any number of
|
|
2795
|
+
normalizations (forward slash, backslash, a `file://` URI, a different case) -- there is no
|
|
2796
|
+
substitution list that could cover all of them, so this refuses to guess and drops the whole
|
|
2797
|
+
detail instead. Returns '' (nothing to append) when unsafe or absent; otherwise ' (<detail>)',
|
|
2798
|
+
ready to append directly to a message."""
|
|
2799
|
+
if not detail or '/' in detail or '\\' in detail or 'file:' in detail:
|
|
2800
|
+
return ''
|
|
2801
|
+
return ' (%s)' % detail
|
|
2802
|
+
|
|
2803
|
+
|
|
2804
|
+
def op_place_image(args):
|
|
2805
|
+
# `requireAbsoluteGimpPath` (the tool layer) is unchanged by this discipline: it only
|
|
2806
|
+
# validates and echoes back the CALLER'S OWN `file_path` value, the same as every other gimp_*
|
|
2807
|
+
# path argument (including community-tier ones) -- it never adds a path the caller didn't
|
|
2808
|
+
# already send. What changed here is this op's OWN messages: every one is built from
|
|
2809
|
+
# `basename` only, and any further detail GIMP or Python supplies is passed through
|
|
2810
|
+
# `_path_free_detail`, which drops it outright rather than trying to redact it, if it looks
|
|
2811
|
+
# like it might carry a path of its own.
|
|
2812
|
+
img = _image(args)
|
|
2813
|
+
path = lib.require(args, 'path')
|
|
2814
|
+
basename = os.path.basename(path)
|
|
2815
|
+
if not os.path.exists(path):
|
|
2816
|
+
raise FileNotFoundError('no file named %s to place' % basename)
|
|
2817
|
+
ext = os.path.splitext(path)[1].lower()
|
|
2818
|
+
load_error = None
|
|
2819
|
+
try:
|
|
2820
|
+
loaded = Gimp.file_load_layer(Gimp.RunMode.NONINTERACTIVE, img, Gio.File.new_for_path(path))
|
|
2821
|
+
except Exception as e:
|
|
2822
|
+
loaded = None
|
|
2823
|
+
load_error = str(e)
|
|
2824
|
+
# A failed load does not always raise: verified live, an unreadable raw-extension file with no
|
|
2825
|
+
# loader plug-in installed makes `file_load_layer` return `None` outright (the same "returned
|
|
2826
|
+
# normally having silently done nothing" class `_assert_layer_attached`'s own comment names),
|
|
2827
|
+
# not raise -- so the raw/no-loader reframing below has to cover BOTH `loaded is None` and a
|
|
2828
|
+
# real exception, not just the exception case `op_open`'s own analogous try/except assumes is
|
|
2829
|
+
# the only failure shape.
|
|
2830
|
+
if loaded is None:
|
|
2831
|
+
detail = _path_free_detail(load_error)
|
|
2832
|
+
if ext in RAW_EXTENSIONS:
|
|
2833
|
+
raise lib.OpError(
|
|
2834
|
+
'gimp_unsupported_file',
|
|
2835
|
+
'GIMP could not load %s as a layer (no raw loader by default); install a '
|
|
2836
|
+
'raw-develop plug-in (darktable, RawTherapee, or ART) for GIMP, or develop it '
|
|
2837
|
+
'externally first and place the resulting JPEG/TIFF.%s' % (basename, detail),
|
|
2838
|
+
)
|
|
2839
|
+
raise lib.OpError('gimp_op_failed', 'could not load %s as a layer%s' % (basename, detail))
|
|
2840
|
+
|
|
2841
|
+
# Everything below either fully succeeds or leaves `img` exactly as it was: every field is
|
|
2842
|
+
# validated (and, for width/height, resolved against the loaded layer's OWN natural size)
|
|
2843
|
+
# BEFORE `insert_layer` ever runs, and the SELECTED layer is restored on any failure --
|
|
2844
|
+
# `insert_layer` changes the image's own selection as a side effect (verified live), even for
|
|
2845
|
+
# an insert that is about to be undone.
|
|
2846
|
+
original_selection = img.get_selected_layers()
|
|
2847
|
+
baked_filters = []
|
|
2848
|
+
try:
|
|
2849
|
+
w0, h0 = loaded.get_width(), loaded.get_height()
|
|
2850
|
+
lib.validate_resize_dims(w0, h0) # the DoS floor applies to the SOURCE file's own size too
|
|
2851
|
+
|
|
2852
|
+
width, height = args.get('width'), args.get('height')
|
|
2853
|
+
if width is not None or height is not None:
|
|
2854
|
+
if width is not None and height is not None:
|
|
2855
|
+
width, height = int(width), int(height)
|
|
2856
|
+
elif width is not None:
|
|
2857
|
+
width = int(width)
|
|
2858
|
+
height = max(1, round(h0 * (width / float(w0))))
|
|
2859
|
+
else:
|
|
2860
|
+
height = int(height)
|
|
2861
|
+
width = max(1, round(w0 * (height / float(h0))))
|
|
2862
|
+
width, height = lib.validate_resize_dims(width, height)
|
|
2863
|
+
else:
|
|
2864
|
+
width, height = w0, h0
|
|
2865
|
+
|
|
2866
|
+
x, y = int(args.get('x', 0)), int(args.get('y', 0))
|
|
2867
|
+
_validated_move_offset(img, x, y)
|
|
2868
|
+
|
|
2869
|
+
default_name = os.path.splitext(basename)[0] or 'Layer'
|
|
2870
|
+
name = _unique_layer_name(img, args.get('name') or default_name)
|
|
2871
|
+
parent = _resolve_parent_group(img, args)
|
|
2872
|
+
position = _validated_position(args)
|
|
2873
|
+
|
|
2874
|
+
# `file_load_layer` on a multi-layer `.xcf` source carries that ONE layer's own live
|
|
2875
|
+
# filters onto the loaded (still unattached) layer -- verified live. Baked here, BEFORE
|
|
2876
|
+
# insert, so the placed layer always lands as plain pixels: the ledger this bridge writes
|
|
2877
|
+
# lives in the TARGET image's own parasite and is keyed by filter NAME, so an unbaked
|
|
2878
|
+
# filter could silently collide with (and get overwritten by, or overwrite) an existing
|
|
2879
|
+
# target filter that happens to share its name -- baking first means there is never a
|
|
2880
|
+
# filter object left on the placed layer for that collision to happen to.
|
|
2881
|
+
if loaded.get_filters():
|
|
2882
|
+
baked_filters = [f.get_name() for f in loaded.get_filters()]
|
|
2883
|
+
loaded.merge_filters()
|
|
2884
|
+
if loaded.get_filters():
|
|
2885
|
+
raise lib.OpError(
|
|
2886
|
+
'gimp_op_failed', "could not bake the placed layer's own live filter(s)"
|
|
2887
|
+
)
|
|
2888
|
+
|
|
2889
|
+
loaded.set_name(name)
|
|
2890
|
+
img.insert_layer(loaded, parent, position)
|
|
2891
|
+
_assert_layer_attached(img, loaded, 'the placed layer')
|
|
2892
|
+
|
|
2893
|
+
if (width, height) != (w0, h0):
|
|
2894
|
+
if not loaded.scale(width, height, False):
|
|
2895
|
+
raise lib.OpError('gimp_op_failed', 'GIMP could not scale the placed layer')
|
|
2896
|
+
loaded.set_offsets(x, y)
|
|
2897
|
+
except Exception:
|
|
2898
|
+
img.set_selected_layers(original_selection)
|
|
2899
|
+
_discard_layer(img, loaded)
|
|
2900
|
+
raise
|
|
2901
|
+
finally:
|
|
2902
|
+
_drop_proxies(img.get_id())
|
|
2903
|
+
ok, off_x, off_y = loaded.get_offsets()
|
|
2904
|
+
result = {
|
|
2905
|
+
'layer_id': loaded.get_id(),
|
|
2906
|
+
'name': loaded.get_name(),
|
|
2907
|
+
'width': loaded.get_width(),
|
|
2908
|
+
'height': loaded.get_height(),
|
|
2909
|
+
'x': off_x if ok else None,
|
|
2910
|
+
'y': off_y if ok else None,
|
|
2911
|
+
'parent_group': parent.get_id() if parent is not None else None,
|
|
2912
|
+
}
|
|
2913
|
+
if baked_filters:
|
|
2914
|
+
result['baked_filters'] = baked_filters
|
|
2915
|
+
return result
|
|
2916
|
+
|
|
2917
|
+
|
|
2918
|
+
def _fill_canvas_layer(layer, fill):
|
|
2919
|
+
if fill in lib.LAYER_FILLS:
|
|
2920
|
+
_fill_new_layer(layer, fill) # white/black -- reuses the same push/pop-context bracket
|
|
2921
|
+
return # and the same fill()-return-value check ('transparent'
|
|
2922
|
+
# never reaches here -- op_canvas skips the layer entirely)
|
|
2923
|
+
# Otherwise `fill` is a validated '#rrggbb' hex string. On a GRAYSCALE image, filling with an
|
|
2924
|
+
# RGB foreground color converts to that color's LUMINANCE (verified live: '#336699' -> gray
|
|
2925
|
+
# level 98, matching neither the R, G, nor B channel alone but their weighted luminance) --
|
|
2926
|
+
# the same automatic base-type coercion `_layer_type_for`'s own comment documents for a plain
|
|
2927
|
+
# layer insert, applied here by GIMP's own fill implementation instead.
|
|
2928
|
+
Gimp.context_push()
|
|
2929
|
+
try:
|
|
2930
|
+
Gimp.context_set_foreground(Gegl.Color.new(fill))
|
|
2931
|
+
ok = layer.fill(Gimp.FillType.FOREGROUND)
|
|
2932
|
+
finally:
|
|
2933
|
+
Gimp.context_pop()
|
|
2934
|
+
if not ok:
|
|
2935
|
+
raise lib.OpError('gimp_op_failed', 'GIMP could not fill the canvas backdrop layer')
|
|
2936
|
+
|
|
2937
|
+
|
|
2938
|
+
def op_canvas(args):
|
|
2939
|
+
img = _image(args)
|
|
2940
|
+
old_w, old_h = img.get_width(), img.get_height()
|
|
2941
|
+
new_w, new_h = int(lib.require(args, 'width')), int(lib.require(args, 'height'))
|
|
2942
|
+
if new_w < old_w or new_h < old_h:
|
|
2943
|
+
raise ValueError(
|
|
2944
|
+
'gimp_canvas only extends the canvas: %dx%d is smaller than the current %dx%d in at '
|
|
2945
|
+
'least one dimension -- use gimp_crop_document to shrink it instead'
|
|
2946
|
+
% (new_w, new_h, old_w, old_h)
|
|
2947
|
+
)
|
|
2948
|
+
if new_w == old_w and new_h == old_h:
|
|
2949
|
+
raise ValueError(
|
|
2950
|
+
'gimp_canvas needs a target that grows at least one dimension -- %dx%d is the '
|
|
2951
|
+
'current size already' % (old_w, old_h)
|
|
2952
|
+
)
|
|
2953
|
+
new_w, new_h = lib.validate_resize_dims(new_w, new_h)
|
|
2954
|
+
fill = lib.validate_canvas_fill(args.get('fill', 'transparent'))
|
|
2955
|
+
# Checked before anything mutates: a non-transparent fill needs a backdrop layer, and
|
|
2956
|
+
# `_LAYER_CAPABLE_BASE_TYPES` (the same allow-list `_layer_type_for` reads) only covers
|
|
2957
|
+
# RGB/GRAY -- refusing here keeps the canvas untouched, rather than committing the resize and
|
|
2958
|
+
# only then discovering the backdrop layer can't be created.
|
|
2959
|
+
if fill != 'transparent' and img.get_base_type() not in _LAYER_CAPABLE_BASE_TYPES:
|
|
2960
|
+
raise ValueError(
|
|
2961
|
+
'gimp_canvas cannot add a %s backdrop layer to an image of type %s -- only RGB and grayscale '
|
|
2962
|
+
'support a non-transparent fill; use fill=transparent instead'
|
|
2963
|
+
% (fill, img.get_base_type().value_nick)
|
|
2964
|
+
)
|
|
2965
|
+
anchor = args.get('anchor')
|
|
2966
|
+
has_explicit_offset = 'offset_x' in args or 'offset_y' in args
|
|
2967
|
+
if has_explicit_offset and anchor is not None:
|
|
2968
|
+
raise ValueError('give either anchor or offset_x/offset_y, not both')
|
|
2969
|
+
if has_explicit_offset:
|
|
2970
|
+
offset_x, offset_y = int(lib.require(args, 'offset_x')), int(lib.require(args, 'offset_y'))
|
|
2971
|
+
else:
|
|
2972
|
+
offset_x, offset_y = lib.canvas_anchor_offset(anchor or 'center', old_w, old_h, new_w, new_h)
|
|
2973
|
+
if not (0 <= offset_x <= new_w - old_w) or not (0 <= offset_y <= new_h - old_h):
|
|
2974
|
+
raise ValueError(
|
|
2975
|
+
'offset_x/offset_y must keep the existing %dx%d content fully within the new %dx%d '
|
|
2976
|
+
'canvas: 0..%d for offset_x, 0..%d for offset_y'
|
|
2977
|
+
% (old_w, old_h, new_w, new_h, new_w - old_w, new_h - old_h)
|
|
2978
|
+
)
|
|
2979
|
+
# Checked only once the ACTUAL offset is known, and skipped entirely at (0, 0): a masked
|
|
2980
|
+
# filter's confinement is a fixed snapshot of the existing content's position, and content that
|
|
2981
|
+
# does not move at all (top-left anchor, or an explicit offset_x/offset_y of 0/0) cannot
|
|
2982
|
+
# misalign it -- refusing here anyway would block the single most common gimp_canvas call
|
|
2983
|
+
# (extend to the bottom-right) for no real reason.
|
|
2984
|
+
if (offset_x, offset_y) != (0, 0):
|
|
2985
|
+
_refuse_if_masked_filters(img, 'canvas', before_hint='Extend the canvas')
|
|
2986
|
+
Gimp.Selection.none(img) # see op_open: geometry always applies to the whole canvas
|
|
2987
|
+
try:
|
|
2988
|
+
img.resize(new_w, new_h, offset_x, offset_y)
|
|
2989
|
+
if (img.get_width(), img.get_height()) != (new_w, new_h):
|
|
2990
|
+
raise lib.OpError(
|
|
2991
|
+
'gimp_op_failed',
|
|
2992
|
+
'GIMP did not resize the canvas to %dx%d (now %dx%d)'
|
|
2993
|
+
% (new_w, new_h, img.get_width(), img.get_height()),
|
|
2994
|
+
)
|
|
2995
|
+
if fill != 'transparent':
|
|
2996
|
+
name = _unique_layer_name(img, 'Canvas Fill')
|
|
2997
|
+
layer = Gimp.Layer.new(img, name, new_w, new_h, _layer_type_for(img), 100.0, Gimp.LayerMode.NORMAL)
|
|
2998
|
+
try:
|
|
2999
|
+
img.insert_layer(layer, None, len(img.get_layers())) # bottom of the top-level stack
|
|
3000
|
+
_assert_layer_attached(img, layer, 'the canvas fill layer')
|
|
3001
|
+
_fill_canvas_layer(layer, fill)
|
|
3002
|
+
except Exception as e:
|
|
3003
|
+
_discard_layer(img, layer)
|
|
3004
|
+
# The canvas resize itself already committed and is not undone here (reversing it
|
|
3005
|
+
# risks compounding one failure into two) -- the error says so plainly instead.
|
|
3006
|
+
raise lib.OpError(
|
|
3007
|
+
'gimp_op_failed',
|
|
3008
|
+
'the canvas was extended to %dx%d, but the %s backdrop layer could not be '
|
|
3009
|
+
'added (%s) -- retry with fill=transparent to skip it, or add a backdrop '
|
|
3010
|
+
'layer manually' % (new_w, new_h, fill, e),
|
|
3011
|
+
)
|
|
3012
|
+
finally:
|
|
3013
|
+
_drop_proxies(img.get_id())
|
|
3014
|
+
return {
|
|
3015
|
+
'width': img.get_width(), 'height': img.get_height(),
|
|
3016
|
+
'offset_x': offset_x, 'offset_y': offset_y,
|
|
3017
|
+
}
|
|
3018
|
+
|
|
3019
|
+
|
|
3020
|
+
def op_convert_image_mode(args):
|
|
3021
|
+
img = _image(args)
|
|
3022
|
+
mode = lib.validate_choice('mode', args.get('mode'), lib.IMAGE_MODES)
|
|
3023
|
+
if img.get_base_type() == Gimp.ImageBaseType.INDEXED:
|
|
3024
|
+
raise ValueError('gimp_convert_image_mode does not support an indexed image as the source')
|
|
3025
|
+
# The no-op check runs BEFORE the live-filter refusal: an image already in the requested mode
|
|
3026
|
+
# has nothing for a color-dependent filter to be endangered by, so a live filter must never
|
|
3027
|
+
# turn a true no-op into a spurious refusal.
|
|
3028
|
+
target_type = Gimp.ImageBaseType.RGB if mode == 'rgb' else Gimp.ImageBaseType.GRAY
|
|
3029
|
+
if img.get_base_type() == target_type:
|
|
3030
|
+
return {'mode': mode, 'converted': False}
|
|
3031
|
+
live_filters = _live_filter_names(img)
|
|
3032
|
+
if live_filters:
|
|
3033
|
+
raise ValueError(
|
|
3034
|
+
'gimp_convert_image_mode refuses while any live filter is present (%s): a '
|
|
3035
|
+
'color-dependent filter could change meaning across a mode change. Bake it first '
|
|
3036
|
+
"(gimp_bake) or delete it (gimp_filter op=delete), then convert."
|
|
3037
|
+
% ', '.join(repr(n) for n in sorted(live_filters))
|
|
3038
|
+
)
|
|
3039
|
+
try:
|
|
3040
|
+
ok = img.convert_rgb() if mode == 'rgb' else img.convert_grayscale()
|
|
3041
|
+
if not ok:
|
|
3042
|
+
raise lib.OpError('gimp_op_failed', 'GIMP could not convert this image to %s' % mode)
|
|
3043
|
+
finally:
|
|
3044
|
+
_drop_proxies(img.get_id())
|
|
3045
|
+
return {'mode': mode, 'converted': True}
|
|
3046
|
+
|
|
3047
|
+
|
|
3048
|
+
def op_select_none(args):
|
|
3049
|
+
Gimp.Selection.none(_image(args))
|
|
3050
|
+
return {'selection': 'none'}
|
|
3051
|
+
|
|
3052
|
+
|
|
3053
|
+
def op_close(args):
|
|
3054
|
+
img = _image(args)
|
|
3055
|
+
_drop_proxies(img.get_id())
|
|
3056
|
+
img.delete()
|
|
3057
|
+
return {'closed': int(args['image'])}
|
|
3058
|
+
|
|
3059
|
+
|
|
3060
|
+
OPS = {
|
|
3061
|
+
'ping': op_ping, 'open': op_open, 'curves': op_curves, 'levels': op_levels,
|
|
3062
|
+
'adjust': op_adjust, 'filter': op_filter, 'effect': op_effect, 'describe': op_describe,
|
|
3063
|
+
'list_filters': op_list_filters, 'preview': op_preview, 'histogram': op_histogram,
|
|
3064
|
+
'compare': op_compare, 'export': op_export, 'close': op_close,
|
|
3065
|
+
'crop': op_crop, 'resize': op_resize, 'rotate': op_rotate, 'flip': op_flip,
|
|
3066
|
+
'create_mask': op_create_mask, 'describe_operation': op_describe_operation,
|
|
3067
|
+
'select_none': op_select_none, 'layer': op_layer, 'bake': op_bake,
|
|
3068
|
+
'create_document': op_create_document, 'place_image': op_place_image,
|
|
3069
|
+
'canvas': op_canvas, 'convert_image_mode': op_convert_image_mode,
|
|
3070
|
+
}
|
|
3071
|
+
|
|
3072
|
+
|
|
3073
|
+
def _dispatch(op, args):
|
|
3074
|
+
"""The real OPS-table lookup `lib.process_request` calls for each
|
|
3075
|
+
request. Raising `lib.OpError('invalid_argument', ...)` for an unknown op
|
|
3076
|
+
(rather than a bare KeyError) is what makes process_request's own
|
|
3077
|
+
classification report it as invalid_argument, not gimp_op_failed."""
|
|
3078
|
+
fn = OPS.get(op)
|
|
3079
|
+
if fn is None:
|
|
3080
|
+
raise lib.OpError('invalid_argument', 'unknown op %r' % op)
|
|
3081
|
+
return fn(args)
|
|
3082
|
+
|
|
3083
|
+
|
|
3084
|
+
def serve(session_dir, poll_s=0.005, parent_check_s=1.0):
|
|
3085
|
+
"""Handle rpc/req-<id>.json in id order until rpc/shutdown appears, or
|
|
3086
|
+
the parent process (EM_GIMP_PARENT_PID, checked about once a second) is
|
|
3087
|
+
gone -- a dead driver otherwise leaves this headless GIMP running
|
|
3088
|
+
forever with nothing left to ever write rpc/shutdown. (Absent entirely
|
|
3089
|
+
for a Flatpak-launched GIMP, whose pid namespace can't see the host
|
|
3090
|
+
parent anyway -- see session.ts's env-building for that decision.)"""
|
|
3091
|
+
rpc = os.path.join(session_dir, 'rpc')
|
|
3092
|
+
with open(os.path.join(rpc, 'ready.tmp'), 'w') as fh:
|
|
3093
|
+
fh.write(str(os.getpid()))
|
|
3094
|
+
os.replace(os.path.join(rpc, 'ready.tmp'), os.path.join(rpc, 'ready'))
|
|
3095
|
+
|
|
3096
|
+
parent_pid_raw = os.environ.get('EM_GIMP_PARENT_PID')
|
|
3097
|
+
parent_pid = int(parent_pid_raw) if parent_pid_raw else None
|
|
3098
|
+
last_parent_check = time.time()
|
|
3099
|
+
|
|
3100
|
+
while not os.path.exists(os.path.join(rpc, 'shutdown')):
|
|
3101
|
+
now = time.time()
|
|
3102
|
+
if parent_pid is not None and now - last_parent_check >= parent_check_s:
|
|
3103
|
+
last_parent_check = now
|
|
3104
|
+
if not lib.is_process_alive(parent_pid):
|
|
3105
|
+
sys.stderr.write('parent process %d is gone; exiting\n' % parent_pid)
|
|
3106
|
+
return
|
|
3107
|
+
names = lib.list_requests(rpc)
|
|
3108
|
+
if not names:
|
|
3109
|
+
time.sleep(poll_s)
|
|
3110
|
+
continue
|
|
3111
|
+
for name in names:
|
|
3112
|
+
try:
|
|
3113
|
+
lib.process_request(os.path.join(rpc, name), rpc, _dispatch)
|
|
3114
|
+
except Exception:
|
|
3115
|
+
# process_request is designed to never raise (see its own
|
|
3116
|
+
# docstring) -- this is belt-and-suspenders so a bug in that
|
|
3117
|
+
# contract takes down one request, not the whole session.
|
|
3118
|
+
sys.stderr.write(
|
|
3119
|
+
'process_request raised for %s:\n%s\n' % (name, traceback.format_exc())
|
|
3120
|
+
)
|