editmamei 1.6.0 → 1.7.1
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 +11 -4
- package/dist/backends/gimp/backend.js +3 -0
- package/dist/backends/gimp/bridge/lib.py +766 -7
- package/dist/backends/gimp/bridge/ops.py +1693 -74
- package/dist/backends/gimp/session.js +3 -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/help.js +2 -2
- package/dist/cli/install.js +2 -2
- package/dist/core/preview-privacy.js +54 -0
- package/dist/core/server.js +4 -0
- package/dist/core/tool-activity.js +8 -0
- package/dist/core/tool-groups.js +8 -0
- package/dist/core/tool-tiers.js +8 -0
- package/dist/install-channel.js +2 -0
- package/dist/modules/gimp/index.js +8 -0
- package/dist/skills/editmamei-skill.zip +0 -0
- package/dist/telemetry/client.js +23 -16
- package/dist/telemetry/outbox.js +248 -28
- package/dist/tools/gimp-adjustment-tools.js +6 -3
- 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 +20 -6
- package/dist/tools/gimp-effect-tools.js +267 -0
- package/dist/tools/gimp-filter-tools.js +2 -1
- package/dist/tools/gimp-geometry-tools.js +46 -14
- package/dist/tools/gimp-inspect-tools.js +108 -21
- package/dist/tools/gimp-layer-tools.js +385 -0
- package/dist/tools/gimp-shared.js +7 -0
- package/dist/tools/gimp-verify-tools.js +38 -3
- package/dist/update/check.js +2 -0
- package/dist/utils/operation-timeouts.js +9 -1
- package/dist/utils/validate.js +11 -0
- package/dist/version.js +1 -1
- package/package.json +3 -3
|
@@ -71,9 +71,6 @@ ADJUST_OPERATIONS = {
|
|
|
71
71
|
'gaussian_blur': 'gegl:gaussian-blur',
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
# The inverse of ADJUST_OPERATIONS, for a ledger record written without a `type` field.
|
|
75
|
-
OPERATION_TYPES = {operation: type_ for type_, operation in ADJUST_OPERATIONS.items()}
|
|
76
|
-
|
|
77
74
|
# Length-typed GEGL properties that must be multiplied by the preview proxy's scale factor
|
|
78
75
|
# when a filter is mirrored onto it (see ops.py's `_mirror_filters`) -- an explicit allow-list
|
|
79
76
|
# per operation, not a heuristic (e.g. "any property named radius"), since a wrong guess here
|
|
@@ -85,6 +82,12 @@ SPATIAL_SCALE_PROPS = {
|
|
|
85
82
|
'gegl:shadows-highlights': ('radius',),
|
|
86
83
|
'gegl:unsharp-mask': ('std-dev',),
|
|
87
84
|
'gegl:gaussian-blur': ('std-dev-x', 'std-dev-y'),
|
|
85
|
+
'gegl:motion-blur-linear': ('length',),
|
|
86
|
+
'gegl:focus-blur': ('blur-radius',),
|
|
87
|
+
'gegl:dropshadow': ('x', 'y', 'radius'),
|
|
88
|
+
# gegl:vignette and gegl:mono-mixer are deliberately absent: vignette's radius/x/y are
|
|
89
|
+
# proportional (fractions of the LAYER's own size, not absolute pixel lengths), and
|
|
90
|
+
# mono-mixer is a per-pixel channel-weight filter with no spatial extent at all.
|
|
88
91
|
}
|
|
89
92
|
|
|
90
93
|
# Integer-valued properties that are scaled by the proxy factor too, but as a ROUNDED count
|
|
@@ -116,6 +119,56 @@ TIFF_COMPRESSIONS = ('none', 'lzw', 'packbits', 'jpeg', 'adobe_deflate')
|
|
|
116
119
|
|
|
117
120
|
MASK_TYPES = ('rectangle', 'ellipse', 'gradient_linear', 'gradient_radial')
|
|
118
121
|
|
|
122
|
+
# gimp_layer's blend-mode allow-list: user-facing name -> the `Gimp.LayerMode` enum member NAME
|
|
123
|
+
# (a string; ops.py does `getattr(Gimp.LayerMode, LAYER_MODES[mode])` since this module stays
|
|
124
|
+
# gi-free). Deliberately the NON-legacy modes only, and only the ones with a plain Photoshop-style
|
|
125
|
+
# name -- GIMP 3.2.6 exposes ~30 more (LCH_*, *_LEGACY, DISSOLVE, BEHIND, ...) this tool doesn't
|
|
126
|
+
# surface. Probed live (GIMP 3.2.6) and goldened via each member's own `.value_nick`:
|
|
127
|
+
# normal=normal multiply=multiply screen=screen overlay=overlay soft_light=softlight
|
|
128
|
+
# hard_light=hardlight darken=darken-only lighten=lighten-only difference=difference
|
|
129
|
+
# exclusion=exclusion addition=addition subtract=subtract divide=divide dodge=dodge burn=burn
|
|
130
|
+
# hue=hsv-hue saturation=hsv-saturation color=hsl-color luminosity=luminance
|
|
131
|
+
# hue/saturation/color/luminosity map to the HSV/HSL (not LCH) variants -- those are the ones
|
|
132
|
+
# GIMP's own UI labels plainly "Hue"/"Saturation"/"Color"/"Luminosity" without a "(LCH)" suffix.
|
|
133
|
+
LAYER_MODES = {
|
|
134
|
+
'normal': 'NORMAL',
|
|
135
|
+
'multiply': 'MULTIPLY',
|
|
136
|
+
'screen': 'SCREEN',
|
|
137
|
+
'overlay': 'OVERLAY',
|
|
138
|
+
'soft_light': 'SOFTLIGHT',
|
|
139
|
+
'hard_light': 'HARDLIGHT',
|
|
140
|
+
'darken': 'DARKEN_ONLY',
|
|
141
|
+
'lighten': 'LIGHTEN_ONLY',
|
|
142
|
+
'difference': 'DIFFERENCE',
|
|
143
|
+
'exclusion': 'EXCLUSION',
|
|
144
|
+
'addition': 'ADDITION',
|
|
145
|
+
'subtract': 'SUBTRACT',
|
|
146
|
+
'divide': 'DIVIDE',
|
|
147
|
+
'dodge': 'DODGE',
|
|
148
|
+
'burn': 'BURN',
|
|
149
|
+
'hue': 'HSV_HUE',
|
|
150
|
+
'saturation': 'HSV_SATURATION',
|
|
151
|
+
'color': 'HSL_COLOR',
|
|
152
|
+
'luminosity': 'LUMINANCE',
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def validate_layer_mode(value):
|
|
157
|
+
if value not in LAYER_MODES:
|
|
158
|
+
raise ValueError('mode must be one of %s' % sorted(LAYER_MODES))
|
|
159
|
+
return value
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# gimp_layer op=create's fill options -- verified live that `Gimp.FillType.TRANSPARENT` exists
|
|
163
|
+
# and that a freshly created layer's content is otherwise undefined, so `create` always fills
|
|
164
|
+
# explicitly rather than trusting whatever `Gimp.Layer.new` leaves behind.
|
|
165
|
+
LAYER_FILLS = ('white', 'black', 'transparent')
|
|
166
|
+
|
|
167
|
+
# gimp_create_document / gimp_convert_image_mode's base-type choices -- indexed is deliberately
|
|
168
|
+
# absent (gimp_create_document has nothing to build a palette from, and gimp_convert_image_mode
|
|
169
|
+
# refuses an indexed source outright; see ops.py's own comments on both).
|
|
170
|
+
IMAGE_MODES = ('rgb', 'grayscale')
|
|
171
|
+
|
|
119
172
|
# Formats `_export_stripped` (ops.py) will write; every export/preview/compare raster save goes
|
|
120
173
|
# through it, and it refuses any other extension outright rather than falling back to a bare,
|
|
121
174
|
# metadata-unaware save.
|
|
@@ -152,6 +205,18 @@ def require_bool(args, name):
|
|
|
152
205
|
return value
|
|
153
206
|
|
|
154
207
|
|
|
208
|
+
def optional_bool(args, name, default=False):
|
|
209
|
+
"""Like `require_bool`, but returns `default` when `name` is absent or explicitly null --
|
|
210
|
+
for an opt-in flag (e.g. `discard_hidden`, `all`) rather than one the caller must always
|
|
211
|
+
supply. Still rejects a non-boolean when the field IS present, the same `bool("false")`
|
|
212
|
+
trap `require_bool` guards against."""
|
|
213
|
+
if name not in args or args[name] is None:
|
|
214
|
+
return default
|
|
215
|
+
if not isinstance(args[name], bool):
|
|
216
|
+
raise ValueError('%s must be a boolean, got %r' % (name, args[name]))
|
|
217
|
+
return args[name]
|
|
218
|
+
|
|
219
|
+
|
|
155
220
|
def validate_range(name, value, lo, hi):
|
|
156
221
|
"""Validate a float field is within [lo, hi]. Raises ValueError naming
|
|
157
222
|
`name` (which the bridge's error classifier maps to `invalid_argument`)."""
|
|
@@ -209,10 +274,82 @@ def validate_resize_dims(width, height):
|
|
|
209
274
|
return width, height
|
|
210
275
|
|
|
211
276
|
|
|
277
|
+
# gimp_create_document's own megapixel ceiling, precision-aware: MAX_RESIZE_MEGAPIXELS (250) is
|
|
278
|
+
# sized for 8-bit-per-channel content; a 16-bit image is 2 bytes/channel (half the pixel budget
|
|
279
|
+
# for the same memory footprint, 125 MP) and a 32-bit float image is 4 bytes/channel (a quarter,
|
|
280
|
+
# 60 MP -- rounded down from the exact 62.5 to a plain number). `create_document` is the one op
|
|
281
|
+
# that picks its own bit depth up front (`open`/`resize`/etc. all work on whatever precision an
|
|
282
|
+
# already-open image happens to be), so it is the one place this DoS floor needs to vary by
|
|
283
|
+
# precision rather than assuming 8-bit throughout.
|
|
284
|
+
DOCUMENT_MEGAPIXEL_CAP = {'8': MAX_RESIZE_MEGAPIXELS, '16': 125, '32': 60}
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def validate_document_dims(width, height, precision='8'):
|
|
288
|
+
"""Like `validate_resize_dims`, but the megapixel ceiling depends on `precision` ('8', '16', or
|
|
289
|
+
'32') -- see `DOCUMENT_MEGAPIXEL_CAP`'s own comment for why create_document needs its own
|
|
290
|
+
variant instead of the plain 8-bit-assuming one."""
|
|
291
|
+
if width <= 0 or height <= 0:
|
|
292
|
+
raise ValueError('width and height must be positive')
|
|
293
|
+
if width > MAX_RESIZE_SIDE_PX or height > MAX_RESIZE_SIDE_PX:
|
|
294
|
+
raise ValueError('width and height must each be at most %d px' % MAX_RESIZE_SIDE_PX)
|
|
295
|
+
cap = DOCUMENT_MEGAPIXEL_CAP[precision]
|
|
296
|
+
megapixels = (width * height) / 1_000_000.0
|
|
297
|
+
if megapixels > cap:
|
|
298
|
+
raise ValueError('a %s-bit document must be at most %d MP' % (precision, cap))
|
|
299
|
+
return width, height
|
|
300
|
+
|
|
301
|
+
|
|
212
302
|
def validate_feather_px(value):
|
|
213
303
|
return validate_range('feather_px', value, 0.0, MAX_FEATHER_PX)
|
|
214
304
|
|
|
215
305
|
|
|
306
|
+
_HEX_COLOR_RE = re.compile(r'^#[0-9a-fA-F]{6}$')
|
|
307
|
+
|
|
308
|
+
# gimp_canvas's fill choices: the same white/black/transparent LAYER_FILLS a new layer gets, plus
|
|
309
|
+
# a #rrggbb hex color the fixed enum can't express -- validated by regex here since it's an open
|
|
310
|
+
# set, not a membership check like every other choice validator in this file.
|
|
311
|
+
CANVAS_FILLS = LAYER_FILLS
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def validate_canvas_fill(value):
|
|
315
|
+
if value in CANVAS_FILLS or (isinstance(value, str) and _HEX_COLOR_RE.fullmatch(value)):
|
|
316
|
+
return value
|
|
317
|
+
raise ValueError(
|
|
318
|
+
"fill must be one of %s, or a '#rrggbb' hex color, got %r" % (sorted(CANVAS_FILLS), value)
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
# gimp_canvas's anchor grid -> the fraction of the GROWTH (new size minus old size) that lands
|
|
323
|
+
# BEFORE the existing content on each axis. 0.0 pins that edge (no padding there); 1.0 puts all
|
|
324
|
+
# the padding there instead; 0.5 splits it evenly. Keyed by the full 3x3 grid a Photoshop-style
|
|
325
|
+
# "Canvas Size" anchor picker offers.
|
|
326
|
+
_CANVAS_ANCHOR_FRACTIONS = {
|
|
327
|
+
'top_left': (0.0, 0.0), 'top_center': (0.5, 0.0), 'top_right': (1.0, 0.0),
|
|
328
|
+
'middle_left': (0.0, 0.5), 'center': (0.5, 0.5), 'middle_right': (1.0, 0.5),
|
|
329
|
+
'bottom_left': (0.0, 1.0), 'bottom_center': (0.5, 1.0), 'bottom_right': (1.0, 1.0),
|
|
330
|
+
}
|
|
331
|
+
CANVAS_ANCHORS = tuple(_CANVAS_ANCHOR_FRACTIONS)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def canvas_anchor_offset(anchor, old_width, old_height, new_width, new_height):
|
|
335
|
+
"""(offset_x, offset_y) for `Image.resize(new_width, new_height, offset_x, offset_y)` that
|
|
336
|
+
places the EXISTING old_width x old_height content at `anchor` within the new, larger canvas.
|
|
337
|
+
Pure arithmetic (gi-free) -- ops.py's `op_canvas` supplies the live image's own before/after
|
|
338
|
+
dimensions and passes the result straight to the bridge primitive."""
|
|
339
|
+
if anchor not in _CANVAS_ANCHOR_FRACTIONS:
|
|
340
|
+
raise ValueError('anchor must be one of %s' % sorted(_CANVAS_ANCHOR_FRACTIONS))
|
|
341
|
+
# Floor, not round: the growth is always >= 0 (gimp_canvas is extend-only), so floor is just
|
|
342
|
+
# `int()` truncation here -- picked over `round()` because Python's round() is round-half-to-
|
|
343
|
+
# EVEN (banker's rounding), which would silently flip which side of an odd split gets the
|
|
344
|
+
# extra pixel depending on whether that half-pixel count happens to be even or odd. floor()
|
|
345
|
+
# always gives the same, simpler rule: the leading edge (top/left) gets the smaller share.
|
|
346
|
+
fx, fy = _CANVAS_ANCHOR_FRACTIONS[anchor]
|
|
347
|
+
return (
|
|
348
|
+
math.floor((new_width - old_width) * fx),
|
|
349
|
+
math.floor((new_height - old_height) * fy),
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
|
|
216
353
|
def pct_to_unit(name, value, lo=-100.0, hi=100.0):
|
|
217
354
|
"""User-facing -100..100 (or a narrower lo..hi) percent-like value -> the -1..1 unit
|
|
218
355
|
GEGL/GIMP properties in this family use."""
|
|
@@ -449,6 +586,213 @@ ADJUST_CREATE_DEFAULTS = {
|
|
|
449
586
|
}
|
|
450
587
|
|
|
451
588
|
|
|
589
|
+
# ---- gimp_add_effect: allow-listed GEGL effect filters --------------------------------------
|
|
590
|
+
#
|
|
591
|
+
# The parallel tables to ADJUST_OPERATIONS/ADJUST_PARAM_BUILDERS/ADJUST_CREATE_DEFAULTS above,
|
|
592
|
+
# for `gimp_add_effect`'s `type` field rather than `gimp_add_adjustment`'s `type` field -- a
|
|
593
|
+
# DIFFERENT bridge op (`effect`, dispatched to `op_effect` in ops.py -- gimp_add_effect is its
|
|
594
|
+
# own tool, tier 'dev', a sibling of gimp_add_adjustment rather than an op on gimp_filter), so
|
|
595
|
+
# these stay separate dicts rather than merged into the ADJUST_* ones; only the GEGL-property
|
|
596
|
+
# SETTERS functions in ops.py are shared across both (an operation name is an operation name
|
|
597
|
+
# regardless of which tool created the filter).
|
|
598
|
+
#
|
|
599
|
+
# Deliberately excludes gegl:gaussian-blur (already ADJUST_OPERATIONS' `gaussian_blur`, see
|
|
600
|
+
# gimp-adjustment-tools.ts) and gegl:c2g: c2g excluded, ~35s full-res export at 24 MP (measured
|
|
601
|
+
# live), over the ~30s budget -- does not ship here.
|
|
602
|
+
EFFECT_OPERATIONS = {
|
|
603
|
+
'vignette': 'gegl:vignette',
|
|
604
|
+
'black_white': 'gegl:mono-mixer',
|
|
605
|
+
'motion_blur': 'gegl:motion-blur-linear',
|
|
606
|
+
# gegl:focus-blur, NOT gegl:lens-blur -- see build_lens_blur_params' own comment: lens-blur
|
|
607
|
+
# exists in this GIMP's GEGL, but GIMP itself refuses to attach it as a non-destructive
|
|
608
|
+
# DrawableFilter (an 'aux'-pad operation), so it is unusable in this bridge's architecture.
|
|
609
|
+
'lens_blur': 'gegl:focus-blur',
|
|
610
|
+
'add_noise': 'gegl:noise-rgb',
|
|
611
|
+
'drop_shadow': 'gegl:dropshadow',
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
# The inverse of ADJUST_OPERATIONS and EFFECT_OPERATIONS together, for a ledger record written
|
|
615
|
+
# without a `type` field -- one shared map, since `op_list_filters` looks a record's operation up
|
|
616
|
+
# here regardless of which tool (gimp_add_adjustment or gimp_add_effect) created it.
|
|
617
|
+
OPERATION_TYPES = {operation: type_ for type_, operation in ADJUST_OPERATIONS.items()}
|
|
618
|
+
OPERATION_TYPES.update({operation: type_ for type_, operation in EFFECT_OPERATIONS.items()})
|
|
619
|
+
|
|
620
|
+
# Every validate_range/validate_int_range call below uses `val`, not `v`, as its lambda's
|
|
621
|
+
# parameter name -- deliberately, so it is NOT matched by gimp-adjustment-tools.test.ts's own
|
|
622
|
+
# `parseLibPyBounds` regex (which requires the literal substring ", v,"). That regex scans this
|
|
623
|
+
# WHOLE file, not just ADJUST_PARAM_BUILDERS, so an accidental match here would silently fold a
|
|
624
|
+
# filter-effect bound into an unrelated adjust field's drift check (e.g. sharpen's `amount`) --
|
|
625
|
+
# `val` keeps the two files' schema-bounds-drift tests fully decoupled.
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def build_vignette_params(args, defaults):
|
|
629
|
+
# Proportional/scale-invariant, relative to the LAYER's own extent (the drawable this filter
|
|
630
|
+
# attaches to, not the document canvas -- every GEGL op here runs per-drawable): x/y are a
|
|
631
|
+
# fraction of width/height. `radius`'s own reference measured live as NOT a single clean
|
|
632
|
+
# formula (e.g. a plain half-diagonal or half-width) across every aspect ratio tried -- rather
|
|
633
|
+
# than assert a specific geometric claim this bridge can't fully verify, the description
|
|
634
|
+
# instead states the one thing confirmed true and load-bearing for SPATIAL_SCALE_PROPS: it is
|
|
635
|
+
# relative, not absolute pixels, so the same value looks visually equivalent at any
|
|
636
|
+
# resolution. Unlike the spatial effects below, nothing here needs a SPATIAL_SCALE_PROPS entry
|
|
637
|
+
# -- the same value renders correctly on the preview proxy as at full resolution.
|
|
638
|
+
return {
|
|
639
|
+
'radius': resolve_field(
|
|
640
|
+
args, 'radius', defaults, 'radius', lambda val: validate_range('radius', val, 0.0, 3.0)
|
|
641
|
+
),
|
|
642
|
+
'softness': resolve_field(
|
|
643
|
+
args, 'softness', defaults, 'softness',
|
|
644
|
+
lambda val: validate_range('softness', val, 0.0, 1.0),
|
|
645
|
+
),
|
|
646
|
+
'gamma': resolve_field(
|
|
647
|
+
args, 'gamma', defaults, 'gamma', lambda val: validate_range('gamma', val, 0.1, 10.0)
|
|
648
|
+
),
|
|
649
|
+
'x': resolve_field(
|
|
650
|
+
args, 'center_x', defaults, 'x', lambda val: validate_range('center_x', val, 0.0, 1.0)
|
|
651
|
+
),
|
|
652
|
+
'y': resolve_field(
|
|
653
|
+
args, 'center_y', defaults, 'y', lambda val: validate_range('center_y', val, 0.0, 1.0)
|
|
654
|
+
),
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def build_black_white_params(args, defaults):
|
|
659
|
+
# `preserve_luminosity`: when True, GEGL rescales the weighted sum so the output matches the
|
|
660
|
+
# ORIGINAL pixel's brightness even when the three weights don't sum to 1 -- verified live, a
|
|
661
|
+
# flat 128-gray input stays 128 with weights (0.1, 0.1, 0.1) and preserve=True, but comes back
|
|
662
|
+
# darker (measured 0x48) with the same weights and preserve=False. Weights summing to exactly
|
|
663
|
+
# 0 (e.g. 0,0,0) do NOT divide by zero or produce NaN either way -- verified live, GEGL returns
|
|
664
|
+
# plain black -- so no extra guard is needed here for that case.
|
|
665
|
+
return {
|
|
666
|
+
'red': resolve_field(
|
|
667
|
+
args, 'red_weight', defaults, 'red',
|
|
668
|
+
lambda val: validate_range('red_weight', val, -5.0, 5.0),
|
|
669
|
+
),
|
|
670
|
+
'green': resolve_field(
|
|
671
|
+
args, 'green_weight', defaults, 'green',
|
|
672
|
+
lambda val: validate_range('green_weight', val, -5.0, 5.0),
|
|
673
|
+
),
|
|
674
|
+
'blue': resolve_field(
|
|
675
|
+
args, 'blue_weight', defaults, 'blue',
|
|
676
|
+
lambda val: validate_range('blue_weight', val, -5.0, 5.0),
|
|
677
|
+
),
|
|
678
|
+
# require_bool, not a bare `bool()` cast (bool("false") is True in Python -- any
|
|
679
|
+
# non-empty string is truthy) -- resolve_field's own convert callback only ever sees the
|
|
680
|
+
# raw VALUE, not (args, name), so the merge-not-reset fallback is inlined here instead of
|
|
681
|
+
# going through resolve_field for this one field.
|
|
682
|
+
'preserve-luminosity': (
|
|
683
|
+
require_bool(args, 'preserve_luminosity')
|
|
684
|
+
if 'preserve_luminosity' in args
|
|
685
|
+
else defaults['preserve-luminosity']
|
|
686
|
+
),
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def build_motion_blur_params(args, defaults):
|
|
691
|
+
return {
|
|
692
|
+
'length': resolve_field(
|
|
693
|
+
args, 'length', defaults, 'length', lambda val: validate_range('length', val, 0.0, 1000.0)
|
|
694
|
+
),
|
|
695
|
+
'angle': resolve_field(
|
|
696
|
+
args, 'angle', defaults, 'angle', lambda val: validate_range('angle', val, -180.0, 180.0)
|
|
697
|
+
),
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def build_lens_blur_params(args, defaults):
|
|
702
|
+
# gegl:lens-blur (GIMP 3.2's GEGL does ship it) turned out unusable here -- verified live,
|
|
703
|
+
# `gimp-drawable-append-filter` refuses it outright ("effects with an 'aux' pad cannot be
|
|
704
|
+
# applied non-destructively"), so every call would silently attach nothing (no exception, no
|
|
705
|
+
# ledger record, filter count staying 0). gegl:focus-blur DOES attach; its blur amount
|
|
706
|
+
# property is named `blur-radius`, not `radius` (the external field name here stays `radius`
|
|
707
|
+
# regardless -- see EFFECT_CREATE_DEFAULTS). Two more properties are forced in ops.py's
|
|
708
|
+
# `_set_lens_blur`, not part of this dict and not user-configurable:
|
|
709
|
+
# - `radius` (an unrelated, same-named property sizing an in-focus zone that never blurs) is
|
|
710
|
+
# forced to 0, so the effect is a uniform blur, not a tilt-shift one.
|
|
711
|
+
# - `blur-type` is forced to 'lens' (its own default is 'gaussian', which would make this a
|
|
712
|
+
# second, redundant gaussian_blur and leave `highlight_factor` inert -- verified live:
|
|
713
|
+
# 'lens' mode produces a real bokeh highlight boost, a bright spot's halo reaching ~3x
|
|
714
|
+
# farther out at highlight_factor 1 vs 0, and 'lens' mode DOES attach as a non-destructive
|
|
715
|
+
# filter despite being the more elaborate mode -- the 'aux'-pad restriction that sank
|
|
716
|
+
# gegl:lens-blur does not apply inside this meta-operation).
|
|
717
|
+
# `blur-radius`'s cap is lowered from the union's 1500 to 150: 'lens' mode measured live as
|
|
718
|
+
# much more expensive than a separable blur (~35s for a full-res 24 MP export at radius 300,
|
|
719
|
+
# ~17s at 150), so the cap keeps the worst case comfortably under the ~30s budget every
|
|
720
|
+
# effect here is held to.
|
|
721
|
+
return {
|
|
722
|
+
'blur-radius': resolve_field(
|
|
723
|
+
args, 'radius', defaults, 'blur-radius',
|
|
724
|
+
lambda val: validate_range('radius', val, 0.0, 150.0),
|
|
725
|
+
),
|
|
726
|
+
'highlight-factor': resolve_field(
|
|
727
|
+
args, 'highlight_factor', defaults, 'highlight-factor',
|
|
728
|
+
lambda val: validate_range('highlight_factor', val, 0.0, 1.0),
|
|
729
|
+
),
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def build_add_noise_params(args, defaults):
|
|
734
|
+
# One user-facing `noise_amount` drives red/green/blue uniformly, the same "one field, several
|
|
735
|
+
# identical GEGL properties" idiom `build_gaussian_blur_params` uses for std-dev-x/std-dev-y.
|
|
736
|
+
amount = resolve_field(
|
|
737
|
+
args, 'noise_amount', defaults, 'red',
|
|
738
|
+
lambda val: validate_range('noise_amount', val, 0.0, 1.0),
|
|
739
|
+
)
|
|
740
|
+
return {
|
|
741
|
+
'red': amount,
|
|
742
|
+
'green': amount,
|
|
743
|
+
'blue': amount,
|
|
744
|
+
'alpha': resolve_field(
|
|
745
|
+
args, 'alpha', defaults, 'alpha', lambda val: validate_range('alpha', val, 0.0, 1.0)
|
|
746
|
+
),
|
|
747
|
+
'seed': resolve_field(
|
|
748
|
+
args, 'seed', defaults, 'seed',
|
|
749
|
+
lambda val: validate_int_range('seed', val, 0, 4294967295),
|
|
750
|
+
),
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def build_drop_shadow_params(args, defaults):
|
|
755
|
+
# Only meaningful on a layer with an alpha channel -- a shadow is cast from what's transparent
|
|
756
|
+
# around the opaque content; on a fully opaque layer there is nothing for it to show through.
|
|
757
|
+
return {
|
|
758
|
+
'x': resolve_field(
|
|
759
|
+
args, 'offset_x', defaults, 'x', lambda val: validate_range('offset_x', val, -500.0, 500.0)
|
|
760
|
+
),
|
|
761
|
+
'y': resolve_field(
|
|
762
|
+
args, 'offset_y', defaults, 'y', lambda val: validate_range('offset_y', val, -500.0, 500.0)
|
|
763
|
+
),
|
|
764
|
+
'radius': resolve_field(
|
|
765
|
+
args, 'radius', defaults, 'radius', lambda val: validate_range('radius', val, 0.0, 1500.0)
|
|
766
|
+
),
|
|
767
|
+
'opacity': resolve_field(
|
|
768
|
+
args, 'opacity', defaults, 'opacity', lambda val: validate_range('opacity', val, 0.0, 1.0)
|
|
769
|
+
),
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
EFFECT_PARAM_BUILDERS = {
|
|
774
|
+
'vignette': build_vignette_params,
|
|
775
|
+
'black_white': build_black_white_params,
|
|
776
|
+
'motion_blur': build_motion_blur_params,
|
|
777
|
+
'lens_blur': build_lens_blur_params,
|
|
778
|
+
'add_noise': build_add_noise_params,
|
|
779
|
+
'drop_shadow': build_drop_shadow_params,
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
# Creation-time defaults, already in GEGL-property units -- probed live via `describe_operation`
|
|
783
|
+
# against GIMP 3.2.6's real GEGL pspecs, except vignette's radius and softness: GEGL's own 1.2/0.8
|
|
784
|
+
# renders the corners black, so these are chosen for a subtle vignette (corners about a quarter
|
|
785
|
+
# darker, measured on real photos).
|
|
786
|
+
EFFECT_CREATE_DEFAULTS = {
|
|
787
|
+
'vignette': {'radius': 2.0, 'softness': 1.0, 'gamma': 2.0, 'x': 0.5, 'y': 0.5},
|
|
788
|
+
'black_white': {'red': 0.333, 'green': 0.333, 'blue': 0.333, 'preserve-luminosity': False},
|
|
789
|
+
'motion_blur': {'length': 10.0, 'angle': 0.0},
|
|
790
|
+
'lens_blur': {'blur-radius': 25.0, 'highlight-factor': 0.0},
|
|
791
|
+
'add_noise': {'red': 0.2, 'green': 0.2, 'blue': 0.2, 'alpha': 0.0, 'seed': 0},
|
|
792
|
+
'drop_shadow': {'x': 20.0, 'y': 20.0, 'radius': 10.0, 'opacity': 0.5},
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
|
|
452
796
|
def _scaled(factor):
|
|
453
797
|
# Rounded so a GEGL value that went through a /100 or /180 on the way in reads back as the
|
|
454
798
|
# number the caller typed (0.2 * 100 is 20.000000000000004 in binary floating point). A value
|
|
@@ -512,11 +856,73 @@ USER_FIELDS = {
|
|
|
512
856
|
),
|
|
513
857
|
'noise_reduction': (('strength', 'iterations', _same),),
|
|
514
858
|
'gaussian_blur': (('radius', 'std-dev-x', _same),),
|
|
859
|
+
# ---- gimp_add_effect's effects (EFFECT_OPERATIONS, not ADJUST_OPERATIONS) ------------------
|
|
860
|
+
'vignette': (
|
|
861
|
+
('radius', 'radius', _same),
|
|
862
|
+
('softness', 'softness', _same),
|
|
863
|
+
('gamma', 'gamma', _same),
|
|
864
|
+
('center_x', 'x', _same),
|
|
865
|
+
('center_y', 'y', _same),
|
|
866
|
+
),
|
|
867
|
+
'black_white': (
|
|
868
|
+
('red_weight', 'red', _same),
|
|
869
|
+
('green_weight', 'green', _same),
|
|
870
|
+
('blue_weight', 'blue', _same),
|
|
871
|
+
('preserve_luminosity', 'preserve-luminosity', _same),
|
|
872
|
+
),
|
|
873
|
+
'motion_blur': (
|
|
874
|
+
('length', 'length', _same),
|
|
875
|
+
('angle', 'angle', _same),
|
|
876
|
+
),
|
|
877
|
+
'lens_blur': (
|
|
878
|
+
('radius', 'blur-radius', _same),
|
|
879
|
+
('highlight_factor', 'highlight-factor', _same),
|
|
880
|
+
),
|
|
881
|
+
'add_noise': (
|
|
882
|
+
('noise_amount', 'red', _same),
|
|
883
|
+
('alpha', 'alpha', _same),
|
|
884
|
+
('seed', 'seed', _same),
|
|
885
|
+
),
|
|
886
|
+
'drop_shadow': (
|
|
887
|
+
('offset_x', 'x', _same),
|
|
888
|
+
('offset_y', 'y', _same),
|
|
889
|
+
('radius', 'radius', _same),
|
|
890
|
+
('opacity', 'opacity', _same),
|
|
891
|
+
),
|
|
515
892
|
}
|
|
516
893
|
|
|
517
894
|
CURVES_USER_FIELDS = ('channel', 'points')
|
|
518
895
|
LEVELS_USER_FIELDS = ('channel', 'in_low', 'in_high', 'gamma', 'out_low', 'out_high')
|
|
519
896
|
|
|
897
|
+
# Keys every adjust/effect call may carry whatever its type: addressing, identity, and the mask.
|
|
898
|
+
FILTER_COMMON_KEYS = frozenset(('image', 'type', 'layer', 'layer_id', 'filter_id', 'mask', 'name'))
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def type_fields(type_):
|
|
902
|
+
"""The tool-facing field names `type_`'s builder reads (same tables `user_params` reports)."""
|
|
903
|
+
if type_ == 'curves':
|
|
904
|
+
return CURVES_USER_FIELDS
|
|
905
|
+
if type_ == 'levels':
|
|
906
|
+
return LEVELS_USER_FIELDS
|
|
907
|
+
return tuple(user for user, _gegl, _convert in USER_FIELDS.get(type_, ()))
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def reject_foreign_fields(type_, args):
|
|
911
|
+
"""Refuse any field `type_` does not read. The tool schemas are flat (one property per name
|
|
912
|
+
across every type), so without this a field meant for another type -- `saturation` on type
|
|
913
|
+
`saturation`, whose knob is `scale` -- validates, is ignored by the builder, and creates a
|
|
914
|
+
filter that does nothing. None values are ignored (an omitted field)."""
|
|
915
|
+
own = type_fields(type_)
|
|
916
|
+
foreign = sorted(
|
|
917
|
+
k for k, v in args.items()
|
|
918
|
+
if v is not None and k not in FILTER_COMMON_KEYS and k not in own
|
|
919
|
+
)
|
|
920
|
+
if foreign:
|
|
921
|
+
raise ValueError(
|
|
922
|
+
"type '%s' does not use field(s) %s; its fields are: %s"
|
|
923
|
+
% (type_, ', '.join(foreign), ', '.join(own) or '(none)')
|
|
924
|
+
)
|
|
925
|
+
|
|
520
926
|
|
|
521
927
|
def user_params(type_, params):
|
|
522
928
|
"""A ledger record's `params` in the tool's own field names and units -- what `list` reports
|
|
@@ -536,8 +942,9 @@ def user_params(type_, params):
|
|
|
536
942
|
|
|
537
943
|
def json_safe(value):
|
|
538
944
|
"""A readback property value made JSON-serialisable: plain JSON scalars pass through, lists
|
|
539
|
-
and dicts are walked,
|
|
540
|
-
str(). One foreign filter with such a property must not fail
|
|
945
|
+
and dicts are walked, a GeglColor becomes its [r, g, b, a] (see below), and anything else (a
|
|
946
|
+
path object, bytes) becomes its str(). One foreign filter with such a property must not fail
|
|
947
|
+
`list` for the whole image."""
|
|
541
948
|
if isinstance(value, float) and not math.isfinite(value):
|
|
542
949
|
return str(value) # JSON has no NaN/Infinity; Node's JSON.parse rejects them
|
|
543
950
|
if value is None or isinstance(value, (bool, int, float, str)):
|
|
@@ -546,6 +953,19 @@ def json_safe(value):
|
|
|
546
953
|
return [json_safe(v) for v in value]
|
|
547
954
|
if isinstance(value, dict):
|
|
548
955
|
return {str(k): json_safe(v) for k, v in value.items()}
|
|
956
|
+
# lib.py is gi-free (no `import gi`/`Gegl`), so a GeglColor can only be recognized by duck
|
|
957
|
+
# type, not `isinstance` -- the same reproducibility problem `op_describe_operation`'s own
|
|
958
|
+
# GeglColor handling exists to fix (a bare str() embeds a live object pointer address that
|
|
959
|
+
# differs every run, e.g. a foreign vignette/dropshadow filter's `color` property read back
|
|
960
|
+
# through `op_list_filters`), solved the same way: `get_rgba()` is the stable, meaningful
|
|
961
|
+
# summary. Any other object that merely happens to expose a zero-arg `get_rgba()` would be
|
|
962
|
+
# vanishingly unlikely and still degrades safely to a 4-number list.
|
|
963
|
+
get_rgba = getattr(value, 'get_rgba', None)
|
|
964
|
+
if callable(get_rgba):
|
|
965
|
+
try:
|
|
966
|
+
return [json_safe(c) for c in get_rgba()]
|
|
967
|
+
except Exception:
|
|
968
|
+
pass
|
|
549
969
|
return str(value)
|
|
550
970
|
|
|
551
971
|
# The only operations `describe_operation` will probe -- an allow-list, not "any GEGL/GIMP
|
|
@@ -553,7 +973,7 @@ def json_safe(value):
|
|
|
553
973
|
# and an unbounded operation name is an unnecessary surface (arbitrary-op instantiation, error
|
|
554
974
|
# text from GIMP's own PDB) for a probe whose only real job is confirming the schema of the
|
|
555
975
|
# operations this engine actually uses.
|
|
556
|
-
ALLOWED_DESCRIBE_OPERATIONS = frozenset(ADJUST_OPERATIONS.values())
|
|
976
|
+
ALLOWED_DESCRIBE_OPERATIONS = frozenset(ADJUST_OPERATIONS.values()) | frozenset(EFFECT_OPERATIONS.values())
|
|
557
977
|
|
|
558
978
|
|
|
559
979
|
# ---- geometry-op masked-filter detection (pure logic; ops.py supplies the live GIMP state) -----
|
|
@@ -585,11 +1005,27 @@ def classify_geometry_filters(filters, live_filters):
|
|
|
585
1005
|
reported `unverifiable`: it might be masked, and there is no way to tell, so the caller must
|
|
586
1006
|
treat it as if it were.
|
|
587
1007
|
|
|
1008
|
+
A name seen on MORE THAN ONE live filter is unverifiable too, regardless of what the ledger
|
|
1009
|
+
says: the ledger's {name: record} shape can only ever describe ONE of them, so a lookup by
|
|
1010
|
+
name can silently answer for the wrong filter -- a masked one hiding behind an unmasked one's
|
|
1011
|
+
ledger record (or the reverse) would otherwise slip through unnoticed. This bridge's own
|
|
1012
|
+
create path never produces a duplicate (`_unique_name` checks every layer first), but nothing
|
|
1013
|
+
stops a foreign filter (the GUI, a hand-edited document) from colliding with one this bridge
|
|
1014
|
+
DID create.
|
|
1015
|
+
|
|
588
1016
|
Returns (masked_names, unverifiable_names), both lists, in the order `live_filters` was
|
|
589
|
-
given
|
|
1017
|
+
given (a duplicate name appears in `unverifiable` once per occurrence, not de-duplicated, so
|
|
1018
|
+
the caller's own count of what it iterated still lines up)."""
|
|
1019
|
+
live_filters = list(live_filters)
|
|
1020
|
+
name_counts = {}
|
|
1021
|
+
for name, _operation in live_filters:
|
|
1022
|
+
name_counts[name] = name_counts.get(name, 0) + 1
|
|
590
1023
|
masked = []
|
|
591
1024
|
unverifiable = []
|
|
592
1025
|
for name, operation in live_filters:
|
|
1026
|
+
if name_counts[name] > 1:
|
|
1027
|
+
unverifiable.append(name)
|
|
1028
|
+
continue
|
|
593
1029
|
rec = filters.get(name)
|
|
594
1030
|
if rec and rec.get('operation') == operation:
|
|
595
1031
|
if rec.get('params', {}).get('mask'):
|
|
@@ -599,6 +1035,329 @@ def classify_geometry_filters(filters, live_filters):
|
|
|
599
1035
|
return masked, unverifiable
|
|
600
1036
|
|
|
601
1037
|
|
|
1038
|
+
def unique_name(taken, base):
|
|
1039
|
+
"""`base`, or `base` suffixed " 2", " 3", ... until it is not in `taken` (an iterable of
|
|
1040
|
+
already-used names). GIMP allows duplicate names for both filters and layers, so nothing on
|
|
1041
|
+
the GIMP side stops a caller who doesn't check first -- this is what backs the "unique names
|
|
1042
|
+
enforced" contract for both the filter ledger (ops.py's `_unique_name`) and `gimp_layer`'s own
|
|
1043
|
+
layer naming (`_unique_layer_name`)."""
|
|
1044
|
+
taken = set(taken)
|
|
1045
|
+
name, n = base, 2
|
|
1046
|
+
while name in taken:
|
|
1047
|
+
name, n = '%s %d' % (base, n), n + 1
|
|
1048
|
+
return name
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
# ---- geometry transforms for direction/position-dependent EFFECT params ---------------------
|
|
1052
|
+
#
|
|
1053
|
+
# Scoped to gimp_add_effect's new effects only: vignette (center_x/center_y), motion_blur
|
|
1054
|
+
# (angle), and drop_shadow (offset_x/offset_y, radius) have params whose MEANING is tied to a
|
|
1055
|
+
# direction or a position, which flip/rotate/resize can silently misalign relative to the
|
|
1056
|
+
# content unless the param itself is transformed the same way the pixels were. black_white and
|
|
1057
|
+
# add_noise are pure per-pixel operations (no direction or position at all) -- neither needs a
|
|
1058
|
+
# flip/rotate entry, and both are simply absent from those two tables below. lens_blur is a
|
|
1059
|
+
# symmetric radial blur (no direction either) but DOES scale under resize (see
|
|
1060
|
+
# resize_effect_params). Existing gimp_add_adjustment types (gaussian_blur, sharpen,
|
|
1061
|
+
# shadows_highlights) are NOT covered here -- a deliberate scope cut, not an oversight.
|
|
1062
|
+
#
|
|
1063
|
+
# ONLY exact cases are supported: flip (horizontal/vertical) for all three; rotate by an exact
|
|
1064
|
+
# right angle (0/90/180/270, mod 360) for all three; any uniform or anisotropic resize. Rotating
|
|
1065
|
+
# by anything else while one of these effects is present is REFUSED outright by ops.py's
|
|
1066
|
+
# `op_rotate` (see `is_right_angle_degrees`) rather than silently approximated -- vignette's own
|
|
1067
|
+
# param is a fraction of its LAYER's own extent (see build_vignette_params), and a layer's own
|
|
1068
|
+
# bounding box only has a well-defined "old vs new size" relationship at a right angle (swap at
|
|
1069
|
+
# 90/270, unchanged at 0/180); at an arbitrary angle a rotated rectangle's bounding box depends on
|
|
1070
|
+
# more than just the old width/height, so there is no single honest formula for the layer's own
|
|
1071
|
+
# new extent. Refusing is simpler and more honest than a formula that would be exact for two of
|
|
1072
|
+
# the three effects and wrong for the third.
|
|
1073
|
+
#
|
|
1074
|
+
# ops.py calls these BEFORE mutating the image at all (`_snapshot_effect_transform`): every
|
|
1075
|
+
# affected filter's new params are computed from a SNAPSHOT of the ledger and validated against
|
|
1076
|
+
# this bridge's own field ranges (`validate_effect_transform`) first, and the WHOLE geometry op is
|
|
1077
|
+
# refused up front if anything would land out of range -- only once every filter's new params are
|
|
1078
|
+
# known-valid does the geometry mutation itself run, followed by pushing the precomputed values
|
|
1079
|
+
# into the live GEGL config and the ledger. This also happens only once `_refuse_if_masked_
|
|
1080
|
+
# filters` has already confirmed every live filter is unmasked and ledgered.
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
def _wrap_angle_deg(angle):
|
|
1084
|
+
"""Wrap to the (-180, 180] range validate_range enforces for every `angle` field here."""
|
|
1085
|
+
wrapped = ((angle + 180.0) % 360.0) - 180.0
|
|
1086
|
+
if wrapped <= -180.0:
|
|
1087
|
+
wrapped += 360.0
|
|
1088
|
+
return wrapped
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
def is_right_angle_degrees(degrees, tolerance=1e-6):
|
|
1092
|
+
"""True when `degrees` is 0/90/180/270 (mod 360, either sign) within `tolerance` -- the only
|
|
1093
|
+
rotations op_rotate allows while a position/direction-dependent effect (vignette, motion_blur,
|
|
1094
|
+
drop_shadow) is present. See this section's own comment for why any other angle is refused
|
|
1095
|
+
rather than approximated."""
|
|
1096
|
+
normalized = degrees % 90.0
|
|
1097
|
+
return normalized < tolerance or normalized > 90.0 - tolerance
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
def _dims_after_right_angle_rotation(width, height, degrees):
|
|
1101
|
+
"""The new (width, height) of a rectangle rotated by an exact right angle about its own
|
|
1102
|
+
center: 90/270 (mod 360) swap the two; 0/180 leave them as they were. Only ever called with a
|
|
1103
|
+
`degrees` `is_right_angle_degrees` has already confirmed."""
|
|
1104
|
+
normalized = degrees % 180.0
|
|
1105
|
+
if abs(normalized - 90.0) < 1e-6:
|
|
1106
|
+
return height, width
|
|
1107
|
+
return width, height
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
def _right_angle_k(degrees):
|
|
1111
|
+
"""`degrees` as a whole number of 90-degree clockwise steps, 0-3. `is_right_angle_degrees`
|
|
1112
|
+
only confirms `degrees` is within floating-point TOLERANCE of an exact multiple of 90 (e.g.
|
|
1113
|
+
89.9999999 or 90.0000003 both pass) -- rounding to the nearest integer multiple here snaps it
|
|
1114
|
+
to the exact canonical step before any trig happens, so that tolerance-level noise can never
|
|
1115
|
+
reach `_right_angle_cos_sin`."""
|
|
1116
|
+
return round(degrees / 90.0) % 4
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
def _right_angle_cos_sin(degrees):
|
|
1120
|
+
"""Exact cos/sin for a right-angle rotation, looked up from {0, 1, -1} rather than computed
|
|
1121
|
+
via `math.cos`/`math.sin(math.radians(...))` -- the latter returns a tiny nonzero (e.g.
|
|
1122
|
+
6.123233995736766e-17) for cos(90 degrees) instead of an exact 0.0. Left uncorrected, that
|
|
1123
|
+
noise propagates into a value that should land EXACTLY on its pre-rotation number (a vignette
|
|
1124
|
+
center at a canvas edge, 0.0 or 1.0; a drop shadow offset at the schema's own bound, +/-500),
|
|
1125
|
+
and `gimp_filter op=list` would report something like 4.999999999999999e-17 or
|
|
1126
|
+
500.00000000000006 instead of a clean value -- and, worse, could push a value that started
|
|
1127
|
+
exactly AT a validated bound just outside it."""
|
|
1128
|
+
k = _right_angle_k(degrees)
|
|
1129
|
+
cos_by_k = (1.0, 0.0, -1.0, 0.0)
|
|
1130
|
+
sin_by_k = (0.0, 1.0, 0.0, -1.0)
|
|
1131
|
+
return cos_by_k[k], sin_by_k[k]
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
# How many decimal places a transformed float is rounded to before it is validated or written to
|
|
1135
|
+
# the ledger -- defence in depth alongside the exact right-angle trig above: resize's sqrt/hypot
|
|
1136
|
+
# math has genuine (much smaller) irrational rounding noise of its own that no lookup table can
|
|
1137
|
+
# remove, and this keeps every transform's output equally clean rather than exact-only for
|
|
1138
|
+
# rotation and merely "close" for resize.
|
|
1139
|
+
_TRANSFORM_ROUND_NDIGITS = 9
|
|
1140
|
+
|
|
1141
|
+
|
|
1142
|
+
def _round_transform_value(value):
|
|
1143
|
+
"""A single transformed float, rounded to `_TRANSFORM_ROUND_NDIGITS` places. Applied ONLY to
|
|
1144
|
+
the specific field(s) a flip_effect_params/rotate_effect_params/resize_effect_params branch
|
|
1145
|
+
below actually computes -- never blanket-applied to a whole params dict. A value this module
|
|
1146
|
+
never touches (every gimp_add_adjustment type's own fields, since none of them are in this
|
|
1147
|
+
table; black_white/add_noise's fields; the other two effects' fields when only one of the
|
|
1148
|
+
three is present) must come back bit-for-bit IDENTICAL to its input: `_snapshot_effect_
|
|
1149
|
+
transform`'s own `new_params == params` check is what decides whether a filter needs
|
|
1150
|
+
re-applying at all, and rounding a value that was never actually touched would manufacture a
|
|
1151
|
+
spurious "this filter changed" for it -- re-setting, re-ledgering, and (if the live update ever
|
|
1152
|
+
failed) wrongly reporting it in `effect_update_failures`, on every single flip/rotate/resize
|
|
1153
|
+
regardless of what it actually did. hue_saturation's own `hue` (stored as degrees/180, a
|
|
1154
|
+
non-terminating binary fraction for most degree values) is exactly the kind of value a
|
|
1155
|
+
blanket, whole-dict round would perturb in its 9th-10th decimal place -- already enough for
|
|
1156
|
+
`==` to call it "changed" even though nothing about it should have moved at all."""
|
|
1157
|
+
return round(value, _TRANSFORM_ROUND_NDIGITS)
|
|
1158
|
+
|
|
1159
|
+
|
|
1160
|
+
def rotate_point_fraction(x_frac, y_frac, degrees, old_width, old_height, new_width, new_height):
|
|
1161
|
+
"""Rotate a point expressed as a FRACTION of a layer's own width/height (vignette's center_x/
|
|
1162
|
+
center_y) by `degrees` -- always an exact right angle, see `is_right_angle_degrees` -- using
|
|
1163
|
+
the exact same clockwise-positive convention `ops.py`'s `op_rotate` applies to layer/channel
|
|
1164
|
+
pixels via `Item.transform_rotate`, re-expressing the result as a fraction of the
|
|
1165
|
+
possibly-different NEW extent (old/new either match or swap at a right angle).
|
|
1166
|
+
|
|
1167
|
+
Rotating a rectangle around its own center always leaves the new bounding box centered at
|
|
1168
|
+
that SAME physical point, so the old and new centers coincide regardless of whether the
|
|
1169
|
+
dimensions swapped -- the old center (in old-extent coordinates) and the new center (in
|
|
1170
|
+
new-extent coordinates) name the identical point in space, which is what lets this convert
|
|
1171
|
+
between the two coordinate frames with no separate translation term. Uses the EXACT cos/sin
|
|
1172
|
+
lookup (`_right_angle_cos_sin`), not raw trig, so a point already at an extent's edge (0.0 or
|
|
1173
|
+
1.0) lands exactly back on an edge rather than a hair off it."""
|
|
1174
|
+
old_cx, old_cy = old_width / 2.0, old_height / 2.0
|
|
1175
|
+
new_cx, new_cy = new_width / 2.0, new_height / 2.0
|
|
1176
|
+
dx = x_frac * old_width - old_cx
|
|
1177
|
+
dy = y_frac * old_height - old_cy
|
|
1178
|
+
cos_t, sin_t = _right_angle_cos_sin(degrees)
|
|
1179
|
+
rx = dx * cos_t - dy * sin_t
|
|
1180
|
+
ry = dx * sin_t + dy * cos_t
|
|
1181
|
+
return (new_cx + rx) / new_width, (new_cy + ry) / new_height
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
def flip_effect_params(operation, params, orientation):
|
|
1185
|
+
"""New params for a ledgered effect filter's `operation` after a flip along `orientation`
|
|
1186
|
+
('horizontal' or 'vertical'), so the effect stays locked to the content instead of the raw
|
|
1187
|
+
pixel grid. Verified live: vignette's `x`/`y` are plain image-pixel-convention fractions (0 =
|
|
1188
|
+
left/top edge, increasing right/down); motion-blur-linear's `angle` is 0 = along +x
|
|
1189
|
+
(horizontal), 90 = along +y (vertical, i.e. downward), increasing CLOCKWISE -- the same sense
|
|
1190
|
+
`op_rotate` uses; dropshadow's `x`/`y` are plain pixel offsets (positive = right/down).
|
|
1191
|
+
Operations with no direction-dependent param -- black_white, add_noise, lens_blur, and every
|
|
1192
|
+
OTHER operation this table has no branch for at all (every gimp_add_adjustment type included)
|
|
1193
|
+
-- come back as the EXACT SAME object, untouched: `_snapshot_effect_transform` compares the
|
|
1194
|
+
result to the input by `==` to decide whether a filter needs re-applying at all, so this must
|
|
1195
|
+
never manufacture a spurious difference (a stray rounding of a value it was never asked to
|
|
1196
|
+
change) for a filter it has nothing to say about."""
|
|
1197
|
+
if operation == 'gegl:vignette':
|
|
1198
|
+
params = dict(params)
|
|
1199
|
+
if orientation == 'horizontal':
|
|
1200
|
+
params['x'] = _round_transform_value(1.0 - params['x'])
|
|
1201
|
+
else:
|
|
1202
|
+
params['y'] = _round_transform_value(1.0 - params['y'])
|
|
1203
|
+
return params
|
|
1204
|
+
if operation == 'gegl:motion-blur-linear':
|
|
1205
|
+
params = dict(params)
|
|
1206
|
+
if orientation == 'horizontal':
|
|
1207
|
+
params['angle'] = _round_transform_value(_wrap_angle_deg(180.0 - params['angle']))
|
|
1208
|
+
else:
|
|
1209
|
+
params['angle'] = _round_transform_value(_wrap_angle_deg(-params['angle']))
|
|
1210
|
+
return params
|
|
1211
|
+
if operation == 'gegl:dropshadow':
|
|
1212
|
+
params = dict(params)
|
|
1213
|
+
if orientation == 'horizontal':
|
|
1214
|
+
params['x'] = _round_transform_value(-params['x'])
|
|
1215
|
+
else:
|
|
1216
|
+
params['y'] = _round_transform_value(-params['y'])
|
|
1217
|
+
return params
|
|
1218
|
+
return params
|
|
1219
|
+
|
|
1220
|
+
|
|
1221
|
+
def rotate_effect_params(operation, params, degrees, layer_width, layer_height):
|
|
1222
|
+
"""New params for a ledgered effect filter's `operation` after a rotate by an exact right
|
|
1223
|
+
angle (0/90/180/270 mod 360 -- op_rotate refuses any other angle while a filter this table
|
|
1224
|
+
covers is present, see `is_right_angle_degrees`). `layer_width`/`layer_height` are the OWNING
|
|
1225
|
+
LAYER's own PRE-rotation dimensions (not the canvas/image's): vignette's center_x/center_y are
|
|
1226
|
+
a fraction of the LAYER's own extent (build_vignette_params), and a layer's own bounding box
|
|
1227
|
+
swaps width/height under a 90/270 rotation independently of whether the canvas itself grows
|
|
1228
|
+
(`expand`) to match. motion_blur and drop_shadow need no dimensions at all -- their params are
|
|
1229
|
+
layer-agnostic at a right angle (see flip_effect_params's own doc comment for the angle/offset
|
|
1230
|
+
conventions reused here, including why every OTHER operation -- every gimp_add_adjustment type
|
|
1231
|
+
included -- comes back as the exact same object, untouched). `degrees` is snapped to its exact
|
|
1232
|
+
canonical step (`_right_angle_k`) before use everywhere below, including the plain addition
|
|
1233
|
+
for motion_blur's angle -- not just where trig is involved -- so a tolerance-fuzzy `degrees`
|
|
1234
|
+
(e.g. 90.0000003) can never leak into a stored value."""
|
|
1235
|
+
if operation == 'gegl:vignette':
|
|
1236
|
+
params = dict(params)
|
|
1237
|
+
new_width, new_height = _dims_after_right_angle_rotation(layer_width, layer_height, degrees)
|
|
1238
|
+
x, y = rotate_point_fraction(
|
|
1239
|
+
params['x'], params['y'], degrees, layer_width, layer_height, new_width, new_height
|
|
1240
|
+
)
|
|
1241
|
+
params['x'], params['y'] = _round_transform_value(x), _round_transform_value(y)
|
|
1242
|
+
return params
|
|
1243
|
+
if operation == 'gegl:motion-blur-linear':
|
|
1244
|
+
params = dict(params)
|
|
1245
|
+
canonical_degrees = _right_angle_k(degrees) * 90.0
|
|
1246
|
+
params['angle'] = _round_transform_value(_wrap_angle_deg(params['angle'] + canonical_degrees))
|
|
1247
|
+
return params
|
|
1248
|
+
if operation == 'gegl:dropshadow':
|
|
1249
|
+
params = dict(params)
|
|
1250
|
+
cos_t, sin_t = _right_angle_cos_sin(degrees)
|
|
1251
|
+
dx, dy = params['x'], params['y']
|
|
1252
|
+
params['x'] = _round_transform_value(dx * cos_t - dy * sin_t)
|
|
1253
|
+
params['y'] = _round_transform_value(dx * sin_t + dy * cos_t)
|
|
1254
|
+
return params
|
|
1255
|
+
return params
|
|
1256
|
+
|
|
1257
|
+
|
|
1258
|
+
def resize_effect_params(operation, params, scale_x, scale_y):
|
|
1259
|
+
"""New params for a ledgered effect filter's `operation` after a resize that scales width by
|
|
1260
|
+
`scale_x` and height by `scale_y` (independently -- gimp_resize_image's width+height form can
|
|
1261
|
+
stretch aspect).
|
|
1262
|
+
|
|
1263
|
+
motion_blur is a directional vector (length, angle), so an ANISOTROPIC resize (scale_x !=
|
|
1264
|
+
scale_y) changes both: treating the blur direction as a unit vector (cos(angle), sin(angle))
|
|
1265
|
+
in the same clockwise-positive, y-down convention flip_effect_params documents, its two
|
|
1266
|
+
components scale independently by (scale_x, scale_y); the resulting vector's own length and
|
|
1267
|
+
angle are the new length and angle -- length' = length * hypot(scale_x * cos(angle), scale_y *
|
|
1268
|
+
sin(angle)), angle' = atan2(scale_y * sin(angle), scale_x * cos(angle)). Exact for any
|
|
1269
|
+
scale_x/scale_y, and reduces to plain isotropic scaling (length * scale_x, angle unchanged)
|
|
1270
|
+
when scale_x == scale_y, since hypot(s*cos,s*sin) == s and atan2(s*sin,s*cos) == atan2(sin,cos)
|
|
1271
|
+
for any positive s.
|
|
1272
|
+
|
|
1273
|
+
lens_blur's `blur-radius` and drop_shadow's `radius` are ISOTROPIC (one scalar radius, no
|
|
1274
|
+
direction), scaled by the geometric mean of scale_x/scale_y -- exact when the resize is
|
|
1275
|
+
uniform, and the least-wrong single number when it isn't (a circular blur/shadow has no single
|
|
1276
|
+
exact radius under an anisotropic stretch). drop_shadow's offset_x/offset_y scale along their
|
|
1277
|
+
own axis exactly. vignette's radius/x/y are already proportional (a fraction of the layer's
|
|
1278
|
+
own, now-resized extent) and need no change at all -- absent from this table on purpose.
|
|
1279
|
+
black_white and add_noise have no absolute-pixel param either, and every OTHER operation this
|
|
1280
|
+
table has no branch for at all (every gimp_add_adjustment type included) comes back as the
|
|
1281
|
+
exact same object, untouched -- see flip_effect_params's own doc comment for why that matters."""
|
|
1282
|
+
if operation == 'gegl:motion-blur-linear':
|
|
1283
|
+
params = dict(params)
|
|
1284
|
+
theta = math.radians(params['angle'])
|
|
1285
|
+
vx = scale_x * math.cos(theta)
|
|
1286
|
+
vy = scale_y * math.sin(theta)
|
|
1287
|
+
params['length'] = _round_transform_value(params['length'] * math.hypot(vx, vy))
|
|
1288
|
+
params['angle'] = _round_transform_value(_wrap_angle_deg(math.degrees(math.atan2(vy, vx))))
|
|
1289
|
+
return params
|
|
1290
|
+
if operation == 'gegl:focus-blur':
|
|
1291
|
+
params = dict(params)
|
|
1292
|
+
isotropic_scale = math.sqrt(scale_x * scale_y)
|
|
1293
|
+
params['blur-radius'] = _round_transform_value(params['blur-radius'] * isotropic_scale)
|
|
1294
|
+
return params
|
|
1295
|
+
if operation == 'gegl:dropshadow':
|
|
1296
|
+
params = dict(params)
|
|
1297
|
+
isotropic_scale = math.sqrt(scale_x * scale_y)
|
|
1298
|
+
params['x'] = _round_transform_value(params['x'] * scale_x)
|
|
1299
|
+
params['y'] = _round_transform_value(params['y'] * scale_y)
|
|
1300
|
+
params['radius'] = _round_transform_value(params['radius'] * isotropic_scale)
|
|
1301
|
+
return params
|
|
1302
|
+
return params
|
|
1303
|
+
|
|
1304
|
+
|
|
1305
|
+
# Bounds for the GEGL properties a geometry transform can touch, keyed by operation -- the SAME
|
|
1306
|
+
# ranges build_vignette_params/build_motion_blur_params/build_lens_blur_params/
|
|
1307
|
+
# build_drop_shadow_params validate on create/re-edit, so a value THIS bridge computes from a
|
|
1308
|
+
# flip/rotate/resize can never silently land somewhere those entry points would have refused (a
|
|
1309
|
+
# plain GObject property setter would otherwise just clamp it there without telling anyone).
|
|
1310
|
+
EFFECT_TRANSFORM_BOUNDS = {
|
|
1311
|
+
'gegl:vignette': {'x': (0.0, 1.0), 'y': (0.0, 1.0)},
|
|
1312
|
+
'gegl:motion-blur-linear': {'length': (0.0, 1000.0), 'angle': (-180.0, 180.0)},
|
|
1313
|
+
'gegl:focus-blur': {'blur-radius': (0.0, 150.0)},
|
|
1314
|
+
'gegl:dropshadow': {'x': (-500.0, 500.0), 'y': (-500.0, 500.0), 'radius': (0.0, 1500.0)},
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
# The user-facing (gimp_add_effect) field name for each EFFECT_TRANSFORM_BOUNDS entry -- so a
|
|
1318
|
+
# refusal message can say "center_x", "offset_x", or "radius" (what the model actually typed and
|
|
1319
|
+
# what gimp_filter op=list reports back), not the internal GEGL property name ("x") a caller has
|
|
1320
|
+
# never seen. TestEffectTransformBoundsMatchBuilders (test_lib.py) pins that every entry here maps
|
|
1321
|
+
# to a real field the matching build_*_params function actually validates.
|
|
1322
|
+
EFFECT_TRANSFORM_FIELD_NAMES = {
|
|
1323
|
+
('gegl:vignette', 'x'): 'center_x',
|
|
1324
|
+
('gegl:vignette', 'y'): 'center_y',
|
|
1325
|
+
('gegl:motion-blur-linear', 'length'): 'length',
|
|
1326
|
+
('gegl:motion-blur-linear', 'angle'): 'angle',
|
|
1327
|
+
('gegl:focus-blur', 'blur-radius'): 'radius',
|
|
1328
|
+
('gegl:dropshadow', 'x'): 'offset_x',
|
|
1329
|
+
('gegl:dropshadow', 'y'): 'offset_y',
|
|
1330
|
+
('gegl:dropshadow', 'radius'): 'radius',
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
|
|
1334
|
+
def validate_effect_transform(op_name, operation, filter_name, new_params):
|
|
1335
|
+
"""Refuse `op_name` (the geometry op about to run) outright if any of `new_params` -- already
|
|
1336
|
+
computed for `operation` by flip_effect_params/rotate_effect_params/resize_effect_params --
|
|
1337
|
+
would leave the range its own create/re-edit path enforces. Called BEFORE the geometry
|
|
1338
|
+
mutation runs (ops.py's `_snapshot_effect_transform`), so a filter that would end up out of
|
|
1339
|
+
range never gets a chance to silently clamp via GObject's own property setter instead of a
|
|
1340
|
+
clear, actionable refusal -- and the geometry op never partially applies. Names the filter
|
|
1341
|
+
(`filter_name`, the same way `_refuse_if_masked_filters` does) and the field in gimp_add_
|
|
1342
|
+
effect's OWN terms (`EFFECT_TRANSFORM_FIELD_NAMES`), not the raw GEGL property name."""
|
|
1343
|
+
for prop, (lo, hi) in EFFECT_TRANSFORM_BOUNDS.get(operation, {}).items():
|
|
1344
|
+
if prop not in new_params:
|
|
1345
|
+
continue
|
|
1346
|
+
value = new_params[prop]
|
|
1347
|
+
if not lo <= value <= hi:
|
|
1348
|
+
field = EFFECT_TRANSFORM_FIELD_NAMES.get((operation, prop), prop)
|
|
1349
|
+
# "the %s field of filter %r" rather than "%r's %s" -- the latter puts repr()'s own
|
|
1350
|
+
# closing quote directly against a literal possessive "'s", printing as a confusing
|
|
1351
|
+
# doubled apostrophe ("'Motion Blur''s"). %.6g (not %.4g): enough significant figures
|
|
1352
|
+
# that a value just barely out of range (1000.1 against a 1000.0 bound) still shows
|
|
1353
|
+
# the ".1" that IS the reason for the refusal, instead of rounding it away to "1000".
|
|
1354
|
+
raise ValueError(
|
|
1355
|
+
'%s would leave the %s field of filter %r at %.6g, outside its %s..%s range. '
|
|
1356
|
+
'Delete it (gimp_filter op=delete) and re-add it after this geometry change.'
|
|
1357
|
+
% (op_name, field, filter_name, value, lo, hi)
|
|
1358
|
+
)
|
|
1359
|
+
|
|
1360
|
+
|
|
602
1361
|
def region_to_proxy_px(region, scale):
|
|
603
1362
|
"""Map a document-pixel `region` dict into proxy-pixel integer bounds at the proxy's
|
|
604
1363
|
`scale` factor (0 < scale <= 1). Returns (x, y, width, height); width/height are at least
|