9
9
"""
10
10
11
11
import hashlib
12
+ import json
12
13
import logging
14
+ import pathlib
15
+ import shutil
13
16
14
17
import torch
15
18
import numpy as np
16
19
17
20
from PIL import Image , ImageOps , ImageSequence
21
+ from PIL .PngImagePlugin import PngInfo
18
22
19
23
from openassetio import EntityReference
20
24
from openassetio .utils import FileUrlPathConverter
21
25
from openassetio .log import LoggerInterface
22
- from openassetio .access import ResolveAccess
26
+ from openassetio .access import ResolveAccess , PublishingAccess
23
27
from openassetio .hostApi import ManagerFactory , HostInterface
24
28
from openassetio .pluginSystem import (
25
29
HybridPluginSystemManagerImplementationFactory ,
26
30
PythonPluginSystemManagerImplementationFactory ,
27
31
CppPluginSystemManagerImplementationFactory ,
28
32
)
33
+ from openassetio_mediacreation .specifications .twoDimensional import (
34
+ PlanarBitmapImageResourceSpecification ,
35
+ )
29
36
from openassetio_mediacreation .traits .content import LocatableContentTrait
30
37
38
+ import folder_paths
31
39
import node_helpers
40
+ from comfy .cli_args import args
32
41
33
42
34
43
class _OpenAssetIOHost :
@@ -87,30 +96,34 @@ def __init__(self):
87
96
"""
88
97
Load and initialise an OpenAssetIO manager plugin.
89
98
"""
90
- self .__logger = self ._LoggerInterface ()
99
+ self .logger = self ._LoggerInterface ()
91
100
# Initialise plugin system, then find and load a manager.
92
101
self .manager = ManagerFactory .defaultManagerForInterface (
93
102
self ._HostInterface (),
94
103
HybridPluginSystemManagerImplementationFactory (
95
104
# Prefer C++ over Python plugins/methods.
96
105
[
97
- CppPluginSystemManagerImplementationFactory (self .__logger ),
98
- PythonPluginSystemManagerImplementationFactory (self .__logger ),
106
+ CppPluginSystemManagerImplementationFactory (self .logger ),
107
+ PythonPluginSystemManagerImplementationFactory (self .logger ),
99
108
],
100
- self .__logger ,
109
+ self .logger ,
101
110
),
102
- self .__logger ,
111
+ self .logger ,
103
112
)
104
113
if self .manager is None :
105
114
raise RuntimeError (
106
115
"Could not create an OpenAssetIO manager instance. Ensure that your OpenAssetIO"
107
116
" configuration is correct and that the environment variable"
108
117
" OPENASSETIO_DEFAULT_CONFIG is set to a valid configuration file."
109
118
)
110
- self .__context = self .manager .createContext ()
111
- self .__file_url_path_converter = FileUrlPathConverter ()
112
-
113
- def resolve_to_path (self , entity_reference : str ) -> str :
119
+ self .context = self .manager .createContext ()
120
+ self .file_url_path_converter = FileUrlPathConverter ()
121
+
122
+ def resolve_to_path (
123
+ self ,
124
+ entity_reference : EntityReference | str ,
125
+ access_mode : ResolveAccess = ResolveAccess .kRead ,
126
+ ) -> str :
114
127
"""
115
128
Resolve an OpenAssetIO entity reference to a local file path.
116
129
@@ -121,10 +134,11 @@ def resolve_to_path(self, entity_reference: str) -> str:
121
134
@throw RuntimeError if the entity reference resolves
122
135
successfully, but the entity has no location.
123
136
"""
124
- entity_reference = self .manager .createEntityReference (entity_reference )
137
+ if isinstance (entity_reference , str ):
138
+ entity_reference = self .manager .createEntityReference (entity_reference )
125
139
126
140
traits_data = self .manager .resolve (
127
- entity_reference , {LocatableContentTrait .kId }, ResolveAccess . kRead , self .__context
141
+ entity_reference , {LocatableContentTrait .kId }, access_mode , self .context
128
142
)
129
143
locatable_content_trait = LocatableContentTrait (traits_data )
130
144
url = locatable_content_trait .getLocation ()
@@ -134,7 +148,7 @@ def resolve_to_path(self, entity_reference: str) -> str:
134
148
f"Failed to resolve entity reference '{ entity_reference } ' to a location"
135
149
)
136
150
137
- return self .__file_url_path_converter .pathFromUrl (url )
151
+ return self .file_url_path_converter .pathFromUrl (url )
138
152
139
153
140
154
class ResolveImage :
@@ -254,12 +268,150 @@ def resolve_image(self, entity_reference: str) -> tuple[torch.Tensor, torch.Tens
254
268
return output_image , output_mask
255
269
256
270
271
+ class PublishImage :
272
+ """
273
+ Node to publish an image to an OpenAssetIO entity reference.
274
+
275
+ The non-OpenAssetIO logic is largely duplicated from the built-in
276
+ ComfyUI SaveImage node (at least as of 4f5812b9).
277
+ """
278
+
279
+ # Tooltip to display when hovering over the node.
280
+ DESCRIPTION = "Publishes images to an asset manager."
281
+ # Menu category.
282
+ CATEGORY = "image"
283
+ # Function to call when node is executed.
284
+ FUNCTION = "publish_images"
285
+ # Node outputs.
286
+ RETURN_TYPES = ()
287
+ # Marks this node as a terminal node, ensuring the associated
288
+ # subgraph is executed when running the graph.
289
+ OUTPUT_NODE = True
290
+
291
+ @classmethod
292
+ def INPUT_TYPES (cls ) -> dict :
293
+ """
294
+ Input sockets and widgets.
295
+ """
296
+ return {
297
+ "required" : {
298
+ "entity_reference" : (
299
+ "STRING" ,
300
+ {"multiline" : False , "tooltip" : "The entity to publish to" },
301
+ ),
302
+ "images" : ("IMAGE" , {"tooltip" : "The images to save." }),
303
+ },
304
+ "hidden" : {"prompt" : "PROMPT" , "extra_pnginfo" : "EXTRA_PNGINFO" },
305
+ }
306
+
307
+ @classmethod
308
+ def VALIDATE_INPUTS (cls , entity_reference : str ) -> bool :
309
+ """
310
+ Validate that the input entity reference is valid syntax for the
311
+ current manager.
312
+ """
313
+ return _OpenAssetIOHost .instance ().manager .isEntityReferenceString (entity_reference )
314
+
315
+ def __init__ (self ):
316
+ """
317
+ Initialise node.
318
+
319
+ Duplicated from SaveImage node.
320
+ """
321
+ self .compress_level = 4
322
+
323
+ def publish_images (
324
+ self , entity_reference : str , images : torch .Tensor , prompt = None , extra_pnginfo = None
325
+ ) -> dict :
326
+ """
327
+ Publish the input images to the specified OpenAssetIO entity.
328
+
329
+ Assumes the working reference provided by `preflight()` can be
330
+ resolved to a destination file path to write to.
331
+
332
+ Non-OpenAssetIO logic largely duplicated from the built-in
333
+ ComfyUI SaveImage node (at least as of 4f5812b9).
334
+
335
+ In particular, we inherit support for a "%batch_num%"
336
+ placeholder in the resolved path.
337
+ """
338
+ entity_reference = _OpenAssetIOHost .instance ().manager .createEntityReference (
339
+ entity_reference
340
+ )
341
+
342
+ spec = PlanarBitmapImageResourceSpecification .create ()
343
+
344
+ working_ref = _OpenAssetIOHost .instance ().manager .preflight (
345
+ entity_reference ,
346
+ spec .traitsData (),
347
+ PublishingAccess .kWrite ,
348
+ _OpenAssetIOHost .instance ().context ,
349
+ )
350
+
351
+ # Get destination file path (potentially containing batch_num
352
+ # placeholder). This may be a temporary/staging path, or it
353
+ # may be the final path, depending on the manager's
354
+ # implementation.
355
+ file_path_tmplt = _OpenAssetIOHost .instance ().resolve_to_path (
356
+ working_ref , access_mode = ResolveAccess .kManagerDriven
357
+ )
358
+
359
+ results = list ()
360
+ for batch_number , image in enumerate (images ):
361
+ i = 255.0 * image .cpu ().numpy ()
362
+ img = Image .fromarray (np .clip (i , 0 , 255 ).astype (np .uint8 ))
363
+ metadata = None
364
+ if not args .disable_metadata :
365
+ metadata = PngInfo ()
366
+ if prompt is not None :
367
+ metadata .add_text ("prompt" , json .dumps (prompt ))
368
+ if extra_pnginfo is not None :
369
+ for x in extra_pnginfo :
370
+ metadata .add_text (x , json .dumps (extra_pnginfo [x ]))
371
+
372
+ file_path = file_path_tmplt .replace ("%batch_num%" , str (batch_number ))
373
+
374
+ img .save (file_path , pnginfo = metadata , compress_level = self .compress_level )
375
+
376
+ # Configure traits to register with the asset manager.
377
+ url = _OpenAssetIOHost .instance ().file_url_path_converter .pathToUrl (file_path )
378
+ spec .locatableContentTrait ().setLocation (url )
379
+
380
+ # Publish the image to the working reference.
381
+ final_ref = _OpenAssetIOHost .instance ().manager .register (
382
+ working_ref ,
383
+ spec .traitsData (),
384
+ PublishingAccess .kWrite ,
385
+ _OpenAssetIOHost .instance ().context ,
386
+ )
387
+ # Get the path of the published image. This may be different
388
+ # to the path resolved from the working reference (i.e. if
389
+ # the manager moved it as part of the publishing process).
390
+ final_file_path = pathlib .Path (_OpenAssetIOHost .instance ().resolve_to_path (final_ref ))
391
+ # Copy to ComfyUI temp directory for display in the UI. For
392
+ # security reasons, ComfyUI does not allow images to be
393
+ # served from arbitrary paths on disk, so we must copy them
394
+ # to an allowed location. Here, we choose ComfyUI's temp
395
+ # directory.
396
+ shutil .copy2 (final_file_path , folder_paths .get_temp_directory ())
397
+
398
+ results .append ({"filename" : final_file_path .name , "subfolder" : "" , "type" : "temp" })
399
+
400
+ # For output nodes, we can return a dict with a "ui" key,
401
+ # containing data to display in the ComfyUI interface.
402
+ # Here, we return the list of published images, which will be
403
+ # displayed in the node.
404
+ return {"ui" : {"images" : results }}
405
+
406
+
257
407
# Plugin registration: node classes.
258
408
NODE_CLASS_MAPPINGS = {
259
409
"OpenAssetIOResolveImage" : ResolveImage ,
410
+ "OpenAssetIOPublishImage" : PublishImage ,
260
411
}
261
412
262
413
# Plugin registration: node names.
263
414
NODE_DISPLAY_NAME_MAPPINGS = {
264
415
"OpenAssetIOResolveImage" : "OpenAssetIO Resolve Image" ,
416
+ "OpenAssetIOPublishImage" : "OpenAssetIO Publish Image" ,
265
417
}
0 commit comments