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 :
@@ -256,12 +270,150 @@ def resolve_image(self, entity_reference: str) -> tuple[torch.Tensor, torch.Tens
256
270
return output_image , output_mask
257
271
258
272
273
+ class PublishImage :
274
+ """
275
+ Node to publish an image to an OpenAssetIO entity reference.
276
+
277
+ The non-OpenAssetIO logic is largely duplicated from the built-in
278
+ ComfyUI SaveImage node (at least as of 4f5812b9).
279
+ """
280
+
281
+ # Tooltip to display when hovering over the node.
282
+ DESCRIPTION = "Publishes images to an asset manager."
283
+ # Menu category.
284
+ CATEGORY = "image"
285
+ # Function to call when node is executed.
286
+ FUNCTION = "publish_images"
287
+ # Node outputs.
288
+ RETURN_TYPES = ()
289
+ # Marks this node as a terminal node, ensuring the associated
290
+ # subgraph is executed when running the graph.
291
+ OUTPUT_NODE = True
292
+
293
+ @classmethod
294
+ def INPUT_TYPES (cls ) -> dict :
295
+ """
296
+ Input sockets and widgets.
297
+ """
298
+ return {
299
+ "required" : {
300
+ "entity_reference" : (
301
+ "STRING" ,
302
+ {"multiline" : False , "tooltip" : "The entity to publish to" },
303
+ ),
304
+ "images" : ("IMAGE" , {"tooltip" : "The images to save." }),
305
+ },
306
+ "hidden" : {"prompt" : "PROMPT" , "extra_pnginfo" : "EXTRA_PNGINFO" },
307
+ }
308
+
309
+ @classmethod
310
+ def VALIDATE_INPUTS (cls , entity_reference : str ) -> bool :
311
+ """
312
+ Validate that the input entity reference is valid syntax for the
313
+ current manager.
314
+ """
315
+ return _OpenAssetIOHost .instance ().manager .isEntityReferenceString (entity_reference )
316
+
317
+ def __init__ (self ):
318
+ """
319
+ Initialise node.
320
+
321
+ Duplicated from SaveImage node.
322
+ """
323
+ self .compress_level = 4
324
+
325
+ def publish_images (
326
+ self , entity_reference : str , images : torch .Tensor , prompt = None , extra_pnginfo = None
327
+ ) -> dict :
328
+ """
329
+ Publish the input images to the specified OpenAssetIO entity.
330
+
331
+ Assumes the working reference provided by `preflight()` can be
332
+ resolved to a destination file path to write to.
333
+
334
+ Non-OpenAssetIO logic largely duplicated from the built-in
335
+ ComfyUI SaveImage node (at least as of 4f5812b9).
336
+
337
+ In particular, we inherit support for a "%batch_num%"
338
+ placeholder in the resolved path.
339
+ """
340
+ entity_reference = _OpenAssetIOHost .instance ().manager .createEntityReference (
341
+ entity_reference
342
+ )
343
+
344
+ spec = PlanarBitmapImageResourceSpecification .create ()
345
+
346
+ working_ref = _OpenAssetIOHost .instance ().manager .preflight (
347
+ entity_reference ,
348
+ spec .traitsData (),
349
+ PublishingAccess .kWrite ,
350
+ _OpenAssetIOHost .instance ().context ,
351
+ )
352
+
353
+ # Get destination file path (potentially containing batch_num
354
+ # placeholder). This may be a temporary/staging path, or it
355
+ # may be the final path, depending on the manager's
356
+ # implementation.
357
+ file_path_tmplt = _OpenAssetIOHost .instance ().resolve_to_path (
358
+ working_ref , access_mode = ResolveAccess .kManagerDriven
359
+ )
360
+
361
+ results = list ()
362
+ for batch_number , image in enumerate (images ):
363
+ i = 255.0 * image .cpu ().numpy ()
364
+ img = Image .fromarray (np .clip (i , 0 , 255 ).astype (np .uint8 ))
365
+ metadata = None
366
+ if not args .disable_metadata :
367
+ metadata = PngInfo ()
368
+ if prompt is not None :
369
+ metadata .add_text ("prompt" , json .dumps (prompt ))
370
+ if extra_pnginfo is not None :
371
+ for x in extra_pnginfo :
372
+ metadata .add_text (x , json .dumps (extra_pnginfo [x ]))
373
+
374
+ file_path = file_path_tmplt .replace ("%batch_num%" , str (batch_number ))
375
+
376
+ img .save (file_path , pnginfo = metadata , compress_level = self .compress_level )
377
+
378
+ # Configure traits to register with the asset manager.
379
+ url = _OpenAssetIOHost .instance ().file_url_path_converter .pathToUrl (file_path )
380
+ spec .locatableContentTrait ().setLocation (url )
381
+
382
+ # Publish the image to the working reference.
383
+ final_ref = _OpenAssetIOHost .instance ().manager .register (
384
+ working_ref ,
385
+ spec .traitsData (),
386
+ PublishingAccess .kWrite ,
387
+ _OpenAssetIOHost .instance ().context ,
388
+ )
389
+ # Get the path of the published image. This may be different
390
+ # to the path resolved from the working reference (i.e. if
391
+ # the manager moved it as part of the publishing process).
392
+ final_file_path = pathlib .Path (_OpenAssetIOHost .instance ().resolve_to_path (final_ref ))
393
+ # Copy to ComfyUI temp directory for display in the UI. For
394
+ # security reasons, ComfyUI does not allow images to be
395
+ # served from arbitrary paths on disk, so we must copy them
396
+ # to an allowed location. Here, we choose ComfyUI's temp
397
+ # directory.
398
+ shutil .copy2 (final_file_path , folder_paths .get_temp_directory ())
399
+
400
+ results .append ({"filename" : final_file_path .name , "subfolder" : "" , "type" : "temp" })
401
+
402
+ # For output nodes, we can return a dict with a "ui" key,
403
+ # containing data to display in the ComfyUI interface.
404
+ # Here, we return the list of published images, which will be
405
+ # displayed in the node.
406
+ return {"ui" : {"images" : results }}
407
+
408
+
259
409
# Plugin registration: node classes.
260
410
NODE_CLASS_MAPPINGS = {
261
411
"OpenAssetIOResolveImage" : ResolveImage ,
412
+ "OpenAssetIOPublishImage" : PublishImage ,
262
413
}
263
414
264
415
# Plugin registration: node names.
265
416
NODE_DISPLAY_NAME_MAPPINGS = {
266
417
"OpenAssetIOResolveImage" : "OpenAssetIO Resolve Image" ,
418
+ "OpenAssetIOPublishImage" : "OpenAssetIO Publish Image" ,
267
419
}
0 commit comments