@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,788 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""GIMP CLI — A stateful command-line interface for image editing.
|
|
3
|
+
|
|
4
|
+
This CLI provides full image editing capabilities using Pillow as the
|
|
5
|
+
backend engine, with a project format that tracks layers, filters,
|
|
6
|
+
and history.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
# One-shot commands
|
|
10
|
+
python3 -m cli.gimp_cli project new --width 1920 --height 1080
|
|
11
|
+
python3 -m cli.gimp_cli layer add-from-file photo.jpg --name "Background"
|
|
12
|
+
python3 -m cli.gimp_cli filter add brightness --layer 0 --param factor=1.3
|
|
13
|
+
|
|
14
|
+
# Interactive REPL
|
|
15
|
+
python3 -m cli.gimp_cli repl
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import sys
|
|
19
|
+
import os
|
|
20
|
+
import json
|
|
21
|
+
import click
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
# Add parent to path for imports
|
|
25
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
26
|
+
|
|
27
|
+
from cli_anything.gimp.core.session import Session
|
|
28
|
+
from cli_anything.gimp.core import project as proj_mod
|
|
29
|
+
from cli_anything.gimp.core import layers as layer_mod
|
|
30
|
+
from cli_anything.gimp.core import filters as filt_mod
|
|
31
|
+
from cli_anything.gimp.core import canvas as canvas_mod
|
|
32
|
+
from cli_anything.gimp.core import media as media_mod
|
|
33
|
+
from cli_anything.gimp.core import export as export_mod
|
|
34
|
+
|
|
35
|
+
# Global session state
|
|
36
|
+
_session: Optional[Session] = None
|
|
37
|
+
_json_output = False
|
|
38
|
+
_repl_mode = False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_session() -> Session:
|
|
42
|
+
global _session
|
|
43
|
+
if _session is None:
|
|
44
|
+
_session = Session()
|
|
45
|
+
return _session
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def output(data, message: str = ""):
|
|
49
|
+
if _json_output:
|
|
50
|
+
click.echo(json.dumps(data, indent=2, default=str))
|
|
51
|
+
else:
|
|
52
|
+
if message:
|
|
53
|
+
click.echo(message)
|
|
54
|
+
if isinstance(data, dict):
|
|
55
|
+
_print_dict(data)
|
|
56
|
+
elif isinstance(data, list):
|
|
57
|
+
_print_list(data)
|
|
58
|
+
else:
|
|
59
|
+
click.echo(str(data))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _print_dict(d: dict, indent: int = 0):
|
|
63
|
+
prefix = " " * indent
|
|
64
|
+
for k, v in d.items():
|
|
65
|
+
if isinstance(v, dict):
|
|
66
|
+
click.echo(f"{prefix}{k}:")
|
|
67
|
+
_print_dict(v, indent + 1)
|
|
68
|
+
elif isinstance(v, list):
|
|
69
|
+
click.echo(f"{prefix}{k}:")
|
|
70
|
+
_print_list(v, indent + 1)
|
|
71
|
+
else:
|
|
72
|
+
click.echo(f"{prefix}{k}: {v}")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _print_list(items: list, indent: int = 0):
|
|
76
|
+
prefix = " " * indent
|
|
77
|
+
for i, item in enumerate(items):
|
|
78
|
+
if isinstance(item, dict):
|
|
79
|
+
click.echo(f"{prefix}[{i}]")
|
|
80
|
+
_print_dict(item, indent + 1)
|
|
81
|
+
else:
|
|
82
|
+
click.echo(f"{prefix}- {item}")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def handle_error(func):
|
|
86
|
+
def wrapper(*args, **kwargs):
|
|
87
|
+
try:
|
|
88
|
+
return func(*args, **kwargs)
|
|
89
|
+
except FileNotFoundError as e:
|
|
90
|
+
if _json_output:
|
|
91
|
+
click.echo(json.dumps({"error": str(e), "type": "file_not_found"}))
|
|
92
|
+
else:
|
|
93
|
+
click.echo(f"Error: {e}", err=True)
|
|
94
|
+
if not _repl_mode:
|
|
95
|
+
sys.exit(1)
|
|
96
|
+
except (ValueError, IndexError, RuntimeError) as e:
|
|
97
|
+
if _json_output:
|
|
98
|
+
click.echo(json.dumps({"error": str(e), "type": type(e).__name__}))
|
|
99
|
+
else:
|
|
100
|
+
click.echo(f"Error: {e}", err=True)
|
|
101
|
+
if not _repl_mode:
|
|
102
|
+
sys.exit(1)
|
|
103
|
+
except FileExistsError as e:
|
|
104
|
+
if _json_output:
|
|
105
|
+
click.echo(json.dumps({"error": str(e), "type": "file_exists"}))
|
|
106
|
+
else:
|
|
107
|
+
click.echo(f"Error: {e}", err=True)
|
|
108
|
+
if not _repl_mode:
|
|
109
|
+
sys.exit(1)
|
|
110
|
+
wrapper.__name__ = func.__name__
|
|
111
|
+
wrapper.__doc__ = func.__doc__
|
|
112
|
+
return wrapper
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ── Main CLI Group ──────────────────────────────────────────────
|
|
116
|
+
@click.group(invoke_without_command=True)
|
|
117
|
+
@click.option("--json", "use_json", is_flag=True, help="Output as JSON")
|
|
118
|
+
@click.option("--project", "project_path", type=str, default=None,
|
|
119
|
+
help="Path to .gimp-cli.json project file")
|
|
120
|
+
@click.pass_context
|
|
121
|
+
def cli(ctx, use_json, project_path):
|
|
122
|
+
"""GIMP CLI — Stateful image editing from the command line.
|
|
123
|
+
|
|
124
|
+
Run without a subcommand to enter interactive REPL mode.
|
|
125
|
+
"""
|
|
126
|
+
global _json_output
|
|
127
|
+
_json_output = use_json
|
|
128
|
+
|
|
129
|
+
if project_path:
|
|
130
|
+
sess = get_session()
|
|
131
|
+
if not sess.has_project():
|
|
132
|
+
proj = proj_mod.open_project(project_path)
|
|
133
|
+
sess.set_project(proj, project_path)
|
|
134
|
+
|
|
135
|
+
if ctx.invoked_subcommand is None:
|
|
136
|
+
ctx.invoke(repl, project_path=None)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ── Project Commands ─────────────────────────────────────────────
|
|
140
|
+
@cli.group()
|
|
141
|
+
def project():
|
|
142
|
+
"""Project management commands."""
|
|
143
|
+
pass
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@project.command("new")
|
|
147
|
+
@click.option("--width", "-w", type=int, default=1920, help="Canvas width")
|
|
148
|
+
@click.option("--height", "-h", type=int, default=1080, help="Canvas height")
|
|
149
|
+
@click.option("--mode", type=click.Choice(["RGB", "RGBA", "L", "LA"]), default="RGB")
|
|
150
|
+
@click.option("--background", "-bg", default="#ffffff", help="Background color")
|
|
151
|
+
@click.option("--dpi", type=int, default=72, help="Resolution in DPI")
|
|
152
|
+
@click.option("--name", "-n", default="untitled", help="Project name")
|
|
153
|
+
@click.option("--profile", "-p", type=str, default=None, help="Canvas profile")
|
|
154
|
+
@click.option("--output", "-o", type=str, default=None, help="Save path")
|
|
155
|
+
@handle_error
|
|
156
|
+
def project_new(width, height, mode, background, dpi, name, profile, output):
|
|
157
|
+
"""Create a new project."""
|
|
158
|
+
proj = proj_mod.create_project(
|
|
159
|
+
width=width, height=height, color_mode=mode,
|
|
160
|
+
background=background, dpi=dpi, name=name, profile=profile,
|
|
161
|
+
)
|
|
162
|
+
sess = get_session()
|
|
163
|
+
sess.set_project(proj, output)
|
|
164
|
+
if output:
|
|
165
|
+
proj_mod.save_project(proj, output)
|
|
166
|
+
output_data = proj_mod.get_project_info(proj)
|
|
167
|
+
globals()["output"](output_data, f"Created project: {name}")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@project.command("open")
|
|
171
|
+
@click.argument("path")
|
|
172
|
+
@handle_error
|
|
173
|
+
def project_open(path):
|
|
174
|
+
"""Open an existing project."""
|
|
175
|
+
proj = proj_mod.open_project(path)
|
|
176
|
+
sess = get_session()
|
|
177
|
+
sess.set_project(proj, path)
|
|
178
|
+
info = proj_mod.get_project_info(proj)
|
|
179
|
+
output(info, f"Opened: {path}")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@project.command("save")
|
|
183
|
+
@click.argument("path", required=False)
|
|
184
|
+
@handle_error
|
|
185
|
+
def project_save(path):
|
|
186
|
+
"""Save the current project."""
|
|
187
|
+
sess = get_session()
|
|
188
|
+
saved = sess.save_session(path)
|
|
189
|
+
output({"saved": saved}, f"Saved to: {saved}")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@project.command("info")
|
|
193
|
+
@handle_error
|
|
194
|
+
def project_info():
|
|
195
|
+
"""Show project information."""
|
|
196
|
+
sess = get_session()
|
|
197
|
+
info = proj_mod.get_project_info(sess.get_project())
|
|
198
|
+
output(info)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@project.command("profiles")
|
|
202
|
+
@handle_error
|
|
203
|
+
def project_profiles():
|
|
204
|
+
"""List available canvas profiles."""
|
|
205
|
+
profiles = proj_mod.list_profiles()
|
|
206
|
+
output(profiles, "Available profiles:")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@project.command("json")
|
|
210
|
+
@handle_error
|
|
211
|
+
def project_json():
|
|
212
|
+
"""Print raw project JSON."""
|
|
213
|
+
sess = get_session()
|
|
214
|
+
click.echo(json.dumps(sess.get_project(), indent=2, default=str))
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ── Layer Commands ───────────────────────────────────────────────
|
|
218
|
+
@cli.group()
|
|
219
|
+
def layer():
|
|
220
|
+
"""Layer management commands."""
|
|
221
|
+
pass
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@layer.command("new")
|
|
225
|
+
@click.option("--name", "-n", default="New Layer", help="Layer name")
|
|
226
|
+
@click.option("--type", "layer_type", type=click.Choice(["image", "text", "solid"]),
|
|
227
|
+
default="image", help="Layer type")
|
|
228
|
+
@click.option("--width", "-w", type=int, default=None, help="Layer width")
|
|
229
|
+
@click.option("--height", "-h", type=int, default=None, help="Layer height")
|
|
230
|
+
@click.option("--fill", default="transparent", help="Fill: transparent, white, black, or hex")
|
|
231
|
+
@click.option("--opacity", type=float, default=1.0, help="Layer opacity 0.0-1.0")
|
|
232
|
+
@click.option("--mode", default="normal", help="Blend mode")
|
|
233
|
+
@click.option("--position", "-p", type=int, default=None, help="Stack position (0=top)")
|
|
234
|
+
@handle_error
|
|
235
|
+
def layer_new(name, layer_type, width, height, fill, opacity, mode, position):
|
|
236
|
+
"""Create a new blank layer."""
|
|
237
|
+
sess = get_session()
|
|
238
|
+
sess.snapshot(f"Add layer: {name}")
|
|
239
|
+
proj = sess.get_project()
|
|
240
|
+
layer = layer_mod.add_layer(
|
|
241
|
+
proj, name=name, layer_type=layer_type, width=width, height=height,
|
|
242
|
+
fill=fill, opacity=opacity, blend_mode=mode, position=position,
|
|
243
|
+
)
|
|
244
|
+
output(layer, f"Added layer: {name}")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@layer.command("add-from-file")
|
|
248
|
+
@click.argument("path")
|
|
249
|
+
@click.option("--name", "-n", default=None, help="Layer name")
|
|
250
|
+
@click.option("--position", "-p", type=int, default=None, help="Stack position")
|
|
251
|
+
@click.option("--opacity", type=float, default=1.0, help="Layer opacity")
|
|
252
|
+
@click.option("--mode", default="normal", help="Blend mode")
|
|
253
|
+
@handle_error
|
|
254
|
+
def layer_add_from_file(path, name, position, opacity, mode):
|
|
255
|
+
"""Add a layer from an image file."""
|
|
256
|
+
sess = get_session()
|
|
257
|
+
sess.snapshot(f"Add layer from: {path}")
|
|
258
|
+
proj = sess.get_project()
|
|
259
|
+
layer = layer_mod.add_from_file(
|
|
260
|
+
proj, path=path, name=name, position=position,
|
|
261
|
+
opacity=opacity, blend_mode=mode,
|
|
262
|
+
)
|
|
263
|
+
output(layer, f"Added layer from: {path}")
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@layer.command("list")
|
|
267
|
+
@handle_error
|
|
268
|
+
def layer_list():
|
|
269
|
+
"""List all layers."""
|
|
270
|
+
sess = get_session()
|
|
271
|
+
layers = layer_mod.list_layers(sess.get_project())
|
|
272
|
+
output(layers, "Layers (top to bottom):")
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@layer.command("remove")
|
|
276
|
+
@click.argument("index", type=int)
|
|
277
|
+
@handle_error
|
|
278
|
+
def layer_remove(index):
|
|
279
|
+
"""Remove a layer by index."""
|
|
280
|
+
sess = get_session()
|
|
281
|
+
sess.snapshot(f"Remove layer {index}")
|
|
282
|
+
removed = layer_mod.remove_layer(sess.get_project(), index)
|
|
283
|
+
output(removed, f"Removed layer {index}: {removed.get('name', '')}")
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@layer.command("duplicate")
|
|
287
|
+
@click.argument("index", type=int)
|
|
288
|
+
@handle_error
|
|
289
|
+
def layer_duplicate(index):
|
|
290
|
+
"""Duplicate a layer."""
|
|
291
|
+
sess = get_session()
|
|
292
|
+
sess.snapshot(f"Duplicate layer {index}")
|
|
293
|
+
dup = layer_mod.duplicate_layer(sess.get_project(), index)
|
|
294
|
+
output(dup, f"Duplicated layer {index}")
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
@layer.command("move")
|
|
298
|
+
@click.argument("index", type=int)
|
|
299
|
+
@click.option("--to", type=int, required=True, help="Target position")
|
|
300
|
+
@handle_error
|
|
301
|
+
def layer_move(index, to):
|
|
302
|
+
"""Move a layer to a new position."""
|
|
303
|
+
sess = get_session()
|
|
304
|
+
sess.snapshot(f"Move layer {index} to {to}")
|
|
305
|
+
layer_mod.move_layer(sess.get_project(), index, to)
|
|
306
|
+
output({"moved": index, "to": to}, f"Moved layer {index} to position {to}")
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@layer.command("set")
|
|
310
|
+
@click.argument("index", type=int)
|
|
311
|
+
@click.argument("prop")
|
|
312
|
+
@click.argument("value")
|
|
313
|
+
@handle_error
|
|
314
|
+
def layer_set(index, prop, value):
|
|
315
|
+
"""Set a layer property (name, opacity, visible, mode, offset_x, offset_y)."""
|
|
316
|
+
sess = get_session()
|
|
317
|
+
sess.snapshot(f"Set layer {index} {prop}={value}")
|
|
318
|
+
layer_mod.set_layer_property(sess.get_project(), index, prop, value)
|
|
319
|
+
output({"layer": index, "property": prop, "value": value},
|
|
320
|
+
f"Set layer {index} {prop} = {value}")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
@layer.command("flatten")
|
|
324
|
+
@handle_error
|
|
325
|
+
def layer_flatten():
|
|
326
|
+
"""Flatten all visible layers."""
|
|
327
|
+
sess = get_session()
|
|
328
|
+
sess.snapshot("Flatten layers")
|
|
329
|
+
layer_mod.flatten_layers(sess.get_project())
|
|
330
|
+
output({"status": "flatten_pending"}, "Layers marked for flattening (applied on export)")
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@layer.command("merge-down")
|
|
334
|
+
@click.argument("index", type=int)
|
|
335
|
+
@handle_error
|
|
336
|
+
def layer_merge_down(index):
|
|
337
|
+
"""Merge a layer with the one below it."""
|
|
338
|
+
sess = get_session()
|
|
339
|
+
sess.snapshot(f"Merge down layer {index}")
|
|
340
|
+
layer_mod.merge_down(sess.get_project(), index)
|
|
341
|
+
output({"status": "merge_pending", "layer": index},
|
|
342
|
+
f"Layer {index} marked for merge-down (applied on export)")
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
# ── Canvas Commands ──────────────────────────────────────────────
|
|
346
|
+
@cli.group()
|
|
347
|
+
def canvas():
|
|
348
|
+
"""Canvas operations."""
|
|
349
|
+
pass
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@canvas.command("info")
|
|
353
|
+
@handle_error
|
|
354
|
+
def canvas_info():
|
|
355
|
+
"""Show canvas information."""
|
|
356
|
+
sess = get_session()
|
|
357
|
+
info = canvas_mod.get_canvas_info(sess.get_project())
|
|
358
|
+
output(info)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
@canvas.command("resize")
|
|
362
|
+
@click.option("--width", "-w", type=int, required=True)
|
|
363
|
+
@click.option("--height", "-h", type=int, required=True)
|
|
364
|
+
@click.option("--anchor", default="center",
|
|
365
|
+
help="Anchor: center, top-left, top-right, bottom-left, bottom-right, top, bottom, left, right")
|
|
366
|
+
@handle_error
|
|
367
|
+
def canvas_resize(width, height, anchor):
|
|
368
|
+
"""Resize the canvas (without scaling content)."""
|
|
369
|
+
sess = get_session()
|
|
370
|
+
sess.snapshot(f"Resize canvas to {width}x{height}")
|
|
371
|
+
result = canvas_mod.resize_canvas(sess.get_project(), width, height, anchor)
|
|
372
|
+
output(result, f"Canvas resized to {width}x{height}")
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
@canvas.command("scale")
|
|
376
|
+
@click.option("--width", "-w", type=int, required=True)
|
|
377
|
+
@click.option("--height", "-h", type=int, required=True)
|
|
378
|
+
@click.option("--resample", default="lanczos",
|
|
379
|
+
type=click.Choice(["nearest", "bilinear", "bicubic", "lanczos"]))
|
|
380
|
+
@handle_error
|
|
381
|
+
def canvas_scale(width, height, resample):
|
|
382
|
+
"""Scale the canvas and all content proportionally."""
|
|
383
|
+
sess = get_session()
|
|
384
|
+
sess.snapshot(f"Scale canvas to {width}x{height}")
|
|
385
|
+
result = canvas_mod.scale_canvas(sess.get_project(), width, height, resample)
|
|
386
|
+
output(result, f"Canvas scaled to {width}x{height}")
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
@canvas.command("crop")
|
|
390
|
+
@click.option("--left", "-l", type=int, required=True)
|
|
391
|
+
@click.option("--top", "-t", type=int, required=True)
|
|
392
|
+
@click.option("--right", "-r", type=int, required=True)
|
|
393
|
+
@click.option("--bottom", "-b", type=int, required=True)
|
|
394
|
+
@handle_error
|
|
395
|
+
def canvas_crop(left, top, right, bottom):
|
|
396
|
+
"""Crop the canvas to a rectangle."""
|
|
397
|
+
sess = get_session()
|
|
398
|
+
sess.snapshot(f"Crop canvas ({left},{top})-({right},{bottom})")
|
|
399
|
+
result = canvas_mod.crop_canvas(sess.get_project(), left, top, right, bottom)
|
|
400
|
+
output(result, "Canvas cropped")
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
@canvas.command("mode")
|
|
404
|
+
@click.argument("mode", type=click.Choice(["RGB", "RGBA", "L", "LA", "CMYK", "P"]))
|
|
405
|
+
@handle_error
|
|
406
|
+
def canvas_mode(mode):
|
|
407
|
+
"""Set the canvas color mode."""
|
|
408
|
+
sess = get_session()
|
|
409
|
+
sess.snapshot(f"Change mode to {mode}")
|
|
410
|
+
result = canvas_mod.set_mode(sess.get_project(), mode)
|
|
411
|
+
output(result, f"Canvas mode changed to {mode}")
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
@canvas.command("dpi")
|
|
415
|
+
@click.argument("dpi", type=int)
|
|
416
|
+
@handle_error
|
|
417
|
+
def canvas_dpi(dpi):
|
|
418
|
+
"""Set the canvas DPI."""
|
|
419
|
+
sess = get_session()
|
|
420
|
+
result = canvas_mod.set_dpi(sess.get_project(), dpi)
|
|
421
|
+
output(result, f"DPI set to {dpi}")
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
# ── Filter Commands ──────────────────────────────────────────────
|
|
425
|
+
@cli.group("filter")
|
|
426
|
+
def filter_group():
|
|
427
|
+
"""Filter management commands."""
|
|
428
|
+
pass
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
@filter_group.command("list-available")
|
|
432
|
+
@click.option("--category", "-c", type=str, default=None,
|
|
433
|
+
help="Filter by category: adjustment, blur, stylize, transform")
|
|
434
|
+
@handle_error
|
|
435
|
+
def filter_list_available(category):
|
|
436
|
+
"""List all available filters."""
|
|
437
|
+
filters = filt_mod.list_available(category)
|
|
438
|
+
output(filters, "Available filters:")
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
@filter_group.command("info")
|
|
442
|
+
@click.argument("name")
|
|
443
|
+
@handle_error
|
|
444
|
+
def filter_info(name):
|
|
445
|
+
"""Show details about a filter."""
|
|
446
|
+
info = filt_mod.get_filter_info(name)
|
|
447
|
+
output(info)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
@filter_group.command("add")
|
|
451
|
+
@click.argument("name")
|
|
452
|
+
@click.option("--layer", "-l", "layer_index", type=int, default=0, help="Layer index")
|
|
453
|
+
@click.option("--param", "-p", multiple=True, help="Parameter: key=value")
|
|
454
|
+
@handle_error
|
|
455
|
+
def filter_add(name, layer_index, param):
|
|
456
|
+
"""Add a filter to a layer."""
|
|
457
|
+
params = {}
|
|
458
|
+
for p in param:
|
|
459
|
+
if "=" not in p:
|
|
460
|
+
raise ValueError(f"Invalid param format: '{p}'. Use key=value.")
|
|
461
|
+
k, v = p.split("=", 1)
|
|
462
|
+
try:
|
|
463
|
+
v = float(v) if "." in v else int(v)
|
|
464
|
+
except ValueError:
|
|
465
|
+
pass
|
|
466
|
+
params[k] = v
|
|
467
|
+
|
|
468
|
+
sess = get_session()
|
|
469
|
+
sess.snapshot(f"Add filter {name} to layer {layer_index}")
|
|
470
|
+
result = filt_mod.add_filter(sess.get_project(), name, layer_index, params)
|
|
471
|
+
output(result, f"Added filter: {name}")
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
@filter_group.command("remove")
|
|
475
|
+
@click.argument("filter_index", type=int)
|
|
476
|
+
@click.option("--layer", "-l", "layer_index", type=int, default=0)
|
|
477
|
+
@handle_error
|
|
478
|
+
def filter_remove(filter_index, layer_index):
|
|
479
|
+
"""Remove a filter by index."""
|
|
480
|
+
sess = get_session()
|
|
481
|
+
sess.snapshot(f"Remove filter {filter_index} from layer {layer_index}")
|
|
482
|
+
result = filt_mod.remove_filter(sess.get_project(), filter_index, layer_index)
|
|
483
|
+
output(result, f"Removed filter {filter_index}")
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
@filter_group.command("set")
|
|
487
|
+
@click.argument("filter_index", type=int)
|
|
488
|
+
@click.argument("param")
|
|
489
|
+
@click.argument("value")
|
|
490
|
+
@click.option("--layer", "-l", "layer_index", type=int, default=0)
|
|
491
|
+
@handle_error
|
|
492
|
+
def filter_set(filter_index, param, value, layer_index):
|
|
493
|
+
"""Set a filter parameter."""
|
|
494
|
+
try:
|
|
495
|
+
value = float(value) if "." in str(value) else int(value)
|
|
496
|
+
except ValueError:
|
|
497
|
+
pass
|
|
498
|
+
sess = get_session()
|
|
499
|
+
sess.snapshot(f"Set filter {filter_index} {param}={value}")
|
|
500
|
+
filt_mod.set_filter_param(sess.get_project(), filter_index, param, value, layer_index)
|
|
501
|
+
output({"filter": filter_index, "param": param, "value": value},
|
|
502
|
+
f"Set filter {filter_index} {param} = {value}")
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
@filter_group.command("list")
|
|
506
|
+
@click.option("--layer", "-l", "layer_index", type=int, default=0)
|
|
507
|
+
@handle_error
|
|
508
|
+
def filter_list(layer_index):
|
|
509
|
+
"""List filters on a layer."""
|
|
510
|
+
sess = get_session()
|
|
511
|
+
filters = filt_mod.list_filters(sess.get_project(), layer_index)
|
|
512
|
+
output(filters, f"Filters on layer {layer_index}:")
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
# ── Media Commands ───────────────────────────────────────────────
|
|
516
|
+
@cli.group()
|
|
517
|
+
def media():
|
|
518
|
+
"""Media file operations."""
|
|
519
|
+
pass
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
@media.command("probe")
|
|
523
|
+
@click.argument("path")
|
|
524
|
+
@handle_error
|
|
525
|
+
def media_probe(path):
|
|
526
|
+
"""Analyze an image file."""
|
|
527
|
+
info = media_mod.probe_image(path)
|
|
528
|
+
output(info)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
@media.command("list")
|
|
532
|
+
@handle_error
|
|
533
|
+
def media_list():
|
|
534
|
+
"""List media files referenced in the project."""
|
|
535
|
+
sess = get_session()
|
|
536
|
+
media = media_mod.list_media_in_project(sess.get_project())
|
|
537
|
+
output(media, "Referenced media files:")
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
@media.command("check")
|
|
541
|
+
@handle_error
|
|
542
|
+
def media_check():
|
|
543
|
+
"""Check that all referenced media files exist."""
|
|
544
|
+
sess = get_session()
|
|
545
|
+
result = media_mod.check_media(sess.get_project())
|
|
546
|
+
output(result)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
@media.command("histogram")
|
|
550
|
+
@click.argument("path")
|
|
551
|
+
@handle_error
|
|
552
|
+
def media_histogram(path):
|
|
553
|
+
"""Show histogram analysis of an image."""
|
|
554
|
+
result = media_mod.get_image_histogram(path)
|
|
555
|
+
output(result)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
# ── Export Commands ──────────────────────────────────────────────
|
|
559
|
+
@cli.group("export")
|
|
560
|
+
def export_group():
|
|
561
|
+
"""Export/render commands."""
|
|
562
|
+
pass
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
@export_group.command("presets")
|
|
566
|
+
@handle_error
|
|
567
|
+
def export_presets():
|
|
568
|
+
"""List export presets."""
|
|
569
|
+
presets = export_mod.list_presets()
|
|
570
|
+
output(presets, "Export presets:")
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
@export_group.command("preset-info")
|
|
574
|
+
@click.argument("name")
|
|
575
|
+
@handle_error
|
|
576
|
+
def export_preset_info(name):
|
|
577
|
+
"""Show preset details."""
|
|
578
|
+
info = export_mod.get_preset_info(name)
|
|
579
|
+
output(info)
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
@export_group.command("render")
|
|
583
|
+
@click.argument("output_path")
|
|
584
|
+
@click.option("--preset", "-p", default="png", help="Export preset")
|
|
585
|
+
@click.option("--overwrite", is_flag=True, help="Overwrite existing file")
|
|
586
|
+
@click.option("--quality", "-q", type=int, default=None, help="Quality override")
|
|
587
|
+
@click.option("--format", "fmt", type=str, default=None, help="Format override")
|
|
588
|
+
@handle_error
|
|
589
|
+
def export_render(output_path, preset, overwrite, quality, fmt):
|
|
590
|
+
"""Render the project to an image file."""
|
|
591
|
+
sess = get_session()
|
|
592
|
+
result = export_mod.render(
|
|
593
|
+
sess.get_project(), output_path,
|
|
594
|
+
preset=preset, overwrite=overwrite,
|
|
595
|
+
quality=quality, format_override=fmt,
|
|
596
|
+
)
|
|
597
|
+
output(result, f"Rendered to: {output_path}")
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
# ── Session Commands ─────────────────────────────────────────────
|
|
601
|
+
@cli.group()
|
|
602
|
+
def session():
|
|
603
|
+
"""Session management commands."""
|
|
604
|
+
pass
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
@session.command("status")
|
|
608
|
+
@handle_error
|
|
609
|
+
def session_status():
|
|
610
|
+
"""Show session status."""
|
|
611
|
+
sess = get_session()
|
|
612
|
+
output(sess.status())
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
@session.command("undo")
|
|
616
|
+
@handle_error
|
|
617
|
+
def session_undo():
|
|
618
|
+
"""Undo the last operation."""
|
|
619
|
+
sess = get_session()
|
|
620
|
+
desc = sess.undo()
|
|
621
|
+
output({"undone": desc}, f"Undone: {desc}")
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
@session.command("redo")
|
|
625
|
+
@handle_error
|
|
626
|
+
def session_redo():
|
|
627
|
+
"""Redo the last undone operation."""
|
|
628
|
+
sess = get_session()
|
|
629
|
+
desc = sess.redo()
|
|
630
|
+
output({"redone": desc}, f"Redone: {desc}")
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
@session.command("history")
|
|
634
|
+
@handle_error
|
|
635
|
+
def session_history():
|
|
636
|
+
"""Show undo history."""
|
|
637
|
+
sess = get_session()
|
|
638
|
+
history = sess.list_history()
|
|
639
|
+
output(history, "Undo history:")
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
# ── Draw Commands ────────────────────────────────────────────────
|
|
643
|
+
@cli.group()
|
|
644
|
+
def draw():
|
|
645
|
+
"""Drawing operations (applied at render time)."""
|
|
646
|
+
pass
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
@draw.command("text")
|
|
650
|
+
@click.option("--layer", "-l", "layer_index", type=int, default=0)
|
|
651
|
+
@click.option("--text", "-t", required=True, help="Text to draw")
|
|
652
|
+
@click.option("--x", type=int, default=0, help="X position")
|
|
653
|
+
@click.option("--y", type=int, default=0, help="Y position")
|
|
654
|
+
@click.option("--font", default="Arial", help="Font name")
|
|
655
|
+
@click.option("--size", type=int, default=24, help="Font size")
|
|
656
|
+
@click.option("--color", default="#000000", help="Text color (hex)")
|
|
657
|
+
@handle_error
|
|
658
|
+
def draw_text(layer_index, text, x, y, font, size, color):
|
|
659
|
+
"""Draw text on a layer (by converting it to a text layer)."""
|
|
660
|
+
sess = get_session()
|
|
661
|
+
sess.snapshot(f"Draw text on layer {layer_index}")
|
|
662
|
+
proj = sess.get_project()
|
|
663
|
+
layers = proj.get("layers", [])
|
|
664
|
+
if layer_index < 0 or layer_index >= len(layers):
|
|
665
|
+
raise IndexError(f"Layer index {layer_index} out of range")
|
|
666
|
+
layer = layers[layer_index]
|
|
667
|
+
layer["type"] = "text"
|
|
668
|
+
layer["text"] = text
|
|
669
|
+
layer["font"] = font
|
|
670
|
+
layer["font_size"] = size
|
|
671
|
+
layer["color"] = color
|
|
672
|
+
layer["offset_x"] = x
|
|
673
|
+
layer["offset_y"] = y
|
|
674
|
+
output({"layer": layer_index, "text": text}, f"Set text on layer {layer_index}")
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
@draw.command("rect")
|
|
678
|
+
@click.option("--layer", "-l", "layer_index", type=int, default=0)
|
|
679
|
+
@click.option("--x1", type=int, required=True)
|
|
680
|
+
@click.option("--y1", type=int, required=True)
|
|
681
|
+
@click.option("--x2", type=int, required=True)
|
|
682
|
+
@click.option("--y2", type=int, required=True)
|
|
683
|
+
@click.option("--fill", default=None, help="Fill color (hex)")
|
|
684
|
+
@click.option("--outline", default=None, help="Outline color (hex)")
|
|
685
|
+
@click.option("--width", "line_width", type=int, default=1, help="Outline width")
|
|
686
|
+
@handle_error
|
|
687
|
+
def draw_rect(layer_index, x1, y1, x2, y2, fill, outline, line_width):
|
|
688
|
+
"""Draw a rectangle (stored as drawing operation)."""
|
|
689
|
+
sess = get_session()
|
|
690
|
+
sess.snapshot(f"Draw rect on layer {layer_index}")
|
|
691
|
+
proj = sess.get_project()
|
|
692
|
+
layers = proj.get("layers", [])
|
|
693
|
+
if layer_index < 0 or layer_index >= len(layers):
|
|
694
|
+
raise IndexError(f"Layer index {layer_index} out of range")
|
|
695
|
+
layer = layers[layer_index]
|
|
696
|
+
if "draw_ops" not in layer:
|
|
697
|
+
layer["draw_ops"] = []
|
|
698
|
+
layer["draw_ops"].append({
|
|
699
|
+
"type": "rect",
|
|
700
|
+
"x1": x1, "y1": y1, "x2": x2, "y2": y2,
|
|
701
|
+
"fill": fill, "outline": outline, "width": line_width,
|
|
702
|
+
})
|
|
703
|
+
output({"layer": layer_index, "shape": "rect", "coords": f"({x1},{y1})-({x2},{y2})"},
|
|
704
|
+
f"Drew rectangle on layer {layer_index}")
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
# ── REPL ─────────────────────────────────────────────────────────
|
|
708
|
+
@cli.command()
|
|
709
|
+
@click.option("--project", "project_path", type=str, default=None)
|
|
710
|
+
@handle_error
|
|
711
|
+
def repl(project_path):
|
|
712
|
+
"""Start interactive REPL session."""
|
|
713
|
+
from cli_anything.gimp.utils.repl_skin import ReplSkin
|
|
714
|
+
|
|
715
|
+
global _repl_mode
|
|
716
|
+
_repl_mode = True
|
|
717
|
+
|
|
718
|
+
skin = ReplSkin("gimp", version="1.0.0")
|
|
719
|
+
|
|
720
|
+
if project_path:
|
|
721
|
+
sess = get_session()
|
|
722
|
+
proj = proj_mod.open_project(project_path)
|
|
723
|
+
sess.set_project(proj, project_path)
|
|
724
|
+
|
|
725
|
+
skin.print_banner()
|
|
726
|
+
|
|
727
|
+
pt_session = skin.create_prompt_session()
|
|
728
|
+
|
|
729
|
+
_repl_commands = {
|
|
730
|
+
"project": "new|open|save|info|profiles|json",
|
|
731
|
+
"layer": "new|add-from-file|list|remove|duplicate|move|set|flatten|merge-down",
|
|
732
|
+
"canvas": "info|resize|scale|crop|mode|dpi",
|
|
733
|
+
"filter": "list-available|info|add|remove|set|list",
|
|
734
|
+
"media": "probe|list|check|histogram",
|
|
735
|
+
"export": "presets|preset-info|render",
|
|
736
|
+
"draw": "text|rect",
|
|
737
|
+
"session": "status|undo|redo|history",
|
|
738
|
+
"help": "Show this help",
|
|
739
|
+
"quit": "Exit REPL",
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
while True:
|
|
743
|
+
try:
|
|
744
|
+
# Determine project name for prompt
|
|
745
|
+
try:
|
|
746
|
+
sess = get_session()
|
|
747
|
+
proj_name = ""
|
|
748
|
+
if sess.has_project():
|
|
749
|
+
p = sess.get_project()
|
|
750
|
+
proj_name = p.get("name", "") if isinstance(p, dict) else ""
|
|
751
|
+
except Exception:
|
|
752
|
+
proj_name = ""
|
|
753
|
+
|
|
754
|
+
line = skin.get_input(pt_session, project_name=proj_name, modified=False)
|
|
755
|
+
if not line:
|
|
756
|
+
continue
|
|
757
|
+
if line.lower() in ("quit", "exit", "q"):
|
|
758
|
+
skin.print_goodbye()
|
|
759
|
+
break
|
|
760
|
+
if line.lower() == "help":
|
|
761
|
+
skin.help(_repl_commands)
|
|
762
|
+
continue
|
|
763
|
+
|
|
764
|
+
# Parse and execute command
|
|
765
|
+
args = line.split()
|
|
766
|
+
try:
|
|
767
|
+
cli.main(args, standalone_mode=False)
|
|
768
|
+
except SystemExit:
|
|
769
|
+
pass
|
|
770
|
+
except click.exceptions.UsageError as e:
|
|
771
|
+
skin.warning(f"Usage error: {e}")
|
|
772
|
+
except Exception as e:
|
|
773
|
+
skin.error(f"{e}")
|
|
774
|
+
|
|
775
|
+
except (EOFError, KeyboardInterrupt):
|
|
776
|
+
skin.print_goodbye()
|
|
777
|
+
break
|
|
778
|
+
|
|
779
|
+
_repl_mode = False
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
# ── Entry Point ──────────────────────────────────────────────────
|
|
783
|
+
def main():
|
|
784
|
+
cli()
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
if __name__ == "__main__":
|
|
788
|
+
main()
|