@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,578 @@
1
+ """End-to-end tests for GIMP CLI with real images.
2
+
3
+ These tests create actual images, apply filters, and verify pixel-level results.
4
+ """
5
+
6
+ import json
7
+ import os
8
+ import sys
9
+ import tempfile
10
+ import subprocess
11
+ import pytest
12
+
13
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
14
+
15
+ from PIL import Image, ImageDraw
16
+ import numpy as np
17
+
18
+ from cli_anything.gimp.core.project import create_project, save_project, open_project, get_project_info
19
+ from cli_anything.gimp.core.layers import add_layer, add_from_file, list_layers, remove_layer
20
+ from cli_anything.gimp.core.filters import add_filter, list_filters
21
+ from cli_anything.gimp.core.canvas import resize_canvas, scale_canvas, crop_canvas, set_mode
22
+ from cli_anything.gimp.core.media import probe_image, check_media
23
+ from cli_anything.gimp.core.export import render
24
+ from cli_anything.gimp.core.session import Session
25
+
26
+
27
+ @pytest.fixture
28
+ def tmp_dir():
29
+ with tempfile.TemporaryDirectory() as d:
30
+ yield d
31
+
32
+
33
+ @pytest.fixture
34
+ def sample_image(tmp_dir):
35
+ """Create a simple test image (red/green/blue stripes)."""
36
+ img = Image.new("RGB", (300, 200))
37
+ draw = ImageDraw.Draw(img)
38
+ draw.rectangle([0, 0, 100, 200], fill=(255, 0, 0)) # Red stripe
39
+ draw.rectangle([100, 0, 200, 200], fill=(0, 255, 0)) # Green stripe
40
+ draw.rectangle([200, 0, 300, 200], fill=(0, 0, 255)) # Blue stripe
41
+ path = os.path.join(tmp_dir, "test_image.png")
42
+ img.save(path)
43
+ return path
44
+
45
+
46
+ @pytest.fixture
47
+ def gradient_image(tmp_dir):
48
+ """Create a gradient test image (black to white horizontal)."""
49
+ img = Image.new("L", (256, 100))
50
+ for x in range(256):
51
+ for y in range(100):
52
+ img.putpixel((x, y), x)
53
+ path = os.path.join(tmp_dir, "gradient.png")
54
+ img.save(path)
55
+ return path
56
+
57
+
58
+ # ── Project Lifecycle ────────────────────────────────────────────
59
+
60
+ class TestProjectLifecycle:
61
+ def test_create_save_open_roundtrip(self, tmp_dir):
62
+ proj = create_project(name="roundtrip")
63
+ path = os.path.join(tmp_dir, "project.gimp-cli.json")
64
+ save_project(proj, path)
65
+ loaded = open_project(path)
66
+ assert loaded["name"] == "roundtrip"
67
+ assert loaded["canvas"]["width"] == 1920
68
+
69
+ def test_project_with_layers_roundtrip(self, tmp_dir, sample_image):
70
+ proj = create_project(name="with_layers")
71
+ add_from_file(proj, sample_image, name="Photo")
72
+ add_filter(proj, "brightness", 0, {"factor": 1.3})
73
+ path = os.path.join(tmp_dir, "project.json")
74
+ save_project(proj, path)
75
+ loaded = open_project(path)
76
+ assert len(loaded["layers"]) == 1
77
+ assert loaded["layers"][0]["filters"][0]["name"] == "brightness"
78
+
79
+ def test_project_info_with_layers(self, sample_image):
80
+ proj = create_project()
81
+ add_from_file(proj, sample_image)
82
+ info = get_project_info(proj)
83
+ assert info["layer_count"] == 1
84
+
85
+
86
+ # ── Layer Operations ─────────────────────────────────────────────
87
+
88
+ class TestLayerOperations:
89
+ def test_add_from_file(self, sample_image):
90
+ proj = create_project()
91
+ layer = add_from_file(proj, sample_image)
92
+ assert layer["source"] == os.path.abspath(sample_image)
93
+ assert layer["width"] == 300
94
+ assert layer["height"] == 200
95
+
96
+ def test_multiple_layers_order(self, tmp_dir):
97
+ img1 = Image.new("RGB", (100, 100), "red")
98
+ img2 = Image.new("RGB", (100, 100), "blue")
99
+ p1 = os.path.join(tmp_dir, "red.png")
100
+ p2 = os.path.join(tmp_dir, "blue.png")
101
+ img1.save(p1)
102
+ img2.save(p2)
103
+
104
+ proj = create_project(width=100, height=100)
105
+ add_from_file(proj, p1, name="Red")
106
+ add_from_file(proj, p2, name="Blue")
107
+ layers = list_layers(proj)
108
+ assert layers[0]["name"] == "Blue" # Top
109
+ assert layers[1]["name"] == "Red" # Bottom
110
+
111
+
112
+ # ── Filter Rendering ─────────────────────────────────────────────
113
+
114
+ class TestFilterRendering:
115
+ def test_brightness_increases_pixels(self, tmp_dir, gradient_image):
116
+ proj = create_project(width=256, height=100, color_mode="RGB")
117
+ add_from_file(proj, gradient_image)
118
+ add_filter(proj, "brightness", 0, {"factor": 1.5})
119
+ out = os.path.join(tmp_dir, "bright.png")
120
+ render(proj, out, preset="png", overwrite=True)
121
+
122
+ original = np.array(Image.open(gradient_image).convert("RGB"), dtype=float)
123
+ result = np.array(Image.open(out).convert("RGB"), dtype=float)
124
+ assert result.mean() > original.mean()
125
+
126
+ def test_contrast_increases_spread(self, tmp_dir, gradient_image):
127
+ proj = create_project(width=256, height=100, color_mode="RGB")
128
+ add_from_file(proj, gradient_image)
129
+ add_filter(proj, "contrast", 0, {"factor": 2.0})
130
+ out = os.path.join(tmp_dir, "contrast.png")
131
+ render(proj, out, preset="png", overwrite=True)
132
+
133
+ result = np.array(Image.open(out).convert("L"), dtype=float)
134
+ original = np.array(Image.open(gradient_image), dtype=float)
135
+ # Higher contrast = larger std deviation
136
+ assert result.std() >= original.std() * 0.9
137
+
138
+ def test_invert_flips_colors(self, tmp_dir, sample_image):
139
+ proj = create_project(width=300, height=200)
140
+ add_from_file(proj, sample_image)
141
+ add_filter(proj, "invert", 0, {})
142
+ out = os.path.join(tmp_dir, "inverted.png")
143
+ render(proj, out, preset="png", overwrite=True)
144
+
145
+ original = np.array(Image.open(sample_image).convert("RGB"), dtype=float)
146
+ result = np.array(Image.open(out).convert("RGB"), dtype=float)
147
+ # Inverted + original should sum to ~255
148
+ total = original + result
149
+ assert abs(total.mean() - 255.0) < 5.0
150
+
151
+ def test_gaussian_blur(self, tmp_dir, sample_image):
152
+ proj = create_project(width=300, height=200)
153
+ add_from_file(proj, sample_image)
154
+ add_filter(proj, "gaussian_blur", 0, {"radius": 10.0})
155
+ out = os.path.join(tmp_dir, "blurred.png")
156
+ render(proj, out, preset="png", overwrite=True)
157
+
158
+ result = Image.open(out)
159
+ assert result.size == (300, 200)
160
+
161
+ def test_sepia_applies(self, tmp_dir, sample_image):
162
+ proj = create_project(width=300, height=200)
163
+ add_from_file(proj, sample_image)
164
+ add_filter(proj, "sepia", 0, {"strength": 1.0})
165
+ out = os.path.join(tmp_dir, "sepia.png")
166
+ render(proj, out, preset="png", overwrite=True)
167
+
168
+ result = np.array(Image.open(out).convert("RGB"), dtype=float)
169
+ r, g, b = result[:,:,0].mean(), result[:,:,1].mean(), result[:,:,2].mean()
170
+ # Sepia: R > G > B
171
+ assert r >= g >= b
172
+
173
+ def test_multiple_filters_chain(self, tmp_dir, sample_image):
174
+ proj = create_project(width=300, height=200)
175
+ add_from_file(proj, sample_image)
176
+ add_filter(proj, "brightness", 0, {"factor": 1.2})
177
+ add_filter(proj, "contrast", 0, {"factor": 1.3})
178
+ add_filter(proj, "saturation", 0, {"factor": 0.5})
179
+ out = os.path.join(tmp_dir, "multi.png")
180
+ render(proj, out, preset="png", overwrite=True)
181
+ assert os.path.exists(out)
182
+
183
+ def test_flip_horizontal(self, tmp_dir, sample_image):
184
+ proj = create_project(width=300, height=200)
185
+ add_from_file(proj, sample_image)
186
+ add_filter(proj, "flip_h", 0, {})
187
+ out = os.path.join(tmp_dir, "flipped.png")
188
+ render(proj, out, preset="png", overwrite=True)
189
+
190
+ original = np.array(Image.open(sample_image).convert("RGB"))
191
+ result = np.array(Image.open(out).convert("RGB"))
192
+ # First column of result should match last column of original
193
+ np.testing.assert_array_equal(result[:, 0, :], original[:, -1, :])
194
+
195
+
196
+ # ── Export Formats ───────────────────────────────────────────────
197
+
198
+ class TestExportFormats:
199
+ def test_export_jpeg(self, tmp_dir, sample_image):
200
+ proj = create_project(width=300, height=200)
201
+ add_from_file(proj, sample_image)
202
+ out = os.path.join(tmp_dir, "output.jpg")
203
+ result = render(proj, out, preset="jpeg-high", overwrite=True)
204
+ assert os.path.exists(out)
205
+ assert result["format"] == "JPEG"
206
+
207
+ def test_export_webp(self, tmp_dir, sample_image):
208
+ proj = create_project(width=300, height=200)
209
+ add_from_file(proj, sample_image)
210
+ out = os.path.join(tmp_dir, "output.webp")
211
+ result = render(proj, out, preset="webp", overwrite=True)
212
+ assert os.path.exists(out)
213
+ assert result["format"] == "WEBP"
214
+
215
+ def test_export_bmp(self, tmp_dir, sample_image):
216
+ proj = create_project(width=300, height=200)
217
+ add_from_file(proj, sample_image)
218
+ out = os.path.join(tmp_dir, "output.bmp")
219
+ result = render(proj, out, preset="bmp", overwrite=True)
220
+ assert os.path.exists(out)
221
+
222
+ def test_export_overwrite_protection(self, tmp_dir, sample_image):
223
+ proj = create_project(width=300, height=200)
224
+ add_from_file(proj, sample_image)
225
+ out = os.path.join(tmp_dir, "output.png")
226
+ render(proj, out, preset="png", overwrite=True)
227
+ with pytest.raises(FileExistsError):
228
+ render(proj, out, preset="png", overwrite=False)
229
+
230
+
231
+ # ── Blend Modes ──────────────────────────────────────────────────
232
+
233
+ class TestBlendModes:
234
+ def _two_layer_project(self, tmp_dir, color1, color2, mode):
235
+ img1 = Image.new("RGBA", (100, 100), color1)
236
+ img2 = Image.new("RGBA", (100, 100), color2)
237
+ p1 = os.path.join(tmp_dir, "layer1.png")
238
+ p2 = os.path.join(tmp_dir, "layer2.png")
239
+ img1.save(p1)
240
+ img2.save(p2)
241
+
242
+ proj = create_project(width=100, height=100, color_mode="RGBA",
243
+ background="transparent")
244
+ add_from_file(proj, p1, name="Bottom")
245
+ add_from_file(proj, p2, name="Top")
246
+ proj["layers"][0]["blend_mode"] = mode
247
+ return proj
248
+
249
+ def test_multiply_darkens(self, tmp_dir):
250
+ proj = self._two_layer_project(tmp_dir, (200, 200, 200, 255),
251
+ (128, 128, 128, 255), "multiply")
252
+ out = os.path.join(tmp_dir, "multiply.png")
253
+ render(proj, out, preset="png", overwrite=True)
254
+ result = np.array(Image.open(out).convert("RGB"), dtype=float)
255
+ # Multiply always darkens
256
+ assert result.mean() < 200
257
+
258
+ def test_screen_brightens(self, tmp_dir):
259
+ proj = self._two_layer_project(tmp_dir, (100, 100, 100, 255),
260
+ (100, 100, 100, 255), "screen")
261
+ out = os.path.join(tmp_dir, "screen.png")
262
+ render(proj, out, preset="png", overwrite=True)
263
+ result = np.array(Image.open(out).convert("RGB"), dtype=float)
264
+ # Screen always brightens
265
+ assert result.mean() > 100
266
+
267
+ def test_difference(self, tmp_dir):
268
+ proj = self._two_layer_project(tmp_dir, (200, 100, 50, 255),
269
+ (100, 100, 100, 255), "difference")
270
+ out = os.path.join(tmp_dir, "diff.png")
271
+ render(proj, out, preset="png", overwrite=True)
272
+ result = np.array(Image.open(out).convert("RGB"), dtype=float)
273
+ # Difference of (200,100,50) and (100,100,100) = (100,0,50)
274
+ assert abs(result[:,:,0].mean() - 100) < 5
275
+ assert abs(result[:,:,1].mean() - 0) < 5
276
+ assert abs(result[:,:,2].mean() - 50) < 5
277
+
278
+
279
+ # ── Canvas Operations ────────────────────────────────────────────
280
+
281
+ class TestCanvasRendering:
282
+ def test_scale_and_export(self, tmp_dir, sample_image):
283
+ proj = create_project(width=300, height=200)
284
+ add_from_file(proj, sample_image)
285
+ scale_canvas(proj, 150, 100)
286
+ out = os.path.join(tmp_dir, "scaled.png")
287
+ render(proj, out, preset="png", overwrite=True)
288
+ result = Image.open(out)
289
+ assert result.size == (150, 100)
290
+
291
+
292
+ # ── Media Probing ────────────────────────────────────────────────
293
+
294
+ class TestMediaProbing:
295
+ def test_probe_png(self, sample_image):
296
+ info = probe_image(sample_image)
297
+ assert info["width"] == 300
298
+ assert info["height"] == 200
299
+ assert info["format"] == "PNG"
300
+ assert info["mode"] == "RGB"
301
+
302
+ def test_probe_jpeg(self, tmp_dir):
303
+ img = Image.new("RGB", (100, 100), "red")
304
+ path = os.path.join(tmp_dir, "test.jpg")
305
+ img.save(path, "JPEG")
306
+ info = probe_image(path)
307
+ assert info["format"] == "JPEG"
308
+ assert info["width"] == 100
309
+
310
+ def test_probe_nonexistent(self):
311
+ with pytest.raises(FileNotFoundError):
312
+ probe_image("/nonexistent/image.png")
313
+
314
+ def test_check_media(self, sample_image):
315
+ proj = create_project()
316
+ add_from_file(proj, sample_image)
317
+ result = check_media(proj)
318
+ assert result["status"] == "ok"
319
+ assert result["missing"] == 0
320
+
321
+ def test_check_media_missing(self, sample_image):
322
+ proj = create_project()
323
+ add_from_file(proj, sample_image)
324
+ proj["layers"][0]["source"] = "/nonexistent/file.png"
325
+ result = check_media(proj)
326
+ assert result["status"] == "missing_files"
327
+
328
+
329
+ # ── Session Integration ──────────────────────────────────────────
330
+
331
+ class TestSessionIntegration:
332
+ def test_undo_layer_add(self, sample_image):
333
+ sess = Session()
334
+ proj = create_project()
335
+ sess.set_project(proj)
336
+
337
+ sess.snapshot("add layer")
338
+ add_from_file(proj, sample_image)
339
+ assert len(proj["layers"]) == 1
340
+
341
+ sess.undo()
342
+ assert len(sess.get_project()["layers"]) == 0
343
+
344
+ def test_undo_filter_add(self, sample_image):
345
+ sess = Session()
346
+ proj = create_project()
347
+ add_from_file(proj, sample_image)
348
+ sess.set_project(proj)
349
+
350
+ sess.snapshot("add filter")
351
+ add_filter(proj, "brightness", 0, {"factor": 1.5})
352
+ assert len(proj["layers"][0]["filters"]) == 1
353
+
354
+ sess.undo()
355
+ assert len(sess.get_project()["layers"][0]["filters"]) == 0
356
+
357
+
358
+ # ── CLI Subprocess Tests ─────────────────────────────────────────
359
+
360
+ def _resolve_cli(name):
361
+ """Resolve installed CLI command; falls back to python -m for dev.
362
+
363
+ Set env CLI_ANYTHING_FORCE_INSTALLED=1 to require the installed command.
364
+ """
365
+ import shutil
366
+ force = os.environ.get("CLI_ANYTHING_FORCE_INSTALLED", "").strip() == "1"
367
+ path = shutil.which(name)
368
+ if path:
369
+ print(f"[_resolve_cli] Using installed command: {path}")
370
+ return [path]
371
+ if force:
372
+ raise RuntimeError(f"{name} not found in PATH. Install with: pip install -e .")
373
+ module = name.replace("cli-anything-", "cli_anything.") + "." + name.split("-")[-1] + "_cli"
374
+ print(f"[_resolve_cli] Falling back to: {sys.executable} -m {module}")
375
+ return [sys.executable, "-m", module]
376
+
377
+
378
+ class TestCLISubprocess:
379
+ CLI_BASE = _resolve_cli("cli-anything-gimp")
380
+
381
+ def _run(self, args, check=True):
382
+ return subprocess.run(
383
+ self.CLI_BASE + args,
384
+ capture_output=True, text=True,
385
+ check=check,
386
+ )
387
+
388
+ def test_help(self):
389
+ result = self._run(["--help"])
390
+ assert result.returncode == 0
391
+ assert "GIMP CLI" in result.stdout
392
+
393
+ def test_project_new(self, tmp_dir):
394
+ out = os.path.join(tmp_dir, "test.json")
395
+ result = self._run(["project", "new", "-o", out])
396
+ assert result.returncode == 0
397
+ assert os.path.exists(out)
398
+
399
+ def test_project_new_json(self, tmp_dir):
400
+ out = os.path.join(tmp_dir, "test.json")
401
+ result = self._run(["--json", "project", "new", "-o", out])
402
+ assert result.returncode == 0
403
+ data = json.loads(result.stdout)
404
+ assert data["canvas"]["width"] == 1920
405
+
406
+ def test_project_profiles(self):
407
+ result = self._run(["project", "profiles"])
408
+ assert result.returncode == 0
409
+ assert "hd1080p" in result.stdout
410
+
411
+ def test_filter_list_available(self):
412
+ result = self._run(["filter", "list-available"])
413
+ assert result.returncode == 0
414
+ assert "brightness" in result.stdout
415
+
416
+ def test_export_presets(self):
417
+ result = self._run(["export", "presets"])
418
+ assert result.returncode == 0
419
+ assert "png" in result.stdout
420
+
421
+ def test_full_workflow_json(self, tmp_dir, sample_image):
422
+ proj_path = os.path.join(tmp_dir, "workflow.json")
423
+ out_path = os.path.join(tmp_dir, "output.png")
424
+
425
+ # Create project
426
+ self._run(["--json", "project", "new", "-o", proj_path, "-w", "300", "-h", "200"])
427
+
428
+ # Add layer
429
+ self._run(["--json", "--project", proj_path,
430
+ "layer", "add-from-file", sample_image])
431
+
432
+ # Save
433
+ self._run(["--project", proj_path, "project", "save"])
434
+
435
+ # Export
436
+ self._run(["--project", proj_path,
437
+ "export", "render", out_path, "--overwrite"])
438
+
439
+ assert os.path.exists(out_path)
440
+ result = Image.open(out_path)
441
+ assert result.size == (300, 200)
442
+
443
+
444
+ # ── Real-World Workflow Tests ────────────────────────────────────
445
+
446
+ class TestRealWorldWorkflows:
447
+ def test_photo_editing_workflow(self, tmp_dir, sample_image):
448
+ """Simulate a photo editing workflow: open, adjust, export."""
449
+ proj = create_project(width=300, height=200, name="photo_edit")
450
+ add_from_file(proj, sample_image, name="Photo")
451
+ add_filter(proj, "brightness", 0, {"factor": 1.15})
452
+ add_filter(proj, "contrast", 0, {"factor": 1.1})
453
+ add_filter(proj, "saturation", 0, {"factor": 1.2})
454
+ add_filter(proj, "sharpness", 0, {"factor": 1.5})
455
+
456
+ out = os.path.join(tmp_dir, "edited.jpg")
457
+ result = render(proj, out, preset="jpeg-high", overwrite=True)
458
+ assert os.path.exists(out)
459
+ assert result["layers_rendered"] == 1
460
+
461
+ def test_collage_workflow(self, tmp_dir):
462
+ """Create a collage from multiple images."""
463
+ images = []
464
+ colors = ["red", "green", "blue", "yellow"]
465
+ for color in colors:
466
+ img = Image.new("RGB", (100, 100), color)
467
+ path = os.path.join(tmp_dir, f"{color}.png")
468
+ img.save(path)
469
+ images.append(path)
470
+
471
+ proj = create_project(width=200, height=200, name="collage")
472
+ add_from_file(proj, images[0], name="TL")
473
+ proj["layers"][0]["offset_x"] = 0
474
+ proj["layers"][0]["offset_y"] = 0
475
+ add_from_file(proj, images[1], name="TR")
476
+ proj["layers"][0]["offset_x"] = 100
477
+ proj["layers"][0]["offset_y"] = 0
478
+ add_from_file(proj, images[2], name="BL")
479
+ proj["layers"][0]["offset_x"] = 0
480
+ proj["layers"][0]["offset_y"] = 100
481
+ add_from_file(proj, images[3], name="BR")
482
+ proj["layers"][0]["offset_x"] = 100
483
+ proj["layers"][0]["offset_y"] = 100
484
+
485
+ out = os.path.join(tmp_dir, "collage.png")
486
+ render(proj, out, preset="png", overwrite=True)
487
+
488
+ result = Image.open(out)
489
+ assert result.size == (200, 200)
490
+
491
+ def test_text_overlay_workflow(self, tmp_dir, sample_image):
492
+ """Add text overlay to an image."""
493
+ proj = create_project(width=300, height=200)
494
+ add_from_file(proj, sample_image, name="Background")
495
+ add_layer(proj, name="Title", layer_type="text")
496
+ proj["layers"][0]["text"] = "Hello World"
497
+ proj["layers"][0]["font_size"] = 32
498
+ proj["layers"][0]["color"] = "#ffffff"
499
+
500
+ out = os.path.join(tmp_dir, "text_overlay.png")
501
+ render(proj, out, preset="png", overwrite=True)
502
+ assert os.path.exists(out)
503
+
504
+ def test_batch_filter_workflow(self, tmp_dir, sample_image):
505
+ """Apply multiple artistic filters in sequence."""
506
+ proj = create_project(width=300, height=200)
507
+ add_from_file(proj, sample_image)
508
+ add_filter(proj, "grayscale", 0, {})
509
+ add_filter(proj, "contrast", 0, {"factor": 1.5})
510
+ add_filter(proj, "find_edges", 0, {})
511
+
512
+ out = os.path.join(tmp_dir, "artistic.png")
513
+ render(proj, out, preset="png", overwrite=True)
514
+ assert os.path.exists(out)
515
+
516
+ def test_save_load_complex_project(self, tmp_dir, sample_image):
517
+ """Create complex project, save, reload, verify integrity."""
518
+ proj = create_project(width=300, height=200, name="complex")
519
+ add_from_file(proj, sample_image, name="Photo")
520
+ add_layer(proj, name="Overlay", layer_type="solid", fill="#ff000080", opacity=0.5)
521
+ add_layer(proj, name="Text", layer_type="text")
522
+ add_filter(proj, "brightness", 2, {"factor": 1.3}) # On bottom layer (Photo)
523
+ add_filter(proj, "gaussian_blur", 2, {"radius": 2.0})
524
+
525
+ path = os.path.join(tmp_dir, "complex.json")
526
+ save_project(proj, path)
527
+
528
+ loaded = open_project(path)
529
+ assert len(loaded["layers"]) == 3
530
+ assert loaded["layers"][2]["filters"][0]["name"] == "brightness"
531
+ assert loaded["layers"][2]["filters"][1]["name"] == "gaussian_blur"
532
+
533
+
534
+ # ── True Backend E2E Tests (requires GIMP installed) ─────────────
535
+
536
+ class TestGIMPBackend:
537
+ """Tests that verify GIMP is installed and accessible."""
538
+
539
+ def test_gimp_is_installed(self):
540
+ from cli_anything.gimp.utils.gimp_backend import find_gimp
541
+ path = find_gimp()
542
+ assert os.path.exists(path)
543
+ print(f"\n GIMP binary: {path}")
544
+
545
+ def test_gimp_version(self):
546
+ from cli_anything.gimp.utils.gimp_backend import get_version
547
+ version = get_version()
548
+ assert "image manipulation" in version.lower() or "gimp" in version.lower()
549
+ print(f"\n GIMP version: {version}")
550
+
551
+
552
+ class TestGIMPRenderE2E:
553
+ """True E2E tests using GIMP batch mode."""
554
+
555
+ def test_create_and_export_png(self):
556
+ """Create a blank image in GIMP and export as PNG."""
557
+ from cli_anything.gimp.utils.gimp_backend import create_and_export
558
+
559
+ with tempfile.TemporaryDirectory() as tmp_dir:
560
+ output = os.path.join(tmp_dir, "test.png")
561
+ result = create_and_export(200, 150, output, fill_color="red", timeout=60)
562
+
563
+ assert os.path.exists(result["output"])
564
+ assert result["file_size"] > 0
565
+ assert result["method"] == "gimp-batch"
566
+ print(f"\n GIMP PNG: {result['output']} ({result['file_size']:,} bytes)")
567
+
568
+ def test_create_and_export_jpeg(self):
569
+ """Create a blank image in GIMP and export as JPEG."""
570
+ from cli_anything.gimp.utils.gimp_backend import create_and_export
571
+
572
+ with tempfile.TemporaryDirectory() as tmp_dir:
573
+ output = os.path.join(tmp_dir, "test.jpg")
574
+ result = create_and_export(200, 150, output, fill_color="blue", timeout=60)
575
+
576
+ assert os.path.exists(result["output"])
577
+ assert result["file_size"] > 0
578
+ print(f"\n GIMP JPEG: {result['output']} ({result['file_size']:,} bytes)")
@@ -0,0 +1 @@
1
+ """GIMP CLI - Utility modules."""