psforge 0.2.0__py3-none-any.whl

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.
@@ -0,0 +1,316 @@
1
+ """Layer management tools - create, delete, duplicate, merge, flatten, rasterize."""
2
+
3
+ from typing import Any
4
+
5
+ from loguru import logger
6
+
7
+ from psforge.decorators import debug_tool, log_tool_call
8
+ from psforge.ps_adapter.application import PhotoshopApp
9
+ from psforge.ps_adapter.utils import js_escape_string
10
+ from psforge.registry import register_tool
11
+
12
+
13
+ def register(mcp) -> list[str]:
14
+ """Register all layer management tools with MCP server.
15
+
16
+ Args:
17
+ mcp: MCP server instance.
18
+
19
+ Returns:
20
+ List of registered tool names.
21
+ """
22
+ registered_tools = []
23
+
24
+ @debug_tool
25
+ @log_tool_call
26
+ def create_layer(name: str = "New Layer") -> dict[str, Any]:
27
+ """Create a new empty layer in the active document.
28
+
29
+ Args:
30
+ name: Name for the new layer (default: "New Layer").
31
+
32
+ Returns:
33
+ dict: Operation result with new layer info and context.
34
+ """
35
+ ps_app = PhotoshopApp()
36
+ doc = ps_app.get_active_document()
37
+
38
+ if not doc:
39
+ return {
40
+ "success": False,
41
+ "error": "No active document",
42
+ }
43
+
44
+ try:
45
+ name_escaped = js_escape_string(name)
46
+
47
+ create_layer_script = f"""
48
+ var doc = app.activeDocument;
49
+ var newLayer = doc.artLayers.add();
50
+ newLayer.name = "{name_escaped}";
51
+ newLayer.name;
52
+ """
53
+
54
+ layer_name = ps_app.execute_javascript(create_layer_script)
55
+
56
+ return {
57
+ "success": True,
58
+ "message": f"Created layer '{layer_name}'",
59
+ "layer_name": layer_name,
60
+ }
61
+
62
+ except Exception as e:
63
+ logger.error(f"Failed to create layer: {e}")
64
+ return {
65
+ "success": False,
66
+ "error": str(e),
67
+ }
68
+
69
+ @debug_tool
70
+ @log_tool_call
71
+ def delete_layer() -> dict[str, Any]:
72
+ """Delete the currently active layer.
73
+
74
+ Returns:
75
+ dict: Operation result and context.
76
+ """
77
+ ps_app = PhotoshopApp()
78
+ doc = ps_app.get_active_document()
79
+
80
+ if not doc:
81
+ return {
82
+ "success": False,
83
+ "error": "No active document",
84
+ }
85
+
86
+ try:
87
+ # Get layer name before deleting
88
+ get_name_script = "app.activeDocument.activeLayer.name;"
89
+ layer_name = ps_app.execute_javascript(get_name_script)
90
+
91
+ # Check if it's a background layer
92
+ check_bg_script = """
93
+ (function() {
94
+ try {
95
+ return app.activeDocument.activeLayer.isBackgroundLayer;
96
+ } catch(e) {
97
+ return false;
98
+ }
99
+ })();
100
+ """
101
+ is_background = ps_app.execute_javascript(check_bg_script)
102
+
103
+ if is_background:
104
+ return {
105
+ "success": False,
106
+ "error": "Cannot delete background layer. Convert it to a regular layer first.",
107
+ }
108
+
109
+ # Delete the layer
110
+ delete_script = "app.activeDocument.activeLayer.remove();"
111
+ ps_app.execute_javascript(delete_script)
112
+
113
+ return {
114
+ "success": True,
115
+ "message": f"Deleted layer '{layer_name}'",
116
+ "deleted_layer": layer_name,
117
+ }
118
+
119
+ except Exception as e:
120
+ logger.error(f"Failed to delete layer: {e}")
121
+ return {
122
+ "success": False,
123
+ "error": str(e),
124
+ }
125
+
126
+ @debug_tool
127
+ @log_tool_call
128
+ def duplicate_layer(new_name: str = "") -> dict[str, Any]:
129
+ """Duplicate the currently active layer.
130
+
131
+ Args:
132
+ new_name: Optional name for the duplicated layer (default: auto-generated).
133
+
134
+ Returns:
135
+ dict: Operation result with duplicated layer info and context.
136
+ """
137
+ ps_app = PhotoshopApp()
138
+ doc = ps_app.get_active_document()
139
+
140
+ if not doc:
141
+ return {
142
+ "success": False,
143
+ "error": "No active document",
144
+ }
145
+
146
+ try:
147
+ if new_name:
148
+ name_escaped = js_escape_string(new_name)
149
+ duplicate_script = f"""
150
+ var originalLayer = app.activeDocument.activeLayer;
151
+ var duplicatedLayer = originalLayer.duplicate();
152
+ duplicatedLayer.name = "{name_escaped}";
153
+ duplicatedLayer.name;
154
+ """
155
+ else:
156
+ duplicate_script = """
157
+ var originalLayer = app.activeDocument.activeLayer;
158
+ var duplicatedLayer = originalLayer.duplicate();
159
+ duplicatedLayer.name;
160
+ """
161
+
162
+ duplicated_name = ps_app.execute_javascript(duplicate_script)
163
+
164
+ return {
165
+ "success": True,
166
+ "message": f"Duplicated layer as '{duplicated_name}'",
167
+ "new_layer_name": duplicated_name,
168
+ }
169
+
170
+ except Exception as e:
171
+ logger.error(f"Failed to duplicate layer: {e}")
172
+ return {
173
+ "success": False,
174
+ "error": str(e),
175
+ }
176
+
177
+ @debug_tool
178
+ @log_tool_call
179
+ def merge_visible_layers() -> dict[str, Any]:
180
+ """Merge all visible layers in the active document.
181
+
182
+ Returns:
183
+ dict: Operation result and context.
184
+ """
185
+ ps_app = PhotoshopApp()
186
+ doc = ps_app.get_active_document()
187
+
188
+ if not doc:
189
+ return {
190
+ "success": False,
191
+ "error": "No active document",
192
+ }
193
+
194
+ try:
195
+ merge_script = """
196
+ app.activeDocument.mergeVisibleLayers();
197
+ "Visible layers merged";
198
+ """
199
+
200
+ ps_app.execute_javascript(merge_script)
201
+
202
+ return {
203
+ "success": True,
204
+ "message": "Merged all visible layers",
205
+ }
206
+
207
+ except Exception as e:
208
+ logger.error(f"Failed to merge visible layers: {e}")
209
+ return {
210
+ "success": False,
211
+ "error": str(e),
212
+ }
213
+
214
+ @debug_tool
215
+ @log_tool_call
216
+ def flatten_image() -> dict[str, Any]:
217
+ """Flatten the image by merging all layers into a single background layer.
218
+
219
+ Returns:
220
+ dict: Operation result and context.
221
+ """
222
+ ps_app = PhotoshopApp()
223
+ doc = ps_app.get_active_document()
224
+
225
+ if not doc:
226
+ return {
227
+ "success": False,
228
+ "error": "No active document",
229
+ }
230
+
231
+ try:
232
+ flatten_script = """
233
+ app.activeDocument.flatten();
234
+ "Image flattened";
235
+ """
236
+
237
+ ps_app.execute_javascript(flatten_script)
238
+
239
+ return {
240
+ "success": True,
241
+ "message": "Image flattened - all layers merged into background",
242
+ }
243
+
244
+ except Exception as e:
245
+ logger.error(f"Failed to flatten image: {e}")
246
+ return {
247
+ "success": False,
248
+ "error": str(e),
249
+ }
250
+
251
+ @debug_tool
252
+ @log_tool_call
253
+ def rasterize_layer() -> dict[str, Any]:
254
+ """Rasterize the currently active layer (converts text/shape/smart object to pixels).
255
+
256
+ Returns:
257
+ dict: Operation result and context.
258
+ """
259
+ ps_app = PhotoshopApp()
260
+ doc = ps_app.get_active_document()
261
+
262
+ if not doc:
263
+ return {
264
+ "success": False,
265
+ "error": "No active document",
266
+ }
267
+
268
+ try:
269
+ # Get layer info before rasterizing
270
+ layer_info_script = """
271
+ (function() {
272
+ var layer = app.activeDocument.activeLayer;
273
+ return {
274
+ name: layer.name,
275
+ kind: layer.kind.toString()
276
+ };
277
+ })();
278
+ """
279
+
280
+ import json
281
+
282
+ layer_info_str = ps_app.execute_javascript(layer_info_script)
283
+ layer_info = json.loads(layer_info_str) if isinstance(layer_info_str, str) else layer_info_str
284
+
285
+ # Rasterize the layer
286
+ rasterize_script = """
287
+ var layer = app.activeDocument.activeLayer;
288
+ layer.rasterize(RasterizeType.ENTIRELAYER);
289
+ "Layer rasterized";
290
+ """
291
+
292
+ ps_app.execute_javascript(rasterize_script)
293
+
294
+ return {
295
+ "success": True,
296
+ "message": f"Rasterized layer '{layer_info.get('name', 'unknown')}'",
297
+ "layer_name": layer_info.get("name"),
298
+ "previous_kind": layer_info.get("kind"),
299
+ }
300
+
301
+ except Exception as e:
302
+ logger.error(f"Failed to rasterize layer: {e}")
303
+ return {
304
+ "success": False,
305
+ "error": str(e),
306
+ }
307
+
308
+ # Register all tools
309
+ registered_tools.append(register_tool(mcp, create_layer, "create_layer"))
310
+ registered_tools.append(register_tool(mcp, delete_layer, "delete_layer"))
311
+ registered_tools.append(register_tool(mcp, duplicate_layer, "duplicate_layer"))
312
+ registered_tools.append(register_tool(mcp, merge_visible_layers, "merge_visible_layers"))
313
+ registered_tools.append(register_tool(mcp, flatten_image, "flatten_image"))
314
+ registered_tools.append(register_tool(mcp, rasterize_layer, "rasterize_layer"))
315
+
316
+ return registered_tools
@@ -0,0 +1,331 @@
1
+ """Layer transformation tools - move, scale, rotate, fit to canvas, resize."""
2
+
3
+ from typing import Any
4
+
5
+ from loguru import logger
6
+
7
+ from psforge.decorators import debug_tool, log_tool_call
8
+ from psforge.ps_adapter.application import PhotoshopApp
9
+ from psforge.ps_adapter.utils import validate_numeric_range
10
+ from psforge.registry import register_tool
11
+
12
+
13
+ def register(mcp) -> list[str]:
14
+ """Register all layer transformation tools with MCP server.
15
+
16
+ Args:
17
+ mcp: MCP server instance.
18
+
19
+ Returns:
20
+ List of registered tool names.
21
+ """
22
+ registered_tools = []
23
+
24
+ @debug_tool
25
+ @log_tool_call
26
+ def move_layer(x: float, y: float) -> dict[str, Any]:
27
+ """Move the currently active layer to a specific position.
28
+
29
+ Args:
30
+ x: Horizontal position offset in pixels (can be negative).
31
+ y: Vertical position offset in pixels (can be negative).
32
+
33
+ Returns:
34
+ dict: Operation result and context.
35
+ """
36
+ ps_app = PhotoshopApp()
37
+ doc = ps_app.get_active_document()
38
+
39
+ if not doc:
40
+ return {
41
+ "success": False,
42
+ "error": "No active document",
43
+ }
44
+
45
+ try:
46
+ move_script = f"""
47
+ var layer = app.activeDocument.activeLayer;
48
+ var layerName = layer.name;
49
+
50
+ // Translate (move relative)
51
+ layer.translate({x}, {y});
52
+
53
+ layerName;
54
+ """
55
+
56
+ layer_name = ps_app.execute_javascript(move_script)
57
+
58
+ return {
59
+ "success": True,
60
+ "message": f"Moved layer '{layer_name}' by ({x}, {y})px",
61
+ "layer_name": layer_name,
62
+ "offset_x": x,
63
+ "offset_y": y,
64
+ }
65
+
66
+ except Exception as e:
67
+ logger.error(f"Failed to move layer: {e}")
68
+ return {
69
+ "success": False,
70
+ "error": str(e),
71
+ }
72
+
73
+ @debug_tool
74
+ @log_tool_call
75
+ def scale_layer(width_percent: float, height_percent: float = None) -> dict[str, Any]:
76
+ """Scale the currently active layer by percentage.
77
+
78
+ Args:
79
+ width_percent: Width scale percentage (e.g., 100 = original, 50 = half, 200 = double).
80
+ height_percent: Height scale percentage (if None, uses width_percent for proportional scaling).
81
+
82
+ Returns:
83
+ dict: Operation result and context.
84
+ """
85
+ validate_numeric_range(width_percent, 0.1, 10000, "width_percent")
86
+
87
+ if height_percent is None:
88
+ height_percent = width_percent
89
+ else:
90
+ validate_numeric_range(height_percent, 0.1, 10000, "height_percent")
91
+
92
+ ps_app = PhotoshopApp()
93
+ doc = ps_app.get_active_document()
94
+
95
+ if not doc:
96
+ return {
97
+ "success": False,
98
+ "error": "No active document",
99
+ }
100
+
101
+ try:
102
+ scale_script = f"""
103
+ var layer = app.activeDocument.activeLayer;
104
+ var layerName = layer.name;
105
+
106
+ // Resize (scale from center)
107
+ layer.resize({width_percent}, {height_percent}, AnchorPosition.MIDDLECENTER);
108
+
109
+ layerName;
110
+ """
111
+
112
+ layer_name = ps_app.execute_javascript(scale_script)
113
+
114
+ return {
115
+ "success": True,
116
+ "message": f"Scaled layer '{layer_name}' to {width_percent}% x {height_percent}%",
117
+ "layer_name": layer_name,
118
+ "width_percent": width_percent,
119
+ "height_percent": height_percent,
120
+ }
121
+
122
+ except Exception as e:
123
+ logger.error(f"Failed to scale layer: {e}")
124
+ return {
125
+ "success": False,
126
+ "error": str(e),
127
+ }
128
+
129
+ @debug_tool
130
+ @log_tool_call
131
+ def rotate_layer(angle: float) -> dict[str, Any]:
132
+ """Rotate the currently active layer by a specified angle.
133
+
134
+ Args:
135
+ angle: Rotation angle in degrees (positive = clockwise, negative = counter-clockwise).
136
+
137
+ Returns:
138
+ dict: Operation result and context.
139
+ """
140
+ validate_numeric_range(angle, -360, 360, "angle")
141
+
142
+ ps_app = PhotoshopApp()
143
+ doc = ps_app.get_active_document()
144
+
145
+ if not doc:
146
+ return {
147
+ "success": False,
148
+ "error": "No active document",
149
+ }
150
+
151
+ try:
152
+ rotate_script = f"""
153
+ var layer = app.activeDocument.activeLayer;
154
+ var layerName = layer.name;
155
+
156
+ // Rotate around center
157
+ layer.rotate({angle}, AnchorPosition.MIDDLECENTER);
158
+
159
+ layerName;
160
+ """
161
+
162
+ layer_name = ps_app.execute_javascript(rotate_script)
163
+
164
+ return {
165
+ "success": True,
166
+ "message": f"Rotated layer '{layer_name}' by {angle} degrees",
167
+ "layer_name": layer_name,
168
+ "angle": angle,
169
+ }
170
+
171
+ except Exception as e:
172
+ logger.error(f"Failed to rotate layer: {e}")
173
+ return {
174
+ "success": False,
175
+ "error": str(e),
176
+ }
177
+
178
+ @debug_tool
179
+ @log_tool_call
180
+ def fit_layer_to_document(fill_document: bool = False) -> dict[str, Any]:
181
+ """Resize the currently active layer to fit or fill the document canvas.
182
+
183
+ Args:
184
+ fill_document: If True, fill entire canvas (may crop layer).
185
+ If False, fit within canvas (may have margins).
186
+
187
+ Returns:
188
+ dict: Operation result and context.
189
+ """
190
+ ps_app = PhotoshopApp()
191
+ doc = ps_app.get_active_document()
192
+
193
+ if not doc:
194
+ return {
195
+ "success": False,
196
+ "error": "No active document",
197
+ }
198
+
199
+ try:
200
+ fill_js = "true" if fill_document else "false"
201
+
202
+ fit_script = f"""
203
+ var doc = app.activeDocument;
204
+ var layer = doc.activeLayer;
205
+ var layerName = layer.name;
206
+
207
+ // Get document dimensions
208
+ var docWidth = doc.width.as('px');
209
+ var docHeight = doc.height.as('px');
210
+
211
+ // Get layer bounds
212
+ var bounds = layer.bounds;
213
+ var layerWidth = bounds[2].as('px') - bounds[0].as('px');
214
+ var layerHeight = bounds[3].as('px') - bounds[1].as('px');
215
+
216
+ // Calculate scale ratio
217
+ var widthRatio = docWidth / layerWidth * 100;
218
+ var heightRatio = docHeight / layerHeight * 100;
219
+
220
+ var scaleRatio;
221
+ if ({fill_js}) {{
222
+ // Fill: use larger ratio to cover entire canvas
223
+ scaleRatio = Math.max(widthRatio, heightRatio);
224
+ }} else {{
225
+ // Fit: use smaller ratio to fit within canvas
226
+ scaleRatio = Math.min(widthRatio, heightRatio);
227
+ }}
228
+
229
+ // Apply scale
230
+ layer.resize(scaleRatio, scaleRatio, AnchorPosition.MIDDLECENTER);
231
+
232
+ // Center layer
233
+ var newBounds = layer.bounds;
234
+ var offsetX = (docWidth - (newBounds[2].as('px') - newBounds[0].as('px'))) / 2 - newBounds[0].as('px');
235
+ var offsetY = (docHeight - (newBounds[3].as('px') - newBounds[1].as('px'))) / 2 - newBounds[1].as('px');
236
+ layer.translate(offsetX, offsetY);
237
+
238
+ layerName;
239
+ """
240
+
241
+ layer_name = ps_app.execute_javascript(fit_script)
242
+
243
+ mode = "fill" if fill_document else "fit"
244
+
245
+ return {
246
+ "success": True,
247
+ "message": f"Layer '{layer_name}' resized to {mode} document canvas",
248
+ "layer_name": layer_name,
249
+ "mode": mode,
250
+ }
251
+
252
+ except Exception as e:
253
+ logger.error(f"Failed to fit layer to document: {e}")
254
+ return {
255
+ "success": False,
256
+ "error": str(e),
257
+ }
258
+
259
+ @debug_tool
260
+ @log_tool_call
261
+ def resize_image(width: int, height: int, resample_method: str = "BICUBIC") -> dict[str, Any]:
262
+ """Resize the entire image (document and all layers).
263
+
264
+ Args:
265
+ width: New width in pixels (1-300000).
266
+ height: New height in pixels (1-300000).
267
+ resample_method: Resampling method - BICUBIC, BILINEAR, NEARESTNEIGHBOR,
268
+ BICUBICSHARPER, BICUBICSMOOTHER (default: BICUBIC).
269
+
270
+ Returns:
271
+ dict: Operation result and context.
272
+ """
273
+ validate_numeric_range(width, 1, 300000, "width")
274
+ validate_numeric_range(height, 1, 300000, "height")
275
+
276
+ resample_method = resample_method.upper()
277
+ valid_methods = ["BICUBIC", "BILINEAR", "NEARESTNEIGHBOR", "BICUBICSHARPER", "BICUBICSMOOTHER"]
278
+
279
+ if resample_method not in valid_methods:
280
+ return {
281
+ "success": False,
282
+ "error": f"Invalid resample_method '{resample_method}'. Must be one of: {', '.join(valid_methods)}",
283
+ }
284
+
285
+ ps_app = PhotoshopApp()
286
+ doc = ps_app.get_active_document()
287
+
288
+ if not doc:
289
+ return {
290
+ "success": False,
291
+ "error": "No active document",
292
+ }
293
+
294
+ try:
295
+ resize_script = f"""
296
+ var doc = app.activeDocument;
297
+ var oldWidth = doc.width.as('px');
298
+ var oldHeight = doc.height.as('px');
299
+
300
+ // Resize image
301
+ doc.resizeImage({width}, {height}, null, ResampleMethod.{resample_method});
302
+
303
+ "Resized from " + oldWidth + "x" + oldHeight + " to {width}x{height}";
304
+ """
305
+
306
+ result = ps_app.execute_javascript(resize_script)
307
+
308
+ return {
309
+ "success": True,
310
+ "message": f"Resized image to {width}x{height}px using {resample_method}",
311
+ "new_width": width,
312
+ "new_height": height,
313
+ "resample_method": resample_method,
314
+ "result": result,
315
+ }
316
+
317
+ except Exception as e:
318
+ logger.error(f"Failed to resize image: {e}")
319
+ return {
320
+ "success": False,
321
+ "error": str(e),
322
+ }
323
+
324
+ # Register all tools
325
+ registered_tools.append(register_tool(mcp, move_layer, "move_layer"))
326
+ registered_tools.append(register_tool(mcp, scale_layer, "scale_layer"))
327
+ registered_tools.append(register_tool(mcp, rotate_layer, "rotate_layer"))
328
+ registered_tools.append(register_tool(mcp, fit_layer_to_document, "fit_layer_to_document"))
329
+ registered_tools.append(register_tool(mcp, resize_image, "resize_image"))
330
+
331
+ return registered_tools