@agent-webui/ai-desk-harness-gimp 1.0.29-beta1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/manifest.json +22 -0
- package/package.json +11 -0
- package/python/agent-harness/GIMP.md +301 -0
- package/python/agent-harness/build/lib/cli_anything/gimp/__init__.py +1 -0
- package/python/agent-harness/build/lib/cli_anything/gimp/__main__.py +3 -0
- package/python/agent-harness/build/lib/cli_anything/gimp/core/__init__.py +1 -0
- package/python/agent-harness/build/lib/cli_anything/gimp/core/canvas.py +193 -0
- package/python/agent-harness/build/lib/cli_anything/gimp/core/export.py +479 -0
- package/python/agent-harness/build/lib/cli_anything/gimp/core/filters.py +382 -0
- package/python/agent-harness/build/lib/cli_anything/gimp/core/layers.py +249 -0
- package/python/agent-harness/build/lib/cli_anything/gimp/core/media.py +174 -0
- package/python/agent-harness/build/lib/cli_anything/gimp/core/project.py +131 -0
- package/python/agent-harness/build/lib/cli_anything/gimp/core/session.py +130 -0
- package/python/agent-harness/build/lib/cli_anything/gimp/gimp_cli.py +788 -0
- package/python/agent-harness/build/lib/cli_anything/gimp/tests/__init__.py +1 -0
- package/python/agent-harness/build/lib/cli_anything/gimp/tests/test_core.py +478 -0
- package/python/agent-harness/build/lib/cli_anything/gimp/tests/test_full_e2e.py +578 -0
- package/python/agent-harness/build/lib/cli_anything/gimp/utils/__init__.py +1 -0
- package/python/agent-harness/build/lib/cli_anything/gimp/utils/gimp_backend.py +208 -0
- package/python/agent-harness/build/lib/cli_anything/gimp/utils/repl_skin.py +498 -0
- package/python/agent-harness/cli_anything/gimp/README.md +202 -0
- package/python/agent-harness/cli_anything/gimp/__init__.py +1 -0
- package/python/agent-harness/cli_anything/gimp/__main__.py +3 -0
- package/python/agent-harness/cli_anything/gimp/core/__init__.py +1 -0
- package/python/agent-harness/cli_anything/gimp/core/canvas.py +193 -0
- package/python/agent-harness/cli_anything/gimp/core/export.py +479 -0
- package/python/agent-harness/cli_anything/gimp/core/filters.py +382 -0
- package/python/agent-harness/cli_anything/gimp/core/layers.py +249 -0
- package/python/agent-harness/cli_anything/gimp/core/media.py +174 -0
- package/python/agent-harness/cli_anything/gimp/core/project.py +131 -0
- package/python/agent-harness/cli_anything/gimp/core/session.py +130 -0
- package/python/agent-harness/cli_anything/gimp/gimp_cli.py +788 -0
- package/python/agent-harness/cli_anything/gimp/tests/TEST.md +137 -0
- package/python/agent-harness/cli_anything/gimp/tests/__init__.py +1 -0
- package/python/agent-harness/cli_anything/gimp/tests/test_core.py +478 -0
- package/python/agent-harness/cli_anything/gimp/tests/test_full_e2e.py +578 -0
- package/python/agent-harness/cli_anything/gimp/utils/__init__.py +1 -0
- package/python/agent-harness/cli_anything/gimp/utils/gimp_backend.py +208 -0
- package/python/agent-harness/cli_anything/gimp/utils/repl_skin.py +498 -0
- package/python/agent-harness/cli_anything_gimp.egg-info/PKG-INFO +236 -0
- package/python/agent-harness/cli_anything_gimp.egg-info/SOURCES.txt +25 -0
- package/python/agent-harness/cli_anything_gimp.egg-info/dependency_links.txt +1 -0
- package/python/agent-harness/cli_anything_gimp.egg-info/entry_points.txt +2 -0
- package/python/agent-harness/cli_anything_gimp.egg-info/not-zip-safe +1 -0
- package/python/agent-harness/cli_anything_gimp.egg-info/requires.txt +7 -0
- package/python/agent-harness/cli_anything_gimp.egg-info/top_level.txt +1 -0
- package/python/agent-harness/setup.py +54 -0
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
"""GIMP CLI - Filter registry and application module."""
|
|
2
|
+
|
|
3
|
+
from typing import Dict, Any, List, Optional, Tuple
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
# Filter registry: maps CLI name -> implementation details
|
|
7
|
+
FILTER_REGISTRY = {
|
|
8
|
+
# Image Adjustments
|
|
9
|
+
"brightness": {
|
|
10
|
+
"category": "adjustment",
|
|
11
|
+
"description": "Adjust image brightness",
|
|
12
|
+
"params": {"factor": {"type": "float", "default": 1.0, "min": 0.0, "max": 10.0,
|
|
13
|
+
"description": "1.0=neutral, >1=brighter, <1=darker"}},
|
|
14
|
+
"engine": "pillow_enhance",
|
|
15
|
+
"pillow_class": "Brightness",
|
|
16
|
+
},
|
|
17
|
+
"contrast": {
|
|
18
|
+
"category": "adjustment",
|
|
19
|
+
"description": "Adjust image contrast",
|
|
20
|
+
"params": {"factor": {"type": "float", "default": 1.0, "min": 0.0, "max": 10.0,
|
|
21
|
+
"description": "1.0=neutral, >1=more contrast"}},
|
|
22
|
+
"engine": "pillow_enhance",
|
|
23
|
+
"pillow_class": "Contrast",
|
|
24
|
+
},
|
|
25
|
+
"saturation": {
|
|
26
|
+
"category": "adjustment",
|
|
27
|
+
"description": "Adjust color saturation",
|
|
28
|
+
"params": {"factor": {"type": "float", "default": 1.0, "min": 0.0, "max": 10.0,
|
|
29
|
+
"description": "1.0=neutral, 0=grayscale, >1=vivid"}},
|
|
30
|
+
"engine": "pillow_enhance",
|
|
31
|
+
"pillow_class": "Color",
|
|
32
|
+
},
|
|
33
|
+
"sharpness": {
|
|
34
|
+
"category": "adjustment",
|
|
35
|
+
"description": "Adjust image sharpness",
|
|
36
|
+
"params": {"factor": {"type": "float", "default": 1.0, "min": 0.0, "max": 10.0,
|
|
37
|
+
"description": "1.0=neutral, >1=sharper, 0=blurred"}},
|
|
38
|
+
"engine": "pillow_enhance",
|
|
39
|
+
"pillow_class": "Sharpness",
|
|
40
|
+
},
|
|
41
|
+
"autocontrast": {
|
|
42
|
+
"category": "adjustment",
|
|
43
|
+
"description": "Automatic contrast stretch",
|
|
44
|
+
"params": {"cutoff": {"type": "float", "default": 0.0, "min": 0.0, "max": 49.0,
|
|
45
|
+
"description": "Percent of lightest/darkest pixels to clip"}},
|
|
46
|
+
"engine": "pillow_ops",
|
|
47
|
+
"pillow_func": "autocontrast",
|
|
48
|
+
},
|
|
49
|
+
"equalize": {
|
|
50
|
+
"category": "adjustment",
|
|
51
|
+
"description": "Equalize histogram",
|
|
52
|
+
"params": {},
|
|
53
|
+
"engine": "pillow_ops",
|
|
54
|
+
"pillow_func": "equalize",
|
|
55
|
+
},
|
|
56
|
+
"invert": {
|
|
57
|
+
"category": "adjustment",
|
|
58
|
+
"description": "Invert colors (negative)",
|
|
59
|
+
"params": {},
|
|
60
|
+
"engine": "pillow_ops",
|
|
61
|
+
"pillow_func": "invert",
|
|
62
|
+
},
|
|
63
|
+
"posterize": {
|
|
64
|
+
"category": "adjustment",
|
|
65
|
+
"description": "Reduce color depth (posterize)",
|
|
66
|
+
"params": {"bits": {"type": "int", "default": 4, "min": 1, "max": 8,
|
|
67
|
+
"description": "Bits per channel (fewer = more posterized)"}},
|
|
68
|
+
"engine": "pillow_ops",
|
|
69
|
+
"pillow_func": "posterize",
|
|
70
|
+
},
|
|
71
|
+
"solarize": {
|
|
72
|
+
"category": "adjustment",
|
|
73
|
+
"description": "Solarize effect",
|
|
74
|
+
"params": {"threshold": {"type": "int", "default": 128, "min": 0, "max": 255,
|
|
75
|
+
"description": "Threshold for inversion"}},
|
|
76
|
+
"engine": "pillow_ops",
|
|
77
|
+
"pillow_func": "solarize",
|
|
78
|
+
},
|
|
79
|
+
"grayscale": {
|
|
80
|
+
"category": "adjustment",
|
|
81
|
+
"description": "Convert to grayscale",
|
|
82
|
+
"params": {},
|
|
83
|
+
"engine": "pillow_ops",
|
|
84
|
+
"pillow_func": "grayscale",
|
|
85
|
+
},
|
|
86
|
+
"sepia": {
|
|
87
|
+
"category": "adjustment",
|
|
88
|
+
"description": "Apply sepia tone",
|
|
89
|
+
"params": {"strength": {"type": "float", "default": 0.8, "min": 0.0, "max": 1.0,
|
|
90
|
+
"description": "Sepia effect strength"}},
|
|
91
|
+
"engine": "custom",
|
|
92
|
+
"custom_func": "apply_sepia",
|
|
93
|
+
},
|
|
94
|
+
# Blur & Sharpen
|
|
95
|
+
"gaussian_blur": {
|
|
96
|
+
"category": "blur",
|
|
97
|
+
"description": "Gaussian blur",
|
|
98
|
+
"params": {"radius": {"type": "float", "default": 2.0, "min": 0.1, "max": 100.0,
|
|
99
|
+
"description": "Blur radius in pixels"}},
|
|
100
|
+
"engine": "pillow_filter",
|
|
101
|
+
"pillow_filter": "GaussianBlur",
|
|
102
|
+
},
|
|
103
|
+
"box_blur": {
|
|
104
|
+
"category": "blur",
|
|
105
|
+
"description": "Box blur (uniform average)",
|
|
106
|
+
"params": {"radius": {"type": "float", "default": 2.0, "min": 0.1, "max": 100.0,
|
|
107
|
+
"description": "Blur radius in pixels"}},
|
|
108
|
+
"engine": "pillow_filter",
|
|
109
|
+
"pillow_filter": "BoxBlur",
|
|
110
|
+
},
|
|
111
|
+
"unsharp_mask": {
|
|
112
|
+
"category": "blur",
|
|
113
|
+
"description": "Unsharp mask (sharpen via blur)",
|
|
114
|
+
"params": {
|
|
115
|
+
"radius": {"type": "float", "default": 2.0, "min": 0.1, "max": 100.0,
|
|
116
|
+
"description": "Blur radius"},
|
|
117
|
+
"percent": {"type": "int", "default": 150, "min": 1, "max": 500,
|
|
118
|
+
"description": "Sharpening strength percent"},
|
|
119
|
+
"threshold": {"type": "int", "default": 3, "min": 0, "max": 255,
|
|
120
|
+
"description": "Minimum brightness change to sharpen"},
|
|
121
|
+
},
|
|
122
|
+
"engine": "pillow_filter",
|
|
123
|
+
"pillow_filter": "UnsharpMask",
|
|
124
|
+
},
|
|
125
|
+
"smooth": {
|
|
126
|
+
"category": "blur",
|
|
127
|
+
"description": "Smooth (reduce noise)",
|
|
128
|
+
"params": {},
|
|
129
|
+
"engine": "pillow_filter",
|
|
130
|
+
"pillow_filter": "SMOOTH_MORE",
|
|
131
|
+
},
|
|
132
|
+
# Stylize
|
|
133
|
+
"find_edges": {
|
|
134
|
+
"category": "stylize",
|
|
135
|
+
"description": "Edge detection",
|
|
136
|
+
"params": {},
|
|
137
|
+
"engine": "pillow_filter",
|
|
138
|
+
"pillow_filter": "FIND_EDGES",
|
|
139
|
+
},
|
|
140
|
+
"emboss": {
|
|
141
|
+
"category": "stylize",
|
|
142
|
+
"description": "Emboss effect",
|
|
143
|
+
"params": {},
|
|
144
|
+
"engine": "pillow_filter",
|
|
145
|
+
"pillow_filter": "EMBOSS",
|
|
146
|
+
},
|
|
147
|
+
"contour": {
|
|
148
|
+
"category": "stylize",
|
|
149
|
+
"description": "Contour tracing",
|
|
150
|
+
"params": {},
|
|
151
|
+
"engine": "pillow_filter",
|
|
152
|
+
"pillow_filter": "CONTOUR",
|
|
153
|
+
},
|
|
154
|
+
"detail": {
|
|
155
|
+
"category": "stylize",
|
|
156
|
+
"description": "Enhance detail",
|
|
157
|
+
"params": {},
|
|
158
|
+
"engine": "pillow_filter",
|
|
159
|
+
"pillow_filter": "DETAIL",
|
|
160
|
+
},
|
|
161
|
+
# Transform (applied at render time)
|
|
162
|
+
"rotate": {
|
|
163
|
+
"category": "transform",
|
|
164
|
+
"description": "Rotate layer",
|
|
165
|
+
"params": {
|
|
166
|
+
"angle": {"type": "float", "default": 0.0, "min": -360.0, "max": 360.0,
|
|
167
|
+
"description": "Rotation angle in degrees"},
|
|
168
|
+
"expand": {"type": "bool", "default": True,
|
|
169
|
+
"description": "Expand canvas to fit rotated image"},
|
|
170
|
+
},
|
|
171
|
+
"engine": "pillow_transform",
|
|
172
|
+
"pillow_method": "rotate",
|
|
173
|
+
},
|
|
174
|
+
"flip_h": {
|
|
175
|
+
"category": "transform",
|
|
176
|
+
"description": "Flip horizontally",
|
|
177
|
+
"params": {},
|
|
178
|
+
"engine": "pillow_transform",
|
|
179
|
+
"pillow_method": "flip_h",
|
|
180
|
+
},
|
|
181
|
+
"flip_v": {
|
|
182
|
+
"category": "transform",
|
|
183
|
+
"description": "Flip vertically",
|
|
184
|
+
"params": {},
|
|
185
|
+
"engine": "pillow_transform",
|
|
186
|
+
"pillow_method": "flip_v",
|
|
187
|
+
},
|
|
188
|
+
"resize": {
|
|
189
|
+
"category": "transform",
|
|
190
|
+
"description": "Resize layer",
|
|
191
|
+
"params": {
|
|
192
|
+
"width": {"type": "int", "default": 0, "min": 1, "max": 65535,
|
|
193
|
+
"description": "Target width"},
|
|
194
|
+
"height": {"type": "int", "default": 0, "min": 1, "max": 65535,
|
|
195
|
+
"description": "Target height"},
|
|
196
|
+
"resample": {"type": "str", "default": "lanczos",
|
|
197
|
+
"description": "Resampling: nearest, bilinear, bicubic, lanczos"},
|
|
198
|
+
},
|
|
199
|
+
"engine": "pillow_transform",
|
|
200
|
+
"pillow_method": "resize",
|
|
201
|
+
},
|
|
202
|
+
"crop": {
|
|
203
|
+
"category": "transform",
|
|
204
|
+
"description": "Crop layer",
|
|
205
|
+
"params": {
|
|
206
|
+
"left": {"type": "int", "default": 0, "min": 0, "max": 65535},
|
|
207
|
+
"top": {"type": "int", "default": 0, "min": 0, "max": 65535},
|
|
208
|
+
"right": {"type": "int", "default": 0, "min": 0, "max": 65535},
|
|
209
|
+
"bottom": {"type": "int", "default": 0, "min": 0, "max": 65535},
|
|
210
|
+
},
|
|
211
|
+
"engine": "pillow_transform",
|
|
212
|
+
"pillow_method": "crop",
|
|
213
|
+
},
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def list_available(category: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
218
|
+
"""List available filters, optionally filtered by category."""
|
|
219
|
+
result = []
|
|
220
|
+
for name, info in FILTER_REGISTRY.items():
|
|
221
|
+
if category and info["category"] != category:
|
|
222
|
+
continue
|
|
223
|
+
result.append({
|
|
224
|
+
"name": name,
|
|
225
|
+
"category": info["category"],
|
|
226
|
+
"description": info["description"],
|
|
227
|
+
"param_count": len(info["params"]),
|
|
228
|
+
})
|
|
229
|
+
return result
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def get_filter_info(name: str) -> Dict[str, Any]:
|
|
233
|
+
"""Get detailed info about a filter."""
|
|
234
|
+
if name not in FILTER_REGISTRY:
|
|
235
|
+
raise ValueError(f"Unknown filter: {name}. Use 'filter list-available' to see options.")
|
|
236
|
+
info = FILTER_REGISTRY[name]
|
|
237
|
+
return {
|
|
238
|
+
"name": name,
|
|
239
|
+
"category": info["category"],
|
|
240
|
+
"description": info["description"],
|
|
241
|
+
"params": info["params"],
|
|
242
|
+
"engine": info["engine"],
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def validate_params(name: str, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
247
|
+
"""Validate and fill defaults for filter parameters."""
|
|
248
|
+
if name not in FILTER_REGISTRY:
|
|
249
|
+
raise ValueError(f"Unknown filter: {name}")
|
|
250
|
+
|
|
251
|
+
spec = FILTER_REGISTRY[name]["params"]
|
|
252
|
+
result = {}
|
|
253
|
+
|
|
254
|
+
for pname, pspec in spec.items():
|
|
255
|
+
if pname in params:
|
|
256
|
+
val = params[pname]
|
|
257
|
+
ptype = pspec["type"]
|
|
258
|
+
if ptype == "float":
|
|
259
|
+
val = float(val)
|
|
260
|
+
if "min" in pspec and val < pspec["min"]:
|
|
261
|
+
raise ValueError(f"Parameter '{pname}' minimum is {pspec['min']}, got {val}")
|
|
262
|
+
if "max" in pspec and val > pspec["max"]:
|
|
263
|
+
raise ValueError(f"Parameter '{pname}' maximum is {pspec['max']}, got {val}")
|
|
264
|
+
elif ptype == "int":
|
|
265
|
+
val = int(val)
|
|
266
|
+
if "min" in pspec and val < pspec["min"]:
|
|
267
|
+
raise ValueError(f"Parameter '{pname}' minimum is {pspec['min']}, got {val}")
|
|
268
|
+
if "max" in pspec and val > pspec["max"]:
|
|
269
|
+
raise ValueError(f"Parameter '{pname}' maximum is {pspec['max']}, got {val}")
|
|
270
|
+
elif ptype == "bool":
|
|
271
|
+
val = str(val).lower() in ("true", "1", "yes")
|
|
272
|
+
elif ptype == "str":
|
|
273
|
+
val = str(val)
|
|
274
|
+
result[pname] = val
|
|
275
|
+
else:
|
|
276
|
+
result[pname] = pspec.get("default")
|
|
277
|
+
|
|
278
|
+
# Warn about unknown params
|
|
279
|
+
unknown = set(params.keys()) - set(spec.keys())
|
|
280
|
+
if unknown:
|
|
281
|
+
raise ValueError(f"Unknown parameters for filter '{name}': {unknown}")
|
|
282
|
+
|
|
283
|
+
return result
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def add_filter(
|
|
287
|
+
project: Dict[str, Any],
|
|
288
|
+
name: str,
|
|
289
|
+
layer_index: int = 0,
|
|
290
|
+
params: Optional[Dict[str, Any]] = None,
|
|
291
|
+
) -> Dict[str, Any]:
|
|
292
|
+
"""Add a filter to a layer."""
|
|
293
|
+
layers = project.get("layers", [])
|
|
294
|
+
if layer_index < 0 or layer_index >= len(layers):
|
|
295
|
+
raise IndexError(f"Layer index {layer_index} out of range (0-{len(layers)-1})")
|
|
296
|
+
|
|
297
|
+
if name not in FILTER_REGISTRY:
|
|
298
|
+
raise ValueError(f"Unknown filter: {name}")
|
|
299
|
+
|
|
300
|
+
validated = validate_params(name, params or {})
|
|
301
|
+
|
|
302
|
+
filter_entry = {
|
|
303
|
+
"name": name,
|
|
304
|
+
"params": validated,
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
layer = layers[layer_index]
|
|
308
|
+
if "filters" not in layer:
|
|
309
|
+
layer["filters"] = []
|
|
310
|
+
layer["filters"].append(filter_entry)
|
|
311
|
+
|
|
312
|
+
return filter_entry
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def remove_filter(
|
|
316
|
+
project: Dict[str, Any],
|
|
317
|
+
filter_index: int,
|
|
318
|
+
layer_index: int = 0,
|
|
319
|
+
) -> Dict[str, Any]:
|
|
320
|
+
"""Remove a filter from a layer by index."""
|
|
321
|
+
layers = project.get("layers", [])
|
|
322
|
+
if layer_index < 0 or layer_index >= len(layers):
|
|
323
|
+
raise IndexError(f"Layer index {layer_index} out of range")
|
|
324
|
+
|
|
325
|
+
layer = layers[layer_index]
|
|
326
|
+
filters = layer.get("filters", [])
|
|
327
|
+
if filter_index < 0 or filter_index >= len(filters):
|
|
328
|
+
raise IndexError(f"Filter index {filter_index} out of range (0-{len(filters)-1})")
|
|
329
|
+
|
|
330
|
+
return filters.pop(filter_index)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def set_filter_param(
|
|
334
|
+
project: Dict[str, Any],
|
|
335
|
+
filter_index: int,
|
|
336
|
+
param: str,
|
|
337
|
+
value: Any,
|
|
338
|
+
layer_index: int = 0,
|
|
339
|
+
) -> None:
|
|
340
|
+
"""Set a filter parameter value."""
|
|
341
|
+
layers = project.get("layers", [])
|
|
342
|
+
if layer_index < 0 or layer_index >= len(layers):
|
|
343
|
+
raise IndexError(f"Layer index {layer_index} out of range")
|
|
344
|
+
|
|
345
|
+
layer = layers[layer_index]
|
|
346
|
+
filters = layer.get("filters", [])
|
|
347
|
+
if filter_index < 0 or filter_index >= len(filters):
|
|
348
|
+
raise IndexError(f"Filter index {filter_index} out of range")
|
|
349
|
+
|
|
350
|
+
filt = filters[filter_index]
|
|
351
|
+
name = filt["name"]
|
|
352
|
+
spec = FILTER_REGISTRY[name]["params"]
|
|
353
|
+
|
|
354
|
+
if param not in spec:
|
|
355
|
+
raise ValueError(f"Unknown parameter '{param}' for filter '{name}'. Valid: {list(spec.keys())}")
|
|
356
|
+
|
|
357
|
+
# Validate using the spec
|
|
358
|
+
test_params = dict(filt["params"])
|
|
359
|
+
test_params[param] = value
|
|
360
|
+
validated = validate_params(name, test_params)
|
|
361
|
+
filt["params"] = validated
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def list_filters(
|
|
365
|
+
project: Dict[str, Any],
|
|
366
|
+
layer_index: int = 0,
|
|
367
|
+
) -> List[Dict[str, Any]]:
|
|
368
|
+
"""List filters on a layer."""
|
|
369
|
+
layers = project.get("layers", [])
|
|
370
|
+
if layer_index < 0 or layer_index >= len(layers):
|
|
371
|
+
raise IndexError(f"Layer index {layer_index} out of range")
|
|
372
|
+
|
|
373
|
+
layer = layers[layer_index]
|
|
374
|
+
result = []
|
|
375
|
+
for i, f in enumerate(layer.get("filters", [])):
|
|
376
|
+
result.append({
|
|
377
|
+
"index": i,
|
|
378
|
+
"name": f["name"],
|
|
379
|
+
"params": f["params"],
|
|
380
|
+
"category": FILTER_REGISTRY.get(f["name"], {}).get("category", "unknown"),
|
|
381
|
+
})
|
|
382
|
+
return result
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""GIMP CLI - Layer management module."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import copy
|
|
5
|
+
from typing import Dict, Any, List, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# Valid blend modes
|
|
9
|
+
BLEND_MODES = [
|
|
10
|
+
"normal", "multiply", "screen", "overlay", "soft_light", "hard_light",
|
|
11
|
+
"difference", "darken", "lighten", "color_dodge", "color_burn",
|
|
12
|
+
"addition", "subtract", "grain_merge", "grain_extract",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def add_layer(
|
|
17
|
+
project: Dict[str, Any],
|
|
18
|
+
name: str = "New Layer",
|
|
19
|
+
layer_type: str = "image",
|
|
20
|
+
source: Optional[str] = None,
|
|
21
|
+
width: Optional[int] = None,
|
|
22
|
+
height: Optional[int] = None,
|
|
23
|
+
fill: str = "transparent",
|
|
24
|
+
opacity: float = 1.0,
|
|
25
|
+
blend_mode: str = "normal",
|
|
26
|
+
position: Optional[int] = None,
|
|
27
|
+
offset_x: int = 0,
|
|
28
|
+
offset_y: int = 0,
|
|
29
|
+
) -> Dict[str, Any]:
|
|
30
|
+
"""Add a new layer to the project.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
project: The project dict
|
|
34
|
+
name: Layer name
|
|
35
|
+
layer_type: "image", "text", "solid"
|
|
36
|
+
source: Path to source image file (for image layers)
|
|
37
|
+
width: Layer width (defaults to canvas width)
|
|
38
|
+
height: Layer height (defaults to canvas height)
|
|
39
|
+
fill: Fill type for new layers: "transparent", "white", "black", or hex color
|
|
40
|
+
opacity: Layer opacity (0.0-1.0)
|
|
41
|
+
blend_mode: Compositing blend mode
|
|
42
|
+
position: Insert position (0=top, None=top)
|
|
43
|
+
offset_x: Horizontal offset from canvas origin
|
|
44
|
+
offset_y: Vertical offset from canvas origin
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
The new layer dict
|
|
48
|
+
"""
|
|
49
|
+
if blend_mode not in BLEND_MODES:
|
|
50
|
+
raise ValueError(f"Invalid blend mode '{blend_mode}'. Valid: {BLEND_MODES}")
|
|
51
|
+
if not 0.0 <= opacity <= 1.0:
|
|
52
|
+
raise ValueError(f"Opacity must be 0.0-1.0, got {opacity}")
|
|
53
|
+
if layer_type not in ("image", "text", "solid"):
|
|
54
|
+
raise ValueError(f"Invalid layer type '{layer_type}'. Use: image, text, solid")
|
|
55
|
+
if layer_type == "image" and source and not os.path.exists(source):
|
|
56
|
+
raise FileNotFoundError(f"Source image not found: {source}")
|
|
57
|
+
|
|
58
|
+
canvas = project["canvas"]
|
|
59
|
+
layer_w = width or canvas["width"]
|
|
60
|
+
layer_h = height or canvas["height"]
|
|
61
|
+
|
|
62
|
+
# Generate next layer ID
|
|
63
|
+
existing_ids = [l.get("id", 0) for l in project.get("layers", [])]
|
|
64
|
+
next_id = max(existing_ids, default=-1) + 1
|
|
65
|
+
|
|
66
|
+
layer = {
|
|
67
|
+
"id": next_id,
|
|
68
|
+
"name": name,
|
|
69
|
+
"type": layer_type,
|
|
70
|
+
"width": layer_w,
|
|
71
|
+
"height": layer_h,
|
|
72
|
+
"visible": True,
|
|
73
|
+
"opacity": opacity,
|
|
74
|
+
"blend_mode": blend_mode,
|
|
75
|
+
"offset_x": offset_x,
|
|
76
|
+
"offset_y": offset_y,
|
|
77
|
+
"filters": [],
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if layer_type == "image":
|
|
81
|
+
layer["source"] = source
|
|
82
|
+
layer["fill"] = fill if not source else None
|
|
83
|
+
elif layer_type == "solid":
|
|
84
|
+
layer["fill"] = fill
|
|
85
|
+
elif layer_type == "text":
|
|
86
|
+
layer["text"] = ""
|
|
87
|
+
layer["font"] = "Arial"
|
|
88
|
+
layer["font_size"] = 24
|
|
89
|
+
layer["color"] = "#000000"
|
|
90
|
+
|
|
91
|
+
if "layers" not in project:
|
|
92
|
+
project["layers"] = []
|
|
93
|
+
|
|
94
|
+
if position is not None:
|
|
95
|
+
position = max(0, min(position, len(project["layers"])))
|
|
96
|
+
project["layers"].insert(position, layer)
|
|
97
|
+
else:
|
|
98
|
+
project["layers"].insert(0, layer) # Top of stack
|
|
99
|
+
|
|
100
|
+
return layer
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def add_from_file(
|
|
104
|
+
project: Dict[str, Any],
|
|
105
|
+
path: str,
|
|
106
|
+
name: Optional[str] = None,
|
|
107
|
+
position: Optional[int] = None,
|
|
108
|
+
opacity: float = 1.0,
|
|
109
|
+
blend_mode: str = "normal",
|
|
110
|
+
) -> Dict[str, Any]:
|
|
111
|
+
"""Add a layer from an image file."""
|
|
112
|
+
if not os.path.exists(path):
|
|
113
|
+
raise FileNotFoundError(f"Image file not found: {path}")
|
|
114
|
+
|
|
115
|
+
layer_name = name or os.path.basename(path)
|
|
116
|
+
|
|
117
|
+
# Try to get image dimensions
|
|
118
|
+
try:
|
|
119
|
+
from PIL import Image
|
|
120
|
+
with Image.open(path) as img:
|
|
121
|
+
w, h = img.size
|
|
122
|
+
except Exception:
|
|
123
|
+
w = project["canvas"]["width"]
|
|
124
|
+
h = project["canvas"]["height"]
|
|
125
|
+
|
|
126
|
+
return add_layer(
|
|
127
|
+
project,
|
|
128
|
+
name=layer_name,
|
|
129
|
+
layer_type="image",
|
|
130
|
+
source=os.path.abspath(path),
|
|
131
|
+
width=w,
|
|
132
|
+
height=h,
|
|
133
|
+
opacity=opacity,
|
|
134
|
+
blend_mode=blend_mode,
|
|
135
|
+
position=position,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def remove_layer(project: Dict[str, Any], index: int) -> Dict[str, Any]:
|
|
140
|
+
"""Remove a layer by index."""
|
|
141
|
+
layers = project.get("layers", [])
|
|
142
|
+
if not layers:
|
|
143
|
+
raise ValueError("No layers to remove")
|
|
144
|
+
if index < 0 or index >= len(layers):
|
|
145
|
+
raise IndexError(f"Layer index {index} out of range (0-{len(layers)-1})")
|
|
146
|
+
removed = layers.pop(index)
|
|
147
|
+
return removed
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def duplicate_layer(project: Dict[str, Any], index: int) -> Dict[str, Any]:
|
|
151
|
+
"""Duplicate a layer."""
|
|
152
|
+
layers = project.get("layers", [])
|
|
153
|
+
if index < 0 or index >= len(layers):
|
|
154
|
+
raise IndexError(f"Layer index {index} out of range (0-{len(layers)-1})")
|
|
155
|
+
|
|
156
|
+
original = layers[index]
|
|
157
|
+
dup = copy.deepcopy(original)
|
|
158
|
+
existing_ids = [l.get("id", 0) for l in layers]
|
|
159
|
+
dup["id"] = max(existing_ids, default=-1) + 1
|
|
160
|
+
dup["name"] = f"{original['name']} copy"
|
|
161
|
+
layers.insert(index, dup)
|
|
162
|
+
return dup
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def move_layer(project: Dict[str, Any], index: int, to: int) -> None:
|
|
166
|
+
"""Move a layer to a new position."""
|
|
167
|
+
layers = project.get("layers", [])
|
|
168
|
+
if index < 0 or index >= len(layers):
|
|
169
|
+
raise IndexError(f"Source layer index {index} out of range")
|
|
170
|
+
to = max(0, min(to, len(layers) - 1))
|
|
171
|
+
layer = layers.pop(index)
|
|
172
|
+
layers.insert(to, layer)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def set_layer_property(
|
|
176
|
+
project: Dict[str, Any], index: int, prop: str, value: Any
|
|
177
|
+
) -> None:
|
|
178
|
+
"""Set a layer property."""
|
|
179
|
+
layers = project.get("layers", [])
|
|
180
|
+
if index < 0 or index >= len(layers):
|
|
181
|
+
raise IndexError(f"Layer index {index} out of range")
|
|
182
|
+
|
|
183
|
+
layer = layers[index]
|
|
184
|
+
|
|
185
|
+
if prop == "opacity":
|
|
186
|
+
value = float(value)
|
|
187
|
+
if not 0.0 <= value <= 1.0:
|
|
188
|
+
raise ValueError(f"Opacity must be 0.0-1.0, got {value}")
|
|
189
|
+
layer["opacity"] = value
|
|
190
|
+
elif prop == "visible":
|
|
191
|
+
layer["visible"] = str(value).lower() in ("true", "1", "yes")
|
|
192
|
+
elif prop == "blend_mode" or prop == "mode":
|
|
193
|
+
if value not in BLEND_MODES:
|
|
194
|
+
raise ValueError(f"Invalid blend mode '{value}'. Valid: {BLEND_MODES}")
|
|
195
|
+
layer["blend_mode"] = value
|
|
196
|
+
elif prop == "name":
|
|
197
|
+
layer["name"] = str(value)
|
|
198
|
+
elif prop == "offset_x":
|
|
199
|
+
layer["offset_x"] = int(value)
|
|
200
|
+
elif prop == "offset_y":
|
|
201
|
+
layer["offset_y"] = int(value)
|
|
202
|
+
else:
|
|
203
|
+
raise ValueError(f"Unknown property: {prop}. Valid: name, opacity, visible, mode, offset_x, offset_y")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def get_layer(project: Dict[str, Any], index: int) -> Dict[str, Any]:
|
|
207
|
+
"""Get a layer by index."""
|
|
208
|
+
layers = project.get("layers", [])
|
|
209
|
+
if index < 0 or index >= len(layers):
|
|
210
|
+
raise IndexError(f"Layer index {index} out of range (0-{len(layers)-1})")
|
|
211
|
+
return layers[index]
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def list_layers(project: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
215
|
+
"""List all layers with summary info."""
|
|
216
|
+
result = []
|
|
217
|
+
for i, l in enumerate(project.get("layers", [])):
|
|
218
|
+
result.append({
|
|
219
|
+
"index": i,
|
|
220
|
+
"id": l.get("id", i),
|
|
221
|
+
"name": l.get("name", f"Layer {i}"),
|
|
222
|
+
"type": l.get("type", "image"),
|
|
223
|
+
"visible": l.get("visible", True),
|
|
224
|
+
"opacity": l.get("opacity", 1.0),
|
|
225
|
+
"blend_mode": l.get("blend_mode", "normal"),
|
|
226
|
+
"size": f"{l.get('width', '?')}x{l.get('height', '?')}",
|
|
227
|
+
"offset": f"({l.get('offset_x', 0)}, {l.get('offset_y', 0)})",
|
|
228
|
+
"filter_count": len(l.get("filters", [])),
|
|
229
|
+
})
|
|
230
|
+
return result
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def flatten_layers(project: Dict[str, Any]) -> None:
|
|
234
|
+
"""Mark project for flattening (merge all visible layers into one)."""
|
|
235
|
+
visible = [l for l in project.get("layers", []) if l.get("visible", True)]
|
|
236
|
+
if not visible:
|
|
237
|
+
raise ValueError("No visible layers to flatten")
|
|
238
|
+
# Create a single flattened layer marker
|
|
239
|
+
project["_flatten_pending"] = True
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def merge_down(project: Dict[str, Any], index: int) -> None:
|
|
243
|
+
"""Mark layers for merging (layer at index merges into the one below)."""
|
|
244
|
+
layers = project.get("layers", [])
|
|
245
|
+
if index < 0 or index >= len(layers):
|
|
246
|
+
raise IndexError(f"Layer index {index} out of range")
|
|
247
|
+
if index >= len(layers) - 1:
|
|
248
|
+
raise ValueError("Cannot merge down the bottom layer")
|
|
249
|
+
project["_merge_down_pending"] = index
|