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
@@ -50,10 +50,38 @@ def _image(args):
50
50
  img = Gimp.Image.get_by_id(int(image_id))
51
51
  if img is None or not img.is_valid():
52
52
  raise ValueError('no open image with id %s' % image_id)
53
+ # A preview proxy (see PROXIES below) is an internal, filter-free duplicate this bridge made
54
+ # for itself -- never one a caller could legitimately know the id of (no op ever returns one),
55
+ # so a request naming one is refused the same way an unknown id would be, not treated as a real
56
+ # open document.
57
+ if any(img.get_id() == proxy.get_id() for proxy in PROXIES.values()):
58
+ raise ValueError('no open image with id %s' % image_id)
53
59
  return img
54
60
 
55
61
 
62
+ def _owning_image(item):
63
+ """`item`'s own image, or None -- a plain wrapper around `Item.get_image()` so every id-based
64
+ membership check below (`_layer`, `_resolve_parent_group`) reads the same way, and so a layer
65
+ that somehow reports no image at all (should not happen for one just returned by GIMP, but
66
+ never trusted blindly) degrades to "not found" rather than an AttributeError."""
67
+ return item.get_image() if item is not None else None
68
+
69
+
56
70
  def _layer(img, args):
71
+ """Resolve the layer `args` addresses: `layer_id` (canonical -- GIMP allows duplicate layer
72
+ names, so an id is the only handle that always tells two apart; the id `gimp_inspect`'s layer
73
+ tree and `gimp_layer`'s own results report) takes priority over `layer` (name, searched inside
74
+ groups too); neither given falls back to the selected layer, or the topmost layer if none is
75
+ selected. `layer_id` is checked for IMAGE MEMBERSHIP the same way `_existing_filter` checks a
76
+ filter_id -- a global id lookup could otherwise hand back a layer belonging to a different
77
+ (possibly closed) image and let an op silently touch the wrong document."""
78
+ layer_id = args.get('layer_id')
79
+ if layer_id is not None:
80
+ layer = Gimp.Layer.get_by_id(int(layer_id))
81
+ owner = _owning_image(layer)
82
+ if layer is None or owner is None or owner.get_id() != img.get_id():
83
+ raise ValueError('no layer with id %s on image %s' % (layer_id, img.get_id()))
84
+ return layer
57
85
  name = args.get('layer')
58
86
  if name:
59
87
  layer = img.get_layer_by_name(name) # searches inside layer groups too
@@ -69,16 +97,21 @@ def _all_layers(img):
69
97
  included, then its children). `img.get_layers()` is top-level only, and `_layer` can resolve a
70
98
  layer nested inside a group, so every walk over the filter stack goes through this: a filter
71
99
  on a nested layer must be listed, mirrored onto the proxy, ledger-tracked, and seen by the
72
- geometry refusal like any other."""
100
+ geometry refusal like any other.
101
+
102
+ Walked with an EXPLICIT stack, the same reasoning (and the same shape) as `_build_layer_tree`'s
103
+ own walk: a pathological chain of nested single-child groups would otherwise risk Python's OWN
104
+ recursion limit, not just some cap this function itself imposes (it imposes none -- every
105
+ caller here matters exactly because it doesn't). A group's children are pushed in REVERSE order
106
+ so popping (LIFO) still visits top-of-stack-first -- positional proxy mirroring and other
107
+ callers depend on that exact order, not just on the same set of layers."""
73
108
  out = []
74
-
75
- def walk(items):
76
- for item in items:
77
- out.append(item)
78
- if item.is_group():
79
- walk(item.get_children())
80
-
81
- walk(img.get_layers())
109
+ stack = list(reversed(img.get_layers()))
110
+ while stack:
111
+ item = stack.pop()
112
+ out.append(item)
113
+ if item.is_group():
114
+ stack.extend(reversed(item.get_children()))
82
115
  return out
83
116
 
84
117
 
@@ -103,9 +136,16 @@ def _existing_filter(img, args, operation):
103
136
  for f in layer.get_filters():
104
137
  if f.get_id() == filter_id:
105
138
  if f.get_operation_name() != operation:
106
- raise ValueError(
107
- 'filter %s is %s, not %s' % (filter_id, f.get_operation_name(), operation)
108
- )
139
+ # Phrased with the tool's own `type` names (lib.OPERATION_TYPES, the merged
140
+ # adjust+effect inverse), not the raw GEGL op strings -- a caller sent
141
+ # `type: 'motion_blur'` or similar, not `gegl:motion-blur-linear`, so the
142
+ # refusal should name what they actually typed. Falls back to the raw
143
+ # operation name for anything not in that table (there is none today, but a
144
+ # future op added to one table and not the other must not raise here instead
145
+ # of just showing an unmapped name).
146
+ actual = lib.OPERATION_TYPES.get(f.get_operation_name(), f.get_operation_name())
147
+ expected = lib.OPERATION_TYPES.get(operation, operation)
148
+ raise ValueError('filter %s is %s, not %s' % (filter_id, actual, expected))
109
149
  return f
110
150
  raise ValueError('no filter with id %s on image %s' % (filter_id, img.get_id()))
111
151
 
@@ -131,13 +171,25 @@ def _composite(img):
131
171
  return dup, dup.flatten()
132
172
 
133
173
 
134
- # Preview proxies, keyed by image id: a filter-free downscale of the document made once, onto
135
- # which the document's live filters are re-applied per preview. Rendering filters on ~0.7 MP
174
+ # Preview proxies, keyed by (image id, max_px): a filter-free downscale of the document made once,
175
+ # onto which the document's live filters are re-applied per preview. Rendering filters on ~0.7 MP
136
176
  # instead of 24 MP is what gets preview under a second; every route that renders from the
137
177
  # full-size document (flatten, scale, get_thumbnail's projection) costs 1.4-4.5 s after an
138
178
  # edit. Exact for per-pixel filters (curves, levels); a spatial filter would need its radius
139
- # scaled. Every op in this file only ever changes layer pixels via a non-destructive filter
140
- # (never bakes), so the proxy's filter-free base never goes stale.
179
+ # scaled.
180
+ #
181
+ # The cache is correct ONLY because every op that changes pixels, layer structure, canvas size, or
182
+ # image mode ends with `_drop_proxies(img.get_id())`, not only the ops that add or edit a live
183
+ # filter.
184
+ # Layer create/create_group/delete/duplicate/move/reorder/set (opacity/mode/visible), merge_down,
185
+ # flatten, and bake all change the document's real pixels or its layer tree, so a proxy built
186
+ # before any of them would render stale content -- or worse, `_mirror_filters` zips the proxy's own
187
+ # layer list against the live document's POSITIONALLY, so a structural drift between them (a layer
188
+ # added, removed, or reordered on one side but not the other) would mis-attach a filter to the
189
+ # wrong layer rather than merely rendering an out-of-date preview; see that function's own guard.
190
+ # `create_document`/`place_image` (new layer tree), `canvas` (canvas size), and
191
+ # `convert_image_mode` (base type) all fall under the same rule. `select` is the one layer op that
192
+ # changes neither pixels nor structure and is deliberately exempt.
141
193
  PROXIES = {}
142
194
 
143
195
 
@@ -178,13 +230,42 @@ def _mirror_filters(src_img, dst_img):
178
230
  minimum, so without this clamp a small enough proxy would render as if the property were
179
231
  never scaled down at all. This is an approximation (see `op_preview`'s `proxy` flag) -- exact
180
232
  for everything else, since every other property here is scale-invariant (a hue shift, a
181
- level, a percentage)."""
233
+ level, a percentage).
234
+
235
+ Invisible filters (toggled off via `filter op=set_visibility`) are skipped entirely, without
236
+ even attempting to re-create them -- they contribute nothing to the render either way. A
237
+ filter GIMP refuses to attach non-destructively (`_append_masked`'s own guard, the same
238
+ silent-failure class it exists to catch) is likewise skipped here rather than raised: unlike
239
+ `_apply_filter`'s CREATE path, where that refusal must be a hard error (nothing has been
240
+ ledgered yet, so failing loudly is free), a proxy render already renders every OTHER live
241
+ filter and is allowed to approximate by leaving one out rather than failing the whole preview/
242
+ histogram/compare call. Returns the list of filter names that could not be mirrored (empty
243
+ when every visible filter mirrored cleanly), which callers surface as `unmirrored_filters` in
244
+ their own result."""
182
245
  filters, _unknown = _ledger_get(src_img)
183
246
  src_w = src_img.get_width()
184
247
  scale = (dst_img.get_width() / src_w) if src_w else 1.0
185
- # dst is a duplicate of src (see `_proxy`), so both walks yield the same layers in the same order.
186
- for src, dst in zip(_all_layers(src_img), _all_layers(dst_img)):
248
+ unmirrored = []
249
+ # dst is a duplicate of src (see `_proxy`), so both walks yield the same layers in the same
250
+ # order -- PROVIDED the proxy-drop discipline above held. Checked explicitly rather than
251
+ # trusted: a future structural op that forgets `_drop_proxies` would otherwise silently zip a
252
+ # stale proxy's layer list against the live document's and mis-attach a filter to the wrong
253
+ # layer instead of failing loudly. Comparing plain lengths alone would miss a COUNT-preserving
254
+ # drift (e.g. two layers swapped by a reorder) -- the (name, is_group) shape at every position
255
+ # is compared too, so a mismatch at any single slot is caught, not just a size change.
256
+ src_layers, dst_layers = _all_layers(src_img), _all_layers(dst_img)
257
+ src_shape = [(l.get_name(), l.is_group()) for l in src_layers]
258
+ dst_shape = [(l.get_name(), l.is_group()) for l in dst_layers]
259
+ if src_shape != dst_shape:
260
+ raise lib.OpError(
261
+ 'gimp_op_failed',
262
+ 'internal error: the preview proxy has drifted from the document\'s layer structure -- '
263
+ 'a structural change did not drop the proxy cache',
264
+ )
265
+ for src, dst in zip(src_layers, dst_layers):
187
266
  for f in reversed(src.get_filters()): # get_filters() is top-first
267
+ if not f.get_visible():
268
+ continue
188
269
  g = Gimp.DrawableFilter.new(dst, f.get_operation_name(), f.get_name())
189
270
  src_cfg, dst_cfg = f.get_config(), g.get_config()
190
271
  for p in src_cfg.list_properties():
@@ -210,8 +291,13 @@ def _mirror_filters(src_img, dst_img):
210
291
  dst_cfg.set_property(prop_name, scaled)
211
292
  g.set_opacity(f.get_opacity())
212
293
  g.set_blend_mode(f.get_blend_mode())
213
- _append_masked(dst_img, dst, g, mask)
214
- g.set_visible(f.get_visible())
294
+ try:
295
+ _append_masked(dst_img, dst, g, mask)
296
+ except lib.OpError:
297
+ unmirrored.append(f.get_name())
298
+ continue
299
+ g.set_visible(True)
300
+ return unmirrored
215
301
 
216
302
 
217
303
  def _drop_proxies(image_id):
@@ -283,10 +369,18 @@ def _ledger_put(img, filters, unknown, removed=None):
283
369
 
284
370
  def _unique_name(img, base):
285
371
  taken = {f.get_name() for layer in _all_layers(img) for f in layer.get_filters()}
286
- name, n = base, 2
287
- while name in taken:
288
- name, n = '%s %d' % (base, n), n + 1
289
- return name
372
+ return lib.unique_name(taken, base)
373
+
374
+
375
+ def _unique_layer_name(img, base, exclude=None):
376
+ """Like `_unique_name`, but over LAYER names (`gimp_layer`'s create/create_group/duplicate/
377
+ set(name=...) naming) rather than filter names. `exclude`, when given, is a layer whose own
378
+ current name never counts as "taken" -- renaming a layer to the name it already has must not
379
+ force an unwanted suffix onto itself."""
380
+ taken = {
381
+ l.get_name() for l in _all_layers(img) if exclude is None or l.get_id() != exclude.get_id()
382
+ }
383
+ return lib.unique_name(taken, base)
290
384
 
291
385
 
292
386
  def _set_curves(cfg, params):
@@ -392,6 +486,76 @@ def _set_gaussian_blur(cfg, params):
392
486
  cfg.set_property('std-dev-y', params['std-dev-y'])
393
487
 
394
488
 
489
+ # ---- gimp_add_effect setters (lib.EFFECT_OPERATIONS' parallel table to the adjust ones
490
+ # above) -- SETTERS itself is one shared dict below: an operation name is an operation name
491
+ # regardless of which tool (gimp_add_adjustment's `adjust` or gimp_add_effect's `effect`) created
492
+ # the filter, and _apply_filter/_mirror_filters dispatch on operation alone.
493
+
494
+ def _set_vignette(cfg, params):
495
+ cfg.set_property('radius', params['radius'])
496
+ cfg.set_property('softness', params['softness'])
497
+ cfg.set_property('gamma', params['gamma'])
498
+ cfg.set_property('x', params['x'])
499
+ cfg.set_property('y', params['y'])
500
+
501
+
502
+ def _set_black_white(cfg, params):
503
+ cfg.set_property('red', params['red'])
504
+ cfg.set_property('green', params['green'])
505
+ cfg.set_property('blue', params['blue'])
506
+ cfg.set_property('preserve-luminosity', params['preserve-luminosity'])
507
+
508
+
509
+ def _set_motion_blur(cfg, params):
510
+ cfg.set_property('length', params['length'])
511
+ cfg.set_property('angle', params['angle'])
512
+
513
+
514
+ def _set_lens_blur(cfg, params):
515
+ # gegl:focus-blur, not gegl:lens-blur -- see lib.build_lens_blur_params' own comment.
516
+ #
517
+ # Two properties are forced here, not part of `params` and not user-configurable:
518
+ # - `radius` (distinct from `blur-radius`) sizes a circular IN-FOCUS zone that stays
519
+ # perfectly sharp regardless of `blur-radius` -- verified live: at its own default (0.75),
520
+ # the center ~75% of the image never blurs at all, which is a tilt-shift/depth-of-field
521
+ # effect, not the uniform "lens blur" this tool advertises. Forced to 0 so the blur applies
522
+ # uniformly across the whole layer instead.
523
+ # - `blur-type` defaults to 'gaussian', which would make this a second, redundant
524
+ # gaussian_blur and leave `highlight_factor` inert. Forced to 'lens' -- verified live, that
525
+ # mode DOES attach as a non-destructive filter (the 'aux'-pad restriction that sank
526
+ # gegl:lens-blur does not apply inside this meta-operation) and produces a real bokeh
527
+ # highlight boost.
528
+ cfg.set_property('radius', 0.0)
529
+ cfg.set_property('blur-type', 'lens')
530
+ cfg.set_property('blur-radius', params['blur-radius'])
531
+ cfg.set_property('highlight-factor', params['highlight-factor'])
532
+
533
+
534
+ def _set_add_noise(cfg, params):
535
+ # `independent=False`: what "add grain" means to a photographer is monochrome noise (the
536
+ # same random value added to R, G, and B at a given pixel, like real film grain or sensor
537
+ # noise's luminance component) -- GEGL's own default (`independent=True`) instead draws THREE
538
+ # separate random values per pixel, one per channel, which reads as colour speckle/confetti
539
+ # rather than grain. Not part of `params`, not user-configurable.
540
+ cfg.set_property('independent', False)
541
+ cfg.set_property('red', params['red'])
542
+ cfg.set_property('green', params['green'])
543
+ cfg.set_property('blue', params['blue'])
544
+ cfg.set_property('alpha', params['alpha'])
545
+ cfg.set_property('seed', params['seed'])
546
+
547
+
548
+ def _set_drop_shadow(cfg, params):
549
+ # The rendered shadow is clipped to the layer's own bounds -- verified live, a large
550
+ # offset/radius does not grow the layer or canvas (measured at the schema maxima and in
551
+ # combination: no size change), it just gets cut off at the edge like any other
552
+ # DrawableFilter here, so no separate cap against "ballooning" is needed.
553
+ cfg.set_property('x', params['x'])
554
+ cfg.set_property('y', params['y'])
555
+ cfg.set_property('radius', params['radius'])
556
+ cfg.set_property('opacity', params['opacity'])
557
+
558
+
395
559
  SETTERS = {
396
560
  'gimp:curves': _set_curves,
397
561
  'gimp:levels': _set_levels,
@@ -406,6 +570,12 @@ SETTERS = {
406
570
  'gegl:unsharp-mask': _set_sharpen,
407
571
  'gegl:noise-reduction': _set_noise_reduction,
408
572
  'gegl:gaussian-blur': _set_gaussian_blur,
573
+ 'gegl:vignette': _set_vignette,
574
+ 'gegl:mono-mixer': _set_black_white,
575
+ 'gegl:motion-blur-linear': _set_motion_blur,
576
+ 'gegl:focus-blur': _set_lens_blur,
577
+ 'gegl:noise-rgb': _set_add_noise,
578
+ 'gegl:dropshadow': _set_drop_shadow,
409
579
  }
410
580
 
411
581
 
@@ -424,13 +594,42 @@ def _append_masked(img, layer, f, mask):
424
594
  When `mask` is falsy, this explicitly clears any active selection FIRST -- defence in depth
425
595
  against a stray selection left active by an earlier op (e.g. `create_mask` used to leave its
426
596
  own channel selected; that bug is fixed at the source too, but an unmasked filter must never
427
- silently inherit whatever happens to be selected regardless)."""
597
+ silently inherit whatever happens to be selected regardless).
598
+
599
+ Verifies the filter actually attached before returning. `Gimp.Drawable.append_filter`
600
+ (`gimp-drawable-append-filter` in the PDB) can refuse an operation outright -- verified live
601
+ for `gegl:lens-blur` ("effects with an 'aux' pad cannot be applied non-destructively") -- and
602
+ does so SILENTLY on the Python side: a GIMP-Error goes to stderr, but `append_filter` itself
603
+ raises nothing and returns `None`, the same as a successful call, with the DrawableFilter's
604
+ own id still perfectly valid. Left unchecked, the caller (`_apply_filter`) would ledger a
605
+ phantom filter: `filter_id` returned as if it worked, the image silently rendering with no
606
+ effect at all. Raised here, before `_apply_filter` ever writes the ledger record, so a
607
+ refused attach never gets persisted as one -- `_mirror_filters` (the other caller) catches
608
+ this same exception instead of letting it propagate, since a proxy render is allowed to skip
609
+ a filter it can't mirror rather than fail outright (see its own doc comment). `f.delete()`
610
+ runs before raising either way, so a refused filter that never actually attached to anything
611
+ doesn't linger as an orphaned DrawableFilter object."""
428
612
  if mask:
429
613
  img.select_item(Gimp.ChannelOps.REPLACE, _channel_by_name(img, mask))
430
614
  else:
431
615
  Gimp.Selection.none(img)
432
616
  try:
433
617
  layer.append_filter(f)
618
+ if f.get_id() not in [x.get_id() for x in layer.get_filters()]:
619
+ # The operation name is captured BEFORE delete() -- a deleted DrawableFilter is not
620
+ # guaranteed to answer get_operation_name() usefully afterward -- and delete() itself
621
+ # is wrapped so that if IT raises for some unrelated reason, that exception can never
622
+ # replace (mask) the actionable OpError below with a confusing, unrelated traceback.
623
+ operation_name = f.get_operation_name()
624
+ try:
625
+ f.delete()
626
+ except Exception:
627
+ pass
628
+ raise lib.OpError(
629
+ 'gimp_op_failed',
630
+ 'GIMP refused to attach %s as a live filter (some GEGL operations with an '
631
+ 'auxiliary input cannot be applied non-destructively)' % operation_name
632
+ )
434
633
  finally:
435
634
  if mask:
436
635
  Gimp.Selection.none(img)
@@ -624,6 +823,8 @@ def op_adjust(args):
624
823
  that only mentions one field (e.g. `{contrast: 50}`) keeps every other field (e.g.
625
824
  `brightness`) exactly as it was, instead of silently resetting it."""
626
825
  type_ = args.get('type')
826
+ if type_ in lib.ADJUST_OPERATIONS: # curves and levels included
827
+ lib.reject_foreign_fields(type_, args)
627
828
  if type_ == 'curves':
628
829
  return op_curves(args)
629
830
  if type_ == 'levels':
@@ -639,35 +840,41 @@ def op_adjust(args):
639
840
  return _apply_filter(img, args, operation, params, default_name, type_=type_)
640
841
 
641
842
 
843
+ def _filter_record(filters, layer, f):
844
+ """One filter's `op_list_filters`-shaped record: `filters` is the ledger's own {name: {...}}
845
+ map (`_ledger_get`'s first return value). Bridge-applied filters report the ledger record
846
+ (`source: editmamei`) with `params` in the adjust tool's own field names and units
847
+ (`lib.user_params`), so a listed value can be passed straight back on a re-edit. Anything else
848
+ reports libgimp's readback (`source: readback`): raw GEGL property names and units, lossy for
849
+ per-channel curves, with any value JSON can't carry (a Gegl.Color, say) stringified. Shared by
850
+ `op_list_filters` (every filter on the image) and `op_describe`'s `what='filter'` (one, by id)."""
851
+ rec = filters.get(f.get_name())
852
+ if rec and rec['operation'] == f.get_operation_name():
853
+ source = 'editmamei'
854
+ type_ = rec.get('type') or lib.OPERATION_TYPES.get(rec['operation'])
855
+ mask = rec['params'].get('mask')
856
+ params = lib.user_params(type_, rec['params'])
857
+ else:
858
+ cfg, params, source, type_, mask = f.get_config(), {}, 'readback', None, None
859
+ for p in cfg.list_properties():
860
+ v = cfg.get_property(p.name)
861
+ if isinstance(v, Gimp.Curve):
862
+ v = _curve_points(v)
863
+ elif hasattr(v, 'value_nick'):
864
+ v = v.value_nick
865
+ params[p.name] = lib.json_safe(v)
866
+ return {'layer': layer.get_name(), 'layer_id': layer.get_id(), 'filter_id': f.get_id(),
867
+ 'name': f.get_name(), 'operation': f.get_operation_name(), 'type': type_,
868
+ 'visible': f.get_visible(), 'source': source, 'mask': mask, 'params': params}
869
+
870
+
642
871
  def op_list_filters(args):
643
- """Bridge-applied filters report the ledger record (`source: editmamei`) with `params` in the
644
- adjust tool's own field names and units (`lib.user_params`), so a listed value can be passed
645
- straight back on a re-edit. Anything else reports libgimp's readback (`source: readback`):
646
- raw GEGL property names and units, lossy for per-channel curves, with any value JSON can't
647
- carry (a Gegl.Color, say) stringified."""
648
872
  img = _image(args)
649
873
  filters, _unknown = _ledger_get(img)
650
874
  out = []
651
875
  for layer in _all_layers(img):
652
876
  for f in layer.get_filters():
653
- rec = filters.get(f.get_name())
654
- if rec and rec['operation'] == f.get_operation_name():
655
- source = 'editmamei'
656
- type_ = rec.get('type') or lib.OPERATION_TYPES.get(rec['operation'])
657
- mask = rec['params'].get('mask')
658
- params = lib.user_params(type_, rec['params'])
659
- else:
660
- cfg, params, source, type_, mask = f.get_config(), {}, 'readback', None, None
661
- for p in cfg.list_properties():
662
- v = cfg.get_property(p.name)
663
- if isinstance(v, Gimp.Curve):
664
- v = _curve_points(v)
665
- elif hasattr(v, 'value_nick'):
666
- v = v.value_nick
667
- params[p.name] = lib.json_safe(v)
668
- out.append({'layer': layer.get_name(), 'filter_id': f.get_id(), 'name': f.get_name(),
669
- 'operation': f.get_operation_name(), 'type': type_, 'visible': f.get_visible(),
670
- 'source': source, 'mask': mask, 'params': params})
877
+ out.append(_filter_record(filters, layer, f))
671
878
  return {'filters': out}
672
879
 
673
880
 
@@ -682,6 +889,29 @@ def _find_filter(img, filter_id):
682
889
  raise ValueError('no filter with id %s on image %s' % (filter_id, img.get_id()))
683
890
 
684
891
 
892
+ def op_effect(args):
893
+ """`gimp_add_effect`: create (or, with `filter_id`, re-edit in place) one of the allow-listed
894
+ GEGL effect filters (`lib.EFFECT_OPERATIONS`) -- the same merge/mask/ledger machinery
895
+ `op_adjust` uses, but reading its own `type` against the EFFECT_* tables
896
+ (`lib.EFFECT_PARAM_BUILDERS`/`EFFECT_CREATE_DEFAULTS`) rather than `op_adjust`'s ADJUST_*
897
+ ones. Dispatched through the shared `_apply_filter`, so the ledger, mask confinement,
898
+ merge-on-re-edit, geometry refusals and proxy mirroring all come free, unchanged.
899
+ gimp_add_effect is its own tool (tier 'dev'), a sibling of gimp_add_adjustment, rather than a
900
+ new op on gimp_filter (already 'community') -- tool-tiers.ts classifies per TOOL, not per op,
901
+ so a new op on an already-shipping tool would have skipped the tier gate entirely."""
902
+ effect_type = args.get('type')
903
+ builder = lib.EFFECT_PARAM_BUILDERS.get(effect_type)
904
+ if builder is None:
905
+ raise ValueError('type must be one of %s' % sorted(lib.EFFECT_OPERATIONS))
906
+ lib.reject_foreign_fields(effect_type, args)
907
+ img = _image(args)
908
+ operation = lib.EFFECT_OPERATIONS[effect_type]
909
+ defaults = _existing_ledger_params(img, args, operation) or lib.EFFECT_CREATE_DEFAULTS[effect_type]
910
+ params = builder(args, defaults)
911
+ default_name = effect_type.replace('_', ' ').title()
912
+ return _apply_filter(img, args, operation, params, default_name, type_=effect_type)
913
+
914
+
685
915
  def op_filter(args):
686
916
  """Stack management: `op` in list | set_visibility | delete. There is deliberately no
687
917
  `reorder` -- `Gimp.DrawableFilter` exposes only delete/set_visible/update, and the PDB has no
@@ -718,6 +948,180 @@ def op_filter(args):
718
948
  raise ValueError('op must be one of list, set_visibility, delete (got %r)' % fop)
719
949
 
720
950
 
951
+ DESCRIBE_TARGETS = ('document', 'layers', 'channels', 'filter')
952
+
953
+ # A hard cap on how many layer-tree nodes `describe`'s `document`/`layers` targets will build,
954
+ # counting every node in the WHOLE tree (top-level and every descendant), not just top-level --
955
+ # an output-size bound, and it is what makes `_build_layer_tree`'s walk itself safe: it stops
956
+ # outright once this many nodes have been visited, so neither a very wide document (many layers)
957
+ # nor a very deep one (many nested groups) can produce unbounded output. `truncated: true` in the
958
+ # result says the cap was hit; a group visited right at the cut-off may be missing some of its own
959
+ # children, since the walk simply stops rather than finishing that group first.
960
+ MAX_DESCRIBE_LAYER_NODES = 2000
961
+
962
+
963
+ def _layer_node_shallow(layer):
964
+ """One layer's own fields for a describe-by-id layer tree node, with `children` left as an
965
+ empty list -- `_build_layer_tree` fills it in as it walks. Id is canonical (unlike `_layer`'s
966
+ name-based lookup, which can't tell two same-named layers apart -- GIMP allows duplicate
967
+ names). `get_offsets()` returns (ok, x, y); `ok` is False only in a genuinely invalid-item
968
+ case that shouldn't arise for a layer this walk just visited via `get_layers()`/
969
+ `get_children()`, but is still checked rather than trusted blindly -- offsets fall back to
970
+ null rather than reporting a wrong position."""
971
+ ok, off_x, off_y = layer.get_offsets()
972
+ return {
973
+ 'layer_id': layer.get_id(),
974
+ 'name': layer.get_name(),
975
+ 'opacity': layer.get_opacity(),
976
+ 'mode': layer.get_mode().value_nick,
977
+ 'visible': layer.get_visible(),
978
+ 'offsets': {'x': off_x, 'y': off_y} if ok else {'x': None, 'y': None},
979
+ 'has_alpha': layer.has_alpha(),
980
+ 'is_group': layer.is_group(),
981
+ 'is_text_layer': layer.is_text_layer(),
982
+ 'children': [],
983
+ }
984
+
985
+
986
+ def _build_layer_tree(top_layers, max_nodes=MAX_DESCRIBE_LAYER_NODES):
987
+ """The nested layer tree for `top_layers` (an image's own `get_layers()`, top of stack first),
988
+ walked with an EXPLICIT stack rather than one recursive call per nesting level -- a
989
+ pathological chain of nested single-child groups would otherwise risk Python's OWN recursion
990
+ limit, not just some output-size limit of this op's choosing. Capped at `max_nodes` total
991
+ nodes across the whole tree. Returns (nodes, truncated, total_nodes) -- `total_nodes` is simply
992
+ the walk's own `seen` counter, so it costs nothing extra to report; it is what a truncated
993
+ response is actually counted from, distinct from `top_level_count` (the caller's own
994
+ `len(top_layers)`, always the TRUE top-level count even when the walk stopped before reaching
995
+ every top-level sibling).
996
+
997
+ Each stack entry is (layer, parent_id); a group's children are pushed in REVERSE order so
998
+ popping (LIFO) still visits them top-of-stack-first, the same order `_all_layers` already
999
+ relies on elsewhere. A layer's own node dict is built and recorded (`by_id`) the moment it is
1000
+ popped, and immediately appended into its parent's `children` list (or the top-level list) --
1001
+ safe because a layer is always popped strictly after its own parent (the parent's children
1002
+ are only ever pushed once the parent itself has already been popped and recorded), so the
1003
+ parent's node dict is guaranteed to already exist. This one pass is enough; no separate
1004
+ bottom-up assembly pass is needed."""
1005
+ truncated = False
1006
+ top = []
1007
+ by_id = {}
1008
+ stack = [(layer, None) for layer in reversed(top_layers)]
1009
+ seen = 0
1010
+ while stack:
1011
+ if seen >= max_nodes:
1012
+ truncated = True
1013
+ break
1014
+ layer, parent_id = stack.pop()
1015
+ seen += 1
1016
+ node = _layer_node_shallow(layer)
1017
+ by_id[layer.get_id()] = node
1018
+ if parent_id is None:
1019
+ top.append(node)
1020
+ else:
1021
+ by_id[parent_id]['children'].append(node)
1022
+ if layer.is_group():
1023
+ for child in reversed(layer.get_children()):
1024
+ stack.append((child, layer.get_id()))
1025
+ return top, truncated, seen
1026
+
1027
+
1028
+ def _channels_summary(img):
1029
+ """Every named channel on img, by id and name only -- `document`'s cheap channel listing.
1030
+ Coverage (`_channel_coverage`) reads a channel's full pixel buffer, so it is computed only for
1031
+ `what='channels'` (`_channels_described`), never bundled into `document`'s broader, cheaper
1032
+ read."""
1033
+ return [{'channel_id': ch.get_id(), 'name': ch.get_name()} for ch in img.get_channels()]
1034
+
1035
+
1036
+ # `_channels_described` checks this after every channel it reads, and stops once that many seconds
1037
+ # have passed, rather than run gimp_inspect's own dispatch budget out on a document with many named
1038
+ # channels (`operation-timeouts.ts`). A plain module global, not a function default, so a test
1039
+ # fixture can reassign it directly (`test_set_channels_deadline`, fixtures/test_ops.py) and force
1040
+ # the stop without needing dozens of real channels.
1041
+ CHANNELS_DESCRIBE_DEADLINE_S = 15.0
1042
+
1043
+
1044
+ def _channels_described(img):
1045
+ """Every named channel on img, with its coverage (`_channel_coverage`) -- the same stat
1046
+ `op_create_mask` returns for the one it just built. `what='channels'`-only; see
1047
+ `_channels_summary`'s own docstring for why `document` doesn't compute this.
1048
+
1049
+ Reads at least the first channel unconditionally, then checks `CHANNELS_DESCRIBE_DEADLINE_S`
1050
+ after each one read: once elapsed time reaches it, stops and returns early. Returns
1051
+ {'channels', 'truncated', 'channels_skipped'} -- `channels` is whatever was read before
1052
+ stopping, `truncated` is whether any named channel was left unread, and `channels_skipped`
1053
+ counts them."""
1054
+ w, h = img.get_width(), img.get_height()
1055
+ channels = img.get_channels()
1056
+ out = []
1057
+ start = time.time()
1058
+ for ch in channels:
1059
+ selected, fraction = _channel_coverage(ch, w, h)
1060
+ out.append({
1061
+ 'channel_id': ch.get_id(), 'name': ch.get_name(),
1062
+ 'selected_pixels': selected, 'fraction': fraction,
1063
+ })
1064
+ if time.time() - start >= CHANNELS_DESCRIBE_DEADLINE_S:
1065
+ break
1066
+ return {
1067
+ 'channels': out,
1068
+ 'truncated': len(out) < len(channels),
1069
+ 'channels_skipped': len(channels) - len(out),
1070
+ }
1071
+
1072
+
1073
+ def op_describe(args):
1074
+ """`gimp_inspect`'s describe-by-id bridge op: `what` in document | layers | channels | filter
1075
+ (`documents` -- every open image's id -- stays on `op_ping`, unchanged; the tool layer never
1076
+ routes it here). `document` bundles dims/base_type/precision/resolution with the layer tree
1077
+ and a cheap by-id/name channel listing in one call; `layers` returns just the tree (capped and
1078
+ flagged, see `_build_layer_tree`/`MAX_DESCRIBE_LAYER_NODES`); `channels` returns the same
1079
+ channels WITH coverage (`_channels_described`), the one part `document` deliberately leaves
1080
+ out since it reads full pixel buffers -- and stops after its own time budget on a document with
1081
+ many named channels, returning whatever it already read (see `_channels_described`). `filter`
1082
+ reports one filter by id, in the exact shape `op_list_filters` reports it in (`_filter_record`,
1083
+ via `_find_filter` so a filter_id from a different or closed image is never mistaken for a
1084
+ match). `document`/`layers` also report `top_level_count` (the image's real top-level layer
1085
+ count, from `get_layers()` directly) and `total_nodes` (how many nodes THIS response carries)
1086
+ alongside `truncated`."""
1087
+ what = args.get('what')
1088
+ if what not in DESCRIBE_TARGETS:
1089
+ raise ValueError('what must be one of %s' % ', '.join(DESCRIBE_TARGETS))
1090
+ img = _image(args)
1091
+ if what == 'filter':
1092
+ filter_id = int(lib.require(args, 'filter_id'))
1093
+ layer, f = _find_filter(img, filter_id)
1094
+ filters, _unknown = _ledger_get(img)
1095
+ return _filter_record(filters, layer, f)
1096
+ if what == 'layers':
1097
+ top_layers = img.get_layers()
1098
+ layers, truncated, total_nodes = _build_layer_tree(top_layers)
1099
+ return {
1100
+ 'layers': layers,
1101
+ 'truncated': truncated,
1102
+ 'top_level_count': len(top_layers),
1103
+ 'total_nodes': total_nodes,
1104
+ }
1105
+ if what == 'channels':
1106
+ return _channels_described(img)
1107
+ ok, xres, yres = img.get_resolution()
1108
+ top_layers = img.get_layers()
1109
+ layers, truncated, total_nodes = _build_layer_tree(top_layers)
1110
+ return {
1111
+ 'image': img.get_id(),
1112
+ 'width': img.get_width(),
1113
+ 'height': img.get_height(),
1114
+ 'base_type': img.get_base_type().value_nick,
1115
+ 'precision': img.get_precision().value_nick,
1116
+ 'resolution': {'x': xres, 'y': yres} if ok else {'x': None, 'y': None},
1117
+ 'layers': layers,
1118
+ 'truncated': truncated,
1119
+ 'top_level_count': len(top_layers),
1120
+ 'total_nodes': total_nodes,
1121
+ 'channels': _channels_summary(img),
1122
+ }
1123
+
1124
+
721
1125
  # ---- geometry -----------------------------------------------------------------------------
722
1126
  #
723
1127
  # Verified live (GIMP 3.2.6): unmasked filters, named mask CHANNELS, and the editmamei-filters
@@ -745,6 +1149,14 @@ def op_filter(args):
745
1149
  # whose ledger record names a mask, rather than silently rendering a masked edit in the wrong
746
1150
  # place -- the same "refuse rather than silently corrupt" contract as `filter op=reorder`. crop
747
1151
  # never refuses for this reason, since it verified correct.
1152
+ #
1153
+ # gimp_add_effect's own vignette/motion_blur/drop_shadow filters get a SEPARATE tracking
1154
+ # mechanism, below (`_snapshot_effect_transform`/`_apply_planned_effect_transform`), that keeps
1155
+ # their params locked to the content through flip/rotate(right angles only)/resize -- see lib.py's
1156
+ # "geometry transforms for direction/position-dependent EFFECT params" comment for the full
1157
+ # design. crop needs none of this: vignette's center is already a fraction of its LAYER's own
1158
+ # extent, so cropping the layer naturally re-centres it on the new, smaller frame -- intended
1159
+ # behaviour (the same way Lightroom's post-crop vignette re-centres), not a gap in tracking.
748
1160
 
749
1161
  def _live_filter_names(img):
750
1162
  return {f.get_name() for layer in _all_layers(img) for f in layer.get_filters()}
@@ -780,24 +1192,138 @@ def _classify_geometry_filters(img):
780
1192
  return lib.classify_geometry_filters(filters, live)
781
1193
 
782
1194
 
783
- def _refuse_if_masked_filters(img, op_name):
1195
+ def _refuse_if_masked_filters(img, op_name, before_hint='Rotate, flip or resize'):
1196
+ """`before_hint` names the alternative ordering this op's own refusal message suggests --
1197
+ 'Rotate, flip or resize' for resize/rotate/flip, 'Extend the canvas' for `gimp_canvas` (see its
1198
+ own call site) -- so the advice matches the op that actually refused."""
784
1199
  masked, unverifiable = _classify_geometry_filters(img)
785
1200
  if unverifiable:
1201
+ # `sorted(set(...))`, not `sorted(...)`: classify_geometry_filters reports a duplicate
1202
+ # live filter NAME once per occurrence (so a caller counting what it iterated still gets
1203
+ # a consistent count), but the refusal message only needs to name it once.
786
1204
  raise ValueError(
787
1205
  '%s cannot proceed: filter(s) %s were not created by Editmamei (no matching ledger '
788
1206
  'record for their name and operation), so whether they are masked cannot be checked. '
789
1207
  'Delete them first, or make this geometry change before adding them.'
790
- % (op_name, ', '.join(repr(n) for n in sorted(unverifiable)))
1208
+ % (op_name, ', '.join(repr(n) for n in sorted(set(unverifiable))))
791
1209
  )
792
1210
  if masked:
793
1211
  raise ValueError(
794
- '%s would misalign the masked adjustment(s) %s: a filter\'s mask cannot move with '
795
- 'this transform. Rotate, flip or resize before adding masked adjustments, or delete '
1212
+ '%s would misalign the masked filter(s) %s: a filter\'s mask cannot move with '
1213
+ 'this transform. %s before adding masked filters, or delete '
796
1214
  'those filters first and re-create them afterwards. Crop is unaffected.'
797
- % (op_name, ', '.join(repr(n) for n in sorted(masked)))
1215
+ % (op_name, ', '.join(repr(n) for n in sorted(set(masked))), before_hint)
798
1216
  )
799
1217
 
800
1218
 
1219
+ # Operations whose params are position/direction-dependent under rotation -- an arbitrary
1220
+ # (non-right-angle) rotation is refused outright while any LIVE, ledgered filter using one of
1221
+ # these is present (see lib.py's own "ONLY exact cases are supported" comment for why).
1222
+ ROTATE_DEPENDENT_OPERATIONS = frozenset({'gegl:vignette', 'gegl:motion-blur-linear', 'gegl:dropshadow'})
1223
+
1224
+
1225
+ def _refuse_if_non_right_angle_with_tracked_effects(img, degrees, op_name):
1226
+ if lib.is_right_angle_degrees(degrees):
1227
+ return
1228
+ filters, _unknown = _ledger_get(img)
1229
+ live_names = _live_filter_names(img)
1230
+ tracked = sorted(
1231
+ name for name, rec in filters.items()
1232
+ if name in live_names and rec.get('operation') in ROTATE_DEPENDENT_OPERATIONS
1233
+ )
1234
+ if tracked:
1235
+ raise ValueError(
1236
+ '%s cannot use an arbitrary angle (%.4g°) while effect filter(s) %s are present: '
1237
+ 'only an exact 0/90/180/270-degree rotation keeps them locked to the content. Rotate '
1238
+ 'at a right angle instead, or delete these filters first and re-add them afterwards.'
1239
+ % (op_name, degrees, ', '.join(repr(n) for n in tracked))
1240
+ )
1241
+
1242
+
1243
+ def _snapshot_effect_transform(img, op_name, transform_fn):
1244
+ """Precompute every ledgered effect filter's new params from a SNAPSHOT of the current
1245
+ ledger, validate each against this bridge's own field ranges, and raise -- refusing `op_name`
1246
+ outright -- if anything would land out of range, ALL BEFORE anything is mutated. Working from
1247
+ a snapshot (rather than re-reading `filters` mid-loop, which a naive loop could otherwise
1248
+ transform twice over if two live filters were ever ledgered under the same name) is what makes
1249
+ this safe to call before the geometry op has committed to anything.
1250
+
1251
+ `transform_fn(operation, params, layer)` is called once per live, ledgered filter still
1252
+ present -- `layer` is that filter's OWNING layer, queried for its CURRENT (pre-mutation)
1253
+ dimensions where the transform needs them (only rotate's vignette case does; flip and resize
1254
+ ignore it). Returns {filter_name: (operation, new_params)} for every filter transform_fn
1255
+ actually changed -- callers apply these with `_apply_planned_effect_transform` AFTER the
1256
+ geometry mutation itself. A filter transform_fn has nothing to change for (black_white,
1257
+ add_noise, and every gimp_add_adjustment type -- out of scope for this table, see lib.py's own
1258
+ comment) comes back with the SAME values (by `==`) and is simply absent from the result."""
1259
+ filters, _unknown = _ledger_get(img)
1260
+ if not filters:
1261
+ return {}
1262
+ snapshot = {name: (rec['operation'], dict(rec['params'])) for name, rec in filters.items()}
1263
+ planned = {}
1264
+ for layer in _all_layers(img):
1265
+ for f in layer.get_filters():
1266
+ entry = snapshot.get(f.get_name())
1267
+ if entry is None or entry[0] != f.get_operation_name():
1268
+ continue
1269
+ operation, params = entry
1270
+ new_params = transform_fn(operation, params, layer)
1271
+ if new_params == params:
1272
+ continue
1273
+ lib.validate_effect_transform(op_name, operation, f.get_name(), new_params)
1274
+ planned[f.get_name()] = (operation, new_params)
1275
+ return planned
1276
+
1277
+
1278
+ def _apply_planned_effect_transform(img, planned):
1279
+ """Push each planned (operation, new_params) -- already validated by
1280
+ `_snapshot_effect_transform` -- into the live GEGL config and the ledger record. Called AFTER
1281
+ the geometry mutation.
1282
+
1283
+ A live update failure for one filter does not stop the others. The ledger must always match
1284
+ what actually renders, so a failure never leaves the ledger record at the new (unapplied)
1285
+ params: this instead tries to restore the filter's OLD params live (best effort -- if that
1286
+ ALSO fails, the filter is simply left wherever the failed attempt left it) and keeps the ledger
1287
+ record at the OLD params either way. The filter's name is collected and returned so the caller
1288
+ can report it (`effect_update_failures`) -- a silent partial failure here would otherwise look
1289
+ identical to a filter the geometry op never touched at all."""
1290
+ if not planned:
1291
+ return []
1292
+ failures = []
1293
+ filters, unknown = _ledger_get(img)
1294
+ for layer in _all_layers(img):
1295
+ for f in layer.get_filters():
1296
+ entry = planned.get(f.get_name())
1297
+ if entry is None:
1298
+ continue
1299
+ operation, new_params = entry
1300
+ rec = filters.get(f.get_name())
1301
+ old_params = rec['params'] if rec is not None else None
1302
+ try:
1303
+ SETTERS[operation](f.get_config(), new_params)
1304
+ f.update()
1305
+ except Exception as e:
1306
+ failures.append(f.get_name())
1307
+ sys.stderr.write(
1308
+ 'geometry transform: live update failed for filter %r (%s): %s -- '
1309
+ 'restoring its old params\n' % (f.get_name(), operation, e)
1310
+ )
1311
+ if old_params is not None:
1312
+ try:
1313
+ SETTERS[operation](f.get_config(), old_params)
1314
+ f.update()
1315
+ except Exception as restore_error:
1316
+ sys.stderr.write(
1317
+ 'geometry transform: restoring filter %r also failed: %s\n'
1318
+ % (f.get_name(), restore_error)
1319
+ )
1320
+ continue # ledger record stays at old_params -- never advances to new_params
1321
+ if rec is not None:
1322
+ rec['params'] = new_params
1323
+ _ledger_put(img, filters, unknown)
1324
+ return failures
1325
+
1326
+
801
1327
  def op_crop(args):
802
1328
  img = _image(args)
803
1329
  left, top = int(lib.require(args, 'left')), int(lib.require(args, 'top'))
@@ -846,10 +1372,19 @@ def op_resize(args):
846
1372
  else:
847
1373
  raise ValueError('resize needs one of width, height, or long_edge')
848
1374
  width, height = lib.validate_resize_dims(width, height)
1375
+ scale_x, scale_y = width / float(w0), height / float(h0)
1376
+ planned = _snapshot_effect_transform(
1377
+ img, 'resize',
1378
+ lambda operation, params, layer: lib.resize_effect_params(operation, params, scale_x, scale_y),
1379
+ )
849
1380
  Gimp.Selection.none(img) # see op_open: geometry always applies to the whole canvas
850
1381
  img.scale(width, height)
1382
+ failures = _apply_planned_effect_transform(img, planned)
851
1383
  _drop_proxies(img.get_id())
852
- return {'width': img.get_width(), 'height': img.get_height()}
1384
+ result = {'width': img.get_width(), 'height': img.get_height()}
1385
+ if failures:
1386
+ result['effect_update_failures'] = failures
1387
+ return result
853
1388
 
854
1389
 
855
1390
  def op_rotate(args):
@@ -857,7 +1392,10 @@ def op_rotate(args):
857
1392
  multiples, so every angle goes through `Item.transform_rotate` instead. `expand` resizes the
858
1393
  canvas to the rotated layers' new bounds (`Image.resize_to_layers`); without it the canvas
859
1394
  stays put and rotated content can fall outside it, same as a Photoshop free-transform without
860
- "reveal all".
1395
+ "reveal all". Any angle is accepted -- UNLESS a position/direction-dependent effect filter
1396
+ (vignette, motion_blur, drop_shadow) is present, in which case only an exact 0/90/180/270
1397
+ degrees is allowed (`_refuse_if_non_right_angle_with_tracked_effects`; see lib.py's own "ONLY
1398
+ exact cases are supported" comment for why).
861
1399
 
862
1400
  `transform_rotate`'s second argument is `auto_center` -- confusingly, passing True means
863
1401
  "IGNORE the center_x/center_y given and use this item's own bounds' center instead," not
@@ -870,11 +1408,22 @@ def op_rotate(args):
870
1408
  img = _image(args)
871
1409
  _refuse_if_masked_filters(img, 'rotate')
872
1410
  degrees = float(lib.require(args, 'degrees'))
1411
+ expand = bool(args.get('expand', False))
1412
+ _refuse_if_non_right_angle_with_tracked_effects(img, degrees, 'rotate')
1413
+ # Every affected filter's new params are computed and range-validated from each owning
1414
+ # layer's CURRENT (pre-rotation) dimensions before anything below mutates the image -- see
1415
+ # `_snapshot_effect_transform`'s own doc comment.
1416
+ planned = _snapshot_effect_transform(
1417
+ img, 'rotate',
1418
+ lambda operation, params, layer: lib.rotate_effect_params(
1419
+ operation, params, degrees, layer.get_width(), layer.get_height()
1420
+ ),
1421
+ )
873
1422
  # With a selection active, transform_rotate moves only the selected pixels and leaves a
874
1423
  # floating selection behind (see op_open).
875
1424
  Gimp.Selection.none(img)
876
- expand = bool(args.get('expand', False))
877
- cx, cy = img.get_width() / 2.0, img.get_height() / 2.0
1425
+ old_w, old_h = img.get_width(), img.get_height()
1426
+ cx, cy = old_w / 2.0, old_h / 2.0
878
1427
  angle = math.radians(degrees)
879
1428
  for layer in img.get_layers():
880
1429
  layer.transform_rotate(angle, False, cx, cy)
@@ -882,8 +1431,12 @@ def op_rotate(args):
882
1431
  channel.transform_rotate(angle, False, cx, cy)
883
1432
  if expand:
884
1433
  img.resize_to_layers()
1434
+ failures = _apply_planned_effect_transform(img, planned)
885
1435
  _drop_proxies(img.get_id())
886
- return {'width': img.get_width(), 'height': img.get_height(), 'degrees': degrees}
1436
+ result = {'width': img.get_width(), 'height': img.get_height(), 'degrees': degrees}
1437
+ if failures:
1438
+ result['effect_update_failures'] = failures
1439
+ return result
887
1440
 
888
1441
 
889
1442
  _FLIP_ORIENTATIONS = {
@@ -900,23 +1453,33 @@ def op_flip(args):
900
1453
  raise ValueError('orientation must be one of %s' % sorted(_FLIP_ORIENTATIONS))
901
1454
  img = _image(args)
902
1455
  _refuse_if_masked_filters(img, 'flip')
1456
+ planned = _snapshot_effect_transform(
1457
+ img, 'flip',
1458
+ lambda operation, params, layer: lib.flip_effect_params(operation, params, orientation),
1459
+ )
903
1460
  Gimp.Selection.none(img) # see op_open: geometry always applies to the whole canvas
904
1461
  img.flip(_FLIP_ORIENTATIONS[orientation])
1462
+ failures = _apply_planned_effect_transform(img, planned)
905
1463
  _drop_proxies(img.get_id())
906
- return {'width': img.get_width(), 'height': img.get_height()}
1464
+ result = {'width': img.get_width(), 'height': img.get_height()}
1465
+ if failures:
1466
+ result['effect_update_failures'] = failures
1467
+ return result
907
1468
 
908
1469
 
909
1470
  def _proxy_render(img, max_px):
910
1471
  """A flattened, downscaled render of img with its live filters: the proxy plus mirrored
911
- filters. Caller deletes it."""
1472
+ filters. Caller deletes it. Returns (dup, unmirrored_filters) -- the second element is
1473
+ `_mirror_filters`'s own return value, surfaced by every caller's result as
1474
+ `unmirrored_filters`."""
912
1475
  dup = _proxy(img, max_px).duplicate()
913
1476
  try:
914
- _mirror_filters(img, dup)
1477
+ unmirrored = _mirror_filters(img, dup)
915
1478
  dup.flatten()
916
1479
  except Exception:
917
1480
  dup.delete()
918
1481
  raise
919
- return dup
1482
+ return dup, unmirrored
920
1483
 
921
1484
 
922
1485
  def _region_full_res(img, region):
@@ -971,11 +1534,11 @@ def op_preview(args):
971
1534
  'proxy': False}
972
1535
  finally:
973
1536
  dup.delete()
974
- dup = _proxy_render(img, max_px)
1537
+ dup, unmirrored = _proxy_render(img, max_px)
975
1538
  try:
976
1539
  _export_stripped(dup, out_path)
977
1540
  return {'path': out_path, 'width': dup.get_width(), 'height': dup.get_height(),
978
- 'proxy': True}
1541
+ 'proxy': True, 'unmirrored_filters': unmirrored}
979
1542
  finally:
980
1543
  dup.delete()
981
1544
 
@@ -1014,7 +1577,7 @@ def op_histogram(args):
1014
1577
  scale = min(1.0, 1024.0 / max(img_w, img.get_height())) if img_w else 1.0
1015
1578
  px, py, pw, ph = lib.region_to_proxy_px(region, scale)
1016
1579
  if pw >= lib.MIN_PROXY_REGION_PX and ph >= lib.MIN_PROXY_REGION_PX:
1017
- dup = _proxy_render(img, 1024)
1580
+ dup, unmirrored = _proxy_render(img, 1024)
1018
1581
  try:
1019
1582
  px = min(px, max(0, dup.get_width() - 1))
1020
1583
  py = min(py, max(0, dup.get_height() - 1))
@@ -1025,22 +1588,27 @@ def op_histogram(args):
1025
1588
  w, h, out = _channel_stats_from(dup, layer, channels)
1026
1589
  finally:
1027
1590
  dup.delete()
1028
- return {'exact': False, 'width': w, 'height': h, 'pixels': w * h, 'channels': out}
1591
+ return {'exact': False, 'width': w, 'height': h, 'pixels': w * h, 'channels': out,
1592
+ 'unmirrored_filters': unmirrored}
1029
1593
  region = dict(region) # fall through to the full-res path below
1030
1594
  exact = True
1031
1595
 
1596
+ unmirrored = []
1032
1597
  if region:
1033
1598
  dup, layer = _region_full_res(img, region)
1034
1599
  elif exact:
1035
1600
  dup, layer = _composite(img)
1036
1601
  else:
1037
- dup = _proxy_render(img, 1024)
1602
+ dup, unmirrored = _proxy_render(img, 1024)
1038
1603
  layer = dup.get_layers()[0]
1039
1604
  try:
1040
1605
  w, h, out = _channel_stats_from(dup, layer, channels)
1041
1606
  finally:
1042
1607
  dup.delete()
1043
- return {'exact': exact, 'width': w, 'height': h, 'pixels': w * h, 'channels': out}
1608
+ result = {'exact': exact, 'width': w, 'height': h, 'pixels': w * h, 'channels': out}
1609
+ if not region and not exact:
1610
+ result['unmirrored_filters'] = unmirrored
1611
+ return result
1044
1612
 
1045
1613
 
1046
1614
  def op_compare(args):
@@ -1069,7 +1637,7 @@ def op_compare(args):
1069
1637
  after = None
1070
1638
  try:
1071
1639
  base = _proxy(img, max_px).duplicate()
1072
- after = _proxy_render(img, max_px)
1640
+ after, unmirrored = _proxy_render(img, max_px)
1073
1641
  if region:
1074
1642
  img_w = img.get_width()
1075
1643
  scale = min(1.0, float(max_px) / max(img_w, img.get_height())) if img_w else 1.0
@@ -1089,7 +1657,8 @@ def op_compare(args):
1089
1657
  deltas[ch] = {
1090
1658
  k: round(a[k] - b[k], 3) for k in ('mean', 'median', 'p1', 'p5', 'p95', 'p99')
1091
1659
  }
1092
- result = {'before': before_stats, 'after': after_stats, 'delta': deltas, 'proxy': True}
1660
+ result = {'before': before_stats, 'after': after_stats, 'delta': deltas, 'proxy': True,
1661
+ 'unmirrored_filters': unmirrored}
1093
1662
  before_path, after_path = args.get('before_path'), args.get('after_path')
1094
1663
  if before_path:
1095
1664
  _export_stripped(base, before_path)
@@ -1265,7 +1834,14 @@ def _channel_coverage(ch, w, h):
1265
1834
  # reproduces the ramp exactly. Every mask/selection buffer read or write in this file uses
1266
1835
  # the primed, perceptual format for the same reason.
1267
1836
  data = ch.get_buffer().get(Gegl.Rectangle.new(0, 0, w, h), 1.0, "Y' u8", Gegl.AbyssPolicy.NONE)
1268
- selected = sum(1 for b in data if b >= 128)
1837
+ # `bytes.translate(None, delete)` runs the byte-by-byte pass in C rather than the interpreter
1838
+ # loop a `sum(1 for b in data if b >= 128)` generator pays per byte. `delete` names the bytes to
1839
+ # DROP, so deleting every byte below 128 (0..127) leaves exactly the >=128 ones behind: len() of
1840
+ # what's left IS the selected count directly, no subtraction needed. Same result as the
1841
+ # generator, measured live (GIMP 3.2.6, ~24MP) at ~120ms/channel -- what keeps `describe`'s
1842
+ # `channels` target, which reads this per NAMED channel rather than just one, fast enough to
1843
+ # stay inside its own time budget (`_channels_described`'s `CHANNELS_DESCRIBE_DEADLINE_S`).
1844
+ selected = len(data.translate(None, bytes(range(128))))
1269
1845
  return selected, round(selected / len(data), 4)
1270
1846
 
1271
1847
 
@@ -1409,6 +1985,11 @@ def op_describe_operation(args):
1409
1985
  # differs every run and would make the schema golden fail on every diff
1410
1986
  # regardless of any real drift; its points are the stable, meaningful summary.
1411
1987
  default = _curve_points(default)
1988
+ elif isinstance(default, Gegl.Color):
1989
+ # Same reasoning as GimpCurve above -- a bare repr() of a GeglColor (e.g.
1990
+ # vignette's/dropshadow's `color`) embeds two object pointer addresses that
1991
+ # differ every run. `get_rgba()` is the stable, meaningful summary.
1992
+ default = list(default.get_rgba())
1412
1993
  elif hasattr(default, 'value_nick'):
1413
1994
  default = default.value_nick
1414
1995
  elif not isinstance(default, (int, float, str, bool, type(None))):
@@ -1428,6 +2009,1042 @@ def op_describe_operation(args):
1428
2009
  img.delete()
1429
2010
 
1430
2011
 
2012
+ # ---- layer management (gimp_layer) ---------------------------------------------------------
2013
+ #
2014
+ # `create_group` + `reorder`'s `parent_group` cover everything a dedicated group tool would --
2015
+ # there is no separate group tool.
2016
+ #
2017
+ # Verified live (GIMP 3.2.6), the assumptions this section is built on:
2018
+ # - `Gimp.GroupLayer.new(image, name)` + `Image.insert_layer(layer, parent, position)` works for
2019
+ # both a plain layer and a group, nested arbitrarily deep; `Image.reorder_item(item, parent,
2020
+ # position)` moves an existing item (including into/out of a group) the same way. Duplicating a
2021
+ # group (`layer.copy()`) renames every descendant itself, uniquely, as `<name> #<n>` (the
2022
+ # ORIGINAL keeps its own name) -- verified across repeated and nested duplicates, so this
2023
+ # section never has to rename a duplicated group's own children itself.
2024
+ # - `layer.copy()` returns a new, unattached layer whose filters carry the SAME NAMES as the
2025
+ # source's -- and `DrawableFilter.set_name` does NOT exist on this GIMP build (verified: not in
2026
+ # its method list at all), so a copy of a layer with an Editmamei filter cannot be renamed. The
2027
+ # ledger is keyed by filter name (`_apply_filter`), so leaving the collision in place would
2028
+ # mean a later re-edit of EITHER copy silently rewrites the other's ledger record. `duplicate`
2029
+ # therefore refuses outright when the source (or, for a group, any descendant) carries an
2030
+ # Editmamei-ledgered filter.
2031
+ # - `Item.set_offsets(x, y)` is an ABSOLUTE move, not a delta -- verified live (two successive
2032
+ # calls land the layer exactly where each one asked, not summed). Called on a GROUP layer, it
2033
+ # moves every descendant by the same delta too (verified live: a child's own reported offset
2034
+ # shifts by exactly the same amount as the group's) -- `move` needs no special-casing for a
2035
+ # group target.
2036
+ # - A masked filter's confinement does NOT travel with `set_offsets`: verified live with a
2037
+ # brightness-contrast filter masked to the left half of a layer, moved by set_offsets -- the
2038
+ # darkened region did not reappear anywhere in the layer's new position (its mask stayed
2039
+ # pinned to the OLD canvas coordinates, the same "confinement is a fixed snapshot" physics
2040
+ # `_refuse_if_masked_filters`'s own comment documents for rotate/flip). `move` therefore
2041
+ # refuses under the same conditions, scoped to the moved layer's own subtree AND its containing
2042
+ # groups (an untouched OTHER, unrelated layer's masked filter is unaffected by this layer
2043
+ # moving) -- see `_refuse_if_masked_filters_on`'s own comment for why duplicate filter NAMES
2044
+ # are classified over the whole image even though the refusal itself stays scoped.
2045
+ # - `Image.merge_down(layer, merge_type)` merges INTO THE FIRST VISIBLE LAYER BELOW -- verified
2046
+ # live that a HIDDEN layer directly beneath the one being merged is skipped entirely and left
2047
+ # untouched, not merged and not removed. Refuses (returns None, no Python exception -- GIMP
2048
+ # logs "Cannot merge down to a layer group." / "There is no visible layer to merge down to." to
2049
+ # its own console) when that target would be a group, or when there is no visible layer below
2050
+ # at all; both are checked here first for a clean, specific error instead of a generic failure.
2051
+ # - `Image.merge_down` and `Image.flatten()` both render each source layer's live filters (masked
2052
+ # ones included) into real pixels BEFORE compositing -- verified live: a masked
2053
+ # brightness-contrast filter's confined darkening survived merge_down exactly, on a top-level
2054
+ # pair and on a pair nested inside a group. Both consume every live filter on every layer they
2055
+ # touch (verified: a layer being merged INTO, which had its own separate live filter, ends up
2056
+ # with `get_filters() == []` afterward too, not just the layer merged away) -- no masked-filter
2057
+ # refusal is needed for either, only a ledger prune afterward.
2058
+ # - `Image.flatten()` DISCARDS every hidden layer outright (verified live: a hidden layer's own
2059
+ # content never reaches the flattened composite, and the layer itself is simply gone
2060
+ # afterward, text layers included) rather than compositing it in -- `flatten` therefore refuses
2061
+ # by default when any layer is hidden, listing them, unless `discard_hidden: true` is given.
2062
+ # Always drops alpha (verified: `has_alpha()` is False on the result even when the only layer
2063
+ # had one) -- surfaced in the result and the tool description, not hidden.
2064
+ # - A VISIBLE text layer merges/flattens into an ordinary raster layer with no error (verified);
2065
+ # a HIDDEN one is simply discarded like any other hidden layer, never rasterized at all, so
2066
+ # `rasterized_text` only counts visible text layers.
2067
+ # - `Drawable.merge_filters()` (gimp_bake) does NOT rasterize a text layer -- verified live:
2068
+ # `is_text_layer()` still reads True after baking away its filter. Bake's result has no
2069
+ # `rasterized_text` field for this reason; the tool description just says so.
2070
+ # - A filter CAN be attached to a group layer (verified live: `DrawableFilter.new` and
2071
+ # `append_filter` both succeed on one) -- `gimp_add_adjustment` does not refuse it. `gimp_bake`
2072
+ # cannot bake one (`merge_filters()` errors on a group, see `_bake_one`), so `op=bake all: true`
2073
+ # reports any such group under `skipped_groups_with_filters` rather than silently ignoring it.
2074
+
2075
+
2076
+ def _layer_subtree(layer):
2077
+ """`layer` itself, plus every descendant if it is a group -- the scope a structural op on
2078
+ `layer` actually reaches (deleting, duplicating, or moving a group takes its children with
2079
+ it). A plain (non-group) layer's subtree is just itself."""
2080
+ out = [layer]
2081
+
2082
+ def walk(items):
2083
+ for item in items:
2084
+ out.append(item)
2085
+ if item.is_group():
2086
+ walk(item.get_children())
2087
+
2088
+ if layer.is_group():
2089
+ walk(layer.get_children())
2090
+ return out
2091
+
2092
+
2093
+ def _ledgered_filter_names_on(img, layers):
2094
+ """Names of live filters on `layers` (see `_layer_subtree`) that match an Editmamei ledger
2095
+ record -- the ones `duplicate` cannot safely carry onto a copy (see its own comment above)."""
2096
+ filters, _unknown = _ledger_get(img)
2097
+ out = []
2098
+ for layer in layers:
2099
+ for f in layer.get_filters():
2100
+ rec = filters.get(f.get_name())
2101
+ if rec and rec.get('operation') == f.get_operation_name():
2102
+ out.append(f.get_name())
2103
+ return out
2104
+
2105
+
2106
+ def _layer_ancestors(layer):
2107
+ """Every GROUP layer between `layer` and the top of the stack, immediate parent first -- the
2108
+ complement of `_layer_subtree` (which walks DOWN into descendants, never up). A masked filter
2109
+ on one of these confines to that group's own rendered composite, which shifts under it when a
2110
+ layer inside the group moves, exactly the same misalignment a masked filter directly on the
2111
+ moving layer would suffer."""
2112
+ out = []
2113
+ parent = layer.get_parent()
2114
+ while parent is not None:
2115
+ out.append(parent)
2116
+ parent = parent.get_parent()
2117
+ return out
2118
+
2119
+
2120
+ def _refuse_if_masked_filters_on(img, op_name, layer):
2121
+ """The same refuse-rather-than-corrupt check as `_refuse_if_masked_filters`, scoped to `layer`
2122
+ itself, its own subtree (`_layer_subtree` -- moving a group takes its children with it), and
2123
+ its containing groups (`_layer_ancestors`) -- moving `layer` cannot misalign a masked filter
2124
+ that lives on some OTHER, unrelated part of the image.
2125
+
2126
+ Duplicate-name classification runs over the WHOLE image's live filters, not just this scope: a
2127
+ name is ambiguous the moment it appears on more than one live filter ANYWHERE in the image,
2128
+ since the ledger's {name: record} shape can only ever describe one of them -- a masked filter
2129
+ elsewhere hiding behind an unmasked one's ledger record in this scope (or the reverse) would
2130
+ otherwise slip through unnoticed. The refusal itself still only fires for a name that actually
2131
+ appears in `layer`'s own scope, so a masked filter on a completely unrelated layer never blocks
2132
+ this move."""
2133
+ scope = set(_layer_subtree(layer)) | set(_layer_ancestors(layer))
2134
+ scoped_names = {f.get_name() for l in scope for f in l.get_filters()}
2135
+ filters = _prune_stale_ledger_records(img)
2136
+ whole_image_live = [
2137
+ (f.get_name(), f.get_operation_name()) for l in _all_layers(img) for f in l.get_filters()
2138
+ ]
2139
+ masked, unverifiable = lib.classify_geometry_filters(filters, whole_image_live)
2140
+ masked = sorted(set(n for n in masked if n in scoped_names))
2141
+ unverifiable = sorted(set(n for n in unverifiable if n in scoped_names))
2142
+ if unverifiable:
2143
+ # An in-scope name lands here for one of two different reasons, which need different
2144
+ # advice: no matching ledger record at all (a genuinely foreign filter -- delete IT), or a
2145
+ # name carried by more than one live filter in the image. The ledger is keyed by name, so
2146
+ # in the second case it cannot say which copy is Editmamei's; the advice names every layer
2147
+ # carrying the name and leaves the choice to the caller. `name_counts` -- not
2148
+ # `lib.classify_geometry_filters`'s own return, which doesn't distinguish the two -- is what
2149
+ # tells them apart.
2150
+ name_counts = {}
2151
+ for live_name, _operation in whole_image_live:
2152
+ name_counts[live_name] = name_counts.get(live_name, 0) + 1
2153
+ duplicated = sorted(n for n in unverifiable if name_counts[n] > 1)
2154
+ unrecognized = sorted(n for n in unverifiable if name_counts[n] <= 1)
2155
+ reasons = []
2156
+ if unrecognized:
2157
+ reasons.append(
2158
+ 'filter(s) %s on this layer, or a group containing it, were not created by '
2159
+ 'Editmamei (no matching ledger record for their name and operation), so whether '
2160
+ 'they are masked cannot be checked -- delete them first, or move the layer before '
2161
+ 'adding them'
2162
+ % ', '.join(repr(n) for n in unrecognized)
2163
+ )
2164
+ if duplicated:
2165
+ dup_descriptions = []
2166
+ for name in duplicated:
2167
+ carriers = sorted({
2168
+ l.get_name() for l in _all_layers(img) for f in l.get_filters()
2169
+ if f.get_name() == name
2170
+ })
2171
+ dup_descriptions.append(
2172
+ '%r (on layer(s) %s)' % (name, ', '.join(repr(c) for c in carriers))
2173
+ )
2174
+ reasons.append(
2175
+ 'the name(s) %s are carried by more than one live filter, so which copy was '
2176
+ 'created by Editmamei cannot be told apart -- rename or delete whichever copy was '
2177
+ 'not created by Editmamei, then retry'
2178
+ % ', '.join(dup_descriptions)
2179
+ )
2180
+ raise ValueError('%s cannot proceed: %s.' % (op_name, '; '.join(reasons)))
2181
+ if masked:
2182
+ raise ValueError(
2183
+ '%s would misalign the masked adjustment(s) %s: a filter\'s mask does not travel with '
2184
+ 'content that moves beneath it, whether the filter is on this layer or a group that '
2185
+ 'contains it. Move the layer before adding masked adjustments, or delete those filters '
2186
+ 'first and re-create them afterwards.'
2187
+ % (op_name, ', '.join(repr(n) for n in masked))
2188
+ )
2189
+
2190
+
2191
+ def _resolve_parent_group(img, args):
2192
+ """`args['parent_group']` (a layer_id) resolved to a group layer belonging to `img`, or None
2193
+ for top-level (the key absent, or explicitly null). Membership- and group-checked the same way
2194
+ `_layer`'s own `layer_id` path is -- a stray id, one from another image, or one that names an
2195
+ ordinary layer, is refused rather than silently misinserted."""
2196
+ parent_id = args.get('parent_group')
2197
+ if parent_id is None:
2198
+ return None
2199
+ parent = Gimp.Layer.get_by_id(int(parent_id))
2200
+ owner = _owning_image(parent)
2201
+ if parent is None or owner is None or owner.get_id() != img.get_id():
2202
+ raise ValueError('no layer with id %s on image %s' % (parent_id, img.get_id()))
2203
+ if not parent.is_group():
2204
+ raise ValueError('parent_group %s is not a group layer' % parent_id)
2205
+ return parent
2206
+
2207
+
2208
+ def _assert_layer_attached(img, layer, what):
2209
+ """Some GIMP calls return normally having silently done nothing at all rather than raising on
2210
+ failure (`convert_precision`, `op_open`'s own comment) -- so every insertion this section
2211
+ relies on is verified the same defensive way: confirm the layer is actually reachable by
2212
+ walking `img`'s own live layer tree (`_all_layers`, not just trusting `Item.get_image()`'s own
2213
+ metadata) before reporting success."""
2214
+ if not any(l.get_id() == layer.get_id() for l in _all_layers(img)):
2215
+ raise lib.OpError('gimp_op_failed', '%s did not attach to the image' % what)
2216
+
2217
+
2218
+ def _discard_layer(img, layer):
2219
+ """Best-effort cleanup for a layer this op created or inserted but must not keep, on any
2220
+ failure path from that point on (never let a half-finished op leave an orphaned GIMP object
2221
+ behind): remove it from the image if it got that far, then delete the underlying object
2222
+ outright. Verified live that `Item.delete()` works both on a layer that was never inserted at
2223
+ all and on one already removed -- so this is safe to call unconditionally. Swallows its own
2224
+ failures: this runs while another exception is already propagating, and cleanup failing must
2225
+ never replace the real error."""
2226
+ owner = _owning_image(layer)
2227
+ if owner is not None and owner.get_id() == img.get_id():
2228
+ try:
2229
+ img.remove_layer(layer)
2230
+ except Exception:
2231
+ pass
2232
+ try:
2233
+ layer.delete()
2234
+ except Exception:
2235
+ pass
2236
+
2237
+
2238
+ def _sibling_index(layer, parent):
2239
+ """`layer`'s own index among its current siblings (`parent`'s children, or the image's
2240
+ top-level layers when `parent` is None) -- compared by id, never by GObject identity (each
2241
+ `get_children()`/`get_layers()` call can hand back a fresh Python wrapper for the same
2242
+ underlying item)."""
2243
+ siblings = list(parent.get_children()) if parent is not None else list(_owning_image(layer).get_layers())
2244
+ return next((i for i, s in enumerate(siblings) if s.get_id() == layer.get_id()), None)
2245
+
2246
+
2247
+ def _validated_position(args):
2248
+ position = int(args.get('position', 0))
2249
+ if position < 0:
2250
+ raise ValueError('position must be >= 0')
2251
+ return position
2252
+
2253
+
2254
+ def _validated_move_offset(img, x, y):
2255
+ """A DoS floor on `move`'s target offset, the same spirit as `lib.validate_resize_dims`'s own
2256
+ bound on `resize`: nothing stops a layer from being moved far outside the canvas on its own,
2257
+ but combined with a later merge_down/flatten/resize_to_layers an arbitrarily distant offset
2258
+ could blow up the working canvas size. Bounded to within `lib.MAX_RESIZE_SIDE_PX` of either
2259
+ edge of the CURRENT canvas, not an absolute limit -- generous enough for any real placement,
2260
+ tight enough to keep a single call's downstream cost bounded."""
2261
+ lo_x, hi_x = -lib.MAX_RESIZE_SIDE_PX, img.get_width() + lib.MAX_RESIZE_SIDE_PX
2262
+ lo_y, hi_y = -lib.MAX_RESIZE_SIDE_PX, img.get_height() + lib.MAX_RESIZE_SIDE_PX
2263
+ if not lo_x <= x <= hi_x:
2264
+ raise ValueError('x must be within %d..%d for this %dpx-wide canvas' % (lo_x, hi_x, img.get_width()))
2265
+ if not lo_y <= y <= hi_y:
2266
+ raise ValueError('y must be within %d..%d for this %dpx-tall canvas' % (lo_y, hi_y, img.get_height()))
2267
+
2268
+
2269
+ def _union_bbox(a, b):
2270
+ """The bounding box `Image.merge_down`'s EXPAND_AS_NECESSARY would produce for merging `a`
2271
+ into `b` -- the union of their two offset rectangles. Checked against the same DoS floor
2272
+ `resize` uses (`lib.validate_resize_dims`) BEFORE the merge runs: two layers offset far apart
2273
+ could otherwise expand the result to an enormous canvas."""
2274
+ _ok_a, ax, ay = a.get_offsets()
2275
+ _ok_b, bx, by = b.get_offsets()
2276
+ aw, ah = a.get_width(), a.get_height()
2277
+ bw, bh = b.get_width(), b.get_height()
2278
+ left, top = min(ax, bx), min(ay, by)
2279
+ right, bottom = max(ax + aw, bx + bw), max(ay + ah, by + bh)
2280
+ return right - left, bottom - top
2281
+
2282
+
2283
+ def _merge_down_target(siblings, idx):
2284
+ """The layer `merge_down` will actually merge `siblings[idx]` into: the first VISIBLE layer
2285
+ below it in the same parent/level -- verified live that GIMP silently skips a hidden layer in
2286
+ between and merges into the next visible one instead, leaving the hidden layer untouched.
2287
+ Returns None if there is no visible layer below."""
2288
+ for candidate in siblings[idx + 1:]:
2289
+ if candidate.get_visible():
2290
+ return candidate
2291
+ return None
2292
+
2293
+
2294
+ # Base types a NEW layer can be given a matching `Gimp.ImageType` for -- everywhere a fresh layer
2295
+ # might need to be added (gimp_layer op=create, a gimp_canvas fill backdrop) reads this SAME dict
2296
+ # rather than repeating the RGB/GRAY-only assumption independently, so the two call sites can
2297
+ # never drift apart on which base types qualify.
2298
+ _LAYER_CAPABLE_BASE_TYPES = {
2299
+ Gimp.ImageBaseType.RGB: Gimp.ImageType.RGBA_IMAGE,
2300
+ Gimp.ImageBaseType.GRAY: Gimp.ImageType.GRAYA_IMAGE,
2301
+ }
2302
+
2303
+
2304
+ def _layer_type_for(img):
2305
+ """The `Gimp.ImageType` a new layer should use so it matches `img`'s own color model instead
2306
+ of relying on GIMP's own silent coercion (verified live: an RGBA layer inserted into a
2307
+ grayscale image is quietly converted to GRAYA on insert -- this asks for the right type up
2308
+ front rather than lean on that)."""
2309
+ base = img.get_base_type()
2310
+ layer_type = _LAYER_CAPABLE_BASE_TYPES.get(base)
2311
+ if layer_type is None:
2312
+ raise ValueError(
2313
+ 'gimp_layer op=create does not support %s images -- only RGB and grayscale' % base.value_nick
2314
+ )
2315
+ return layer_type
2316
+
2317
+
2318
+ # `Gimp.FillType` has no BLACK member (verified live: WHITE, TRANSPARENT, FOREGROUND, BACKGROUND,
2319
+ # PATTERN, CIELAB_MIDDLE_GRAY only) -- FOREGROUND/BACKGROUND are whatever color the session's
2320
+ # ambient context happens to hold, not reliably black, so a black fill is done by pushing the
2321
+ # context, setting the foreground to an explicit black, filling with FOREGROUND, and popping the
2322
+ # context back -- the same push/pop bracket `op_create_mask` already uses around its own context
2323
+ # changes. Module-level so a typo'd enum member here fails at import time, not on the first call
2324
+ # that happens to hit it.
2325
+ _LAYER_FILL_TYPES = {'white': Gimp.FillType.WHITE, 'transparent': Gimp.FillType.TRANSPARENT}
2326
+
2327
+
2328
+ def _fill_new_layer(layer, fill):
2329
+ # `Drawable.fill()` reports failure by returning False rather than raising (the same "calling
2330
+ # error returns False" class `op_open`'s own `convert_precision` comment documents, verified
2331
+ # live for this call too) -- checked here so a failed fill is never silently reported as
2332
+ # success with the layer left at whatever undefined content `Layer.new` gave it.
2333
+ if fill == 'black':
2334
+ Gimp.context_push()
2335
+ try:
2336
+ Gimp.context_set_foreground(Gegl.Color.new('black'))
2337
+ ok = layer.fill(Gimp.FillType.FOREGROUND)
2338
+ finally:
2339
+ Gimp.context_pop()
2340
+ else:
2341
+ ok = layer.fill(_LAYER_FILL_TYPES[fill])
2342
+ if not ok:
2343
+ raise lib.OpError('gimp_op_failed', 'GIMP could not fill the new layer with %s' % fill)
2344
+
2345
+
2346
+ def _op_layer_create(img, args):
2347
+ # Every field is validated BEFORE any GIMP mutation runs, and the whole insert+fill sequence
2348
+ # is one try/finally: a failure partway through must still drop the proxy cache (something may
2349
+ # already have changed) and must not leave an orphaned, half-inserted layer object behind.
2350
+ name = _unique_layer_name(img, args.get('name') or 'Layer')
2351
+ width = int(args['width']) if args.get('width') is not None else img.get_width()
2352
+ height = int(args['height']) if args.get('height') is not None else img.get_height()
2353
+ width, height = lib.validate_resize_dims(width, height)
2354
+ fill = lib.validate_choice('fill', args.get('fill', 'transparent'), lib.LAYER_FILLS)
2355
+ parent = _resolve_parent_group(img, args)
2356
+ position = _validated_position(args)
2357
+ layer_type = _layer_type_for(img)
2358
+ layer = Gimp.Layer.new(img, name, width, height, layer_type, 100.0, Gimp.LayerMode.NORMAL)
2359
+ try:
2360
+ img.insert_layer(layer, parent, position)
2361
+ _assert_layer_attached(img, layer, 'the new layer')
2362
+ _fill_new_layer(layer, fill)
2363
+ except Exception:
2364
+ _discard_layer(img, layer)
2365
+ raise
2366
+ finally:
2367
+ _drop_proxies(img.get_id())
2368
+ return {'layer_id': layer.get_id(), 'name': layer.get_name(), 'is_group': False}
2369
+
2370
+
2371
+ def _op_layer_create_group(img, args):
2372
+ name = _unique_layer_name(img, args.get('name') or 'Group')
2373
+ parent = _resolve_parent_group(img, args)
2374
+ position = _validated_position(args)
2375
+ group = Gimp.GroupLayer.new(img, name)
2376
+ try:
2377
+ img.insert_layer(group, parent, position)
2378
+ _assert_layer_attached(img, group, 'the new group')
2379
+ except Exception:
2380
+ _discard_layer(img, group)
2381
+ raise
2382
+ finally:
2383
+ _drop_proxies(img.get_id())
2384
+ return {'layer_id': group.get_id(), 'name': group.get_name(), 'is_group': True}
2385
+
2386
+
2387
+ def _op_layer_delete(img, args):
2388
+ layer = _layer(img, args)
2389
+ subtree = _layer_subtree(layer)
2390
+ removed_drawables = sum(1 for l in subtree if not l.is_group())
2391
+ total_drawables = sum(1 for l in _all_layers(img) if not l.is_group())
2392
+ if total_drawables - removed_drawables <= 0:
2393
+ raise ValueError(
2394
+ 'cannot delete %r: it is (or contains) every remaining layer in this image -- an '
2395
+ 'image needs at least one' % layer.get_name()
2396
+ )
2397
+ layer_id, name = layer.get_id(), layer.get_name()
2398
+ try:
2399
+ img.remove_layer(layer)
2400
+ if any(l.get_id() == layer_id for l in _all_layers(img)):
2401
+ raise lib.OpError('gimp_op_failed', 'GIMP did not remove this layer')
2402
+ finally:
2403
+ _prune_stale_ledger_records(img)
2404
+ _drop_proxies(img.get_id())
2405
+ return {'layer_id': layer_id, 'name': name, 'deleted': True}
2406
+
2407
+
2408
+ def _op_layer_duplicate(img, args):
2409
+ layer = _layer(img, args)
2410
+ ledgered = _ledgered_filter_names_on(img, _layer_subtree(layer))
2411
+ if ledgered:
2412
+ raise ValueError(
2413
+ 'cannot duplicate %r: it carries Editmamei filter(s) %s. GIMP has no way to rename a '
2414
+ "copied filter in this build (DrawableFilter.set_name does not exist), and the ledger "
2415
+ "is keyed by filter name, so the copy's filter would silently rewrite the original's "
2416
+ 'own record the next time either is re-edited. Bake it first (gimp_bake) or delete it, '
2417
+ 'then duplicate.' % (layer.get_name(), ', '.join(repr(n) for n in sorted(set(ledgered))))
2418
+ )
2419
+ copy = layer.copy()
2420
+ if copy is None:
2421
+ raise lib.OpError('gimp_op_failed', 'GIMP could not duplicate this layer')
2422
+ try:
2423
+ copy.set_name(_unique_layer_name(img, layer.get_name()))
2424
+ parent = layer.get_parent()
2425
+ position = _sibling_index(layer, parent)
2426
+ img.insert_layer(copy, parent, position if position is not None else 0)
2427
+ _assert_layer_attached(img, copy, 'the duplicated layer')
2428
+ except Exception:
2429
+ _discard_layer(img, copy)
2430
+ raise
2431
+ finally:
2432
+ _drop_proxies(img.get_id())
2433
+ return {'layer_id': copy.get_id(), 'name': copy.get_name(), 'is_group': copy.is_group()}
2434
+
2435
+
2436
+ def _op_layer_select(img, args):
2437
+ # The one sub-op that changes neither pixels nor structure -- deliberately does NOT drop
2438
+ # proxies (see the PROXIES cache's own comment).
2439
+ layer = _layer(img, args)
2440
+ img.set_selected_layers([layer])
2441
+ return {'layer_id': layer.get_id(), 'name': layer.get_name()}
2442
+
2443
+
2444
+ def _op_layer_set(img, args):
2445
+ layer = _layer(img, args)
2446
+ # Every field is validated FIRST, before any setter runs at all: a later field failing
2447
+ # validation must never leave an earlier one already applied with nothing to show for it (no
2448
+ # proxy drop, no error).
2449
+ to_apply = []
2450
+ if args.get('opacity') is not None:
2451
+ to_apply.append(('opacity', lib.validate_range('opacity', args['opacity'], 0.0, 100.0)))
2452
+ if args.get('mode') is not None:
2453
+ to_apply.append(('mode', lib.validate_layer_mode(args['mode'])))
2454
+ if 'visible' in args and args['visible'] is not None:
2455
+ to_apply.append(('visible', lib.require_bool(args, 'visible')))
2456
+ if args.get('name') is not None:
2457
+ new_name = str(args['name']).strip()
2458
+ if not new_name:
2459
+ raise ValueError('name must not be empty')
2460
+ to_apply.append(('name', _unique_layer_name(img, new_name, exclude=layer)))
2461
+ if not to_apply:
2462
+ raise ValueError('set needs at least one of opacity, mode, visible, name')
2463
+
2464
+ changed = {}
2465
+ try:
2466
+ for field, value in to_apply:
2467
+ if field == 'opacity':
2468
+ layer.set_opacity(value)
2469
+ elif field == 'mode':
2470
+ layer.set_mode(getattr(Gimp.LayerMode, lib.LAYER_MODES[value]))
2471
+ elif field == 'visible':
2472
+ layer.set_visible(value)
2473
+ else:
2474
+ layer.set_name(value)
2475
+ changed[field] = value
2476
+ finally:
2477
+ if changed:
2478
+ _drop_proxies(img.get_id())
2479
+ return {'layer_id': layer.get_id(), 'name': layer.get_name(), **changed}
2480
+
2481
+
2482
+ def _op_layer_move(img, args):
2483
+ layer = _layer(img, args)
2484
+ _refuse_if_masked_filters_on(img, 'move', layer)
2485
+ x, y = int(lib.require(args, 'x')), int(lib.require(args, 'y'))
2486
+ _validated_move_offset(img, x, y)
2487
+ try:
2488
+ layer.set_offsets(x, y)
2489
+ finally:
2490
+ _drop_proxies(img.get_id())
2491
+ ok, off_x, off_y = layer.get_offsets()
2492
+ return {'layer_id': layer.get_id(), 'x': off_x if ok else None, 'y': off_y if ok else None}
2493
+
2494
+
2495
+ def _op_layer_reorder(img, args):
2496
+ layer = _layer(img, args)
2497
+ parent = _resolve_parent_group(img, args) if 'parent_group' in args else layer.get_parent()
2498
+ # GIMP itself only logs a "Gimp-Core-CRITICAL" console assertion for this (verified live) --
2499
+ # it does not raise -- so it is checked here explicitly rather than left to that assertion plus
2500
+ # the post-hoc `_sibling_index` check below to catch it.
2501
+ if parent is not None and any(s.get_id() == parent.get_id() for s in _layer_subtree(layer)):
2502
+ raise ValueError('parent_group cannot be the layer itself, or one of its own descendants')
2503
+ position = _validated_position(args)
2504
+ try:
2505
+ img.reorder_item(layer, parent, position)
2506
+ if _sibling_index(layer, parent) is None:
2507
+ raise lib.OpError('gimp_op_failed', 'GIMP did not reorder this layer')
2508
+ finally:
2509
+ _drop_proxies(img.get_id())
2510
+ return {'layer_id': layer.get_id(), 'parent_group': parent.get_id() if parent is not None else None}
2511
+
2512
+
2513
+ def _op_layer_merge_down(img, args):
2514
+ layer = _layer(img, args)
2515
+ if layer.is_group():
2516
+ raise ValueError(
2517
+ 'merge_down does not accept a group layer -- move its children out first, or use '
2518
+ 'flatten to collapse the whole image'
2519
+ )
2520
+ # Verified live: GIMP's own merge_down silently fails (returns None) when the SOURCE layer's
2521
+ # own visibility is off, regardless of the target -- checked explicitly, right after the group
2522
+ # check above, so the refusal names the real reason instead of surfacing as a generic
2523
+ # gimp_op_failed. An ancestor group being hidden does not trigger this: verified live, GIMP
2524
+ # merges two VISIBLE siblings inside a hidden group without complaint, since the merge only
2525
+ # ever touches the two layers directly involved.
2526
+ if not layer.get_visible():
2527
+ raise ValueError(
2528
+ 'merge_down refuses on the HIDDEN layer %r -- GIMP cannot merge a hidden layer down. '
2529
+ 'Make it visible first, or use gimp_layer op=delete to drop just that layer instead.'
2530
+ % layer.get_name()
2531
+ )
2532
+ parent = layer.get_parent()
2533
+ siblings = list(parent.get_children()) if parent is not None else list(img.get_layers())
2534
+ idx = next((i for i, s in enumerate(siblings) if s.get_id() == layer.get_id()), None)
2535
+ target = _merge_down_target(siblings, idx) if idx is not None else None
2536
+ if target is None:
2537
+ raise ValueError(
2538
+ 'there is no VISIBLE layer below %r to merge into (a hidden layer in between is '
2539
+ 'skipped, not merged, so it is never a valid target)' % layer.get_name()
2540
+ )
2541
+ if target.is_group():
2542
+ raise ValueError(
2543
+ 'cannot merge %r into %r: GIMP cannot merge a layer into a group'
2544
+ % (layer.get_name(), target.get_name())
2545
+ )
2546
+ lib.validate_resize_dims(*_union_bbox(layer, target))
2547
+ rasterized_text = layer.is_text_layer() or target.is_text_layer()
2548
+ try:
2549
+ merged = img.merge_down(layer, Gimp.MergeType.EXPAND_AS_NECESSARY)
2550
+ if merged is None:
2551
+ raise lib.OpError('gimp_op_failed', 'GIMP could not merge this layer down')
2552
+ finally:
2553
+ _prune_stale_ledger_records(img)
2554
+ _drop_proxies(img.get_id())
2555
+ return {'layer_id': merged.get_id(), 'name': merged.get_name(), 'rasterized_text': rasterized_text}
2556
+
2557
+
2558
+ def _effectively_visible(layer):
2559
+ """Whether `layer` actually renders: its own visible flag AND every ANCESTOR group's own
2560
+ visible flag. `Item.get_visible()` reflects only that one item's own checkbox -- a visible
2561
+ child of a hidden group renders as invisible (and flatten discards it) exactly like a directly
2562
+ hidden layer would."""
2563
+ return layer.get_visible() and all(a.get_visible() for a in _layer_ancestors(layer))
2564
+
2565
+
2566
+ def _op_layer_flatten(img, args):
2567
+ all_layers = _all_layers(img)
2568
+ hidden = [l for l in all_layers if not _effectively_visible(l)]
2569
+ discard_hidden = lib.optional_bool(args, 'discard_hidden')
2570
+ if hidden and not discard_hidden:
2571
+ raise ValueError(
2572
+ 'flatten would discard %d hidden layer(s) (%s) -- GIMP drops a hidden layer entirely '
2573
+ 'rather than compositing it in (a layer inside a hidden group counts as hidden too, '
2574
+ 'even when its own visibility is on). Pass discard_hidden: true to proceed, or make '
2575
+ 'them visible first.' % (len(hidden), ', '.join(repr(l.get_name()) for l in hidden))
2576
+ )
2577
+ # Every LAYER hidden -- not just every leaf's content. A visible GROUP whose own content is
2578
+ # all hidden (or which has no children at all) never trips this: the group itself is not
2579
+ # hidden, and verified live, `Image.flatten()` handles that case fine on its own, producing a
2580
+ # single background-colored layer rather than failing -- so nothing extra is needed here for
2581
+ # it.
2582
+ if len(hidden) == len(all_layers):
2583
+ raise ValueError('cannot flatten: every layer is hidden, and flatten needs at least one visible layer')
2584
+ rasterized_text = any(l.is_text_layer() and _effectively_visible(l) for l in all_layers)
2585
+ discarded_hidden_layers = [{'layer_id': l.get_id(), 'name': l.get_name()} for l in hidden]
2586
+ try:
2587
+ flat = img.flatten()
2588
+ if flat is None:
2589
+ raise lib.OpError('gimp_op_failed', 'GIMP could not flatten this image')
2590
+ finally:
2591
+ _prune_stale_ledger_records(img)
2592
+ _drop_proxies(img.get_id())
2593
+ return {
2594
+ 'layer_id': flat.get_id(), 'name': flat.get_name(),
2595
+ 'rasterized_text': rasterized_text, 'has_alpha': flat.has_alpha(),
2596
+ 'discarded_hidden_layers': discarded_hidden_layers,
2597
+ }
2598
+
2599
+
2600
+ LAYER_OPS = {
2601
+ 'create': _op_layer_create,
2602
+ 'create_group': _op_layer_create_group,
2603
+ 'delete': _op_layer_delete,
2604
+ 'duplicate': _op_layer_duplicate,
2605
+ 'select': _op_layer_select,
2606
+ 'set': _op_layer_set,
2607
+ 'move': _op_layer_move,
2608
+ 'reorder': _op_layer_reorder,
2609
+ 'merge_down': _op_layer_merge_down,
2610
+ 'flatten': _op_layer_flatten,
2611
+ }
2612
+
2613
+
2614
+ def op_layer(args):
2615
+ img = _image(args)
2616
+ lop = args.get('op')
2617
+ fn = LAYER_OPS.get(lop)
2618
+ if fn is None:
2619
+ raise ValueError('op must be one of %s' % sorted(LAYER_OPS))
2620
+ return fn(img, args)
2621
+
2622
+
2623
+ # ---- gimp_bake --------------------------------------------------------------------------------
2624
+
2625
+
2626
+ def _bake_one(layer):
2627
+ """Bake every live filter on `layer` into its own pixels via `Drawable.merge_filters()` --
2628
+ probed live (GIMP 3.2.6) and confirmed correct even for a masked filter (its confinement
2629
+ survives into the baked pixels exactly), and confirmed NOT to rasterize a text layer (it stays
2630
+ a text layer, filters gone). Returns False (a no-op, not an error) for a layer with no filters.
2631
+ Raises for a group layer: verified live that GIMP accepts the call but silently does nothing (a
2632
+ console \"Calling error ... cannot be modified because it is a group item\", no Python
2633
+ exception, no filters removed) -- the same "returned without raising, but didn't do it" failure
2634
+ class `_assert_layer_attached`'s own comment names, so this is refused up front rather than
2635
+ reported as a silent success."""
2636
+ if layer.is_group():
2637
+ raise ValueError(
2638
+ 'layer %r is a group; bake targets individual layers, not groups -- GIMP has no way to '
2639
+ 'merge filters on a group item' % layer.get_name()
2640
+ )
2641
+ if not layer.get_filters():
2642
+ return False
2643
+ layer.merge_filters()
2644
+ if layer.get_filters():
2645
+ raise lib.OpError('gimp_op_failed', 'GIMP did not bake every filter on layer %r' % layer.get_name())
2646
+ return True
2647
+
2648
+
2649
+ def op_bake(args):
2650
+ """`layer_id`/`layer` bakes one layer (defaulting to the selected/topmost layer, same as any
2651
+ other `_layer` call, when neither is given); `all: true` bakes every non-group layer on the
2652
+ image that has any live filters. A group layer can legitimately carry its own filter (verified
2653
+ live), but GIMP cannot bake one -- `all: true` reports any such group under
2654
+ `skipped_groups_with_filters` rather than silently ignoring it; a single-target `bake` on a
2655
+ group is refused outright (`_bake_one`). Either way, baked filters' ledger records are pruned
2656
+ (`_prune_stale_ledger_records`, the same generic staleness sweep every other structural op
2657
+ relies on) and the proxy cache is dropped -- both in a `finally`, so a failure partway through
2658
+ `all: true` still leaves the ledger and proxy consistent with whatever DID get baked. Baking a
2659
+ layer's masked filter also clears it from the next geometry op's refusal check for free:
2660
+ `_refuse_if_masked_filters`/`_refuse_if_masked_filters_on` only ever look at LIVE filters, and
2661
+ baking leaves none behind."""
2662
+ img = _image(args)
2663
+ if lib.optional_bool(args, 'all'):
2664
+ baked = []
2665
+ skipped_groups_with_filters = []
2666
+ try:
2667
+ for layer in _all_layers(img):
2668
+ if layer.is_group():
2669
+ if layer.get_filters():
2670
+ skipped_groups_with_filters.append(
2671
+ {'layer_id': layer.get_id(), 'name': layer.get_name()}
2672
+ )
2673
+ continue
2674
+ if not layer.get_filters():
2675
+ continue
2676
+ _bake_one(layer)
2677
+ baked.append({'layer_id': layer.get_id(), 'name': layer.get_name()})
2678
+ finally:
2679
+ _prune_stale_ledger_records(img)
2680
+ _drop_proxies(img.get_id())
2681
+ return {'baked_layers': baked, 'skipped_groups_with_filters': skipped_groups_with_filters}
2682
+ layer = _layer(img, args)
2683
+ try:
2684
+ baked = _bake_one(layer)
2685
+ finally:
2686
+ _prune_stale_ledger_records(img)
2687
+ _drop_proxies(img.get_id())
2688
+ return {'layer_id': layer.get_id(), 'name': layer.get_name(), 'baked': baked}
2689
+
2690
+
2691
+ # ---- document composition (gimp_create_document / gimp_place_image / gimp_canvas /
2692
+ # gimp_convert_image_mode) -----------------------------------------------------------------
2693
+ #
2694
+ # Verified live (GIMP 3.2.6):
2695
+ # - `Gimp.Image.resize(width, height, offx, offy)` is the canvas-resize primitive `op_crop`'s
2696
+ # own `Image.crop` already wraps for the shrink direction; called directly here for the grow
2697
+ # direction it also supports. It repositions every existing layer by ADDING (offx, offy) to
2698
+ # that layer's own current offset -- layer CONTENT and size are untouched, only where it sits
2699
+ # in the (now larger) canvas moves.
2700
+ # - That repositioning is exactly the "layer moves, its own pixels don't" case
2701
+ # `_refuse_if_masked_filters`'s own comment already documents for rotate/flip/resize: a masked
2702
+ # filter's confinement is a fixed snapshot that does NOT travel with a moving layer (verified
2703
+ # live with a rectangle-masked brightness-contrast filter on the right half of a layer --
2704
+ # after growing the canvas on the left, the darkened region stayed at the OLD absolute pixel
2705
+ # positions, not the layer's new ones) -- `gimp_canvas` therefore refuses under the same
2706
+ # conditions `resize`/`rotate`/`flip` do. An UNMASKED effect filter (vignette, motion_blur,
2707
+ # drop_shadow) is unaffected: its own params key off the LAYER's own unchanged extent, never
2708
+ # the image/canvas, so no transform-tracking table entry is needed here the way rotate/resize
2709
+ # need one for those.
2710
+ # - `Gimp.FillType` has no BLACK (see `_fill_new_layer`'s own comment); a hex fill goes through
2711
+ # the same context-push/foreground/pop bracket.
2712
+ # - `Gimp.file_load_layer(run_mode, image, file)` returns an UNATTACHED layer (an explicit
2713
+ # `image.insert_layer` is still required -- verified live: the loaded layer is not a member of
2714
+ # `image.get_layers()` until that call, even though `layer.get_image()` already answers
2715
+ # non-None before it) already converted to `image`'s own base type at LOAD time, before that
2716
+ # insert -- verified both directions (a grayscale source loaded into an RGB target reports
2717
+ # `rgb-image` even before insert; an RGB source into a grayscale target reports `gray-image`
2718
+ # after). Loading into a HIGHER-precision (16/32-bit) target succeeds with no error either.
2719
+ # Placing a file carrying its own metadata (EXIF/XMP) does not attach anything to the TARGET
2720
+ # image's own metadata -- verified with a real GPS-bearing source. Given a MULTI-layer source
2721
+ # (e.g. a `.xcf`), only ONE layer comes back -- GIMP's own choice, not a flattened composite of
2722
+ # the whole file -- and that ONE layer's own LIVE FILTERS come with it (verified live: a
2723
+ # brightness-contrast filter on the source's layer is present on the loaded, still-unattached
2724
+ # layer too, `Drawable.merge_filters()` bakes it there cleanly even before insert, and a
2725
+ # SAME-NAMED filter already on the target's own, unrelated layer is provably untouched by the
2726
+ # bake) -- `op_place_image` always bakes before inserting, so a placed layer is never a
2727
+ # ledger-name-collision risk the way `gimp_layer op=duplicate` guards against for a copy.
2728
+ # - Inserting a NEW layer changes the image's own SELECTED layer to it -- verified live (a layer
2729
+ # explicitly selected beforehand is no longer selected once a different layer is inserted) --
2730
+ # which is why a failure after `insert_layer` restores the pre-call selection explicitly rather
2731
+ # than assuming discarding the newly-inserted layer alone leaves everything as it was.
2732
+ # - `Item.scale(width, height, local_origin)` resizes just that one layer; called here while its
2733
+ # offset is still whatever `file_load_layer` gave it (verified: local_origin's own effect on
2734
+ # the resulting offset only matters when the pre-scale offset is non-zero), and the caller's
2735
+ # `x`/`y` are applied afterward via the same absolute `set_offsets` `gimp_layer op=move` uses --
2736
+ # so the requested position is always exact regardless of local_origin's own math. Both
2737
+ # `Drawable.scale()` and `Drawable.fill()` report failure by returning `False` rather than
2738
+ # raising (the same class `op_open`'s own `convert_precision` comment documents) -- checked
2739
+ # everywhere this file calls either, including the pre-existing `_fill_new_layer` shared with
2740
+ # `gimp_layer op=create`.
2741
+ # - `Gimp.Image.convert_rgb()` / `convert_grayscale()` exist; calling one when the image is
2742
+ # ALREADY that base type is the same "calling error returns False, doesn't raise" class
2743
+ # `op_open`'s own `convert_precision` comment documents, which is why `op_convert_image_mode`
2744
+ # checks the current base type itself first rather than relying on that return value to tell
2745
+ # the two cases apart.
2746
+ # - `fill: transparent`'s "no fill layer" is only genuinely transparent in a LIVE, layered
2747
+ # document (e.g. saved via `gimp_save_xcf`) -- verified live that both `Image.flatten()`
2748
+ # (`gimp_layer op=flatten`) and `_composite`'s own flatten (every `gimp_export`) fill any
2749
+ # transparent area with GIMP's ambient BACKGROUND context color (white, unmodified here) and
2750
+ # drop alpha entirely, not with transparency. A PNG exported WITHOUT going through that flatten
2751
+ # path (this bridge always does, via `_composite`) would keep the padding genuinely
2752
+ # transparent, but that route is not one `gimp_export` takes -- the tool description says so.
2753
+
2754
+
2755
+ def op_create_document(args):
2756
+ precision = args.get('precision')
2757
+ if precision is not None and precision not in ('16', '32'):
2758
+ raise ValueError("precision must be one of '16', '32'")
2759
+ # The megapixel ceiling is precision-aware (`lib.DOCUMENT_MEGAPIXEL_CAP`) -- a 16/32-bit
2760
+ # request costs 2x/4x the memory of the same pixel count at 8-bit, so it gets a
2761
+ # correspondingly lower cap rather than the plain 8-bit-assuming `validate_resize_dims`.
2762
+ width, height = lib.validate_document_dims(
2763
+ int(lib.require(args, 'width')), int(lib.require(args, 'height')), precision or '8'
2764
+ )
2765
+ fill = lib.validate_choice('fill', args.get('fill', 'white'), lib.LAYER_FILLS)
2766
+ mode = lib.validate_choice('mode', args.get('mode', 'rgb'), lib.IMAGE_MODES)
2767
+ name = args.get('name') or 'Background'
2768
+ base_type = Gimp.ImageBaseType.RGB if mode == 'rgb' else Gimp.ImageBaseType.GRAY
2769
+ img = Gimp.Image.new(width, height, base_type)
2770
+ try:
2771
+ if precision is not None:
2772
+ if not img.convert_precision(_PRECISION_ENUM[precision]):
2773
+ raise lib.OpError(
2774
+ 'gimp_op_failed',
2775
+ 'precision promotion to %r failed for a new %s image' % (precision, mode),
2776
+ )
2777
+ layer = Gimp.Layer.new(img, name, width, height, _layer_type_for(img), 100.0, Gimp.LayerMode.NORMAL)
2778
+ img.insert_layer(layer, None, 0)
2779
+ _assert_layer_attached(img, layer, 'the background layer')
2780
+ _fill_new_layer(layer, fill)
2781
+ Gimp.Selection.none(img)
2782
+ _proxy(img, 1024) # see op_open: build the default preview proxy up front too
2783
+ except Exception:
2784
+ img.delete()
2785
+ raise
2786
+ return _describe(img)
2787
+
2788
+
2789
+ def _path_free_detail(detail):
2790
+ """A GIMP- or Python-supplied error detail, made safe to surface -- DROPPED ENTIRELY (not
2791
+ redacted in place) when it contains anything that looks like a filesystem path (a `/` or `\\`
2792
+ separator) or a `file:` URI scheme. Unlike `_export_stripped`'s rewrite, which only ever has to
2793
+ erase the ONE exact path spelling this bridge itself passed in, a detail string coming back
2794
+ from GIMP or a raised Python exception could quote the caller's path in any number of
2795
+ normalizations (forward slash, backslash, a `file://` URI, a different case) -- there is no
2796
+ substitution list that could cover all of them, so this refuses to guess and drops the whole
2797
+ detail instead. Returns '' (nothing to append) when unsafe or absent; otherwise ' (<detail>)',
2798
+ ready to append directly to a message."""
2799
+ if not detail or '/' in detail or '\\' in detail or 'file:' in detail:
2800
+ return ''
2801
+ return ' (%s)' % detail
2802
+
2803
+
2804
+ def op_place_image(args):
2805
+ # `requireAbsoluteGimpPath` (the tool layer) is unchanged by this discipline: it only
2806
+ # validates and echoes back the CALLER'S OWN `file_path` value, the same as every other gimp_*
2807
+ # path argument (including community-tier ones) -- it never adds a path the caller didn't
2808
+ # already send. What changed here is this op's OWN messages: every one is built from
2809
+ # `basename` only, and any further detail GIMP or Python supplies is passed through
2810
+ # `_path_free_detail`, which drops it outright rather than trying to redact it, if it looks
2811
+ # like it might carry a path of its own.
2812
+ img = _image(args)
2813
+ path = lib.require(args, 'path')
2814
+ basename = os.path.basename(path)
2815
+ if not os.path.exists(path):
2816
+ raise FileNotFoundError('no file named %s to place' % basename)
2817
+ ext = os.path.splitext(path)[1].lower()
2818
+ load_error = None
2819
+ try:
2820
+ loaded = Gimp.file_load_layer(Gimp.RunMode.NONINTERACTIVE, img, Gio.File.new_for_path(path))
2821
+ except Exception as e:
2822
+ loaded = None
2823
+ load_error = str(e)
2824
+ # A failed load does not always raise: verified live, an unreadable raw-extension file with no
2825
+ # loader plug-in installed makes `file_load_layer` return `None` outright (the same "returned
2826
+ # normally having silently done nothing" class `_assert_layer_attached`'s own comment names),
2827
+ # not raise -- so the raw/no-loader reframing below has to cover BOTH `loaded is None` and a
2828
+ # real exception, not just the exception case `op_open`'s own analogous try/except assumes is
2829
+ # the only failure shape.
2830
+ if loaded is None:
2831
+ detail = _path_free_detail(load_error)
2832
+ if ext in RAW_EXTENSIONS:
2833
+ raise lib.OpError(
2834
+ 'gimp_unsupported_file',
2835
+ 'GIMP could not load %s as a layer (no raw loader by default); install a '
2836
+ 'raw-develop plug-in (darktable, RawTherapee, or ART) for GIMP, or develop it '
2837
+ 'externally first and place the resulting JPEG/TIFF.%s' % (basename, detail),
2838
+ )
2839
+ raise lib.OpError('gimp_op_failed', 'could not load %s as a layer%s' % (basename, detail))
2840
+
2841
+ # Everything below either fully succeeds or leaves `img` exactly as it was: every field is
2842
+ # validated (and, for width/height, resolved against the loaded layer's OWN natural size)
2843
+ # BEFORE `insert_layer` ever runs, and the SELECTED layer is restored on any failure --
2844
+ # `insert_layer` changes the image's own selection as a side effect (verified live), even for
2845
+ # an insert that is about to be undone.
2846
+ original_selection = img.get_selected_layers()
2847
+ baked_filters = []
2848
+ try:
2849
+ w0, h0 = loaded.get_width(), loaded.get_height()
2850
+ lib.validate_resize_dims(w0, h0) # the DoS floor applies to the SOURCE file's own size too
2851
+
2852
+ width, height = args.get('width'), args.get('height')
2853
+ if width is not None or height is not None:
2854
+ if width is not None and height is not None:
2855
+ width, height = int(width), int(height)
2856
+ elif width is not None:
2857
+ width = int(width)
2858
+ height = max(1, round(h0 * (width / float(w0))))
2859
+ else:
2860
+ height = int(height)
2861
+ width = max(1, round(w0 * (height / float(h0))))
2862
+ width, height = lib.validate_resize_dims(width, height)
2863
+ else:
2864
+ width, height = w0, h0
2865
+
2866
+ x, y = int(args.get('x', 0)), int(args.get('y', 0))
2867
+ _validated_move_offset(img, x, y)
2868
+
2869
+ default_name = os.path.splitext(basename)[0] or 'Layer'
2870
+ name = _unique_layer_name(img, args.get('name') or default_name)
2871
+ parent = _resolve_parent_group(img, args)
2872
+ position = _validated_position(args)
2873
+
2874
+ # `file_load_layer` on a multi-layer `.xcf` source carries that ONE layer's own live
2875
+ # filters onto the loaded (still unattached) layer -- verified live. Baked here, BEFORE
2876
+ # insert, so the placed layer always lands as plain pixels: the ledger this bridge writes
2877
+ # lives in the TARGET image's own parasite and is keyed by filter NAME, so an unbaked
2878
+ # filter could silently collide with (and get overwritten by, or overwrite) an existing
2879
+ # target filter that happens to share its name -- baking first means there is never a
2880
+ # filter object left on the placed layer for that collision to happen to.
2881
+ if loaded.get_filters():
2882
+ baked_filters = [f.get_name() for f in loaded.get_filters()]
2883
+ loaded.merge_filters()
2884
+ if loaded.get_filters():
2885
+ raise lib.OpError(
2886
+ 'gimp_op_failed', "could not bake the placed layer's own live filter(s)"
2887
+ )
2888
+
2889
+ loaded.set_name(name)
2890
+ img.insert_layer(loaded, parent, position)
2891
+ _assert_layer_attached(img, loaded, 'the placed layer')
2892
+
2893
+ if (width, height) != (w0, h0):
2894
+ if not loaded.scale(width, height, False):
2895
+ raise lib.OpError('gimp_op_failed', 'GIMP could not scale the placed layer')
2896
+ loaded.set_offsets(x, y)
2897
+ except Exception:
2898
+ img.set_selected_layers(original_selection)
2899
+ _discard_layer(img, loaded)
2900
+ raise
2901
+ finally:
2902
+ _drop_proxies(img.get_id())
2903
+ ok, off_x, off_y = loaded.get_offsets()
2904
+ result = {
2905
+ 'layer_id': loaded.get_id(),
2906
+ 'name': loaded.get_name(),
2907
+ 'width': loaded.get_width(),
2908
+ 'height': loaded.get_height(),
2909
+ 'x': off_x if ok else None,
2910
+ 'y': off_y if ok else None,
2911
+ 'parent_group': parent.get_id() if parent is not None else None,
2912
+ }
2913
+ if baked_filters:
2914
+ result['baked_filters'] = baked_filters
2915
+ return result
2916
+
2917
+
2918
+ def _fill_canvas_layer(layer, fill):
2919
+ if fill in lib.LAYER_FILLS:
2920
+ _fill_new_layer(layer, fill) # white/black -- reuses the same push/pop-context bracket
2921
+ return # and the same fill()-return-value check ('transparent'
2922
+ # never reaches here -- op_canvas skips the layer entirely)
2923
+ # Otherwise `fill` is a validated '#rrggbb' hex string. On a GRAYSCALE image, filling with an
2924
+ # RGB foreground color converts to that color's LUMINANCE (verified live: '#336699' -> gray
2925
+ # level 98, matching neither the R, G, nor B channel alone but their weighted luminance) --
2926
+ # the same automatic base-type coercion `_layer_type_for`'s own comment documents for a plain
2927
+ # layer insert, applied here by GIMP's own fill implementation instead.
2928
+ Gimp.context_push()
2929
+ try:
2930
+ Gimp.context_set_foreground(Gegl.Color.new(fill))
2931
+ ok = layer.fill(Gimp.FillType.FOREGROUND)
2932
+ finally:
2933
+ Gimp.context_pop()
2934
+ if not ok:
2935
+ raise lib.OpError('gimp_op_failed', 'GIMP could not fill the canvas backdrop layer')
2936
+
2937
+
2938
+ def op_canvas(args):
2939
+ img = _image(args)
2940
+ old_w, old_h = img.get_width(), img.get_height()
2941
+ new_w, new_h = int(lib.require(args, 'width')), int(lib.require(args, 'height'))
2942
+ if new_w < old_w or new_h < old_h:
2943
+ raise ValueError(
2944
+ 'gimp_canvas only extends the canvas: %dx%d is smaller than the current %dx%d in at '
2945
+ 'least one dimension -- use gimp_crop_document to shrink it instead'
2946
+ % (new_w, new_h, old_w, old_h)
2947
+ )
2948
+ if new_w == old_w and new_h == old_h:
2949
+ raise ValueError(
2950
+ 'gimp_canvas needs a target that grows at least one dimension -- %dx%d is the '
2951
+ 'current size already' % (old_w, old_h)
2952
+ )
2953
+ new_w, new_h = lib.validate_resize_dims(new_w, new_h)
2954
+ fill = lib.validate_canvas_fill(args.get('fill', 'transparent'))
2955
+ # Checked before anything mutates: a non-transparent fill needs a backdrop layer, and
2956
+ # `_LAYER_CAPABLE_BASE_TYPES` (the same allow-list `_layer_type_for` reads) only covers
2957
+ # RGB/GRAY -- refusing here keeps the canvas untouched, rather than committing the resize and
2958
+ # only then discovering the backdrop layer can't be created.
2959
+ if fill != 'transparent' and img.get_base_type() not in _LAYER_CAPABLE_BASE_TYPES:
2960
+ raise ValueError(
2961
+ 'gimp_canvas cannot add a %s backdrop layer to an image of type %s -- only RGB and grayscale '
2962
+ 'support a non-transparent fill; use fill=transparent instead'
2963
+ % (fill, img.get_base_type().value_nick)
2964
+ )
2965
+ anchor = args.get('anchor')
2966
+ has_explicit_offset = 'offset_x' in args or 'offset_y' in args
2967
+ if has_explicit_offset and anchor is not None:
2968
+ raise ValueError('give either anchor or offset_x/offset_y, not both')
2969
+ if has_explicit_offset:
2970
+ offset_x, offset_y = int(lib.require(args, 'offset_x')), int(lib.require(args, 'offset_y'))
2971
+ else:
2972
+ offset_x, offset_y = lib.canvas_anchor_offset(anchor or 'center', old_w, old_h, new_w, new_h)
2973
+ if not (0 <= offset_x <= new_w - old_w) or not (0 <= offset_y <= new_h - old_h):
2974
+ raise ValueError(
2975
+ 'offset_x/offset_y must keep the existing %dx%d content fully within the new %dx%d '
2976
+ 'canvas: 0..%d for offset_x, 0..%d for offset_y'
2977
+ % (old_w, old_h, new_w, new_h, new_w - old_w, new_h - old_h)
2978
+ )
2979
+ # Checked only once the ACTUAL offset is known, and skipped entirely at (0, 0): a masked
2980
+ # filter's confinement is a fixed snapshot of the existing content's position, and content that
2981
+ # does not move at all (top-left anchor, or an explicit offset_x/offset_y of 0/0) cannot
2982
+ # misalign it -- refusing here anyway would block the single most common gimp_canvas call
2983
+ # (extend to the bottom-right) for no real reason.
2984
+ if (offset_x, offset_y) != (0, 0):
2985
+ _refuse_if_masked_filters(img, 'canvas', before_hint='Extend the canvas')
2986
+ Gimp.Selection.none(img) # see op_open: geometry always applies to the whole canvas
2987
+ try:
2988
+ img.resize(new_w, new_h, offset_x, offset_y)
2989
+ if (img.get_width(), img.get_height()) != (new_w, new_h):
2990
+ raise lib.OpError(
2991
+ 'gimp_op_failed',
2992
+ 'GIMP did not resize the canvas to %dx%d (now %dx%d)'
2993
+ % (new_w, new_h, img.get_width(), img.get_height()),
2994
+ )
2995
+ if fill != 'transparent':
2996
+ name = _unique_layer_name(img, 'Canvas Fill')
2997
+ layer = Gimp.Layer.new(img, name, new_w, new_h, _layer_type_for(img), 100.0, Gimp.LayerMode.NORMAL)
2998
+ try:
2999
+ img.insert_layer(layer, None, len(img.get_layers())) # bottom of the top-level stack
3000
+ _assert_layer_attached(img, layer, 'the canvas fill layer')
3001
+ _fill_canvas_layer(layer, fill)
3002
+ except Exception as e:
3003
+ _discard_layer(img, layer)
3004
+ # The canvas resize itself already committed and is not undone here (reversing it
3005
+ # risks compounding one failure into two) -- the error says so plainly instead.
3006
+ raise lib.OpError(
3007
+ 'gimp_op_failed',
3008
+ 'the canvas was extended to %dx%d, but the %s backdrop layer could not be '
3009
+ 'added (%s) -- retry with fill=transparent to skip it, or add a backdrop '
3010
+ 'layer manually' % (new_w, new_h, fill, e),
3011
+ )
3012
+ finally:
3013
+ _drop_proxies(img.get_id())
3014
+ return {
3015
+ 'width': img.get_width(), 'height': img.get_height(),
3016
+ 'offset_x': offset_x, 'offset_y': offset_y,
3017
+ }
3018
+
3019
+
3020
+ def op_convert_image_mode(args):
3021
+ img = _image(args)
3022
+ mode = lib.validate_choice('mode', args.get('mode'), lib.IMAGE_MODES)
3023
+ if img.get_base_type() == Gimp.ImageBaseType.INDEXED:
3024
+ raise ValueError('gimp_convert_image_mode does not support an indexed image as the source')
3025
+ # The no-op check runs BEFORE the live-filter refusal: an image already in the requested mode
3026
+ # has nothing for a color-dependent filter to be endangered by, so a live filter must never
3027
+ # turn a true no-op into a spurious refusal.
3028
+ target_type = Gimp.ImageBaseType.RGB if mode == 'rgb' else Gimp.ImageBaseType.GRAY
3029
+ if img.get_base_type() == target_type:
3030
+ return {'mode': mode, 'converted': False}
3031
+ live_filters = _live_filter_names(img)
3032
+ if live_filters:
3033
+ raise ValueError(
3034
+ 'gimp_convert_image_mode refuses while any live filter is present (%s): a '
3035
+ 'color-dependent filter could change meaning across a mode change. Bake it first '
3036
+ "(gimp_bake) or delete it (gimp_filter op=delete), then convert."
3037
+ % ', '.join(repr(n) for n in sorted(live_filters))
3038
+ )
3039
+ try:
3040
+ ok = img.convert_rgb() if mode == 'rgb' else img.convert_grayscale()
3041
+ if not ok:
3042
+ raise lib.OpError('gimp_op_failed', 'GIMP could not convert this image to %s' % mode)
3043
+ finally:
3044
+ _drop_proxies(img.get_id())
3045
+ return {'mode': mode, 'converted': True}
3046
+
3047
+
1431
3048
  def op_select_none(args):
1432
3049
  Gimp.Selection.none(_image(args))
1433
3050
  return {'selection': 'none'}
@@ -1442,12 +3059,14 @@ def op_close(args):
1442
3059
 
1443
3060
  OPS = {
1444
3061
  'ping': op_ping, 'open': op_open, 'curves': op_curves, 'levels': op_levels,
1445
- 'adjust': op_adjust, 'filter': op_filter,
3062
+ 'adjust': op_adjust, 'filter': op_filter, 'effect': op_effect, 'describe': op_describe,
1446
3063
  'list_filters': op_list_filters, 'preview': op_preview, 'histogram': op_histogram,
1447
3064
  'compare': op_compare, 'export': op_export, 'close': op_close,
1448
3065
  'crop': op_crop, 'resize': op_resize, 'rotate': op_rotate, 'flip': op_flip,
1449
3066
  'create_mask': op_create_mask, 'describe_operation': op_describe_operation,
1450
- 'select_none': op_select_none,
3067
+ 'select_none': op_select_none, 'layer': op_layer, 'bake': op_bake,
3068
+ 'create_document': op_create_document, 'place_image': op_place_image,
3069
+ 'canvas': op_canvas, 'convert_image_mode': op_convert_image_mode,
1451
3070
  }
1452
3071
 
1453
3072