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.
Files changed (36) hide show
  1. package/README.md +11 -4
  2. package/dist/backends/gimp/backend.js +3 -0
  3. package/dist/backends/gimp/bridge/lib.py +766 -7
  4. package/dist/backends/gimp/bridge/ops.py +1693 -74
  5. package/dist/backends/gimp/session.js +3 -0
  6. package/dist/bin/editmamei-core-darwin-arm64 +0 -0
  7. package/dist/bin/editmamei-core-darwin-x64 +0 -0
  8. package/dist/bin/editmamei-core-win-x64.exe +0 -0
  9. package/dist/cli/help.js +2 -2
  10. package/dist/cli/install.js +2 -2
  11. package/dist/core/preview-privacy.js +54 -0
  12. package/dist/core/server.js +4 -0
  13. package/dist/core/tool-activity.js +8 -0
  14. package/dist/core/tool-groups.js +8 -0
  15. package/dist/core/tool-tiers.js +8 -0
  16. package/dist/install-channel.js +2 -0
  17. package/dist/modules/gimp/index.js +8 -0
  18. package/dist/skills/editmamei-skill.zip +0 -0
  19. package/dist/telemetry/client.js +23 -16
  20. package/dist/telemetry/outbox.js +248 -28
  21. package/dist/tools/gimp-adjustment-tools.js +6 -3
  22. package/dist/tools/gimp-checkpoint-tools.js +541 -0
  23. package/dist/tools/gimp-compose-tools.js +399 -0
  24. package/dist/tools/gimp-core-tools.js +20 -6
  25. package/dist/tools/gimp-effect-tools.js +267 -0
  26. package/dist/tools/gimp-filter-tools.js +2 -1
  27. package/dist/tools/gimp-geometry-tools.js +46 -14
  28. package/dist/tools/gimp-inspect-tools.js +108 -21
  29. package/dist/tools/gimp-layer-tools.js +385 -0
  30. package/dist/tools/gimp-shared.js +7 -0
  31. package/dist/tools/gimp-verify-tools.js +38 -3
  32. package/dist/update/check.js +2 -0
  33. package/dist/utils/operation-timeouts.js +9 -1
  34. package/dist/utils/validate.js +11 -0
  35. package/dist/version.js +1 -1
  36. 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, and anything else (a Gegl.Color, a path object, bytes) becomes its
540
- str(). One foreign filter with such a property must not fail `list` for the whole image."""
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