@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
package/README.md
ADDED
package/manifest.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "gimp",
|
|
3
|
+
"display_name": "CLI-Anything GIMP Harness",
|
|
4
|
+
"module": "cli_anything.gimp",
|
|
5
|
+
"version": "1.0.0",
|
|
6
|
+
"source_path": "python/agent-harness",
|
|
7
|
+
"commands": [
|
|
8
|
+
"cli-anything-gimp"
|
|
9
|
+
],
|
|
10
|
+
"python_requirements": [
|
|
11
|
+
"numpy>=2.0.0"
|
|
12
|
+
],
|
|
13
|
+
"capabilities": [
|
|
14
|
+
"json-output",
|
|
15
|
+
"repl-mode",
|
|
16
|
+
"agent-native"
|
|
17
|
+
],
|
|
18
|
+
"upstream": {
|
|
19
|
+
"repository": "https://github.com/HKUDS/CLI-Anything",
|
|
20
|
+
"subpath": "gimp/agent-harness"
|
|
21
|
+
}
|
|
22
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
# GIMP: Project-Specific Analysis & SOP
|
|
2
|
+
|
|
3
|
+
## Architecture Summary
|
|
4
|
+
|
|
5
|
+
GIMP (GNU Image Manipulation Program) is a GTK-based raster image editor built on
|
|
6
|
+
the **GEGL** (Generic Graphics Library) processing engine and **Babl** color management.
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
┌──────────────────────────────────────────────┐
|
|
10
|
+
│ GIMP GUI │
|
|
11
|
+
│ ┌──────────┐ ┌──────────┐ ┌─────────────┐ │
|
|
12
|
+
│ │ Canvas │ │ Layers │ │ Filters │ │
|
|
13
|
+
│ │ (GTK) │ │ (GTK) │ │ (GTK) │ │
|
|
14
|
+
│ └────┬──────┘ └────┬─────┘ └──────┬──────┘ │
|
|
15
|
+
│ │ │ │ │
|
|
16
|
+
│ ┌────┴─────────────┴──────────────┴───────┐ │
|
|
17
|
+
│ │ PDB (Procedure Database) │ │
|
|
18
|
+
│ │ 500+ registered procedures for all │ │
|
|
19
|
+
│ │ image operations, filters, I/O │ │
|
|
20
|
+
│ └─────────────────┬───────────────────────┘ │
|
|
21
|
+
│ │ │
|
|
22
|
+
│ ┌─────────────────┴───────────────────────┐ │
|
|
23
|
+
│ │ GEGL Processing Engine │ │
|
|
24
|
+
│ │ DAG-based image processing pipeline │ │
|
|
25
|
+
│ │ 70+ built-in operations │ │
|
|
26
|
+
│ └─────────────────┬───────────────────────┘ │
|
|
27
|
+
└────────────────────┼─────────────────────────┘
|
|
28
|
+
│
|
|
29
|
+
┌───────────┴──────────┐
|
|
30
|
+
│ Babl (color mgmt) │
|
|
31
|
+
│ + GEGL operations │
|
|
32
|
+
│ + File format I/O │
|
|
33
|
+
└──────────────────────┘
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## CLI Strategy: Pillow + External Tools
|
|
37
|
+
|
|
38
|
+
Unlike Shotcut (which manipulates XML project files), GIMP's native .xcf format
|
|
39
|
+
is a complex binary format. Our strategy:
|
|
40
|
+
|
|
41
|
+
1. **Pillow** — Python's standard imaging library. Handles image I/O (PNG, JPEG,
|
|
42
|
+
TIFF, BMP, GIF, WebP, etc.), pixel manipulation, basic filters, color
|
|
43
|
+
adjustments, drawing, and compositing. This is our primary engine.
|
|
44
|
+
2. **GEGL CLI** — If available, use `gegl` command for advanced operations.
|
|
45
|
+
3. **GIMP batch mode** — If `gimp` is installed, use `gimp -i -b` for XCF
|
|
46
|
+
operations and advanced filters via Script-Fu/Python-Fu.
|
|
47
|
+
|
|
48
|
+
### Why Not XCF Directly?
|
|
49
|
+
|
|
50
|
+
XCF is a tile-based binary format with compression, layers, channels, paths,
|
|
51
|
+
and GEGL filter graphs. Parsing it from scratch is extremely complex (5000+ lines
|
|
52
|
+
of C in GIMP's xcf-load.c). Instead:
|
|
53
|
+
- For new projects, we build layer stacks in memory using Pillow
|
|
54
|
+
- For XCF import/export, we delegate to GIMP batch mode if available
|
|
55
|
+
- Our "project file" is a JSON manifest tracking layers, operations, and history
|
|
56
|
+
|
|
57
|
+
## The Project Format (.gimp-cli.json)
|
|
58
|
+
|
|
59
|
+
Since we can't easily manipulate XCF directly, we use a JSON project format:
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{
|
|
63
|
+
"version": "1.0",
|
|
64
|
+
"name": "my_project",
|
|
65
|
+
"canvas": {
|
|
66
|
+
"width": 1920,
|
|
67
|
+
"height": 1080,
|
|
68
|
+
"color_mode": "RGB",
|
|
69
|
+
"background": "#ffffff",
|
|
70
|
+
"dpi": 300
|
|
71
|
+
},
|
|
72
|
+
"layers": [
|
|
73
|
+
{
|
|
74
|
+
"id": 0,
|
|
75
|
+
"name": "Background",
|
|
76
|
+
"type": "image",
|
|
77
|
+
"source": "/path/to/image.png",
|
|
78
|
+
"visible": true,
|
|
79
|
+
"opacity": 1.0,
|
|
80
|
+
"blend_mode": "normal",
|
|
81
|
+
"offset_x": 0,
|
|
82
|
+
"offset_y": 0,
|
|
83
|
+
"filters": [
|
|
84
|
+
{"name": "brightness", "params": {"factor": 1.2}},
|
|
85
|
+
{"name": "gaussian_blur", "params": {"radius": 3}}
|
|
86
|
+
]
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
"id": 1,
|
|
90
|
+
"name": "Text Layer",
|
|
91
|
+
"type": "text",
|
|
92
|
+
"text": "Hello World",
|
|
93
|
+
"font": "Arial",
|
|
94
|
+
"font_size": 48,
|
|
95
|
+
"color": "#000000",
|
|
96
|
+
"visible": true,
|
|
97
|
+
"opacity": 0.8,
|
|
98
|
+
"blend_mode": "normal",
|
|
99
|
+
"offset_x": 100,
|
|
100
|
+
"offset_y": 50,
|
|
101
|
+
"filters": []
|
|
102
|
+
}
|
|
103
|
+
],
|
|
104
|
+
"selection": null,
|
|
105
|
+
"guides": [],
|
|
106
|
+
"metadata": {}
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Core Operations via Pillow
|
|
111
|
+
|
|
112
|
+
### Image I/O
|
|
113
|
+
| Operation | Pillow API |
|
|
114
|
+
|-----------|-----------|
|
|
115
|
+
| Open image | `Image.open(path)` |
|
|
116
|
+
| Save image | `image.save(path, format)` |
|
|
117
|
+
| Create blank | `Image.new(mode, (w,h), color)` |
|
|
118
|
+
| Convert mode | `image.convert("RGB"/"L"/"RGBA")` |
|
|
119
|
+
| Resize | `image.resize((w,h), resample)` |
|
|
120
|
+
| Crop | `image.crop((l, t, r, b))` |
|
|
121
|
+
| Rotate | `image.rotate(angle, expand=True)` |
|
|
122
|
+
| Flip | `image.transpose(Image.FLIP_LEFT_RIGHT)` |
|
|
123
|
+
|
|
124
|
+
### Filters & Adjustments
|
|
125
|
+
| Operation | Pillow API |
|
|
126
|
+
|-----------|-----------|
|
|
127
|
+
| Brightness | `ImageEnhance.Brightness(img).enhance(factor)` |
|
|
128
|
+
| Contrast | `ImageEnhance.Contrast(img).enhance(factor)` |
|
|
129
|
+
| Saturation | `ImageEnhance.Color(img).enhance(factor)` |
|
|
130
|
+
| Sharpness | `ImageEnhance.Sharpness(img).enhance(factor)` |
|
|
131
|
+
| Gaussian blur | `image.filter(ImageFilter.GaussianBlur(radius))` |
|
|
132
|
+
| Box blur | `image.filter(ImageFilter.BoxBlur(radius))` |
|
|
133
|
+
| Unsharp mask | `image.filter(ImageFilter.UnsharpMask(radius, percent, threshold))` |
|
|
134
|
+
| Find edges | `image.filter(ImageFilter.FIND_EDGES)` |
|
|
135
|
+
| Emboss | `image.filter(ImageFilter.EMBOSS)` |
|
|
136
|
+
| Contour | `image.filter(ImageFilter.CONTOUR)` |
|
|
137
|
+
| Detail | `image.filter(ImageFilter.DETAIL)` |
|
|
138
|
+
| Smooth | `image.filter(ImageFilter.SMOOTH_MORE)` |
|
|
139
|
+
| Grayscale | `ImageOps.grayscale(image)` |
|
|
140
|
+
| Invert | `ImageOps.invert(image)` |
|
|
141
|
+
| Posterize | `ImageOps.posterize(image, bits)` |
|
|
142
|
+
| Solarize | `ImageOps.solarize(image, threshold)` |
|
|
143
|
+
| Autocontrast | `ImageOps.autocontrast(image)` |
|
|
144
|
+
| Equalize | `ImageOps.equalize(image)` |
|
|
145
|
+
| Sepia | Custom kernel via `ImageOps.colorize()` |
|
|
146
|
+
|
|
147
|
+
### Compositing & Drawing
|
|
148
|
+
| Operation | Pillow API |
|
|
149
|
+
|-----------|-----------|
|
|
150
|
+
| Paste layer | `Image.alpha_composite(base, overlay)` |
|
|
151
|
+
| Blend modes | Custom implementations (multiply, screen, overlay, etc.) |
|
|
152
|
+
| Draw rectangle | `ImageDraw.rectangle(xy, fill, outline)` |
|
|
153
|
+
| Draw ellipse | `ImageDraw.ellipse(xy, fill, outline)` |
|
|
154
|
+
| Draw text | `ImageDraw.text(xy, text, font, fill)` |
|
|
155
|
+
| Draw line | `ImageDraw.line(xy, fill, width)` |
|
|
156
|
+
|
|
157
|
+
## Blend Modes
|
|
158
|
+
|
|
159
|
+
Pillow doesn't natively support Photoshop/GIMP blend modes. We implement the
|
|
160
|
+
most common ones using NumPy-style pixel math:
|
|
161
|
+
|
|
162
|
+
| Mode | Formula |
|
|
163
|
+
|------|---------|
|
|
164
|
+
| Normal | `top` (with alpha compositing) |
|
|
165
|
+
| Multiply | `base * top / 255` |
|
|
166
|
+
| Screen | `255 - (255-base)*(255-top)/255` |
|
|
167
|
+
| Overlay | `if base < 128: 2*base*top/255 else: 255 - 2*(255-base)*(255-top)/255` |
|
|
168
|
+
| Soft Light | Photoshop-style formula |
|
|
169
|
+
| Hard Light | Overlay with base/top swapped |
|
|
170
|
+
| Difference | `abs(base - top)` |
|
|
171
|
+
| Darken | `min(base, top)` |
|
|
172
|
+
| Lighten | `max(base, top)` |
|
|
173
|
+
| Color Dodge | `base / (255 - top) * 255` |
|
|
174
|
+
| Color Burn | `255 - (255-base) / top * 255` |
|
|
175
|
+
|
|
176
|
+
## Command Map: GUI Action -> CLI Command
|
|
177
|
+
|
|
178
|
+
| GUI Action | CLI Command |
|
|
179
|
+
|-----------|-------------|
|
|
180
|
+
| File -> New | `project new --width 1920 --height 1080 [--mode RGB]` |
|
|
181
|
+
| File -> Open | `project open <path>` |
|
|
182
|
+
| File -> Save | `project save [path]` |
|
|
183
|
+
| File -> Export As | `export render <output> [--format png] [--quality 95]` |
|
|
184
|
+
| Image -> Canvas Size | `canvas resize --width W --height H` |
|
|
185
|
+
| Image -> Scale Image | `canvas scale --width W --height H` |
|
|
186
|
+
| Image -> Crop to Selection | `canvas crop --left L --top T --right R --bottom B` |
|
|
187
|
+
| Image -> Mode -> RGB | `canvas mode RGB` |
|
|
188
|
+
| Layer -> New Layer | `layer new [--name "Layer"] [--width W] [--height H]` |
|
|
189
|
+
| Layer -> Duplicate | `layer duplicate <index>` |
|
|
190
|
+
| Layer -> Delete | `layer remove <index>` |
|
|
191
|
+
| Layer -> Flatten Image | `layer flatten` |
|
|
192
|
+
| Layer -> Merge Down | `layer merge-down <index>` |
|
|
193
|
+
| Move layer | `layer move <index> --to <position>` |
|
|
194
|
+
| Set layer opacity | `layer set <index> opacity <value>` |
|
|
195
|
+
| Set blend mode | `layer set <index> mode <mode>` |
|
|
196
|
+
| Toggle visibility | `layer set <index> visible <true/false>` |
|
|
197
|
+
| Layer -> Add from File | `layer add-from-file <path> [--name N] [--position P]` |
|
|
198
|
+
| Filters -> Blur -> Gaussian | `filter add gaussian_blur --layer L --param radius=5` |
|
|
199
|
+
| Colors -> Brightness-Contrast | `filter add brightness --layer L --param factor=1.2` |
|
|
200
|
+
| Colors -> Hue-Saturation | `filter add saturation --layer L --param factor=1.3` |
|
|
201
|
+
| Colors -> Invert | `filter add invert --layer L` |
|
|
202
|
+
| Draw text on layer | `draw text --layer L --text "Hi" --x 10 --y 10 --font Arial --size 24` |
|
|
203
|
+
| Draw rectangle | `draw rect --layer L --x1 0 --y1 0 --x2 100 --y2 100 --fill "#ff0000"` |
|
|
204
|
+
| View layers | `layer list` |
|
|
205
|
+
| View project info | `project info` |
|
|
206
|
+
| Undo | `session undo` |
|
|
207
|
+
| Redo | `session redo` |
|
|
208
|
+
|
|
209
|
+
## Filter Registry
|
|
210
|
+
|
|
211
|
+
### Image Adjustments
|
|
212
|
+
| CLI Name | Pillow Implementation | Key Parameters |
|
|
213
|
+
|----------|----------------------|----------------|
|
|
214
|
+
| `brightness` | `ImageEnhance.Brightness` | `factor` (1.0 = neutral, >1 = brighter) |
|
|
215
|
+
| `contrast` | `ImageEnhance.Contrast` | `factor` (1.0 = neutral) |
|
|
216
|
+
| `saturation` | `ImageEnhance.Color` | `factor` (1.0 = neutral, 0 = grayscale) |
|
|
217
|
+
| `sharpness` | `ImageEnhance.Sharpness` | `factor` (1.0 = neutral, >1 = sharper) |
|
|
218
|
+
| `autocontrast` | `ImageOps.autocontrast` | `cutoff` (0-49, percent to clip) |
|
|
219
|
+
| `equalize` | `ImageOps.equalize` | (no params) |
|
|
220
|
+
| `invert` | `ImageOps.invert` | (no params) |
|
|
221
|
+
| `posterize` | `ImageOps.posterize` | `bits` (1-8) |
|
|
222
|
+
| `solarize` | `ImageOps.solarize` | `threshold` (0-255) |
|
|
223
|
+
| `grayscale` | `ImageOps.grayscale` | (no params) |
|
|
224
|
+
| `sepia` | Custom colorize | `strength` (0.0-1.0) |
|
|
225
|
+
|
|
226
|
+
### Blur & Sharpen
|
|
227
|
+
| CLI Name | Pillow Implementation | Key Parameters |
|
|
228
|
+
|----------|----------------------|----------------|
|
|
229
|
+
| `gaussian_blur` | `ImageFilter.GaussianBlur` | `radius` (pixels) |
|
|
230
|
+
| `box_blur` | `ImageFilter.BoxBlur` | `radius` (pixels) |
|
|
231
|
+
| `unsharp_mask` | `ImageFilter.UnsharpMask` | `radius`, `percent`, `threshold` |
|
|
232
|
+
| `smooth` | `ImageFilter.SMOOTH_MORE` | (no params) |
|
|
233
|
+
|
|
234
|
+
### Stylize
|
|
235
|
+
| CLI Name | Pillow Implementation | Key Parameters |
|
|
236
|
+
|----------|----------------------|----------------|
|
|
237
|
+
| `find_edges` | `ImageFilter.FIND_EDGES` | (no params) |
|
|
238
|
+
| `emboss` | `ImageFilter.EMBOSS` | (no params) |
|
|
239
|
+
| `contour` | `ImageFilter.CONTOUR` | (no params) |
|
|
240
|
+
| `detail` | `ImageFilter.DETAIL` | (no params) |
|
|
241
|
+
|
|
242
|
+
### Transform
|
|
243
|
+
| CLI Name | Pillow Implementation | Key Parameters |
|
|
244
|
+
|----------|----------------------|----------------|
|
|
245
|
+
| `rotate` | `Image.rotate` | `angle` (degrees), `expand` (bool) |
|
|
246
|
+
| `flip_h` | `Image.transpose(FLIP_LEFT_RIGHT)` | (no params) |
|
|
247
|
+
| `flip_v` | `Image.transpose(FLIP_TOP_BOTTOM)` | (no params) |
|
|
248
|
+
| `resize` | `Image.resize` | `width`, `height`, `resample` (nearest/bilinear/bicubic/lanczos) |
|
|
249
|
+
| `crop` | `Image.crop` | `left`, `top`, `right`, `bottom` |
|
|
250
|
+
|
|
251
|
+
## Export Formats
|
|
252
|
+
|
|
253
|
+
| Format | Extension | Quality Param | Notes |
|
|
254
|
+
|--------|-----------|---------------|-------|
|
|
255
|
+
| PNG | .png | `compress_level` (0-9) | Lossless, supports alpha |
|
|
256
|
+
| JPEG | .jpg/.jpeg | `quality` (1-95) | Lossy, no alpha |
|
|
257
|
+
| WebP | .webp | `quality` (1-100) | Both lossy/lossless |
|
|
258
|
+
| TIFF | .tiff | `compression` (none/lzw/jpeg) | Professional |
|
|
259
|
+
| BMP | .bmp | (none) | Uncompressed |
|
|
260
|
+
| GIF | .gif | (none) | 256 colors max |
|
|
261
|
+
| ICO | .ico | (none) | Icon format |
|
|
262
|
+
| PDF | .pdf | (none) | Multi-page possible |
|
|
263
|
+
|
|
264
|
+
## Rendering Pipeline
|
|
265
|
+
|
|
266
|
+
For GIMP CLI, "rendering" means flattening the layer stack with all filters
|
|
267
|
+
applied and exporting to a target format.
|
|
268
|
+
|
|
269
|
+
### Pipeline Steps:
|
|
270
|
+
1. Start with canvas (background color or transparent)
|
|
271
|
+
2. For each visible layer (bottom to top):
|
|
272
|
+
a. Load/create the layer content
|
|
273
|
+
b. Apply all layer filters in order
|
|
274
|
+
c. Position at layer offset
|
|
275
|
+
d. Composite onto canvas using blend mode and opacity
|
|
276
|
+
3. Export final composited image
|
|
277
|
+
|
|
278
|
+
### Rendering Gap Assessment: **Medium**
|
|
279
|
+
- Most operations (resize, crop, filters, compositing) work via Pillow directly
|
|
280
|
+
- Advanced GEGL operations (high-pass filter, wavelet decompose) not available
|
|
281
|
+
- No XCF round-trip without GIMP installed
|
|
282
|
+
- Blend modes require custom implementation but are mathematically straightforward
|
|
283
|
+
|
|
284
|
+
## Test Coverage Plan
|
|
285
|
+
|
|
286
|
+
1. **Unit tests** (`test_core.py`): Synthetic data, no real images needed
|
|
287
|
+
- Project create/open/save/info
|
|
288
|
+
- Layer add/remove/reorder/properties
|
|
289
|
+
- Filter application and parameter validation
|
|
290
|
+
- Canvas operations (resize, scale, crop, mode conversion)
|
|
291
|
+
- Session undo/redo
|
|
292
|
+
- JSON project serialization/deserialization
|
|
293
|
+
|
|
294
|
+
2. **E2E tests** (`test_full_e2e.py`): Real images
|
|
295
|
+
- Full workflow: create project, add layers, apply filters, export
|
|
296
|
+
- Format conversion (PNG->JPEG, etc.)
|
|
297
|
+
- Blend mode compositing verification
|
|
298
|
+
- Filter effect pixel-level verification
|
|
299
|
+
- Multi-layer compositing
|
|
300
|
+
- Text rendering
|
|
301
|
+
- CLI subprocess invocation
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""GIMP CLI - A stateful CLI for image editing."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""GIMP CLI - Core modules."""
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""GIMP CLI - Canvas operations module."""
|
|
2
|
+
|
|
3
|
+
from typing import Dict, Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
VALID_MODES = ("RGB", "RGBA", "L", "LA", "CMYK", "P")
|
|
7
|
+
RESAMPLE_METHODS = ("nearest", "bilinear", "bicubic", "lanczos")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def resize_canvas(
|
|
11
|
+
project: Dict[str, Any],
|
|
12
|
+
width: int,
|
|
13
|
+
height: int,
|
|
14
|
+
anchor: str = "center",
|
|
15
|
+
) -> Dict[str, Any]:
|
|
16
|
+
"""Resize the canvas (does not scale content, adds/removes space).
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
project: The project dict
|
|
20
|
+
width: New canvas width
|
|
21
|
+
height: New canvas height
|
|
22
|
+
anchor: Where to anchor existing content:
|
|
23
|
+
"center", "top-left", "top-right", "bottom-left", "bottom-right",
|
|
24
|
+
"top", "bottom", "left", "right"
|
|
25
|
+
"""
|
|
26
|
+
if width < 1 or height < 1:
|
|
27
|
+
raise ValueError(f"Canvas dimensions must be positive: {width}x{height}")
|
|
28
|
+
|
|
29
|
+
valid_anchors = [
|
|
30
|
+
"center", "top-left", "top-right", "bottom-left", "bottom-right",
|
|
31
|
+
"top", "bottom", "left", "right",
|
|
32
|
+
]
|
|
33
|
+
if anchor not in valid_anchors:
|
|
34
|
+
raise ValueError(f"Invalid anchor: {anchor}. Valid: {valid_anchors}")
|
|
35
|
+
|
|
36
|
+
old_w = project["canvas"]["width"]
|
|
37
|
+
old_h = project["canvas"]["height"]
|
|
38
|
+
|
|
39
|
+
# Calculate offset for existing layers based on anchor
|
|
40
|
+
dx, dy = _anchor_offset(old_w, old_h, width, height, anchor)
|
|
41
|
+
|
|
42
|
+
project["canvas"]["width"] = width
|
|
43
|
+
project["canvas"]["height"] = height
|
|
44
|
+
|
|
45
|
+
# Adjust layer offsets
|
|
46
|
+
for layer in project.get("layers", []):
|
|
47
|
+
layer["offset_x"] = layer.get("offset_x", 0) + dx
|
|
48
|
+
layer["offset_y"] = layer.get("offset_y", 0) + dy
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
"old_size": f"{old_w}x{old_h}",
|
|
52
|
+
"new_size": f"{width}x{height}",
|
|
53
|
+
"anchor": anchor,
|
|
54
|
+
"offset_applied": f"({dx}, {dy})",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def scale_canvas(
|
|
59
|
+
project: Dict[str, Any],
|
|
60
|
+
width: int,
|
|
61
|
+
height: int,
|
|
62
|
+
resample: str = "lanczos",
|
|
63
|
+
) -> Dict[str, Any]:
|
|
64
|
+
"""Scale the canvas and all layers proportionally.
|
|
65
|
+
|
|
66
|
+
This marks layers for rescaling at render time.
|
|
67
|
+
"""
|
|
68
|
+
if width < 1 or height < 1:
|
|
69
|
+
raise ValueError(f"Canvas dimensions must be positive: {width}x{height}")
|
|
70
|
+
if resample not in RESAMPLE_METHODS:
|
|
71
|
+
raise ValueError(f"Invalid resample method: {resample}. Valid: {list(RESAMPLE_METHODS)}")
|
|
72
|
+
|
|
73
|
+
old_w = project["canvas"]["width"]
|
|
74
|
+
old_h = project["canvas"]["height"]
|
|
75
|
+
scale_x = width / old_w
|
|
76
|
+
scale_y = height / old_h
|
|
77
|
+
|
|
78
|
+
project["canvas"]["width"] = width
|
|
79
|
+
project["canvas"]["height"] = height
|
|
80
|
+
|
|
81
|
+
# Mark layers for proportional scaling
|
|
82
|
+
for layer in project.get("layers", []):
|
|
83
|
+
layer["_scale_x"] = scale_x
|
|
84
|
+
layer["_scale_y"] = scale_y
|
|
85
|
+
layer["_resample"] = resample
|
|
86
|
+
layer["offset_x"] = round(layer.get("offset_x", 0) * scale_x)
|
|
87
|
+
layer["offset_y"] = round(layer.get("offset_y", 0) * scale_y)
|
|
88
|
+
if "width" in layer:
|
|
89
|
+
layer["width"] = round(layer["width"] * scale_x)
|
|
90
|
+
if "height" in layer:
|
|
91
|
+
layer["height"] = round(layer["height"] * scale_y)
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
"old_size": f"{old_w}x{old_h}",
|
|
95
|
+
"new_size": f"{width}x{height}",
|
|
96
|
+
"scale": f"({scale_x:.3f}, {scale_y:.3f})",
|
|
97
|
+
"resample": resample,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def crop_canvas(
|
|
102
|
+
project: Dict[str, Any],
|
|
103
|
+
left: int,
|
|
104
|
+
top: int,
|
|
105
|
+
right: int,
|
|
106
|
+
bottom: int,
|
|
107
|
+
) -> Dict[str, Any]:
|
|
108
|
+
"""Crop the canvas to a rectangle."""
|
|
109
|
+
if left < 0 or top < 0:
|
|
110
|
+
raise ValueError(f"Crop coordinates must be non-negative: left={left}, top={top}")
|
|
111
|
+
if right <= left or bottom <= top:
|
|
112
|
+
raise ValueError(f"Invalid crop region: ({left},{top})-({right},{bottom})")
|
|
113
|
+
|
|
114
|
+
old_w = project["canvas"]["width"]
|
|
115
|
+
old_h = project["canvas"]["height"]
|
|
116
|
+
|
|
117
|
+
if right > old_w or bottom > old_h:
|
|
118
|
+
raise ValueError(
|
|
119
|
+
f"Crop region ({left},{top})-({right},{bottom}) exceeds canvas {old_w}x{old_h}"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
new_w = right - left
|
|
123
|
+
new_h = bottom - top
|
|
124
|
+
|
|
125
|
+
project["canvas"]["width"] = new_w
|
|
126
|
+
project["canvas"]["height"] = new_h
|
|
127
|
+
|
|
128
|
+
# Adjust layer offsets
|
|
129
|
+
for layer in project.get("layers", []):
|
|
130
|
+
layer["offset_x"] = layer.get("offset_x", 0) - left
|
|
131
|
+
layer["offset_y"] = layer.get("offset_y", 0) - top
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
"old_size": f"{old_w}x{old_h}",
|
|
135
|
+
"new_size": f"{new_w}x{new_h}",
|
|
136
|
+
"crop_region": f"({left},{top})-({right},{bottom})",
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def set_mode(project: Dict[str, Any], mode: str) -> Dict[str, Any]:
|
|
141
|
+
"""Set the canvas color mode."""
|
|
142
|
+
mode = mode.upper()
|
|
143
|
+
if mode not in VALID_MODES:
|
|
144
|
+
raise ValueError(f"Invalid color mode: {mode}. Valid: {list(VALID_MODES)}")
|
|
145
|
+
old_mode = project["canvas"].get("color_mode", "RGB")
|
|
146
|
+
project["canvas"]["color_mode"] = mode
|
|
147
|
+
return {"old_mode": old_mode, "new_mode": mode}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def set_dpi(project: Dict[str, Any], dpi: int) -> Dict[str, Any]:
|
|
151
|
+
"""Set the canvas DPI (dots per inch)."""
|
|
152
|
+
if dpi < 1:
|
|
153
|
+
raise ValueError(f"DPI must be positive: {dpi}")
|
|
154
|
+
old_dpi = project["canvas"].get("dpi", 72)
|
|
155
|
+
project["canvas"]["dpi"] = dpi
|
|
156
|
+
return {"old_dpi": old_dpi, "new_dpi": dpi}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def get_canvas_info(project: Dict[str, Any]) -> Dict[str, Any]:
|
|
160
|
+
"""Get canvas information."""
|
|
161
|
+
c = project["canvas"]
|
|
162
|
+
w, h = c["width"], c["height"]
|
|
163
|
+
dpi = c.get("dpi", 72)
|
|
164
|
+
return {
|
|
165
|
+
"width": w,
|
|
166
|
+
"height": h,
|
|
167
|
+
"color_mode": c.get("color_mode", "RGB"),
|
|
168
|
+
"background": c.get("background", "#ffffff"),
|
|
169
|
+
"dpi": dpi,
|
|
170
|
+
"size_inches": f"{w/dpi:.2f} x {h/dpi:.2f}",
|
|
171
|
+
"megapixels": f"{w * h / 1_000_000:.2f} MP",
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _anchor_offset(
|
|
176
|
+
old_w: int, old_h: int, new_w: int, new_h: int, anchor: str
|
|
177
|
+
) -> tuple:
|
|
178
|
+
"""Calculate pixel offset for content based on anchor position."""
|
|
179
|
+
dx_map = {
|
|
180
|
+
"top-left": 0, "left": 0, "bottom-left": 0,
|
|
181
|
+
"top": (new_w - old_w) // 2, "center": (new_w - old_w) // 2,
|
|
182
|
+
"bottom": (new_w - old_w) // 2,
|
|
183
|
+
"top-right": new_w - old_w, "right": new_w - old_w,
|
|
184
|
+
"bottom-right": new_w - old_w,
|
|
185
|
+
}
|
|
186
|
+
dy_map = {
|
|
187
|
+
"top-left": 0, "top": 0, "top-right": 0,
|
|
188
|
+
"left": (new_h - old_h) // 2, "center": (new_h - old_h) // 2,
|
|
189
|
+
"right": (new_h - old_h) // 2,
|
|
190
|
+
"bottom-left": new_h - old_h, "bottom": new_h - old_h,
|
|
191
|
+
"bottom-right": new_h - old_h,
|
|
192
|
+
}
|
|
193
|
+
return dx_map.get(anchor, 0), dy_map.get(anchor, 0)
|