Skip to content

Commit f931308

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 35639fd commit f931308

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:
@@ -254,12 +268,150 @@ def resolve_image(self, entity_reference: str) -> tuple[torch.Tensor, torch.Tens
254268
return output_image, output_mask
255269

256270

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+
257407
# Plugin registration: node classes.
258408
NODE_CLASS_MAPPINGS = {
259409
"OpenAssetIOResolveImage": ResolveImage,
410+
"OpenAssetIOPublishImage": PublishImage,
260411
}
261412

262413
# Plugin registration: node names.
263414
NODE_DISPLAY_NAME_MAPPINGS = {
264415
"OpenAssetIOResolveImage": "OpenAssetIO Resolve Image",
416+
"OpenAssetIOPublishImage": "OpenAssetIO Publish Image",
265417
}

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)