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.
- psforge/__init__.py +5 -0
- psforge/app.py +5 -0
- psforge/decorators.py +91 -0
- psforge/ps_adapter/__init__.py +12 -0
- psforge/ps_adapter/action_manager.py +5 -0
- psforge/ps_adapter/application.py +149 -0
- psforge/ps_adapter/context.py +219 -0
- psforge/ps_adapter/process_guard.py +109 -0
- psforge/ps_adapter/utils.py +80 -0
- psforge/registry.py +121 -0
- psforge/resources/__init__.py +3 -0
- psforge/resources/registry.py +3 -0
- psforge/server.py +79 -0
- psforge/tools/__init__.py +3 -0
- psforge/tools/action_tools.py +149 -0
- psforge/tools/adjustment_tools.py +316 -0
- psforge/tools/batch_tools.py +124 -0
- psforge/tools/document_tools.py +341 -0
- psforge/tools/filter_tools.py +252 -0
- psforge/tools/history_tools.py +241 -0
- psforge/tools/image_tools.py +201 -0
- psforge/tools/layer_ordering_tools.py +306 -0
- psforge/tools/layer_properties_tools.py +364 -0
- psforge/tools/layer_tools.py +316 -0
- psforge/tools/layer_transform_tools.py +331 -0
- psforge/tools/mask_tools.py +286 -0
- psforge/tools/registry.py +6 -0
- psforge/tools/selection_tools.py +248 -0
- psforge/tools/session_tools.py +244 -0
- psforge/tools/text_tools.py +390 -0
- psforge-0.2.0.dist-info/METADATA +594 -0
- psforge-0.2.0.dist-info/RECORD +35 -0
- psforge-0.2.0.dist-info/WHEEL +4 -0
- psforge-0.2.0.dist-info/entry_points.txt +3 -0
- psforge-0.2.0.dist-info/licenses/LICENSE +21 -0
psforge/__init__.py
ADDED
psforge/app.py
ADDED
psforge/decorators.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""MCP tool decorators for logging, debugging, and error handling."""
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import traceback
|
|
5
|
+
from typing import Any, Callable
|
|
6
|
+
|
|
7
|
+
from loguru import logger
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def log_tool_call(func: Callable) -> Callable:
|
|
11
|
+
"""Log tool call entry and exit with parameters and results.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
func: The tool function to wrap.
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
Wrapped function with logging.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
@functools.wraps(func)
|
|
21
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
22
|
+
tool_name = func.__name__
|
|
23
|
+
logger.info(f"Tool called: {tool_name}")
|
|
24
|
+
logger.debug(f"Tool params: args={args}, kwargs={kwargs}")
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
result = func(*args, **kwargs)
|
|
28
|
+
if isinstance(result, dict) and "success" in result:
|
|
29
|
+
status = "SUCCESS" if result["success"] else "FAILED"
|
|
30
|
+
logger.info(f"Tool {tool_name} {status}")
|
|
31
|
+
else:
|
|
32
|
+
logger.info(f"Tool {tool_name} completed")
|
|
33
|
+
return result
|
|
34
|
+
except Exception as e:
|
|
35
|
+
logger.error(f"Tool {tool_name} raised exception: {e}")
|
|
36
|
+
raise
|
|
37
|
+
|
|
38
|
+
return wrapper
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def debug_tool(func: Callable) -> Callable:
|
|
42
|
+
"""Enhanced error handling decorator that ensures consistent error format.
|
|
43
|
+
|
|
44
|
+
All errors are caught and converted to standardized dict format:
|
|
45
|
+
{
|
|
46
|
+
"success": False,
|
|
47
|
+
"error": "Brief error message",
|
|
48
|
+
"detailed_error": "Full error with traceback",
|
|
49
|
+
"context": {...} # Current PS state (if available)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
func: The tool function to wrap.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
Wrapped function with enhanced error handling.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
@functools.wraps(func)
|
|
60
|
+
def wrapper(*args: Any, **kwargs: Any) -> dict:
|
|
61
|
+
try:
|
|
62
|
+
result = func(*args, **kwargs)
|
|
63
|
+
# If function returns dict with success field, pass through
|
|
64
|
+
if isinstance(result, dict):
|
|
65
|
+
return result
|
|
66
|
+
# Otherwise wrap in success format
|
|
67
|
+
return {"success": True, "result": result}
|
|
68
|
+
except Exception as e:
|
|
69
|
+
error_msg = str(e)
|
|
70
|
+
detailed_error = traceback.format_exc()
|
|
71
|
+
|
|
72
|
+
logger.error(f"Tool {func.__name__} failed: {error_msg}")
|
|
73
|
+
logger.debug(f"Full traceback:\n{detailed_error}")
|
|
74
|
+
|
|
75
|
+
# Try to get context even on error
|
|
76
|
+
context = None
|
|
77
|
+
try:
|
|
78
|
+
from psforge.ps_adapter.context import get_context_info
|
|
79
|
+
|
|
80
|
+
context = get_context_info()
|
|
81
|
+
except Exception as ctx_error:
|
|
82
|
+
logger.debug(f"Could not retrieve context after error: {ctx_error}")
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
"success": False,
|
|
86
|
+
"error": error_msg,
|
|
87
|
+
"detailed_error": detailed_error,
|
|
88
|
+
"context": context,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return wrapper
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Photoshop adapter layer - manages PS connection, execution, and context."""
|
|
2
|
+
|
|
3
|
+
from psforge.ps_adapter.application import PhotoshopApp
|
|
4
|
+
from psforge.ps_adapter.context import get_context_info
|
|
5
|
+
from psforge.ps_adapter.process_guard import check_photoshop_alive, restart_photoshop
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"PhotoshopApp",
|
|
9
|
+
"get_context_info",
|
|
10
|
+
"check_photoshop_alive",
|
|
11
|
+
"restart_photoshop",
|
|
12
|
+
]
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Photoshop application singleton and connection management."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from loguru import logger
|
|
7
|
+
from photoshop import Session
|
|
8
|
+
|
|
9
|
+
from psforge.ps_adapter.utils import retry_on_ps_error
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PhotoshopApp:
|
|
13
|
+
"""Singleton class for managing Photoshop application connection."""
|
|
14
|
+
|
|
15
|
+
_instance = None
|
|
16
|
+
_session = None
|
|
17
|
+
_app = None
|
|
18
|
+
_operation_count = 0
|
|
19
|
+
_max_operations_before_restart = 1000
|
|
20
|
+
|
|
21
|
+
def __new__(cls):
|
|
22
|
+
"""Ensure only one instance exists (singleton pattern)."""
|
|
23
|
+
if cls._instance is None:
|
|
24
|
+
cls._instance = super().__new__(cls)
|
|
25
|
+
return cls._instance
|
|
26
|
+
|
|
27
|
+
def __init__(self):
|
|
28
|
+
"""Initialize Photoshop connection."""
|
|
29
|
+
if self._app is None:
|
|
30
|
+
self._connect()
|
|
31
|
+
|
|
32
|
+
def _connect(self) -> None:
|
|
33
|
+
"""Establish connection to Photoshop."""
|
|
34
|
+
try:
|
|
35
|
+
logger.info("Connecting to Photoshop...")
|
|
36
|
+
|
|
37
|
+
# Create session
|
|
38
|
+
self._session = Session()
|
|
39
|
+
self._session.__enter__()
|
|
40
|
+
self._app = self._session.app
|
|
41
|
+
|
|
42
|
+
# Disable all dialogs to prevent blocking
|
|
43
|
+
self._execute_javascript_internal("app.displayDialogs = DialogModes.NO;")
|
|
44
|
+
|
|
45
|
+
logger.info("Successfully connected to Photoshop")
|
|
46
|
+
|
|
47
|
+
except Exception as e:
|
|
48
|
+
logger.error(f"Failed to connect to Photoshop: {e}")
|
|
49
|
+
raise ConnectionError(f"Could not connect to Photoshop: {e}")
|
|
50
|
+
|
|
51
|
+
def _disconnect(self) -> None:
|
|
52
|
+
"""Disconnect from Photoshop."""
|
|
53
|
+
try:
|
|
54
|
+
if self._session:
|
|
55
|
+
self._session.__exit__(None, None, None)
|
|
56
|
+
self._session = None
|
|
57
|
+
self._app = None
|
|
58
|
+
logger.info("Disconnected from Photoshop")
|
|
59
|
+
except Exception as e:
|
|
60
|
+
logger.warning(f"Error during disconnect: {e}")
|
|
61
|
+
|
|
62
|
+
def reconnect(self) -> None:
|
|
63
|
+
"""Reconnect to Photoshop (useful after restart)."""
|
|
64
|
+
logger.info("Reconnecting to Photoshop...")
|
|
65
|
+
self._disconnect()
|
|
66
|
+
time.sleep(2) # Give PS time to fully start
|
|
67
|
+
self._connect()
|
|
68
|
+
self._operation_count = 0
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def app(self):
|
|
72
|
+
"""Get the Photoshop Application object."""
|
|
73
|
+
if self._app is None:
|
|
74
|
+
self._connect()
|
|
75
|
+
return self._app
|
|
76
|
+
|
|
77
|
+
def _execute_javascript_internal(self, script: str) -> Any:
|
|
78
|
+
"""Internal JavaScript execution (single attempt, retry handled by tenacity).
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
script: JavaScript/ExtendScript code to execute.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Result from JavaScript execution.
|
|
85
|
+
"""
|
|
86
|
+
if self._app is None:
|
|
87
|
+
self._connect()
|
|
88
|
+
|
|
89
|
+
return self._app.doJavaScript(script)
|
|
90
|
+
|
|
91
|
+
@retry_on_ps_error(max_attempts=3, base_wait=1.0)
|
|
92
|
+
def execute_javascript(self, script: str) -> Any:
|
|
93
|
+
"""Execute JavaScript/ExtendScript in Photoshop with retry logic.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
script: JavaScript/ExtendScript code to execute.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Result from JavaScript execution.
|
|
100
|
+
"""
|
|
101
|
+
# Increment operation counter
|
|
102
|
+
self._operation_count += 1
|
|
103
|
+
|
|
104
|
+
# Optional: Auto-restart PS after many operations to prevent memory leaks
|
|
105
|
+
# Uncomment if you experience stability issues
|
|
106
|
+
# if self._operation_count >= self._max_operations_before_restart:
|
|
107
|
+
# logger.info(f"Reached {self._operation_count} operations, considering restart...")
|
|
108
|
+
# from psforge.ps_adapter.process_guard import restart_photoshop
|
|
109
|
+
# restart_photoshop()
|
|
110
|
+
# self.reconnect()
|
|
111
|
+
|
|
112
|
+
return self._execute_javascript_internal(script)
|
|
113
|
+
|
|
114
|
+
def get_active_document(self):
|
|
115
|
+
"""Get the currently active document.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
Active document object, or None if no document is open.
|
|
119
|
+
"""
|
|
120
|
+
try:
|
|
121
|
+
if self.app.documents.length == 0:
|
|
122
|
+
return None
|
|
123
|
+
return self.app.activeDocument
|
|
124
|
+
except Exception as e:
|
|
125
|
+
logger.error(f"Failed to get active document: {e}")
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
def has_active_document(self) -> bool:
|
|
129
|
+
"""Check if there is an active document.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
True if a document is open and active, False otherwise.
|
|
133
|
+
"""
|
|
134
|
+
try:
|
|
135
|
+
return self.app.documents.length > 0
|
|
136
|
+
except Exception:
|
|
137
|
+
return False
|
|
138
|
+
|
|
139
|
+
def get_photoshop_version(self) -> str:
|
|
140
|
+
"""Get Photoshop version string.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
Version string, or 'Unknown' if cannot be determined.
|
|
144
|
+
"""
|
|
145
|
+
try:
|
|
146
|
+
return str(self.app.version)
|
|
147
|
+
except Exception as e:
|
|
148
|
+
logger.error(f"Failed to get PS version: {e}")
|
|
149
|
+
return "Unknown"
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Context tracking for Photoshop state - documents, layers, selections."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from loguru import logger
|
|
6
|
+
|
|
7
|
+
from psforge.ps_adapter.application import PhotoshopApp
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_context_info() -> dict[str, Any]:
|
|
11
|
+
"""Get comprehensive context information about current Photoshop state.
|
|
12
|
+
|
|
13
|
+
This function executes JavaScript in Photoshop to retrieve:
|
|
14
|
+
- Whether a document is open
|
|
15
|
+
- Document details (name, dimensions, resolution, color mode, layer count, selection status)
|
|
16
|
+
- Active layer details (name, type, opacity, blend mode, visibility, bounds, etc.)
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
Dictionary containing context information with structure:
|
|
20
|
+
{
|
|
21
|
+
"has_document": bool,
|
|
22
|
+
"document": {
|
|
23
|
+
"name": str,
|
|
24
|
+
"width": int,
|
|
25
|
+
"height": int,
|
|
26
|
+
"resolution": float,
|
|
27
|
+
"color_mode": str,
|
|
28
|
+
"bit_depth": int,
|
|
29
|
+
"layer_count": int,
|
|
30
|
+
"has_selection": bool,
|
|
31
|
+
} or None,
|
|
32
|
+
"active_layer": {
|
|
33
|
+
"name": str,
|
|
34
|
+
"kind": str,
|
|
35
|
+
"opacity": float,
|
|
36
|
+
"blend_mode": str,
|
|
37
|
+
"visible": bool,
|
|
38
|
+
"locked": bool,
|
|
39
|
+
"is_background": bool,
|
|
40
|
+
"bounds": {"left": int, "top": int, "right": int, "bottom": int}
|
|
41
|
+
} or None
|
|
42
|
+
}
|
|
43
|
+
"""
|
|
44
|
+
ps_app = PhotoshopApp()
|
|
45
|
+
|
|
46
|
+
# JavaScript to extract context information
|
|
47
|
+
context_script = """
|
|
48
|
+
(function() {
|
|
49
|
+
var context = {
|
|
50
|
+
has_document: false,
|
|
51
|
+
document: null,
|
|
52
|
+
active_layer: null
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// Check if any document is open
|
|
56
|
+
if (app.documents.length === 0) {
|
|
57
|
+
return JSON.stringify(context);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
context.has_document = true;
|
|
61
|
+
var doc = app.activeDocument;
|
|
62
|
+
|
|
63
|
+
// Get document information
|
|
64
|
+
var colorModeMap = {
|
|
65
|
+
1: "BITMAP",
|
|
66
|
+
2: "GRAYSCALE",
|
|
67
|
+
3: "INDEXED",
|
|
68
|
+
4: "RGB",
|
|
69
|
+
5: "CMYK",
|
|
70
|
+
7: "MULTICHANNEL",
|
|
71
|
+
8: "DUOTONE",
|
|
72
|
+
9: "LAB"
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
var bitDepthMap = {
|
|
76
|
+
1: 1,
|
|
77
|
+
8: 8,
|
|
78
|
+
16: 16,
|
|
79
|
+
32: 32
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
context.document = {
|
|
83
|
+
name: doc.name,
|
|
84
|
+
width: parseInt(doc.width),
|
|
85
|
+
height: parseInt(doc.height),
|
|
86
|
+
resolution: parseFloat(doc.resolution),
|
|
87
|
+
color_mode: colorModeMap[doc.mode] || "UNKNOWN",
|
|
88
|
+
bit_depth: bitDepthMap[doc.bitsPerChannel] || 8,
|
|
89
|
+
layer_count: doc.layers.length,
|
|
90
|
+
has_selection: false
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// Check for active selection
|
|
94
|
+
try {
|
|
95
|
+
var selBounds = doc.selection.bounds;
|
|
96
|
+
if (selBounds) {
|
|
97
|
+
context.document.has_selection = true;
|
|
98
|
+
}
|
|
99
|
+
} catch(e) {
|
|
100
|
+
context.document.has_selection = false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Get active layer information
|
|
104
|
+
if (doc.activeLayer) {
|
|
105
|
+
var layer = doc.activeLayer;
|
|
106
|
+
|
|
107
|
+
var layerKindMap = {
|
|
108
|
+
1: "NORMAL",
|
|
109
|
+
2: "TEXT",
|
|
110
|
+
3: "SOLIDFILL",
|
|
111
|
+
4: "GRADIENTFILL",
|
|
112
|
+
5: "PATTERNFILL",
|
|
113
|
+
6: "LEVELS",
|
|
114
|
+
7: "CURVES",
|
|
115
|
+
8: "COLORBALANCE",
|
|
116
|
+
9: "BRIGHTNESSCONTRAST",
|
|
117
|
+
10: "HUESATURATION",
|
|
118
|
+
11: "SELECTIVECOLOR",
|
|
119
|
+
12: "CHANNELMIXER",
|
|
120
|
+
13: "GRADIENTMAP",
|
|
121
|
+
14: "INVERT",
|
|
122
|
+
15: "THRESHOLD",
|
|
123
|
+
16: "POSTERIZE",
|
|
124
|
+
17: "SMARTOBJECT",
|
|
125
|
+
18: "PHOTOFILTER",
|
|
126
|
+
19: "EXPOSURE",
|
|
127
|
+
20: "VIBRANCE",
|
|
128
|
+
21: "VIDEO",
|
|
129
|
+
22: "3D",
|
|
130
|
+
23: "LAYER3D"
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
var blendModeMap = {
|
|
134
|
+
"NORMAL": "NORMAL",
|
|
135
|
+
"DISSOLVE": "DISSOLVE",
|
|
136
|
+
"DARKEN": "DARKEN",
|
|
137
|
+
"MULTIPLY": "MULTIPLY",
|
|
138
|
+
"COLORBURN": "COLORBURN",
|
|
139
|
+
"LINEARBURN": "LINEARBURN",
|
|
140
|
+
"DARKERCOLOR": "DARKERCOLOR",
|
|
141
|
+
"LIGHTEN": "LIGHTEN",
|
|
142
|
+
"SCREEN": "SCREEN",
|
|
143
|
+
"COLORDODGE": "COLORDODGE",
|
|
144
|
+
"LINEARDODGE": "LINEARDODGE",
|
|
145
|
+
"LIGHTERCOLOR": "LIGHTERCOLOR",
|
|
146
|
+
"OVERLAY": "OVERLAY",
|
|
147
|
+
"SOFTLIGHT": "SOFTLIGHT",
|
|
148
|
+
"HARDLIGHT": "HARDLIGHT",
|
|
149
|
+
"VIVIDLIGHT": "VIVIDLIGHT",
|
|
150
|
+
"LINEARLIGHT": "LINEARLIGHT",
|
|
151
|
+
"PINLIGHT": "PINLIGHT",
|
|
152
|
+
"HARDMIX": "HARDMIX",
|
|
153
|
+
"DIFFERENCE": "DIFFERENCE",
|
|
154
|
+
"EXCLUSION": "EXCLUSION",
|
|
155
|
+
"SUBTRACT": "SUBTRACT",
|
|
156
|
+
"DIVIDE": "DIVIDE",
|
|
157
|
+
"HUE": "HUE",
|
|
158
|
+
"SATURATION": "SATURATION",
|
|
159
|
+
"COLOR": "COLOR",
|
|
160
|
+
"LUMINOSITY": "LUMINOSITY"
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
var isBackground = false;
|
|
164
|
+
try {
|
|
165
|
+
isBackground = layer.isBackgroundLayer;
|
|
166
|
+
} catch(e) {
|
|
167
|
+
isBackground = false;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
var bounds = {left: 0, top: 0, right: 0, bottom: 0};
|
|
171
|
+
try {
|
|
172
|
+
bounds = {
|
|
173
|
+
left: parseInt(layer.bounds[0]),
|
|
174
|
+
top: parseInt(layer.bounds[1]),
|
|
175
|
+
right: parseInt(layer.bounds[2]),
|
|
176
|
+
bottom: parseInt(layer.bounds[3])
|
|
177
|
+
};
|
|
178
|
+
} catch(e) {}
|
|
179
|
+
|
|
180
|
+
context.active_layer = {
|
|
181
|
+
name: layer.name,
|
|
182
|
+
kind: layerKindMap[layer.kind] || "UNKNOWN",
|
|
183
|
+
opacity: parseFloat(layer.opacity),
|
|
184
|
+
blend_mode: blendModeMap[layer.blendMode.toString()] || "NORMAL",
|
|
185
|
+
visible: layer.visible,
|
|
186
|
+
locked: layer.allLocked || layer.pixelsLocked,
|
|
187
|
+
is_background: isBackground,
|
|
188
|
+
bounds: bounds
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return JSON.stringify(context);
|
|
193
|
+
})();
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
result = ps_app.execute_javascript(context_script)
|
|
198
|
+
|
|
199
|
+
# Parse JSON result
|
|
200
|
+
import json
|
|
201
|
+
|
|
202
|
+
if isinstance(result, str):
|
|
203
|
+
context = json.loads(result)
|
|
204
|
+
else:
|
|
205
|
+
context = result
|
|
206
|
+
|
|
207
|
+
logger.debug(f"Context retrieved: {context}")
|
|
208
|
+
return context
|
|
209
|
+
|
|
210
|
+
except Exception as e:
|
|
211
|
+
logger.error(f"Failed to get context info: {e}")
|
|
212
|
+
|
|
213
|
+
# Return minimal error context
|
|
214
|
+
return {
|
|
215
|
+
"has_document": False,
|
|
216
|
+
"document": None,
|
|
217
|
+
"active_layer": None,
|
|
218
|
+
"error": str(e),
|
|
219
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Process guard for Photoshop - health checks and restart."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
import psutil
|
|
8
|
+
from loguru import logger
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def check_photoshop_alive() -> bool:
|
|
12
|
+
"""Check if Photoshop process is running.
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
True if Photoshop.exe is running, False otherwise.
|
|
16
|
+
"""
|
|
17
|
+
try:
|
|
18
|
+
for proc in psutil.process_iter(["name"]):
|
|
19
|
+
if proc.info["name"] and "photoshop.exe" in proc.info["name"].lower():
|
|
20
|
+
return True
|
|
21
|
+
return False
|
|
22
|
+
except Exception as e:
|
|
23
|
+
logger.error(f"Failed to check Photoshop process: {e}")
|
|
24
|
+
return False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def kill_photoshop_process() -> bool:
|
|
28
|
+
"""Force kill all Photoshop processes.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
True if any process was killed, False otherwise.
|
|
32
|
+
"""
|
|
33
|
+
killed = False
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
for proc in psutil.process_iter(["name", "pid"]):
|
|
37
|
+
if proc.info["name"] and "photoshop.exe" in proc.info["name"].lower():
|
|
38
|
+
logger.warning(f"Force killing Photoshop process (PID: {proc.info['pid']})")
|
|
39
|
+
try:
|
|
40
|
+
process = psutil.Process(proc.info["pid"])
|
|
41
|
+
process.kill()
|
|
42
|
+
killed = True
|
|
43
|
+
except Exception as e:
|
|
44
|
+
logger.error(f"Failed to kill process {proc.info['pid']}: {e}")
|
|
45
|
+
|
|
46
|
+
if killed:
|
|
47
|
+
time.sleep(2) # Wait for processes to fully terminate
|
|
48
|
+
|
|
49
|
+
except Exception as e:
|
|
50
|
+
logger.error(f"Error while killing Photoshop: {e}")
|
|
51
|
+
|
|
52
|
+
return killed
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def restart_photoshop(photoshop_path: str | None = None) -> bool:
|
|
56
|
+
"""Restart Photoshop by killing existing processes and starting a new one.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
photoshop_path: Optional path to Photoshop executable.
|
|
60
|
+
If not provided, attempts to find it automatically.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
True if restart was successful, False otherwise.
|
|
64
|
+
"""
|
|
65
|
+
logger.info("Restarting Photoshop...")
|
|
66
|
+
|
|
67
|
+
# Kill existing processes
|
|
68
|
+
kill_photoshop_process()
|
|
69
|
+
|
|
70
|
+
# Try to start Photoshop
|
|
71
|
+
if photoshop_path is None:
|
|
72
|
+
# Common Photoshop installation paths
|
|
73
|
+
possible_paths = [
|
|
74
|
+
r"C:\Program Files\Adobe\Adobe Photoshop 2024\Photoshop.exe",
|
|
75
|
+
r"C:\Program Files\Adobe\Adobe Photoshop 2023\Photoshop.exe",
|
|
76
|
+
r"C:\Program Files\Adobe\Adobe Photoshop 2022\Photoshop.exe",
|
|
77
|
+
r"C:\Program Files\Adobe\Adobe Photoshop CC 2019\Photoshop.exe",
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
for path in possible_paths:
|
|
81
|
+
if os.path.exists(path):
|
|
82
|
+
photoshop_path = path
|
|
83
|
+
break
|
|
84
|
+
|
|
85
|
+
if photoshop_path and os.path.exists(photoshop_path):
|
|
86
|
+
try:
|
|
87
|
+
logger.info(f"Starting Photoshop from: {photoshop_path}")
|
|
88
|
+
subprocess.Popen([photoshop_path], shell=False)
|
|
89
|
+
|
|
90
|
+
# Wait for Photoshop to start
|
|
91
|
+
max_wait = 30
|
|
92
|
+
for i in range(max_wait):
|
|
93
|
+
time.sleep(1)
|
|
94
|
+
if check_photoshop_alive():
|
|
95
|
+
logger.info("Photoshop started successfully")
|
|
96
|
+
time.sleep(3) # Additional wait for full initialization
|
|
97
|
+
return True
|
|
98
|
+
|
|
99
|
+
logger.error(f"Photoshop did not start within {max_wait} seconds")
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
except Exception as e:
|
|
103
|
+
logger.error(f"Failed to start Photoshop: {e}")
|
|
104
|
+
return False
|
|
105
|
+
else:
|
|
106
|
+
logger.warning("Could not find Photoshop executable path")
|
|
107
|
+
logger.info("Please start Photoshop manually")
|
|
108
|
+
return False
|
|
109
|
+
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Utility functions and decorators for PS adapter."""
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
from typing import Any, Callable
|
|
5
|
+
|
|
6
|
+
from loguru import logger
|
|
7
|
+
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def retry_on_ps_error(max_attempts: int = 3, base_wait: float = 1.0) -> Callable:
|
|
11
|
+
"""Retry decorator for Photoshop operations that may fail transiently.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
max_attempts: Maximum number of retry attempts.
|
|
15
|
+
base_wait: Base wait time in seconds (exponential backoff).
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
Decorator function.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def decorator(func: Callable) -> Callable:
|
|
22
|
+
@functools.wraps(func)
|
|
23
|
+
@retry(
|
|
24
|
+
retry=retry_if_exception_type((ConnectionError, TimeoutError, RuntimeError)),
|
|
25
|
+
stop=stop_after_attempt(max_attempts),
|
|
26
|
+
wait=wait_exponential(multiplier=base_wait, min=base_wait, max=10),
|
|
27
|
+
reraise=True,
|
|
28
|
+
)
|
|
29
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
30
|
+
try:
|
|
31
|
+
return func(*args, **kwargs)
|
|
32
|
+
except Exception as e:
|
|
33
|
+
logger.warning(f"PS operation {func.__name__} failed, will retry: {e}")
|
|
34
|
+
raise
|
|
35
|
+
|
|
36
|
+
return wrapper
|
|
37
|
+
|
|
38
|
+
return decorator
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def validate_numeric_range(value: float, min_val: float, max_val: float, param_name: str) -> None:
|
|
42
|
+
"""Validate that a numeric parameter is within the allowed range.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
value: The value to validate.
|
|
46
|
+
min_val: Minimum allowed value.
|
|
47
|
+
max_val: Maximum allowed value.
|
|
48
|
+
param_name: Parameter name for error messages.
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
ValueError: If value is out of range.
|
|
52
|
+
"""
|
|
53
|
+
if not (min_val <= value <= max_val):
|
|
54
|
+
raise ValueError(f"{param_name} must be between {min_val} and {max_val}, got {value}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def validate_color_channel(value: int, channel_name: str = "color") -> None:
|
|
58
|
+
"""Validate that a color channel value is in valid range (0-255).
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
value: The color channel value.
|
|
62
|
+
channel_name: Channel name for error messages.
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
ValueError: If value is out of range.
|
|
66
|
+
"""
|
|
67
|
+
if not (0 <= value <= 255):
|
|
68
|
+
raise ValueError(f"{channel_name} must be between 0 and 255, got {value}")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def js_escape_string(text: str) -> str:
|
|
72
|
+
"""Escape a string for safe use in JavaScript/ExtendScript.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
text: The string to escape.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Escaped string safe for JavaScript.
|
|
79
|
+
"""
|
|
80
|
+
return text.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n").replace("\r", "\\r")
|