[Core] Add ResolveImage node

Copy-paste current implementation of core LoadImage, but use a string
input providing an entity reference instead of a file upload, and
retrieve input file path from the OpenAssetIO manager.

Due to the mix-and-match by ComfyUI of class vs. instance methods, plus
no way to inject context, use a singleton class to represent the
OpenAssetIO host application.

Signed-off-by: David Feltell <david.feltell@foundry.com>
This commit is contained in:
David Feltell
2025-09-05 17:46:51 +01:00
parent 7b9843a862
commit 35639fdffc
10 changed files with 530 additions and 142 deletions
+3
View File
@@ -29,3 +29,6 @@ indent_style = tab
[{*.yml,*.yaml}]
indent_size = 2
[*.json]
indent_size = 2
+15 -3
View File
@@ -2,7 +2,16 @@
# Copyright (c) 2025 The Foundry Visionmongers Ltd
# SPDX-License-Identifier: Apache-2.0
"""Top-level package for openassetio-comfyui."""
"""
Top-level package for openassetio-comfyui.
This __init__.py will not be packaged, but is useful if the repository
is checked out under ComfyUI's custom_nodes directory, as it allows
ComfyUI to discover the nodes contained within.
"""
import sys
import pathlib
__all__ = [
"NODE_CLASS_MAPPINGS",
@@ -13,5 +22,8 @@ __author__ = """Contributors to the OpenAssetIO project"""
__email__ = "openassetio-discussion@lists.aswf.io"
__version__ = "1.0.0"
from .src.openassetio_comfyui.nodes import NODE_CLASS_MAPPINGS
from .src.openassetio_comfyui.nodes import NODE_DISPLAY_NAME_MAPPINGS
# Ensure src/ is on the path so we can import from there.
sys.path.append(str(pathlib.Path(__file__).parent / "src"))
from openassetio_comfyui.nodes import NODE_CLASS_MAPPINGS
from openassetio_comfyui.nodes import NODE_DISPLAY_NAME_MAPPINGS
+32 -4
View File
@@ -17,8 +17,10 @@ readme = "README.md"
license = { text = "Apache Software License 2.0" }
requires-python = ">=3.10,<3.12"
classifiers = []
dependencies = []
dependencies = [
"openassetio>=1.0.0",
"openassetio-mediacreation>=1.0.0a12"
]
[project.optional-dependencies]
dev = [
@@ -28,6 +30,7 @@ dev = [
"pre-commit", # runs linting on commit
"pytest", # testing
"ruff", # linting
"openassetio-manager-bal" # mock manager for testing
]
[project.urls]
@@ -77,12 +80,37 @@ target-version = "py311"
# Add rules to ban exec/eval
[tool.ruff.lint]
select = [
# See all rules here: https://docs.astral.sh/ruff/rules
"S102", # exec-builtin
"S307", # eval-used
"W293",
"F", # The "F" series in Ruff stands for "Pyflakes" rules, which catch various Python syntax errors and undefined names.
# See all rules here: https://docs.astral.sh/ruff/rules/#pyflakes-f
# The "F" series in Ruff stands for "Pyflakes" rules, which catch
# various Python syntax errors and undefined names.
"F",
# pydocstyle
"D",
# pycodestyle
"E", "W",
# pylint
"PL"
]
ignore = [
# "One-line docstring should fit on one line" - i.e. expects
# """summary""", not """\nsummary\n""", which is inconsistent with
# other OpenAssetIO projects.
"D200",
# "1 blank line required between summary line and description" -
# triggers if the summary spans more than one line.
"D205"
]
[tool.ruff.lint.flake8-quotes]
inline-quotes = "double"
[tool.ruff.lint.pydocstyle]
convention = "pep257"
[tool.ruff.lint.pycodestyle]
max-doc-length = 72
max-line-length = 99
+235 -101
View File
@@ -1,131 +1,265 @@
# openassetio-comfyui
# Copyright (c) 2025 The Foundry Visionmongers Ltd
# SPDX-License-Identifier: Apache-2.0
"""
OpenAssetIO nodes for ComfyUI.
from inspect import cleandoc
Also contains a singleton class representing the OpenAssetIO host
application.
"""
import hashlib
import logging
import torch
import numpy as np
from PIL import Image, ImageOps, ImageSequence
from openassetio import EntityReference
from openassetio.utils import FileUrlPathConverter
from openassetio.log import LoggerInterface
from openassetio.access import ResolveAccess
from openassetio.hostApi import ManagerFactory, HostInterface
from openassetio.pluginSystem import (
HybridPluginSystemManagerImplementationFactory,
PythonPluginSystemManagerImplementationFactory,
CppPluginSystemManagerImplementationFactory,
)
from openassetio_mediacreation.traits.content import LocatableContentTrait
import node_helpers
class Example:
class _OpenAssetIOHost:
"""
A example node
Singleton class representing the OpenAssetIO host.
Class methods
-------------
INPUT_TYPES (dict):
Tell the main program input parameters of nodes.
IS_CHANGED:
optional method to control when the node is re executed.
Instantiates and exposes a manager and context, and provides common
utility methods.
Attributes
----------
RETURN_TYPES (`tuple`):
The type of each element in the output tulple.
RETURN_NAMES (`tuple`):
Optional: The name of each output in the output tulple.
FUNCTION (`str`):
The name of the entry-point method. For example, if `FUNCTION = "execute"` then it will run Example().execute()
OUTPUT_NODE ([`bool`]):
If this node is an output node that outputs a result/image from the graph. The SaveImage node is an example.
The backend iterates on these output nodes and tries to execute all their parents if their parent graph is properly connected.
Assumed to be False if not present.
CATEGORY (`str`):
The category the node should appear in the UI.
execute(s) -> tuple || None:
The entry point method. The name of this method must be the same as the value of property `FUNCTION`.
For example, if `FUNCTION = "execute"` then this method's name must be `execute`, if `FUNCTION = "foo"` then it must be `foo`.
The OpenAssetIO manager must be configured via a config file,
located via the OPENASSETIO_DEFAULT_CONFIG environment variable. See
the OpenAssetIO documentation for more details.
"""
def __init__(self):
pass
_instance = None
@classmethod
def INPUT_TYPES(s):
def instance(cls) -> "_OpenAssetIOHost":
"""
Return a dictionary which contains config for all input fields.
Some types (string): "MODEL", "VAE", "CLIP", "CONDITIONING", "LATENT", "IMAGE", "INT", "STRING", "FLOAT".
Input types "INT", "STRING" or "FLOAT" are special values for fields on the node.
The type can be a list for selection.
Get or create the singleton instance of the OpenAssetIO host.
"""
if cls._instance is None:
cls._instance = cls()
return cls._instance
Returns: `dict`:
- Key input_fields_group (`string`): Can be either required, hidden or optional. A node class must have property `required`
- Value input_fields (`dict`): Contains input fields config:
* Key field_name (`string`): Name of a entry-point method's argument
* Value field_config (`tuple`):
+ First value is a string indicate the type of field or a list for selection.
+ Secound value is a config for type "INT", "STRING" or "FLOAT".
class _HostInterface(HostInterface):
def displayName(self) -> str:
return "ComfyUI"
def identifier(self) -> str:
return "org.foundry.comfyui"
def info(self) -> dict:
return super().info()
class _LoggerInterface(LoggerInterface):
def __init__(self):
super().__init__()
self.__logger = logging.getLogger("openassetio-comfyui")
def log(self, severity, message) -> None:
if severity == LoggerInterface.Severity.kDebug:
self.__logger.debug(message)
elif severity == LoggerInterface.Severity.kInfo:
self.__logger.info(message)
elif severity == LoggerInterface.Severity.kWarning:
self.__logger.warning(message)
elif severity == LoggerInterface.Severity.kError:
self.__logger.error(message)
elif severity == LoggerInterface.Severity.kCritical:
self.__logger.critical(message)
else:
self.__logger.log(logging.NOTSET, message)
def __init__(self):
"""
Load and initialise an OpenAssetIO manager plugin.
"""
self.__logger = self._LoggerInterface()
# Initialise plugin system, then find and load a manager.
self.manager = ManagerFactory.defaultManagerForInterface(
self._HostInterface(),
HybridPluginSystemManagerImplementationFactory(
# Prefer C++ over Python plugins/methods.
[
CppPluginSystemManagerImplementationFactory(self.__logger),
PythonPluginSystemManagerImplementationFactory(self.__logger),
],
self.__logger,
),
self.__logger,
)
if self.manager is None:
raise RuntimeError(
"Could not create an OpenAssetIO manager instance. Ensure that your OpenAssetIO"
" configuration is correct and that the environment variable"
" OPENASSETIO_DEFAULT_CONFIG is set to a valid configuration file."
)
self.__context = self.manager.createContext()
self.__file_url_path_converter = FileUrlPathConverter()
def resolve_to_path(self, entity_reference: str) -> str:
"""
Resolve an OpenAssetIO entity reference to a local file path.
OpenAssetIO will raise exceptions if the entity reference is
invalid, if there is a problem resolving the entity, or if the
given entity location is not a `file://` URL.
@throw RuntimeError if the entity reference resolves
successfully, but the entity has no location.
"""
entity_reference = self.manager.createEntityReference(entity_reference)
traits_data = self.manager.resolve(
entity_reference, {LocatableContentTrait.kId}, ResolveAccess.kRead, self.__context
)
locatable_content_trait = LocatableContentTrait(traits_data)
url = locatable_content_trait.getLocation()
if url is None:
raise RuntimeError(
f"Failed to resolve entity reference '{entity_reference}' to a location"
)
return self.__file_url_path_converter.pathFromUrl(url)
class ResolveImage:
"""
Node to resolve an image from an OpenAssetIO entity reference.
The non-OpenAssetIO logic is largely duplicated from the built-in
ComfyUI LoadImage node (at least as of 4f5812b9).
"""
# Tooltip to display when hovering over the node.
DESCRIPTION = "Resolve images from an asset manager."
# Menu category.
CATEGORY = "image"
# Function to call when node is executed.
FUNCTION = "resolve_image"
# Node outputs.
RETURN_TYPES = ("IMAGE", "MASK")
@classmethod
def INPUT_TYPES(cls) -> dict:
"""
Input sockets and widgets.
"""
return {
"required": {
"image": ("Image", {"tooltip": "This is an image"}),
"int_field": (
"INT",
{
"default": 0,
"min": 0, # Minimum value
"max": 4096, # Maximum value
"step": 64, # Slider's step
"display": "number", # Cosmetic only: display as "number" or "slider"
},
),
"float_field": (
"FLOAT",
{
"default": 1.0,
"min": 0.0,
"max": 10.0,
"step": 0.01,
"round": 0.001, # The value represeting the precision to round to, will be set to the step value by default. Can be set to False to disable rounding.
"display": "number",
},
),
"print_to_screen": (["enable", "disable"],),
"string_field": (
"entity_reference": (
"STRING",
{
"multiline": False, # True if you want the field to look like the one on the ClipTextEncode node
"default": "Hello World!",
"multiline": False,
"default": "",
"placeholder": "Entity reference...",
"tooltip": "The entity to resolve",
},
),
},
)
}
}
RETURN_TYPES = ("IMAGE",)
# RETURN_NAMES = ("image_output_name",)
DESCRIPTION = cleandoc(__doc__)
FUNCTION = "test"
@classmethod
def IS_CHANGED(cls, entity_reference: str) -> str:
"""
Resolve the entity reference to a file path, then return a hash
of the file contents.
# OUTPUT_NODE = False
# OUTPUT_TOOLTIPS = ("",) # Tooltips for the output node
This allows ComfyUI to determine if the node needs to be
re-executed.
CATEGORY = "Example"
Non-OpenAssetIO logic largely duplicated from the built-in
ComfyUI LoadImage node (at least as of 4f5812b9).
"""
image_path = _OpenAssetIOHost.instance().resolve_to_path(entity_reference)
m = hashlib.sha256()
with open(image_path, "rb") as f:
m.update(f.read())
return m.digest().hex()
def test(self, image, string_field, int_field, float_field, print_to_screen):
if print_to_screen == "enable":
print(f"""Your input contains:
string_field aka input text: {string_field}
int_field: {int_field}
float_field: {float_field}
""")
# do some processing on the image, in this example I just invert it
image = 1.0 - image
return (image,)
@classmethod
def VALIDATE_INPUTS(cls, entity_reference: str) -> bool:
"""
Validate that the input entity reference is valid syntax for the
current manager.
"""
return _OpenAssetIOHost.instance().manager.isEntityReferenceString(entity_reference)
"""
The node will always be re executed if any of the inputs change but
this method can be used to force the node to execute again even when the inputs don't change.
You can make this node return a number or a string. This value will be compared to the one returned the last time the node was
executed, if it is different the node will be executed again.
This method is used in the core repo for the LoadImage node where they return the image hash as a string, if the image hash
changes between executions the LoadImage node is executed again.
"""
# @classmethod
# def IS_CHANGED(s, image, string_field, int_field, float_field, print_to_screen):
# return ""
def resolve_image(self, entity_reference: str) -> tuple[torch.Tensor, torch.Tensor]:
"""
Resolve an OpenAssetIO entity reference to a file path, then
load the image(s) at that path and return as tensors.
Non-OpenAssetIO logic largely duplicated from the built-in
ComfyUI LoadImage node (at least as of 4f5812b9).
"""
image_path = _OpenAssetIOHost.instance().resolve_to_path(entity_reference)
sequence = node_helpers.pillow(Image.open, image_path)
output_images = []
output_masks = []
w, h = None, None
excluded_formats = ["MPO"]
for frame in ImageSequence.Iterator(sequence):
frame = node_helpers.pillow(ImageOps.exif_transpose, frame) # noqa: PLW2901 - frame overwrite
if frame.mode == "I":
frame = frame.point(lambda px: px * (1 / 255)) # noqa: PLW2901 - frame overwrite
image = frame.convert("RGB")
if len(output_images) == 0:
w = image.size[0]
h = image.size[1]
if image.size[0] != w or image.size[1] != h:
continue
image = np.array(image).astype(np.float32) / 255.0
image = torch.from_numpy(image)[None,]
if "A" in frame.getbands():
mask = np.array(frame.getchannel("A")).astype(np.float32) / 255.0
mask = 1.0 - torch.from_numpy(mask)
elif frame.mode == "P" and "transparency" in frame.info:
mask = np.array(frame.convert("RGBA").getchannel("A")).astype(np.float32) / 255.0
mask = 1.0 - torch.from_numpy(mask)
else:
mask = torch.zeros((64, 64), dtype=torch.float32, device="cpu")
output_images.append(image)
output_masks.append(mask.unsqueeze(0))
if len(output_images) > 1 and sequence.format not in excluded_formats:
output_image = torch.cat(output_images, dim=0)
output_mask = torch.cat(output_masks, dim=0)
else:
output_image = output_images[0]
output_mask = output_masks[0]
return output_image, output_mask
# A dictionary that contains all nodes you want to export with their names
# NOTE: names should be globally unique
NODE_CLASS_MAPPINGS = {"Example": Example}
# Plugin registration: node classes.
NODE_CLASS_MAPPINGS = {
"OpenAssetIOResolveImage": ResolveImage,
}
# A dictionary that contains the friendly/humanly readable titles for the nodes
NODE_DISPLAY_NAME_MAPPINGS = {"Example": "Example Node"}
# Plugin registration: node names.
NODE_DISPLAY_NAME_MAPPINGS = {
"OpenAssetIOResolveImage": "OpenAssetIO Resolve Image",
}
+3 -2
View File
@@ -1,5 +1,6 @@
# openassetio-comfyui
# Copyright (c) 2025 The Foundry Visionmongers Ltd
# SPDX-License-Identifier: Apache-2.0
"""Unit test package for openassetio-comfyui"""
"""
Unit test package for openassetio-comfyui.
"""
-10
View File
@@ -1,10 +0,0 @@
# openassetio-comfyui
# Copyright (c) 2025 The Foundry Visionmongers Ltd
# SPDX-License-Identifier: Apache-2.0
import os
import sys
# Add the project root directory to Python path
# This allows the tests to import the project
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
-8
View File
@@ -1,8 +0,0 @@
# openassetio-comfyui
# Copyright (c) 2025 The Foundry Visionmongers Ltd
# SPDX-License-Identifier: Apache-2.0
[pytest]
testpaths = . # Run tests in the current directory
python_files = test_*.py # Run tests in files that start with "test_"
norecursedirs = .. # Don't run tests in the parent directory
+27
View File
@@ -0,0 +1,27 @@
{
"entities": {
"image_file": {
"versions": [
{
"traits": {
"openassetio-mediacreation:usage.Entity": {},
"openassetio-mediacreation:twoDimensional.Image": {},
"openassetio-mediacreation:content.LocatableContent": {
"location": "file://${test_tmp_dir}/cat.png"
}
}
}
]
},
"no_file": {
"versions": [
{
"traits": {
"openassetio-mediacreation:usage.Entity": {},
"openassetio-mediacreation:twoDimensional.Image": {}
}
}
]
}
}
}
+10
View File
@@ -0,0 +1,10 @@
# openassetio-comfyui
# SPDX-License-Identifier: Apache-2.0
# Copyright 2025 The Foundry Visionmongers Ltd
[manager]
identifier = "org.openassetio.examples.manager.bal"
[manager.settings]
library_path = "${config_dir}/bal_db.json"
simulated_query_latency_ms = 0
+205 -14
View File
@@ -1,27 +1,218 @@
#!/usr/bin/env python
# openassetio-comfyui
# Copyright (c) 2025 The Foundry Visionmongers Ltd
# SPDX-License-Identifier: Apache-2.0
"""
Tests for `openassetio-comfyui` package.
"""
"""Tests for `openassetio-comfyui` package."""
import contextlib
import hashlib
# D101: "Missing docstring in public class"
# D102: "Missing docstring in public method"
# ruff: noqa: D101,D102
import inspect
import pathlib
import numpy as np
import pytest
from src.openassetio_comfyui.nodes import Example
import torch
from PIL import Image
from openassetio_comfyui.nodes import ResolveImage, _OpenAssetIOHost
class Test_ResolveImage_init:
"""
Test that the node can be instantiated.
"""
def test_has_correct_type(self, resolve_image_node):
assert isinstance(resolve_image_node, ResolveImage)
class Test_ResolveImage_constants:
"""
Test the node's metadata.
"""
def test_has_expected_values(self):
assert ResolveImage.DESCRIPTION == "Resolve images from an asset manager."
assert ResolveImage.CATEGORY == "image"
assert ResolveImage.FUNCTION == "resolve_image"
assert ResolveImage.RETURN_TYPES == ("IMAGE", "MASK")
def test_function_matches(self):
assert inspect.isfunction(getattr(ResolveImage, ResolveImage.FUNCTION))
class Test_ResolveImage_INPUT_TYPES:
def test_has_expected_structure(self):
input_types = ResolveImage.INPUT_TYPES()
assert input_types == {
"required": {
"entity_reference": (
"STRING",
{
"multiline": False,
"placeholder": "Entity reference...",
"default": "",
"tooltip": "The entity to resolve",
},
)
}
}
class Test_ResolveImage_VALIDATE_INPUTS:
def test_when_is_a_reference_then_returns_true(self):
valid = ResolveImage.VALIDATE_INPUTS("bal:///")
assert valid
def test_when_not_a_reference_then_returns_false(self):
valid = ResolveImage.VALIDATE_INPUTS("notbal:///")
assert not valid
def test_when_no_manager_found_then_raises(self, assert_raises_missing_manager):
with assert_raises_missing_manager():
ResolveImage.VALIDATE_INPUTS("bal:///")
class Test_ResolveImage_IS_CHANGED:
def test_when_reference_resolves_to_file_then_hash_matches_file(self, image_file_hash):
actual_file_hash = ResolveImage.IS_CHANGED("bal:///image_file")
assert actual_file_hash == image_file_hash
def test_when_reference_has_no_path_then_raises(self, image_file_hash):
expected_error_message = (
"Failed to resolve entity reference 'bal:///no_file' to a location"
)
with pytest.raises(RuntimeError, match=expected_error_message):
ResolveImage.IS_CHANGED("bal:///no_file")
def test_when_no_manager_found_then_raises(self, assert_raises_missing_manager):
with assert_raises_missing_manager():
ResolveImage.IS_CHANGED("bal:///image_file")
class Test_ResolveImage_resolve_image:
def test_when_reference_resolves_to_file_then_returns_image(
self, resolve_image_node, image_file_path
):
images, masks = resolve_image_node.resolve_image("bal:///image_file")
assert isinstance(images, torch.Tensor)
assert images.shape == (1, 2, 2, 3)
assert isinstance(masks, torch.Tensor)
assert masks.shape == (1, 2, 2)
image = images[0]
mask = masks[0]
# Assert channels at each pixel.
assert torch.equal(image[0, 0], torch.tensor([0, 0, 0]))
assert torch.equal(image[0, 1], torch.tensor([1, 1, 1]))
assert torch.equal(image[1, 0], torch.tensor([0, 0, 0]))
assert torch.equal(image[1, 1], torch.tensor([1, 1, 1]))
# Note: mask is `1 - alpha`.
assert mask[0, 0].item() == 0
assert mask[0, 1].item() == 1
assert mask[1, 0].item() == 1
assert mask[1, 1].item() == 0
def test_when_no_manager_found_then_raises(
self, resolve_image_node, assert_raises_missing_manager
):
with assert_raises_missing_manager():
resolve_image_node.resolve_image("bal:///image_file")
@pytest.fixture
def example_node():
"""Fixture to create an Example node instance."""
return Example()
def assert_raises_missing_manager(monkeypatch):
"""
Fixture to assert that a RuntimeError is raised when no manager can
be created.
Since a lazily-created singleton is used to manage the OpenAssetIO
host, this assertion must be tested for every function that uses
that singleton.
"""
@contextlib.contextmanager
def assert_ctx():
monkeypatch.delenv("OPENASSETIO_DEFAULT_CONFIG", raising=False)
expected_error_msg = (
"Could not create an OpenAssetIO manager instance. Ensure that your OpenAssetIO"
" configuration is correct and that the environment variable"
" OPENASSETIO_DEFAULT_CONFIG is set to a valid configuration file."
)
with pytest.raises(RuntimeError, match=expected_error_msg):
yield
return assert_ctx
def test_example_node_initialization(example_node):
"""Test that the node can be instantiated."""
assert isinstance(example_node, Example)
@pytest.fixture
def image_file_hash(image_file_path):
"""
Fixture to provide the hash of the temporary image file.
"""
hasher = hashlib.sha256()
hasher.update(image_file_path.read_bytes())
return hasher.hexdigest()
def test_return_types():
"""Test the node's metadata."""
assert Example.RETURN_TYPES == ("IMAGE",)
assert Example.FUNCTION == "test"
assert Example.CATEGORY == "Example"
@pytest.fixture
def image_file_path(monkeypatch, tmp_path):
"""
Fixture to provide a temporary image file.
"""
# See bal_db.json - the resolved path will interpolate this env var.
monkeypatch.setenv("test_tmp_dir", str(tmp_path))
# See bal_db.json - the resolved path for bal:///cat
file_path = pathlib.Path(tmp_path) / "cat.png"
content = np.array(
[[[0, 0, 0, 255], [255, 255, 255, 0]], [[0, 0, 0, 0], [255, 255, 255, 255]]]
).astype(np.uint8)
img = Image.fromarray(content)
img.save(file_path)
return file_path
@pytest.fixture
def resolve_image_node():
"""
Fixture to create an ResolveImage node instance.
"""
return ResolveImage()
@pytest.fixture(autouse=True)
def openassetio_config_env_var(monkeypatch, resources_dir):
"""
Fixture to set the OPENASSETIO_DEFAULT_CONFIG environment variable
to point to a test config file.
"""
test_config_path = resources_dir / "openassetio_config.toml"
monkeypatch.setenv("OPENASSETIO_DEFAULT_CONFIG", str(test_config_path))
@pytest.fixture(autouse=True)
def reset_singleton_host(monkeypatch):
"""
Fixture to reset the singleton instance before each test.
"""
_OpenAssetIOHost._instance = None
@pytest.fixture(scope="module")
def resources_dir():
"""
Fixture to provide the path to the resources directory.
"""
return pathlib.Path(__file__).parent / "resources"