Skip to content

Commit ae82e0d

Browse files
committed
[Core] Add PublishImage node
Copy-paste current implementation of core SaveImage, but use a string input providing an entity reference instead of a file name, and retrieve target file path from the OpenAssetIO manager. Support image preview in the UI by copying the published file to a (temp) directory on ComfyUI's allow-list. Signed-off-by: David Feltell <[email protected]>
1 parent 0c9441b commit ae82e0d

File tree

3 files changed

+335
-14
lines changed

3 files changed

+335
-14
lines changed

src/openassetio_comfyui/nodes.py

Lines changed: 165 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,26 +9,35 @@
99
"""
1010

1111
import hashlib
12+
import json
1213
import logging
14+
import pathlib
15+
import shutil
1316

1417
import torch
1518
import numpy as np
1619

1720
from PIL import Image, ImageOps, ImageSequence
21+
from PIL.PngImagePlugin import PngInfo
1822

1923
from openassetio import EntityReference
2024
from openassetio.utils import FileUrlPathConverter
2125
from openassetio.log import LoggerInterface
22-
from openassetio.access import ResolveAccess
26+
from openassetio.access import ResolveAccess, PublishingAccess
2327
from openassetio.hostApi import ManagerFactory, HostInterface
2428
from openassetio.pluginSystem import (
2529
HybridPluginSystemManagerImplementationFactory,
2630
PythonPluginSystemManagerImplementationFactory,
2731
CppPluginSystemManagerImplementationFactory,
2832
)
33+
from openassetio_mediacreation.specifications.twoDimensional import (
34+
PlanarBitmapImageResourceSpecification,
35+
)
2936
from openassetio_mediacreation.traits.content import LocatableContentTrait
3037

38+
import folder_paths
3139
import node_helpers
40+
from comfy.cli_args import args
3241

3342

3443
class _OpenAssetIOHost:
@@ -87,30 +96,34 @@ def __init__(self):
8796
"""
8897
Load and initialise an OpenAssetIO manager plugin.
8998
"""
90-
self.__logger = self._LoggerInterface()
99+
self.logger = self._LoggerInterface()
91100
# Initialise plugin system, then find and load a manager.
92101
self.manager = ManagerFactory.defaultManagerForInterface(
93102
self._HostInterface(),
94103
HybridPluginSystemManagerImplementationFactory(
95104
# Prefer C++ over Python plugins/methods.
96105
[
97-
CppPluginSystemManagerImplementationFactory(self.__logger),
98-
PythonPluginSystemManagerImplementationFactory(self.__logger),
106+
CppPluginSystemManagerImplementationFactory(self.logger),
107+
PythonPluginSystemManagerImplementationFactory(self.logger),
99108
],
100-
self.__logger,
109+
self.logger,
101110
),
102-
self.__logger,
111+
self.logger,
103112
)
104113
if self.manager is None:
105114
raise RuntimeError(
106115
"Could not create an OpenAssetIO manager instance. Ensure that your OpenAssetIO"
107116
" configuration is correct and that the environment variable"
108117
" OPENASSETIO_DEFAULT_CONFIG is set to a valid configuration file."
109118
)
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:
114127
"""
115128
Resolve an OpenAssetIO entity reference to a local file path.
116129
@@ -121,10 +134,11 @@ def resolve_to_path(self, entity_reference: str) -> str:
121134
@throw RuntimeError if the entity reference resolves
122135
successfully, but the entity has no location.
123136
"""
124-
entity_reference = self.manager.createEntityReference(entity_reference)
137+
if isinstance(entity_reference, str):
138+
entity_reference = self.manager.createEntityReference(entity_reference)
125139

126140
traits_data = self.manager.resolve(
127-
entity_reference, {LocatableContentTrait.kId}, ResolveAccess.kRead, self.__context
141+
entity_reference, {LocatableContentTrait.kId}, access_mode, self.context
128142
)
129143
locatable_content_trait = LocatableContentTrait(traits_data)
130144
url = locatable_content_trait.getLocation()
@@ -134,7 +148,7 @@ def resolve_to_path(self, entity_reference: str) -> str:
134148
f"Failed to resolve entity reference '{entity_reference}' to a location"
135149
)
136150

137-
return self.__file_url_path_converter.pathFromUrl(url)
151+
return self.file_url_path_converter.pathFromUrl(url)
138152

139153

140154
class ResolveImage:
@@ -256,12 +270,150 @@ def resolve_image(self, entity_reference: str) -> tuple[torch.Tensor, torch.Tens
256270
return output_image, output_mask
257271

258272

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+
259409
# Plugin registration: node classes.
260410
NODE_CLASS_MAPPINGS = {
261411
"OpenAssetIOResolveImage": ResolveImage,
412+
"OpenAssetIOPublishImage": PublishImage,
262413
}
263414

264415
# Plugin registration: node names.
265416
NODE_DISPLAY_NAME_MAPPINGS = {
266417
"OpenAssetIOResolveImage": "OpenAssetIO Resolve Image",
418+
"OpenAssetIOPublishImage": "OpenAssetIO Publish Image",
267419
}

tests/resources/bal_db.json

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,21 @@
11
{
2+
"managementPolicy": {
3+
"read": {
4+
"default": {
5+
"openassetio-mediacreation:managementPolicy.Managed": {}
6+
}
7+
},
8+
"write": {
9+
"default": {
10+
"openassetio-mediacreation:managementPolicy.Managed": {}
11+
}
12+
},
13+
"managerDriven": {
14+
"default": {
15+
"openassetio-mediacreation:managementPolicy.Managed": {}
16+
}
17+
}
18+
},
219
"entities": {
320
"image_file": {
421
"versions": [
@@ -22,6 +39,27 @@
2239
}
2340
}
2441
]
42+
},
43+
"new_image_file": {
44+
"versions": [],
45+
"overrideByAccess": {
46+
"write": {
47+
"traits": {
48+
"openassetio-mediacreation:usage.Entity": {},
49+
"openassetio-mediacreation:twoDimensional.Image": {},
50+
"openassetio-mediacreation:twoDimensional.PixelBased": {},
51+
"openassetio-mediacreation:twoDimensional.Planar": {},
52+
"openassetio-mediacreation:content.LocatableContent": {}
53+
}
54+
},
55+
"managerDriven": {
56+
"traits": {
57+
"openassetio-mediacreation:content.LocatableContent": {
58+
"location": "file://${test_tmp_dir}/dog.png"
59+
}
60+
}
61+
}
62+
}
2563
}
2664
}
2765
}

0 commit comments

Comments
 (0)