griptape-nodes 0.71.0__py3-none-any.whl → 0.72.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.
Files changed (50) hide show
  1. griptape_nodes/app/app.py +4 -0
  2. griptape_nodes/bootstrap/workflow_executors/local_workflow_executor.py +10 -1
  3. griptape_nodes/bootstrap/workflow_executors/utils/subprocess_script.py +4 -0
  4. griptape_nodes/bootstrap/workflow_publishers/utils/subprocess_script.py +4 -0
  5. griptape_nodes/common/node_executor.py +1 -1
  6. griptape_nodes/drivers/image_metadata/__init__.py +21 -0
  7. griptape_nodes/drivers/image_metadata/base_image_metadata_driver.py +63 -0
  8. griptape_nodes/drivers/image_metadata/exif_metadata_driver.py +218 -0
  9. griptape_nodes/drivers/image_metadata/image_metadata_driver_registry.py +55 -0
  10. griptape_nodes/drivers/image_metadata/png_metadata_driver.py +71 -0
  11. griptape_nodes/drivers/storage/base_storage_driver.py +32 -0
  12. griptape_nodes/drivers/storage/griptape_cloud_storage_driver.py +384 -10
  13. griptape_nodes/drivers/storage/local_storage_driver.py +65 -4
  14. griptape_nodes/drivers/thread_storage/local_thread_storage_driver.py +1 -0
  15. griptape_nodes/exe_types/node_groups/base_node_group.py +3 -0
  16. griptape_nodes/exe_types/node_types.py +13 -0
  17. griptape_nodes/exe_types/param_components/log_parameter.py +3 -2
  18. griptape_nodes/exe_types/param_types/parameter_float.py +4 -4
  19. griptape_nodes/exe_types/param_types/parameter_int.py +4 -4
  20. griptape_nodes/exe_types/param_types/parameter_number.py +34 -30
  21. griptape_nodes/node_library/workflow_registry.py +5 -8
  22. griptape_nodes/retained_mode/events/app_events.py +1 -0
  23. griptape_nodes/retained_mode/events/base_events.py +42 -26
  24. griptape_nodes/retained_mode/events/flow_events.py +67 -0
  25. griptape_nodes/retained_mode/events/library_events.py +1 -1
  26. griptape_nodes/retained_mode/events/node_events.py +1 -0
  27. griptape_nodes/retained_mode/events/os_events.py +22 -0
  28. griptape_nodes/retained_mode/events/static_file_events.py +28 -4
  29. griptape_nodes/retained_mode/managers/flow_manager.py +134 -0
  30. griptape_nodes/retained_mode/managers/image_metadata_injector.py +339 -0
  31. griptape_nodes/retained_mode/managers/library_manager.py +71 -41
  32. griptape_nodes/retained_mode/managers/model_manager.py +1 -0
  33. griptape_nodes/retained_mode/managers/node_manager.py +8 -5
  34. griptape_nodes/retained_mode/managers/os_manager.py +269 -32
  35. griptape_nodes/retained_mode/managers/project_manager.py +3 -7
  36. griptape_nodes/retained_mode/managers/session_manager.py +1 -0
  37. griptape_nodes/retained_mode/managers/settings.py +5 -0
  38. griptape_nodes/retained_mode/managers/static_files_manager.py +83 -17
  39. griptape_nodes/retained_mode/managers/workflow_manager.py +71 -41
  40. griptape_nodes/servers/static.py +31 -0
  41. griptape_nodes/traits/clamp.py +52 -9
  42. griptape_nodes/utils/__init__.py +9 -1
  43. griptape_nodes/utils/file_utils.py +13 -13
  44. griptape_nodes/utils/http_file_patch.py +613 -0
  45. griptape_nodes/utils/path_utils.py +58 -0
  46. griptape_nodes/utils/url_utils.py +106 -0
  47. {griptape_nodes-0.71.0.dist-info → griptape_nodes-0.72.0.dist-info}/METADATA +2 -1
  48. {griptape_nodes-0.71.0.dist-info → griptape_nodes-0.72.0.dist-info}/RECORD +50 -41
  49. {griptape_nodes-0.71.0.dist-info → griptape_nodes-0.72.0.dist-info}/WHEEL +1 -1
  50. {griptape_nodes-0.71.0.dist-info → griptape_nodes-0.72.0.dist-info}/entry_points.txt +0 -0
@@ -1,11 +1,18 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import base64
3
4
  import logging
5
+ import pickle
4
6
  from enum import StrEnum
7
+ from io import BytesIO
8
+ from pathlib import Path
5
9
  from queue import Queue
6
10
  from typing import TYPE_CHECKING, Any, NamedTuple, cast
7
11
  from uuid import uuid4
8
12
 
13
+ import httpx
14
+ from PIL import Image
15
+
9
16
  from griptape_nodes.common.node_executor import NodeExecutor
10
17
  from griptape_nodes.exe_types.base_iterative_nodes import BaseIterativeStartNode
11
18
  from griptape_nodes.exe_types.connections import Connections
@@ -92,6 +99,9 @@ from griptape_nodes.retained_mode.events.flow_events import (
92
99
  DeserializeFlowFromCommandsRequest,
93
100
  DeserializeFlowFromCommandsResultFailure,
94
101
  DeserializeFlowFromCommandsResultSuccess,
102
+ ExtractFlowCommandsFromImageMetadataRequest,
103
+ ExtractFlowCommandsFromImageMetadataResultFailure,
104
+ ExtractFlowCommandsFromImageMetadataResultSuccess,
95
105
  GetFlowDetailsRequest,
96
106
  GetFlowDetailsResultFailure,
97
107
  GetFlowDetailsResultSuccess,
@@ -148,6 +158,7 @@ from griptape_nodes.retained_mode.events.workflow_events import (
148
158
  ImportWorkflowAsReferencedSubFlowResultSuccess,
149
159
  )
150
160
  from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes
161
+ from griptape_nodes.retained_mode.managers.image_metadata_injector import FLOW_COMMANDS_KEY
151
162
 
152
163
  if TYPE_CHECKING:
153
164
  from griptape_nodes.retained_mode.events.base_events import ResultPayload
@@ -268,6 +279,9 @@ class FlowManager:
268
279
  event_manager.assign_manager_to_request_type(
269
280
  DeserializeFlowFromCommandsRequest, self.on_deserialize_flow_from_commands
270
281
  )
282
+ event_manager.assign_manager_to_request_type(
283
+ ExtractFlowCommandsFromImageMetadataRequest, self.on_extract_flow_commands_from_image_metadata
284
+ )
271
285
  event_manager.assign_manager_to_request_type(
272
286
  PackageNodesAsSerializedFlowRequest, self.on_package_nodes_as_serialized_flow_request
273
287
  )
@@ -3601,6 +3615,126 @@ class FlowManager:
3601
3615
  ExecutionGriptapeNodeEvent(wrapped_event=ExecutionEvent(payload=InvolvedNodesEvent(involved_nodes=[])))
3602
3616
  )
3603
3617
 
3618
+ def on_extract_flow_commands_from_image_metadata( # noqa: PLR0911, C901
3619
+ self, request: ExtractFlowCommandsFromImageMetadataRequest
3620
+ ) -> ResultPayload:
3621
+ """Extract flow commands from PNG image metadata.
3622
+
3623
+ Args:
3624
+ request: ExtractFlowCommandsFromImageMetadataRequest with file path or URL
3625
+
3626
+ Returns:
3627
+ ExtractFlowCommandsFromImageMetadataResultSuccess if successful
3628
+ ExtractFlowCommandsFromImageMetadataResultFailure if any step fails
3629
+ """
3630
+ file_url_or_path = request.file_url_or_path
3631
+
3632
+ # Check if input is a URL or file path
3633
+ is_url = file_url_or_path.startswith(("http://", "https://"))
3634
+
3635
+ if is_url:
3636
+ # Handle URL: download the image
3637
+ try:
3638
+ response = httpx.get(file_url_or_path, timeout=30.0)
3639
+ response.raise_for_status()
3640
+ pil_image = Image.open(BytesIO(response.content))
3641
+ except Exception as e:
3642
+ return ExtractFlowCommandsFromImageMetadataResultFailure(
3643
+ result_details=f"Failed to download or open image from URL: {e}",
3644
+ file_path=file_url_or_path,
3645
+ )
3646
+ else:
3647
+ # Handle file path: validate and open
3648
+ path_obj = Path(file_url_or_path)
3649
+ if not path_obj.exists():
3650
+ return ExtractFlowCommandsFromImageMetadataResultFailure(
3651
+ result_details=f"File not found: {file_url_or_path}",
3652
+ file_path=file_url_or_path,
3653
+ )
3654
+
3655
+ if not path_obj.is_file():
3656
+ return ExtractFlowCommandsFromImageMetadataResultFailure(
3657
+ result_details=f"Path is not a file: {file_url_or_path}",
3658
+ file_path=file_url_or_path,
3659
+ )
3660
+
3661
+ # Read PNG image and extract metadata
3662
+ try:
3663
+ pil_image = Image.open(file_url_or_path)
3664
+ except Exception as e:
3665
+ return ExtractFlowCommandsFromImageMetadataResultFailure(
3666
+ result_details=f"Failed to open image file: {e}",
3667
+ file_path=file_url_or_path,
3668
+ )
3669
+
3670
+ # Validation: Check if image has metadata
3671
+ if not hasattr(pil_image, "info") or not pil_image.info:
3672
+ return ExtractFlowCommandsFromImageMetadataResultFailure(
3673
+ result_details=f"Image has no metadata: {file_url_or_path}",
3674
+ file_path=file_url_or_path,
3675
+ )
3676
+
3677
+ metadata = pil_image.info
3678
+ metadata_keys = [str(key) for key in metadata]
3679
+
3680
+ # Validation: Check if flow commands metadata exists
3681
+ if FLOW_COMMANDS_KEY not in metadata:
3682
+ return ExtractFlowCommandsFromImageMetadataResultFailure(
3683
+ result_details=f"No flow commands metadata found in image. Available keys: {metadata_keys}",
3684
+ file_path=file_url_or_path,
3685
+ )
3686
+
3687
+ encoded_flow_commands = metadata[FLOW_COMMANDS_KEY]
3688
+
3689
+ # Decode base64
3690
+ try:
3691
+ pickled_data = base64.b64decode(encoded_flow_commands)
3692
+ except Exception as e:
3693
+ return ExtractFlowCommandsFromImageMetadataResultFailure(
3694
+ result_details=f"Failed to decode base64 flow commands: {e}",
3695
+ file_path=file_url_or_path,
3696
+ )
3697
+
3698
+ # Unpickle SerializedFlowCommands
3699
+ try:
3700
+ # Pickle is safe here: we're deserializing workflow data from images saved by this application
3701
+ # Converting to JSON would require significant serialization infrastructure for SerializedFlowCommands
3702
+ serialized_flow_commands = pickle.loads(pickled_data) # noqa: S301
3703
+ except Exception as e:
3704
+ return ExtractFlowCommandsFromImageMetadataResultFailure(
3705
+ result_details=f"Failed to unpickle flow commands: {e}",
3706
+ file_path=file_url_or_path,
3707
+ )
3708
+
3709
+ # Check if we should also deserialize the flow
3710
+ if request.deserialize:
3711
+ # Deserialize the flow
3712
+ deserialize_request = DeserializeFlowFromCommandsRequest(serialized_flow_commands=serialized_flow_commands)
3713
+ deserialize_result = GriptapeNodes.handle_request(deserialize_request)
3714
+
3715
+ # Validation: Check if deserialization succeeded
3716
+ if not isinstance(deserialize_result, DeserializeFlowFromCommandsResultSuccess):
3717
+ return ExtractFlowCommandsFromImageMetadataResultFailure(
3718
+ result_details=f"Failed to deserialize flow from image metadata: {deserialize_result.result_details}",
3719
+ file_path=file_url_or_path,
3720
+ )
3721
+
3722
+ # Success path: Return with deserialization info
3723
+ return ExtractFlowCommandsFromImageMetadataResultSuccess(
3724
+ result_details=f"Successfully extracted and deserialized flow '{deserialize_result.flow_name}' from image metadata",
3725
+ serialized_flow_commands=serialized_flow_commands,
3726
+ flow_name=deserialize_result.flow_name,
3727
+ node_name_mappings=deserialize_result.node_name_mappings,
3728
+ altered_workflow_state=True,
3729
+ )
3730
+
3731
+ # Success path: Return the extracted commands for user inspection (no deserialization)
3732
+ return ExtractFlowCommandsFromImageMetadataResultSuccess(
3733
+ result_details="Successfully extracted flow commands from image metadata",
3734
+ serialized_flow_commands=serialized_flow_commands,
3735
+ altered_workflow_state=False,
3736
+ )
3737
+
3604
3738
  def check_for_existing_running_flow(self) -> bool:
3605
3739
  if self._global_control_flow_machine is None:
3606
3740
  return False
@@ -0,0 +1,339 @@
1
+ """Automatic workflow metadata injection for images saved through StaticFilesManager.
2
+
3
+ This module provides functionality to automatically inject workflow metadata into
4
+ images when they are saved. Metadata format depends on image type:
5
+ - PNG: Stored in PNG text chunks
6
+ - JPEG/TIFF/MPO: Stored as JSON in EXIF UserComment field
7
+ """
8
+
9
+ import base64
10
+ import logging
11
+ import pickle
12
+ from datetime import UTC, datetime
13
+ from io import BytesIO
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from PIL import Image
18
+
19
+ from griptape_nodes.drivers.image_metadata.image_metadata_driver_registry import (
20
+ ImageMetadataDriverRegistry,
21
+ )
22
+ from griptape_nodes.exe_types.core_types import ParameterMode
23
+ from griptape_nodes.exe_types.node_types import BaseNode
24
+ from griptape_nodes.exe_types.type_validator import TypeValidator
25
+ from griptape_nodes.node_library.workflow_registry import WorkflowRegistry
26
+ from griptape_nodes.retained_mode.events.flow_events import (
27
+ SerializeFlowToCommandsRequest,
28
+ SerializeFlowToCommandsResultSuccess,
29
+ )
30
+ from griptape_nodes.retained_mode.events.node_events import (
31
+ SerializeNodeToCommandsRequest,
32
+ SerializeNodeToCommandsResultSuccess,
33
+ )
34
+ from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes
35
+
36
+ logger = logging.getLogger("griptape_nodes")
37
+
38
+ # Metadata namespace prefix for all auto-injected fields
39
+ METADATA_NAMESPACE = "gtn_"
40
+
41
+ # Metadata key for storing flow commands
42
+ FLOW_COMMANDS_KEY = f"{METADATA_NAMESPACE}flow_commands"
43
+
44
+ # Recognized image formats for file extension mapping (not all support metadata injection)
45
+ SUPPORTED_FORMATS = {
46
+ ".jpg": "JPEG",
47
+ ".jpeg": "JPEG",
48
+ ".png": "PNG",
49
+ ".tiff": "TIFF",
50
+ ".tif": "TIFF",
51
+ ".mpo": "MPO",
52
+ }
53
+
54
+
55
+ def get_image_format_from_filename(filename: str) -> str | None:
56
+ """Extract image format from filename extension.
57
+
58
+ Args:
59
+ filename: Name of the file including extension
60
+
61
+ Returns:
62
+ Image format string (e.g., "JPEG", "PNG") or None if not a supported image format
63
+ """
64
+ ext = Path(filename).suffix.lower()
65
+ return SUPPORTED_FORMATS.get(ext)
66
+
67
+
68
+ def supports_metadata(format_str: str | None) -> bool:
69
+ """Check if image format supports automatic metadata injection.
70
+
71
+ Currently limited to PNG to avoid EXIF size limits on workflow metadata.
72
+
73
+ Args:
74
+ format_str: Image format string (e.g., "PNG", "JPEG")
75
+
76
+ Returns:
77
+ True only for PNG format
78
+ """
79
+ return format_str == "PNG"
80
+
81
+
82
+ def _serialize_node(node_name: str) -> str | None:
83
+ """Serialize a specific node to JSON commands.
84
+
85
+ Args:
86
+ node_name: Name of the node to serialize
87
+
88
+ Returns:
89
+ JSON string of serialized node commands, or None if serialization fails
90
+ """
91
+ serialize_request = SerializeNodeToCommandsRequest(
92
+ node_name=node_name,
93
+ )
94
+ serialize_result = GriptapeNodes.handle_request(serialize_request)
95
+
96
+ if isinstance(serialize_result, SerializeNodeToCommandsResultSuccess):
97
+ # Convert to dict and then to JSON string
98
+ return serialize_result.to_json()
99
+
100
+ return None
101
+
102
+
103
+ def _serialize_flow(flow_name: str | None = None) -> str | None:
104
+ """Serialize a flow to pickle + base64 encoded commands.
105
+
106
+ Args:
107
+ flow_name: Name of the flow to serialize (None for current context flow)
108
+
109
+ Returns:
110
+ Base64-encoded pickle string of serialized flow commands, or None if serialization fails
111
+ """
112
+ # Validation: Check if we have a flow context
113
+ if flow_name is None and not GriptapeNodes.ContextManager().has_current_flow():
114
+ logger.warning("Cannot serialize flow: no current flow context available")
115
+ return None
116
+
117
+ # Create serialize request
118
+ serialize_request = SerializeFlowToCommandsRequest(
119
+ flow_name=flow_name,
120
+ include_create_flow_command=False,
121
+ )
122
+ serialize_result = GriptapeNodes.handle_request(serialize_request)
123
+
124
+ # Validation: Check if serialization succeeded
125
+ if not isinstance(serialize_result, SerializeFlowToCommandsResultSuccess):
126
+ logger.warning("Failed to serialize flow '%s' to commands", flow_name or "current")
127
+ return None
128
+
129
+ # Success path: Serialize using pickle + base64
130
+ try:
131
+ serialized_flow_commands = serialize_result.serialized_flow_commands
132
+ # Pickle is safe here: serializing workflow data for metadata injection into saved images
133
+ # The data will only be deserialized by this same application
134
+ pickled_data = pickle.dumps(serialized_flow_commands)
135
+ encoded_data = base64.b64encode(pickled_data).decode("ascii")
136
+ except Exception as e:
137
+ logger.warning("Failed to pickle/encode flow '%s': %s", flow_name or "current", e)
138
+ return None
139
+ else:
140
+ return encoded_data
141
+
142
+
143
+ def _collect_parameter_values(node_name: str) -> dict[str, Any] | None:
144
+ """Collect current parameter values from a node's INPUT and PROPERTY parameters.
145
+
146
+ Args:
147
+ node_name: Name of the node to collect parameters from
148
+
149
+ Returns:
150
+ Dictionary of parameter names to serialized values, or None if collection fails
151
+ """
152
+ # Failure case: Attempt to get node object
153
+ obj_mgr = GriptapeNodes.ObjectManager()
154
+ try:
155
+ node = obj_mgr.attempt_get_object_by_name_as_type(node_name, BaseNode)
156
+ except Exception as e:
157
+ logger.warning("Failed to get node '%s' for parameter collection: %s", node_name, e)
158
+ return None
159
+
160
+ if node is None:
161
+ logger.warning("Node '%s' not found for parameter collection", node_name)
162
+ return None
163
+
164
+ # Get all parameters from node
165
+ all_parameters = node.parameters
166
+
167
+ # Filter to INPUT and PROPERTY mode parameters only
168
+ eligible_parameters = [
169
+ param
170
+ for param in all_parameters
171
+ if ParameterMode.INPUT in param.allowed_modes or ParameterMode.PROPERTY in param.allowed_modes
172
+ ]
173
+
174
+ # Collect and serialize parameter values
175
+ parameter_values = {}
176
+
177
+ for param in eligible_parameters:
178
+ # Get current value
179
+ value = node.get_parameter_value(param.name)
180
+
181
+ # Skip None values (not set)
182
+ if value is None:
183
+ continue
184
+
185
+ # Serialize value with error handling
186
+ try:
187
+ serialized_value = TypeValidator.safe_serialize(value)
188
+ parameter_values[param.name] = serialized_value
189
+ except Exception as e:
190
+ logger.warning("Failed to serialize parameter '%s' on node '%s': %s", param.name, node_name, e)
191
+ continue
192
+
193
+ # Success path: return collected values (may be empty dict)
194
+ return parameter_values
195
+
196
+
197
+ def _collect_workflow_details(workflow_name: str, metadata: dict[str, str]) -> None:
198
+ """Collect workflow details from registry and add to metadata dict.
199
+
200
+ Args:
201
+ workflow_name: Name of the workflow
202
+ metadata: Dictionary to populate with workflow metadata (modified in-place)
203
+ """
204
+ try:
205
+ workflow = WorkflowRegistry.get_workflow_by_name(workflow_name)
206
+
207
+ if workflow.metadata.creation_date:
208
+ metadata[f"{METADATA_NAMESPACE}workflow_created"] = workflow.metadata.creation_date.isoformat()
209
+
210
+ if workflow.metadata.last_modified_date:
211
+ metadata[f"{METADATA_NAMESPACE}workflow_modified"] = workflow.metadata.last_modified_date.isoformat()
212
+
213
+ if workflow.metadata.engine_version_created_with:
214
+ metadata[f"{METADATA_NAMESPACE}engine_version"] = workflow.metadata.engine_version_created_with
215
+
216
+ if workflow.metadata.description:
217
+ metadata[f"{METADATA_NAMESPACE}workflow_description"] = workflow.metadata.description
218
+ except Exception: # noqa: S110
219
+ pass
220
+
221
+
222
+ def collect_workflow_metadata() -> dict[str, str]:
223
+ """Collect available workflow metadata from current execution context.
224
+
225
+ Gathers metadata from GriptapeNodes ContextManager and WorkflowRegistry.
226
+ All keys are prefixed with METADATA_NAMESPACE to avoid conflicts.
227
+
228
+ Returns:
229
+ Dictionary of metadata key-value pairs, may be empty if no context available
230
+ """
231
+ metadata = {}
232
+
233
+ # Add save timestamp (always available)
234
+ metadata[f"{METADATA_NAMESPACE}saved_at"] = datetime.now(UTC).isoformat()
235
+
236
+ # Get context manager
237
+ context_manager = GriptapeNodes.ContextManager()
238
+
239
+ # Check workflow context
240
+ if not context_manager.has_current_workflow():
241
+ return metadata
242
+
243
+ # Get workflow name
244
+ try:
245
+ workflow_name = context_manager.get_current_workflow_name()
246
+ metadata[f"{METADATA_NAMESPACE}workflow_name"] = workflow_name
247
+ _collect_workflow_details(workflow_name, metadata)
248
+ except Exception: # noqa: S110
249
+ pass
250
+
251
+ # Get flow context and resolving nodes
252
+ if context_manager.has_current_flow():
253
+ try:
254
+ flow = context_manager.get_current_flow()
255
+ metadata[f"{METADATA_NAMESPACE}flow_name"] = flow.name
256
+
257
+ # Get resolving nodes (currently running nodes) from flow_state
258
+ flow_manager = GriptapeNodes.FlowManager()
259
+ _, resolving_nodes, _ = flow_manager.flow_state(flow)
260
+
261
+ if resolving_nodes:
262
+ # Store node name(s) - if multiple, join with comma
263
+ metadata[f"{METADATA_NAMESPACE}node_name"] = ", ".join(resolving_nodes)
264
+
265
+ # Serialize the entire current flow to commands
266
+ # This captures all nodes, connections, and parameter values in the flow
267
+ flow_commands = _serialize_flow()
268
+ if flow_commands:
269
+ metadata[FLOW_COMMANDS_KEY] = flow_commands
270
+
271
+ if resolving_nodes:
272
+ # Collect parameter values from the first resolving node
273
+ parameter_values = _collect_parameter_values(resolving_nodes[0])
274
+ if parameter_values:
275
+ # Store each parameter as its own metadata key
276
+ for param_name, param_value in parameter_values.items():
277
+ metadata[f"{METADATA_NAMESPACE}param_{param_name}"] = str(param_value)
278
+ except Exception:
279
+ logger.exception("Failed to collect flow/node metadata")
280
+
281
+ return metadata
282
+
283
+
284
+ def inject_workflow_metadata_if_image(data: bytes, file_name: str) -> bytes: # noqa: PLR0911
285
+ """Inject workflow metadata into image if format supports it.
286
+
287
+ Main entry point for automatic metadata injection. Detects image format,
288
+ collects workflow metadata, and delegates to appropriate driver.
289
+
290
+ Args:
291
+ data: Raw image bytes
292
+ file_name: Filename including extension
293
+
294
+ Returns:
295
+ Image bytes with metadata injected, or original bytes if:
296
+ - Format doesn't support metadata
297
+ - No workflow context available
298
+ - Image loading/processing fails
299
+ """
300
+ # Validation: Check format
301
+ format_str = get_image_format_from_filename(file_name)
302
+ if not supports_metadata(format_str):
303
+ return data
304
+
305
+ # Validation: Check if we have data
306
+ if not data:
307
+ logger.warning("Cannot inject metadata: empty data")
308
+ return data
309
+
310
+ # Collect metadata
311
+ metadata = collect_workflow_metadata()
312
+ if not metadata:
313
+ # No context available, nothing to inject
314
+ return data
315
+
316
+ # Load PIL image
317
+ try:
318
+ pil_image = Image.open(BytesIO(data))
319
+ except Exception as e:
320
+ logger.warning("Failed to load image for metadata injection: %s", e)
321
+ return data
322
+
323
+ # Verify format matches
324
+ if pil_image.format is None:
325
+ logger.warning("Could not detect image format from data")
326
+ return data
327
+
328
+ # Get driver for this format
329
+ driver = ImageMetadataDriverRegistry.get_driver_for_format(pil_image.format)
330
+ if driver is None:
331
+ logger.warning("No metadata driver found for format: %s", pil_image.format)
332
+ return data
333
+
334
+ # Inject metadata using driver
335
+ try:
336
+ return driver.inject_metadata(pil_image, metadata)
337
+ except Exception as e:
338
+ logger.warning("Failed to inject metadata into %s: %s", file_name, e)
339
+ return data