@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.
Files changed (48) hide show
  1. package/README.md +4 -0
  2. package/manifest.json +22 -0
  3. package/package.json +11 -0
  4. package/python/agent-harness/GIMP.md +301 -0
  5. package/python/agent-harness/build/lib/cli_anything/gimp/__init__.py +1 -0
  6. package/python/agent-harness/build/lib/cli_anything/gimp/__main__.py +3 -0
  7. package/python/agent-harness/build/lib/cli_anything/gimp/core/__init__.py +1 -0
  8. package/python/agent-harness/build/lib/cli_anything/gimp/core/canvas.py +193 -0
  9. package/python/agent-harness/build/lib/cli_anything/gimp/core/export.py +479 -0
  10. package/python/agent-harness/build/lib/cli_anything/gimp/core/filters.py +382 -0
  11. package/python/agent-harness/build/lib/cli_anything/gimp/core/layers.py +249 -0
  12. package/python/agent-harness/build/lib/cli_anything/gimp/core/media.py +174 -0
  13. package/python/agent-harness/build/lib/cli_anything/gimp/core/project.py +131 -0
  14. package/python/agent-harness/build/lib/cli_anything/gimp/core/session.py +130 -0
  15. package/python/agent-harness/build/lib/cli_anything/gimp/gimp_cli.py +788 -0
  16. package/python/agent-harness/build/lib/cli_anything/gimp/tests/__init__.py +1 -0
  17. package/python/agent-harness/build/lib/cli_anything/gimp/tests/test_core.py +478 -0
  18. package/python/agent-harness/build/lib/cli_anything/gimp/tests/test_full_e2e.py +578 -0
  19. package/python/agent-harness/build/lib/cli_anything/gimp/utils/__init__.py +1 -0
  20. package/python/agent-harness/build/lib/cli_anything/gimp/utils/gimp_backend.py +208 -0
  21. package/python/agent-harness/build/lib/cli_anything/gimp/utils/repl_skin.py +498 -0
  22. package/python/agent-harness/cli_anything/gimp/README.md +202 -0
  23. package/python/agent-harness/cli_anything/gimp/__init__.py +1 -0
  24. package/python/agent-harness/cli_anything/gimp/__main__.py +3 -0
  25. package/python/agent-harness/cli_anything/gimp/core/__init__.py +1 -0
  26. package/python/agent-harness/cli_anything/gimp/core/canvas.py +193 -0
  27. package/python/agent-harness/cli_anything/gimp/core/export.py +479 -0
  28. package/python/agent-harness/cli_anything/gimp/core/filters.py +382 -0
  29. package/python/agent-harness/cli_anything/gimp/core/layers.py +249 -0
  30. package/python/agent-harness/cli_anything/gimp/core/media.py +174 -0
  31. package/python/agent-harness/cli_anything/gimp/core/project.py +131 -0
  32. package/python/agent-harness/cli_anything/gimp/core/session.py +130 -0
  33. package/python/agent-harness/cli_anything/gimp/gimp_cli.py +788 -0
  34. package/python/agent-harness/cli_anything/gimp/tests/TEST.md +137 -0
  35. package/python/agent-harness/cli_anything/gimp/tests/__init__.py +1 -0
  36. package/python/agent-harness/cli_anything/gimp/tests/test_core.py +478 -0
  37. package/python/agent-harness/cli_anything/gimp/tests/test_full_e2e.py +578 -0
  38. package/python/agent-harness/cli_anything/gimp/utils/__init__.py +1 -0
  39. package/python/agent-harness/cli_anything/gimp/utils/gimp_backend.py +208 -0
  40. package/python/agent-harness/cli_anything/gimp/utils/repl_skin.py +498 -0
  41. package/python/agent-harness/cli_anything_gimp.egg-info/PKG-INFO +236 -0
  42. package/python/agent-harness/cli_anything_gimp.egg-info/SOURCES.txt +25 -0
  43. package/python/agent-harness/cli_anything_gimp.egg-info/dependency_links.txt +1 -0
  44. package/python/agent-harness/cli_anything_gimp.egg-info/entry_points.txt +2 -0
  45. package/python/agent-harness/cli_anything_gimp.egg-info/not-zip-safe +1 -0
  46. package/python/agent-harness/cli_anything_gimp.egg-info/requires.txt +7 -0
  47. package/python/agent-harness/cli_anything_gimp.egg-info/top_level.txt +1 -0
  48. 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