From 3d30afa73e50ead7ba86990f553a4c789c4b9a1d Mon Sep 17 00:00:00 2001 From: David Feltell Date: Fri, 5 Sep 2025 10:47:36 +0100 Subject: [PATCH 01/10] [Docs] Add NOTICE Signed-off-by: David Feltell --- NOTICE | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 NOTICE diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..cf752c8 --- /dev/null +++ b/NOTICE @@ -0,0 +1,5 @@ +openassetio-comfyui +Copyright (c) 2025 The Foundry Visionmongers Ltd. + +This product includes software developed at +The Foundry Visionmongers Ltd. From 243285b07c94bb0ac0d03e81408906f0d776c2f1 Mon Sep 17 00:00:00 2001 From: David Feltell Date: Mon, 1 Sep 2025 12:44:02 +0100 Subject: [PATCH 02/10] [Core] Initial cookiecutter project See https://github.com/Comfy-Org/cookiecutter-comfy-extension Signed-off-by: David Feltell --- .editorconfig | 24 ++++++ .github/ISSUE_TEMPLATE.md | 15 ++++ .github/workflows/build-pipeline.yml | 33 ++++++++ .github/workflows/publish_node.yml | 21 +++++ .github/workflows/validate.yml | 13 +++ .gitignore | 114 ++++++++++++++++++++++++++ .pre-commit-config.yaml | 10 +++ MANIFEST.in | 9 ++ README.md | 67 +++++++++++++++ __init__.py | 13 +++ pyproject.toml | 84 +++++++++++++++++++ src/openassetio_comfyui/__init__.py | 13 +++ src/openassetio_comfyui/nodes.py | 118 +++++++++++++++++++++++++++ tests/__init__.py | 1 + tests/conftest.py | 6 ++ tests/pytest.ini | 4 + tests/test_openassetio_comfyui.py | 21 +++++ 17 files changed, 566 insertions(+) create mode 100644 .editorconfig create mode 100644 .github/ISSUE_TEMPLATE.md create mode 100644 .github/workflows/build-pipeline.yml create mode 100644 .github/workflows/publish_node.yml create mode 100644 .github/workflows/validate.yml create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 MANIFEST.in create mode 100644 README.md create mode 100644 __init__.py create mode 100644 pyproject.toml create mode 100644 src/openassetio_comfyui/__init__.py create mode 100644 src/openassetio_comfyui/nodes.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/pytest.ini create mode 100644 tests/test_openassetio_comfyui.py diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..ab0bba9 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,24 @@ +# http://editorconfig.org + +root = true + +[*] +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true +insert_final_newline = true +charset = utf-8 +end_of_line = lf +max_line_length = 140 +# For docstrings and comments. +ij_visual_guides = 72 + +[*.bat] +indent_style = tab +end_of_line = crlf + +[LICENSE] +insert_final_newline = false + +[Makefile] +indent_style = tab diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..243396f --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,15 @@ +* openassetio-comfyui version: +* Python version: +* Operating System: + +### Description + +Describe what you were trying to get done. +Tell us what happened, what went wrong, and what you expected to happen. + +### What I Did + +``` +Paste the command(s) you ran and the output. +If there was a crash, please include the traceback here. +``` diff --git a/.github/workflows/build-pipeline.yml b/.github/workflows/build-pipeline.yml new file mode 100644 index 0000000..43a5a19 --- /dev/null +++ b/.github/workflows/build-pipeline.yml @@ -0,0 +1,33 @@ +name: CI build + +on: + pull_request: + branches: + - master + - main +jobs: + build: + runs-on: ${{ matrix.os }} + env: + PYTHONIOENCODING: "utf8" + strategy: + matrix: + os: [ubuntu-latest] + python-version: ["3.12"] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install .[dev] + - name: Run Linting + run: | + ruff check . + - name: Run Tests + run: | + pytest tests/ diff --git a/.github/workflows/publish_node.yml b/.github/workflows/publish_node.yml new file mode 100644 index 0000000..0118542 --- /dev/null +++ b/.github/workflows/publish_node.yml @@ -0,0 +1,21 @@ +name: 📦 Publish to Comfy registry +on: + workflow_dispatch: + push: + tags: + - '*' + +permissions: + issues: write + +jobs: + publish-node: + name: Publish Custom Node to registry + runs-on: ubuntu-latest + steps: + - name: ♻️ Check out code + uses: actions/checkout@v4 + - name: 📦 Publish Custom Node + uses: Comfy-Org/publish-node-action@main + with: + personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }} diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..8d3b5d4 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,13 @@ +name: Validate backwards compatibility + +on: + pull_request: + branches: + - master + - main + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: comfy-org/node-diff@main diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0fedfe7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,114 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# OSX useful to ignore +*.DS_Store +.AppleDouble +.LSOverride + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +venv/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log + +# Sphinx documentation +docs/_build/ + +# IntelliJ Idea +.idea +*.iml +*.ipr +*.iws + +# PyBuilder +target/ + +# Cookiecutter +output/ +python_boilerplate/ +cookiecutter-pypackage-env/ + +# vscode settings +.history/ +*.code-workspace + +# Frontend extension +node_modules/ +.env +.env.local +.env.development.local +.env.test.local +.env.production.local +npm-debug.log* +yarn-debug.log* +yarn-error.log* +node.zip +.vscode/ +.claude/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..2539924 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,10 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.4.9 + hooks: + # Run the linter. + - id: ruff + args: [ --fix ] + # Run the formatter. + - id: ruff-format diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..6514960 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,9 @@ +include LICENSE +include README.md + +recursive-exclude * __pycache__ +recursive-exclude * *.py[co] + +recursive-include docs *.rst conf.py Makefile make.bat *.jpg *.png *.gif + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..fb65fb2 --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# OpenAssetIO-ComfyUI + +OpenAssetIO ingress and egress + +> [!NOTE] +> This projected was created with a [cookiecutter](https://github.com/Comfy-Org/cookiecutter-comfy-extension) template. It helps you start writing custom nodes without worrying about the Python setup. + +## Quickstart + +1. Install [ComfyUI](https://docs.comfy.org/get_started). +1. Install [ComfyUI-Manager](https://github.com/ltdrdata/ComfyUI-Manager) +1. Look up this extension in ComfyUI-Manager. If you are installing manually, clone this repository under `ComfyUI/custom_nodes`. +1. Restart ComfyUI. + +# Features + +- A list of features + +## Develop + +To install the dev dependencies and pre-commit (will run the ruff hook), do: + +```bash +cd openassetio-comfyui +pip install -e .[dev] +pre-commit install +``` + +The `-e` flag above will result in a "live" install, in the sense that any changes you make to your node extension will automatically be picked up the next time you run ComfyUI. + +## Publish to Github + +Install Github Desktop or follow these [instructions](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent) for ssh. + +1. Create a Github repository that matches the directory name. +2. Push the files to Git +``` +git add . +git commit -m "project scaffolding" +git push +``` + +## Writing custom nodes + +An example custom node is located in [node.py](src/openassetio-comfyui/nodes.py). To learn more, read the [docs](https://docs.comfy.org/essentials/custom_node_overview). + + +## Tests + +This repo contains unit tests written in Pytest in the `tests/` directory. It is recommended to unit test your custom node. + +- [build-pipeline.yml](.github/workflows/build-pipeline.yml) will run pytest and linter on any open PRs +- [validate.yml](.github/workflows/validate.yml) will run [node-diff](https://github.com/Comfy-Org/node-diff) to check for breaking changes + +## Publishing to Registry + +If you wish to share this custom node with others in the community, you can publish it to the registry. We've already auto-populated some fields in `pyproject.toml` under `tool.comfy`, but please double-check that they are correct. + +You need to make an account on https://registry.comfy.org and create an API key token. + +- [ ] Go to the [registry](https://registry.comfy.org). Login and create a publisher id (everything after the `@` sign on your registry profile). +- [ ] Add the publisher id into the pyproject.toml file. +- [ ] Create an api key on the Registry for publishing from Github. [Instructions](https://docs.comfy.org/registry/publishing#create-an-api-key-for-publishing). +- [ ] Add it to your Github Repository Secrets as `REGISTRY_ACCESS_TOKEN`. + +A Github action will run on every git push. You can also run the Github action manually. Full instructions [here](https://docs.comfy.org/registry/publishing). Join our [discord](https://discord.com/invite/comfyorg) if you have any questions! + diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..7b76773 --- /dev/null +++ b/__init__.py @@ -0,0 +1,13 @@ +"""Top-level package for openassetio-comfyui.""" + +__all__ = [ + "NODE_CLASS_MAPPINGS", + "NODE_DISPLAY_NAME_MAPPINGS", +] + +__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 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e8341c5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,84 @@ +[build-system] +requires = ["setuptools>=70.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "openassetio-comfyui" +version = "1.0.0" +description = "ComfyUI custom nodes adding asset resolution and publishing via OpenAssetIO" +authors = [ + { name = "Contributors to the OpenAssetIO project", email = "openassetio-discussion@lists.aswf.io" } +] +readme = "README.md" +license = { text = "Apache Software License 2.0" } +requires-python = ">=3.10,<3.12" +classifiers = [] +dependencies = [] + + +[project.optional-dependencies] +dev = [ + "bump-my-version", + "coverage", # testing + "mypy", # linting + "pre-commit", # runs linting on commit + "pytest", # testing + "ruff", # linting +] + +[project.urls] +Repository = "https://github.com/OpenAssetIO/OpenAssetIO-ComfyUI" +BugTracker = "https://github.com/OpenAssetIO/OpenAssetIO-ComfyUI/issues" +Documentation = "https://github.com/OpenAssetIO/OpenAssetIO-ComfyUI/wiki" + + +[tool.comfy] +PublisherId = "OpenAssetIO" +DisplayName = "OpenAssetIO" +Icon = "" +Tags = [] +Repository = "https://github.com/OpenAssetIO/OpenAssetIO-ComfyUI" + +includes = [] + +[tool.setuptools.package-data] +"*" = ["*.*"] + +[tool.pytest.ini_options] +minversion = "8.0" +testpaths = [ + "tests", +] + +[tool.mypy] +files = "." + +# Use strict defaults +strict = true +warn_unreachable = true +warn_no_return = true + +[[tool.mypy.overrides]] +# Don't require test functions to include types +module = "tests.*" +allow_untyped_defs = true +disable_error_code = "attr-defined" + +[tool.ruff] +# extend-exclude = ["static", "ci/templates"] +line-length = 140 +src = ["src", "tests"] +target-version = "py311" + +# Add rules to ban exec/eval +[tool.ruff.lint] +select = [ + "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 +] + +[tool.ruff.lint.flake8-quotes] +inline-quotes = "double" diff --git a/src/openassetio_comfyui/__init__.py b/src/openassetio_comfyui/__init__.py new file mode 100644 index 0000000..8b97f67 --- /dev/null +++ b/src/openassetio_comfyui/__init__.py @@ -0,0 +1,13 @@ +"""Top-level package for openassetio-comfyui.""" + +__all__ = [ + "NODE_CLASS_MAPPINGS", + "NODE_DISPLAY_NAME_MAPPINGS", +] + +__author__ = """Contributors to the OpenAssetIO project""" +__email__ = "openassetio-discussion@lists.aswf.io" +__version__ = "1.0.0" + +from .nodes import NODE_CLASS_MAPPINGS +from .nodes import NODE_DISPLAY_NAME_MAPPINGS diff --git a/src/openassetio_comfyui/nodes.py b/src/openassetio_comfyui/nodes.py new file mode 100644 index 0000000..fab5cff --- /dev/null +++ b/src/openassetio_comfyui/nodes.py @@ -0,0 +1,118 @@ +from inspect import cleandoc +class Example: + """ + A example node + + 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. + + 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`. + """ + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(s): + """ + 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. + + 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". + """ + 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": ("STRING", { + "multiline": False, #True if you want the field to look like the one on the ClipTextEncode node + "default": "Hello World!" + }), + }, + } + + RETURN_TYPES = ("IMAGE",) + #RETURN_NAMES = ("image_output_name",) + DESCRIPTION = cleandoc(__doc__) + FUNCTION = "test" + + #OUTPUT_NODE = False + #OUTPUT_TOOLTIPS = ("",) # Tooltips for the output node + + CATEGORY = "Example" + + 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,) + + """ + 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 "" + + +# A dictionary that contains all nodes you want to export with their names +# NOTE: names should be globally unique +NODE_CLASS_MAPPINGS = { + "Example": Example +} + +# A dictionary that contains the friendly/humanly readable titles for the nodes +NODE_DISPLAY_NAME_MAPPINGS = { + "Example": "Example Node" +} diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..83b3d7a --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Unit test package for openassetio-comfyui.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..310609c --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,6 @@ +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__), '..'))) diff --git a/tests/pytest.ini b/tests/pytest.ini new file mode 100644 index 0000000..95c76f1 --- /dev/null +++ b/tests/pytest.ini @@ -0,0 +1,4 @@ +[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 diff --git a/tests/test_openassetio_comfyui.py b/tests/test_openassetio_comfyui.py new file mode 100644 index 0000000..0c895db --- /dev/null +++ b/tests/test_openassetio_comfyui.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python + +"""Tests for `openassetio-comfyui` package.""" + +import pytest +from src.openassetio_comfyui.nodes import Example + +@pytest.fixture +def example_node(): + """Fixture to create an Example node instance.""" + return Example() + +def test_example_node_initialization(example_node): + """Test that the node can be instantiated.""" + assert isinstance(example_node, Example) + +def test_return_types(): + """Test the node's metadata.""" + assert Example.RETURN_TYPES == ("IMAGE",) + assert Example.FUNCTION == "test" + assert Example.CATEGORY == "Example" From 1f51166b29db5957f90ad3526c955a73957d687b Mon Sep 17 00:00:00 2001 From: David Feltell Date: Tue, 2 Sep 2025 16:23:26 +0100 Subject: [PATCH 03/10] [Docs] Add SPDX license identifiers Ensure all files that can support comments have an SPDX license identifier. Signed-off-by: David Feltell --- .editorconfig | 4 ++++ .github/workflows/build-pipeline.yml | 4 ++++ .github/workflows/publish_node.yml | 4 ++++ .github/workflows/validate.yml | 4 ++++ .gitignore | 4 ++++ .pre-commit-config.yaml | 4 ++++ MANIFEST.in | 4 ++++ __init__.py | 4 ++++ pyproject.toml | 4 ++++ src/openassetio_comfyui/__init__.py | 4 ++++ src/openassetio_comfyui/nodes.py | 4 ++++ tests/__init__.py | 6 +++++- tests/conftest.py | 4 ++++ tests/pytest.ini | 4 ++++ tests/test_openassetio_comfyui.py | 3 +++ 15 files changed, 60 insertions(+), 1 deletion(-) diff --git a/.editorconfig b/.editorconfig index ab0bba9..1b8f4df 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,3 +1,7 @@ +# openassetio-comfyui +# Copyright (c) 2025 The Foundry Visionmongers Ltd +# SPDX-License-Identifier: Apache-2.0 + # http://editorconfig.org root = true diff --git a/.github/workflows/build-pipeline.yml b/.github/workflows/build-pipeline.yml index 43a5a19..223678a 100644 --- a/.github/workflows/build-pipeline.yml +++ b/.github/workflows/build-pipeline.yml @@ -1,3 +1,7 @@ +# openassetio-comfyui +# Copyright (c) 2025 The Foundry Visionmongers Ltd +# SPDX-License-Identifier: Apache-2.0 + name: CI build on: diff --git a/.github/workflows/publish_node.yml b/.github/workflows/publish_node.yml index 0118542..8d9d80b 100644 --- a/.github/workflows/publish_node.yml +++ b/.github/workflows/publish_node.yml @@ -1,3 +1,7 @@ +# openassetio-comfyui +# Copyright (c) 2025 The Foundry Visionmongers Ltd +# SPDX-License-Identifier: Apache-2.0 + name: 📦 Publish to Comfy registry on: workflow_dispatch: diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 8d3b5d4..b273847 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -1,3 +1,7 @@ +# openassetio-comfyui +# Copyright (c) 2025 The Foundry Visionmongers Ltd +# SPDX-License-Identifier: Apache-2.0 + name: Validate backwards compatibility on: diff --git a/.gitignore b/.gitignore index 0fedfe7..035b179 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ +# openassetio-comfyui +# Copyright (c) 2025 The Foundry Visionmongers Ltd +# SPDX-License-Identifier: Apache-2.0 + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2539924..91a7860 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,3 +1,7 @@ +# openassetio-comfyui +# Copyright (c) 2025 The Foundry Visionmongers Ltd +# SPDX-License-Identifier: Apache-2.0 + repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. diff --git a/MANIFEST.in b/MANIFEST.in index 6514960..9f74509 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,7 @@ +# openassetio-comfyui +# Copyright (c) 2025 The Foundry Visionmongers Ltd +# SPDX-License-Identifier: Apache-2.0 + include LICENSE include README.md diff --git a/__init__.py b/__init__.py index 7b76773..d061182 100644 --- a/__init__.py +++ b/__init__.py @@ -1,3 +1,7 @@ +# openassetio-comfyui +# Copyright (c) 2025 The Foundry Visionmongers Ltd +# SPDX-License-Identifier: Apache-2.0 + """Top-level package for openassetio-comfyui.""" __all__ = [ diff --git a/pyproject.toml b/pyproject.toml index e8341c5..492f4bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,7 @@ +# openassetio-comfyui +# Copyright (c) 2025 The Foundry Visionmongers Ltd +# SPDX-License-Identifier: Apache-2.0 + [build-system] requires = ["setuptools>=70.0"] build-backend = "setuptools.build_meta" diff --git a/src/openassetio_comfyui/__init__.py b/src/openassetio_comfyui/__init__.py index 8b97f67..d759700 100644 --- a/src/openassetio_comfyui/__init__.py +++ b/src/openassetio_comfyui/__init__.py @@ -1,3 +1,7 @@ +# openassetio-comfyui +# Copyright (c) 2025 The Foundry Visionmongers Ltd +# SPDX-License-Identifier: Apache-2.0 + """Top-level package for openassetio-comfyui.""" __all__ = [ diff --git a/src/openassetio_comfyui/nodes.py b/src/openassetio_comfyui/nodes.py index fab5cff..c824df3 100644 --- a/src/openassetio_comfyui/nodes.py +++ b/src/openassetio_comfyui/nodes.py @@ -1,3 +1,7 @@ +# openassetio-comfyui +# Copyright (c) 2025 The Foundry Visionmongers Ltd +# SPDX-License-Identifier: Apache-2.0 + from inspect import cleandoc class Example: """ diff --git a/tests/__init__.py b/tests/__init__.py index 83b3d7a..80e43d3 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1,5 @@ -"""Unit test package for openassetio-comfyui.""" +# openassetio-comfyui +# Copyright (c) 2025 The Foundry Visionmongers Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""Unit test package for openassetio-comfyui""" diff --git a/tests/conftest.py b/tests/conftest.py index 310609c..378122e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,7 @@ +# openassetio-comfyui +# Copyright (c) 2025 The Foundry Visionmongers Ltd +# SPDX-License-Identifier: Apache-2.0 + import os import sys diff --git a/tests/pytest.ini b/tests/pytest.ini index 95c76f1..706fd53 100644 --- a/tests/pytest.ini +++ b/tests/pytest.ini @@ -1,3 +1,7 @@ +# 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_" diff --git a/tests/test_openassetio_comfyui.py b/tests/test_openassetio_comfyui.py index 0c895db..2d9373c 100644 --- a/tests/test_openassetio_comfyui.py +++ b/tests/test_openassetio_comfyui.py @@ -1,4 +1,7 @@ #!/usr/bin/env python +# openassetio-comfyui +# Copyright (c) 2025 The Foundry Visionmongers Ltd +# SPDX-License-Identifier: Apache-2.0 """Tests for `openassetio-comfyui` package.""" From 7b9843a86252aebd3d9463e0a22182b49f2c03fe Mon Sep 17 00:00:00 2001 From: David Feltell Date: Tue, 2 Sep 2025 18:10:02 +0100 Subject: [PATCH 04/10] [Lint] Set 99 char line length and bulk reformat Set up ruff to enforce a 99 char line limit, mirroring other OpenAssetIO projects. Configure yml with 2-space tabs, mirroring other OpenAssetIO projects (and the ComfyUI cookiecutter project's defaults). Bulk reformat using PyCharm configured to use ruff - i.e. Python files are reformatted using ruff, whilst other files are reformatted according to .editorconfig and/or PyCharm defaults. Signed-off-by: David Feltell --- .editorconfig | 5 +- .github/workflows/build-pipeline.yml | 4 +- README.md | 47 ++++++++++---- pyproject.toml | 2 +- src/openassetio_comfyui/nodes.py | 95 +++++++++++++++------------- tests/conftest.py | 2 +- tests/test_openassetio_comfyui.py | 3 + 7 files changed, 97 insertions(+), 61 deletions(-) diff --git a/.editorconfig b/.editorconfig index 1b8f4df..2f1d858 100644 --- a/.editorconfig +++ b/.editorconfig @@ -13,7 +13,7 @@ trim_trailing_whitespace = true insert_final_newline = true charset = utf-8 end_of_line = lf -max_line_length = 140 +max_line_length = 99 # For docstrings and comments. ij_visual_guides = 72 @@ -26,3 +26,6 @@ insert_final_newline = false [Makefile] indent_style = tab + +[{*.yml,*.yaml}] +indent_size = 2 diff --git a/.github/workflows/build-pipeline.yml b/.github/workflows/build-pipeline.yml index 223678a..32c49b9 100644 --- a/.github/workflows/build-pipeline.yml +++ b/.github/workflows/build-pipeline.yml @@ -16,8 +16,8 @@ jobs: PYTHONIOENCODING: "utf8" strategy: matrix: - os: [ubuntu-latest] - python-version: ["3.12"] + os: [ ubuntu-latest ] + python-version: [ "3.12" ] steps: - uses: actions/checkout@v4 diff --git a/README.md b/README.md index fb65fb2..fdb1246 100644 --- a/README.md +++ b/README.md @@ -3,13 +3,17 @@ OpenAssetIO ingress and egress > [!NOTE] -> This projected was created with a [cookiecutter](https://github.com/Comfy-Org/cookiecutter-comfy-extension) template. It helps you start writing custom nodes without worrying about the Python setup. +> This projected was created with +> a [cookiecutter](https://github.com/Comfy-Org/cookiecutter-comfy-extension) template. It helps you +> start +> writing custom nodes without worrying about the Python setup. ## Quickstart 1. Install [ComfyUI](https://docs.comfy.org/get_started). 1. Install [ComfyUI-Manager](https://github.com/ltdrdata/ComfyUI-Manager) -1. Look up this extension in ComfyUI-Manager. If you are installing manually, clone this repository under `ComfyUI/custom_nodes`. +1. Look up this extension in ComfyUI-Manager. If you are installing manually, clone this repository + under `ComfyUI/custom_nodes`. 1. Restart ComfyUI. # Features @@ -26,14 +30,19 @@ pip install -e .[dev] pre-commit install ``` -The `-e` flag above will result in a "live" install, in the sense that any changes you make to your node extension will automatically be picked up the next time you run ComfyUI. +The `-e` flag above will result in a "live" install, in the sense that any changes you make to your +node extension will automatically be +picked up the next time you run ComfyUI. ## Publish to Github -Install Github Desktop or follow these [instructions](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent) for ssh. +Install Github Desktop or follow +these [instructions](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent) +for ssh. 1. Create a Github repository that matches the directory name. 2. Push the files to Git + ``` git add . git commit -m "project scaffolding" @@ -42,26 +51,38 @@ git push ## Writing custom nodes -An example custom node is located in [node.py](src/openassetio-comfyui/nodes.py). To learn more, read the [docs](https://docs.comfy.org/essentials/custom_node_overview). - +An example custom node is located in [node.py](src/openassetio-comfyui/nodes.py). To learn more, +read +the [docs](https://docs.comfy.org/essentials/custom_node_overview). ## Tests -This repo contains unit tests written in Pytest in the `tests/` directory. It is recommended to unit test your custom node. +This repo contains unit tests written in Pytest in the `tests/` directory. It is recommended to +unit test your custom node. -- [build-pipeline.yml](.github/workflows/build-pipeline.yml) will run pytest and linter on any open PRs -- [validate.yml](.github/workflows/validate.yml) will run [node-diff](https://github.com/Comfy-Org/node-diff) to check for breaking changes +- [build-pipeline.yml](.github/workflows/build-pipeline.yml) will run pytest and linter on any open + PRs +- [validate.yml](.github/workflows/validate.yml) will + run [node-diff](https://github.com/Comfy-Org/node-diff) to check for breaking changes ## Publishing to Registry -If you wish to share this custom node with others in the community, you can publish it to the registry. We've already auto-populated some fields in `pyproject.toml` under `tool.comfy`, but please double-check that they are correct. +If you wish to share this custom node with others in the community, you can publish it to the +registry. We've already auto-populated some +fields in `pyproject.toml` under `tool.comfy`, but please double-check that they are correct. You need to make an account on https://registry.comfy.org and create an API key token. -- [ ] Go to the [registry](https://registry.comfy.org). Login and create a publisher id (everything after the `@` sign on your registry profile). +- [ ] Go to the [registry](https://registry.comfy.org). Login and create a publisher id (everything + after the `@` sign on your registry + profile). - [ ] Add the publisher id into the pyproject.toml file. -- [ ] Create an api key on the Registry for publishing from Github. [Instructions](https://docs.comfy.org/registry/publishing#create-an-api-key-for-publishing). +- [ ] Create an api key on the Registry for publishing from + Github. [Instructions](https://docs.comfy.org/registry/publishing#create-an-api-key-for-publishing). - [ ] Add it to your Github Repository Secrets as `REGISTRY_ACCESS_TOKEN`. -A Github action will run on every git push. You can also run the Github action manually. Full instructions [here](https://docs.comfy.org/registry/publishing). Join our [discord](https://discord.com/invite/comfyorg) if you have any questions! +A Github action will run on every git push. You can also run the Github action manually. Full +instructions [here](https://docs.comfy.org/registry/publishing). Join +our [discord](https://discord.com/invite/comfyorg) if you have any +questions! diff --git a/pyproject.toml b/pyproject.toml index 492f4bd..a827261 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,7 +70,7 @@ disable_error_code = "attr-defined" [tool.ruff] # extend-exclude = ["static", "ci/templates"] -line-length = 140 +line-length = 99 src = ["src", "tests"] target-version = "py311" diff --git a/src/openassetio_comfyui/nodes.py b/src/openassetio_comfyui/nodes.py index c824df3..6720fe1 100644 --- a/src/openassetio_comfyui/nodes.py +++ b/src/openassetio_comfyui/nodes.py @@ -3,6 +3,8 @@ # SPDX-License-Identifier: Apache-2.0 from inspect import cleandoc + + class Example: """ A example node @@ -32,57 +34,68 @@ class Example: 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`. """ + def __init__(self): pass @classmethod def INPUT_TYPES(s): """ - 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. - - 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". + 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. + + 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". """ 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"}), + "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": ("STRING", { - "multiline": False, #True if you want the field to look like the one on the ClipTextEncode node - "default": "Hello World!" - }), + "string_field": ( + "STRING", + { + "multiline": False, # True if you want the field to look like the one on the ClipTextEncode node + "default": "Hello World!", + }, + ), }, } RETURN_TYPES = ("IMAGE",) - #RETURN_NAMES = ("image_output_name",) + # RETURN_NAMES = ("image_output_name",) DESCRIPTION = cleandoc(__doc__) FUNCTION = "test" - #OUTPUT_NODE = False - #OUTPUT_TOOLTIPS = ("",) # Tooltips for the output node + # OUTPUT_NODE = False + # OUTPUT_TOOLTIPS = ("",) # Tooltips for the output node CATEGORY = "Example" @@ -93,7 +106,7 @@ def test(self, image, string_field, int_field, float_field, print_to_screen): int_field: {int_field} float_field: {float_field} """) - #do some processing on the image, in this example I just invert it + # do some processing on the image, in this example I just invert it image = 1.0 - image return (image,) @@ -105,18 +118,14 @@ def test(self, image, string_field, int_field, float_field, print_to_screen): 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): + # @classmethod + # def IS_CHANGED(s, image, string_field, int_field, float_field, print_to_screen): # return "" # A dictionary that contains all nodes you want to export with their names # NOTE: names should be globally unique -NODE_CLASS_MAPPINGS = { - "Example": Example -} +NODE_CLASS_MAPPINGS = {"Example": Example} # A dictionary that contains the friendly/humanly readable titles for the nodes -NODE_DISPLAY_NAME_MAPPINGS = { - "Example": "Example Node" -} +NODE_DISPLAY_NAME_MAPPINGS = {"Example": "Example Node"} diff --git a/tests/conftest.py b/tests/conftest.py index 378122e..ed9c8f5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,4 +7,4 @@ # 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__), '..'))) +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) diff --git a/tests/test_openassetio_comfyui.py b/tests/test_openassetio_comfyui.py index 2d9373c..d89e69d 100644 --- a/tests/test_openassetio_comfyui.py +++ b/tests/test_openassetio_comfyui.py @@ -8,15 +8,18 @@ import pytest from src.openassetio_comfyui.nodes import Example + @pytest.fixture def example_node(): """Fixture to create an Example node instance.""" return Example() + def test_example_node_initialization(example_node): """Test that the node can be instantiated.""" assert isinstance(example_node, Example) + def test_return_types(): """Test the node's metadata.""" assert Example.RETURN_TYPES == ("IMAGE",) From 35639fdffc37a8dd7b5639d98ab8b46d803ca08b Mon Sep 17 00:00:00 2001 From: David Feltell Date: Mon, 1 Sep 2025 12:51:11 +0100 Subject: [PATCH 05/10] [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 --- .editorconfig | 3 + __init__.py | 18 +- pyproject.toml | 36 ++- src/openassetio_comfyui/nodes.py | 340 +++++++++++++++++------- tests/__init__.py | 5 +- tests/conftest.py | 10 - tests/pytest.ini | 8 - tests/resources/bal_db.json | 27 ++ tests/resources/openassetio_config.toml | 10 + tests/test_openassetio_comfyui.py | 219 ++++++++++++++- 10 files changed, 532 insertions(+), 144 deletions(-) delete mode 100644 tests/conftest.py delete mode 100644 tests/pytest.ini create mode 100644 tests/resources/bal_db.json create mode 100644 tests/resources/openassetio_config.toml diff --git a/.editorconfig b/.editorconfig index 2f1d858..fe4eb9e 100644 --- a/.editorconfig +++ b/.editorconfig @@ -29,3 +29,6 @@ indent_style = tab [{*.yml,*.yaml}] indent_size = 2 + +[*.json] +indent_size = 2 \ No newline at end of file diff --git a/__init__.py b/__init__.py index d061182..43745b6 100644 --- a/__init__.py +++ b/__init__.py @@ -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 @@ __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 diff --git a/pyproject.toml b/pyproject.toml index a827261..bb82c15 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 diff --git a/src/openassetio_comfyui/nodes.py b/src/openassetio_comfyui/nodes.py index 6720fe1..9ab92f7 100644 --- a/src/openassetio_comfyui/nodes.py +++ b/src/openassetio_comfyui/nodes.py @@ -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 -class Example: +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 _OpenAssetIOHost: """ - A example node - - 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. - - 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`. + Singleton class representing the OpenAssetIO host. + + Instantiates and exposes a manager and context, and provides common + utility methods. + + 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. """ + _instance = None + + @classmethod + def instance(cls) -> "_OpenAssetIOHost": + """ + Get or create the singleton instance of the OpenAssetIO host. + """ + if cls._instance is None: + cls._instance = cls() + return cls._instance + + 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): - pass + """ + 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(s): + def INPUT_TYPES(cls) -> dict: """ - 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. - - 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". + 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", +} diff --git a/tests/__init__.py b/tests/__init__.py index 80e43d3..5b381ee 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -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. +""" diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index ed9c8f5..0000000 --- a/tests/conftest.py +++ /dev/null @@ -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__), ".."))) diff --git a/tests/pytest.ini b/tests/pytest.ini deleted file mode 100644 index 706fd53..0000000 --- a/tests/pytest.ini +++ /dev/null @@ -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 diff --git a/tests/resources/bal_db.json b/tests/resources/bal_db.json new file mode 100644 index 0000000..eaa6fb8 --- /dev/null +++ b/tests/resources/bal_db.json @@ -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": {} + } + } + ] + } + } +} diff --git a/tests/resources/openassetio_config.toml b/tests/resources/openassetio_config.toml new file mode 100644 index 0000000..dc9d08c --- /dev/null +++ b/tests/resources/openassetio_config.toml @@ -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 diff --git a/tests/test_openassetio_comfyui.py b/tests/test_openassetio_comfyui.py index d89e69d..7cf0bf6 100644 --- a/tests/test_openassetio_comfyui.py +++ b/tests/test_openassetio_comfyui.py @@ -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 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 + + +@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() @pytest.fixture -def example_node(): - """Fixture to create an Example node instance.""" - return Example() +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)) -def test_example_node_initialization(example_node): - """Test that the node can be instantiated.""" - assert isinstance(example_node, Example) +@pytest.fixture(autouse=True) +def reset_singleton_host(monkeypatch): + """ + Fixture to reset the singleton instance before each test. + """ + _OpenAssetIOHost._instance = None -def test_return_types(): - """Test the node's metadata.""" - assert Example.RETURN_TYPES == ("IMAGE",) - assert Example.FUNCTION == "test" - assert Example.CATEGORY == "Example" +@pytest.fixture(scope="module") +def resources_dir(): + """ + Fixture to provide the path to the resources directory. + """ + return pathlib.Path(__file__).parent / "resources" From f931308026d6a9e55d1f0e7ef45c7d876eeaf125 Mon Sep 17 00:00:00 2001 From: David Feltell Date: Mon, 1 Sep 2025 19:32:41 +0100 Subject: [PATCH 06/10] [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 --- src/openassetio_comfyui/nodes.py | 178 +++++++++++++++++++++++++++--- tests/resources/bal_db.json | 38 +++++++ tests/test_openassetio_comfyui.py | 133 +++++++++++++++++++++- 3 files changed, 335 insertions(+), 14 deletions(-) diff --git a/src/openassetio_comfyui/nodes.py b/src/openassetio_comfyui/nodes.py index 9ab92f7..7f759b5 100644 --- a/src/openassetio_comfyui/nodes.py +++ b/src/openassetio_comfyui/nodes.py @@ -9,26 +9,35 @@ """ import hashlib +import json import logging +import pathlib +import shutil import torch import numpy as np from PIL import Image, ImageOps, ImageSequence +from PIL.PngImagePlugin import PngInfo from openassetio import EntityReference from openassetio.utils import FileUrlPathConverter from openassetio.log import LoggerInterface -from openassetio.access import ResolveAccess +from openassetio.access import ResolveAccess, PublishingAccess from openassetio.hostApi import ManagerFactory, HostInterface from openassetio.pluginSystem import ( HybridPluginSystemManagerImplementationFactory, PythonPluginSystemManagerImplementationFactory, CppPluginSystemManagerImplementationFactory, ) +from openassetio_mediacreation.specifications.twoDimensional import ( + PlanarBitmapImageResourceSpecification, +) from openassetio_mediacreation.traits.content import LocatableContentTrait +import folder_paths import node_helpers +from comfy.cli_args import args class _OpenAssetIOHost: @@ -87,19 +96,19 @@ def __init__(self): """ Load and initialise an OpenAssetIO manager plugin. """ - self.__logger = self._LoggerInterface() + 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), + CppPluginSystemManagerImplementationFactory(self.logger), + PythonPluginSystemManagerImplementationFactory(self.logger), ], - self.__logger, + self.logger, ), - self.__logger, + self.logger, ) if self.manager is None: raise RuntimeError( @@ -107,10 +116,14 @@ def __init__(self): " 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: + self.context = self.manager.createContext() + self.file_url_path_converter = FileUrlPathConverter() + + def resolve_to_path( + self, + entity_reference: EntityReference | str, + access_mode: ResolveAccess = ResolveAccess.kRead, + ) -> str: """ Resolve an OpenAssetIO entity reference to a local file path. @@ -121,10 +134,11 @@ def resolve_to_path(self, entity_reference: str) -> str: @throw RuntimeError if the entity reference resolves successfully, but the entity has no location. """ - entity_reference = self.manager.createEntityReference(entity_reference) + if isinstance(entity_reference, str): + entity_reference = self.manager.createEntityReference(entity_reference) traits_data = self.manager.resolve( - entity_reference, {LocatableContentTrait.kId}, ResolveAccess.kRead, self.__context + entity_reference, {LocatableContentTrait.kId}, access_mode, self.context ) locatable_content_trait = LocatableContentTrait(traits_data) url = locatable_content_trait.getLocation() @@ -134,7 +148,7 @@ def resolve_to_path(self, entity_reference: str) -> str: f"Failed to resolve entity reference '{entity_reference}' to a location" ) - return self.__file_url_path_converter.pathFromUrl(url) + return self.file_url_path_converter.pathFromUrl(url) class ResolveImage: @@ -254,12 +268,150 @@ def resolve_image(self, entity_reference: str) -> tuple[torch.Tensor, torch.Tens return output_image, output_mask +class PublishImage: + """ + Node to publish an image to an OpenAssetIO entity reference. + + The non-OpenAssetIO logic is largely duplicated from the built-in + ComfyUI SaveImage node (at least as of 4f5812b9). + """ + + # Tooltip to display when hovering over the node. + DESCRIPTION = "Publishes images to an asset manager." + # Menu category. + CATEGORY = "image" + # Function to call when node is executed. + FUNCTION = "publish_images" + # Node outputs. + RETURN_TYPES = () + # Marks this node as a terminal node, ensuring the associated + # subgraph is executed when running the graph. + OUTPUT_NODE = True + + @classmethod + def INPUT_TYPES(cls) -> dict: + """ + Input sockets and widgets. + """ + return { + "required": { + "entity_reference": ( + "STRING", + {"multiline": False, "tooltip": "The entity to publish to"}, + ), + "images": ("IMAGE", {"tooltip": "The images to save."}), + }, + "hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"}, + } + + @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) + + def __init__(self): + """ + Initialise node. + + Duplicated from SaveImage node. + """ + self.compress_level = 4 + + def publish_images( + self, entity_reference: str, images: torch.Tensor, prompt=None, extra_pnginfo=None + ) -> dict: + """ + Publish the input images to the specified OpenAssetIO entity. + + Assumes the working reference provided by `preflight()` can be + resolved to a destination file path to write to. + + Non-OpenAssetIO logic largely duplicated from the built-in + ComfyUI SaveImage node (at least as of 4f5812b9). + + In particular, we inherit support for a "%batch_num%" + placeholder in the resolved path. + """ + entity_reference = _OpenAssetIOHost.instance().manager.createEntityReference( + entity_reference + ) + + spec = PlanarBitmapImageResourceSpecification.create() + + working_ref = _OpenAssetIOHost.instance().manager.preflight( + entity_reference, + spec.traitsData(), + PublishingAccess.kWrite, + _OpenAssetIOHost.instance().context, + ) + + # Get destination file path (potentially containing batch_num + # placeholder). This may be a temporary/staging path, or it + # may be the final path, depending on the manager's + # implementation. + file_path_tmplt = _OpenAssetIOHost.instance().resolve_to_path( + working_ref, access_mode=ResolveAccess.kManagerDriven + ) + + results = list() + for batch_number, image in enumerate(images): + i = 255.0 * image.cpu().numpy() + img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8)) + metadata = None + if not args.disable_metadata: + metadata = PngInfo() + if prompt is not None: + metadata.add_text("prompt", json.dumps(prompt)) + if extra_pnginfo is not None: + for x in extra_pnginfo: + metadata.add_text(x, json.dumps(extra_pnginfo[x])) + + file_path = file_path_tmplt.replace("%batch_num%", str(batch_number)) + + img.save(file_path, pnginfo=metadata, compress_level=self.compress_level) + + # Configure traits to register with the asset manager. + url = _OpenAssetIOHost.instance().file_url_path_converter.pathToUrl(file_path) + spec.locatableContentTrait().setLocation(url) + + # Publish the image to the working reference. + final_ref = _OpenAssetIOHost.instance().manager.register( + working_ref, + spec.traitsData(), + PublishingAccess.kWrite, + _OpenAssetIOHost.instance().context, + ) + # Get the path of the published image. This may be different + # to the path resolved from the working reference (i.e. if + # the manager moved it as part of the publishing process). + final_file_path = pathlib.Path(_OpenAssetIOHost.instance().resolve_to_path(final_ref)) + # Copy to ComfyUI temp directory for display in the UI. For + # security reasons, ComfyUI does not allow images to be + # served from arbitrary paths on disk, so we must copy them + # to an allowed location. Here, we choose ComfyUI's temp + # directory. + shutil.copy2(final_file_path, folder_paths.get_temp_directory()) + + results.append({"filename": final_file_path.name, "subfolder": "", "type": "temp"}) + + # For output nodes, we can return a dict with a "ui" key, + # containing data to display in the ComfyUI interface. + # Here, we return the list of published images, which will be + # displayed in the node. + return {"ui": {"images": results}} + + # Plugin registration: node classes. NODE_CLASS_MAPPINGS = { "OpenAssetIOResolveImage": ResolveImage, + "OpenAssetIOPublishImage": PublishImage, } # Plugin registration: node names. NODE_DISPLAY_NAME_MAPPINGS = { "OpenAssetIOResolveImage": "OpenAssetIO Resolve Image", + "OpenAssetIOPublishImage": "OpenAssetIO Publish Image", } diff --git a/tests/resources/bal_db.json b/tests/resources/bal_db.json index eaa6fb8..d443d3b 100644 --- a/tests/resources/bal_db.json +++ b/tests/resources/bal_db.json @@ -1,4 +1,21 @@ { + "managementPolicy": { + "read": { + "default": { + "openassetio-mediacreation:managementPolicy.Managed": {} + } + }, + "write": { + "default": { + "openassetio-mediacreation:managementPolicy.Managed": {} + } + }, + "managerDriven": { + "default": { + "openassetio-mediacreation:managementPolicy.Managed": {} + } + } + }, "entities": { "image_file": { "versions": [ @@ -22,6 +39,27 @@ } } ] + }, + "new_image_file": { + "versions": [], + "overrideByAccess": { + "write": { + "traits": { + "openassetio-mediacreation:usage.Entity": {}, + "openassetio-mediacreation:twoDimensional.Image": {}, + "openassetio-mediacreation:twoDimensional.PixelBased": {}, + "openassetio-mediacreation:twoDimensional.Planar": {}, + "openassetio-mediacreation:content.LocatableContent": {} + } + }, + "managerDriven": { + "traits": { + "openassetio-mediacreation:content.LocatableContent": { + "location": "file://${test_tmp_dir}/dog.png" + } + } + } + } } } } diff --git a/tests/test_openassetio_comfyui.py b/tests/test_openassetio_comfyui.py index 7cf0bf6..6eb8b4e 100644 --- a/tests/test_openassetio_comfyui.py +++ b/tests/test_openassetio_comfyui.py @@ -19,7 +19,9 @@ import torch from PIL import Image -from openassetio_comfyui.nodes import ResolveImage, _OpenAssetIOHost +from openassetio_comfyui.nodes import ResolveImage, _OpenAssetIOHost, PublishImage + +import folder_paths class Test_ResolveImage_init: @@ -131,6 +133,102 @@ def test_when_no_manager_found_then_raises( resolve_image_node.resolve_image("bal:///image_file") +class Test_PublishImage_init: + """ + Test that the node can be instantiated. + """ + + def test_has_correct_type(self, publish_image_node): + assert isinstance(publish_image_node, PublishImage) + + +class Test_PublishImage_constants: + """ + Test the node's metadata. + """ + + def test_has_expected_values(self): + assert PublishImage.DESCRIPTION == "Publishes images to an asset manager." + assert PublishImage.CATEGORY == "image" + assert PublishImage.FUNCTION == "publish_images" + assert PublishImage.RETURN_TYPES == tuple() + assert PublishImage.OUTPUT_NODE is True + + def test_function_matches(self): + assert inspect.isfunction(getattr(PublishImage, PublishImage.FUNCTION)) + + +class Test_PublishImage_INPUT_TYPES: + def test_has_expected_structure(self): + input_types = PublishImage.INPUT_TYPES() + assert input_types == { + "required": { + "entity_reference": ( + "STRING", + {"multiline": False, "tooltip": "The entity to publish to"}, + ), + "images": ("IMAGE", {"tooltip": "The images to save."}), + }, + "hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"}, + } + + +class Test_PublishImage_VALIDATE_INPUTS: + def test_when_is_a_reference_then_returns_true(self): + valid = PublishImage.VALIDATE_INPUTS("bal:///") + assert valid + + def test_when_not_a_reference_then_returns_false(self): + valid = PublishImage.VALIDATE_INPUTS("notbal:///") + assert not valid + + def test_when_no_manager_found_then_raises(self, assert_raises_missing_manager): + with assert_raises_missing_manager(): + PublishImage.VALIDATE_INPUTS("bal:///") + + +class Test_PublishImage_publish_images: + def test_when_valid_reference_then_writes_image( + self, publish_image_node, image_file_path, image_file_tensor + ): + publish_image_node.publish_images("bal:///new_image_file", image_file_tensor) + + new_image_file_path = image_file_path.with_name("dog.png") # See bal_db.json + assert new_image_file_path.exists() + + def test_when_valid_reference_then_provides_preview_of_result( + self, publish_image_node, image_file_tensor, tmp_path + ): + expected_ui_desc = { + "ui": { + "images": [ + { + "type": "temp", + "subfolder": "", + "filename": "dog.png", # See bal_db.json + } + ] + } + } + actual_ui_desc = publish_image_node.publish_images( + "bal:///new_image_file", image_file_tensor + ) + + assert expected_ui_desc == actual_ui_desc + + published_file_path = tmp_path / "dog.png" + ui_file_path = pathlib.Path(folder_paths.get_temp_directory()) / "dog.png" + + assert ui_file_path != published_file_path # Confidence check. + assert ui_file_path.read_bytes() == published_file_path.read_bytes() # i.e. copied. + + def test_when_no_manager_found_then_raises( + self, publish_image_node, assert_raises_missing_manager + ): + with assert_raises_missing_manager(): + publish_image_node.publish_images("bal:///new_image_file", torch.zeros((1, 2, 2, 3))) + + @pytest.fixture def assert_raises_missing_manager(monkeypatch): """ @@ -156,6 +254,17 @@ def assert_ctx(): return assert_ctx +@pytest.fixture +def image_file_tensor(resolve_image_node, image_file_path): + """ + Fixture to provide a tensor representation of the input image file. + """ + # Short-cut making use of ResolveImage node to get a tensor with + # expected structure. + images, _ = resolve_image_node.resolve_image("bal:///image_file") + return images + + @pytest.fixture def image_file_hash(image_file_path): """ @@ -184,6 +293,14 @@ def image_file_path(monkeypatch, tmp_path): return file_path +@pytest.fixture +def publish_image_node(): + """ + Fixture to create a PublishImage node instance. + """ + return PublishImage() + + @pytest.fixture def resolve_image_node(): """ @@ -192,6 +309,20 @@ def resolve_image_node(): return ResolveImage() +@pytest.fixture(autouse=True) +def set_comfyui_temp_dir(tmp_path_factory): + """ + Override default ComfyUI temp directory to a location we control. + + We do this because: + * ComfyUI's default temp directory (a top-level "temp" directory + under the ComfyUI repo) doesn't exist during test runs. + * We would like to ensure cleanup when the test completes. + """ + tmp_dir = tmp_path_factory.mktemp("temp") + folder_paths.set_temp_directory(str(tmp_dir)) + + @pytest.fixture(autouse=True) def openassetio_config_env_var(monkeypatch, resources_dir): """ From 1c9303a1c1c75b95bf6e3f8e332c9e8bcd585850 Mon Sep 17 00:00:00 2001 From: David Feltell Date: Thu, 4 Sep 2025 20:12:14 +0100 Subject: [PATCH 07/10] [Build] Add requirements.txt ComfyUI discovers plugins from a `custom_nodes` directory, and so it doesn't use the Python environment to locate plugins. However, we have dependencies that must be installed into the Python environment for our plugin to work. It is implied in the docs that bundling a `requirements.txt` file is how the ComfyUI CLI and Manager discovers and installs plugin dependencies. In addition, a separate `requirements.txt` allows cleaner manual installation (i.e. clone the repo under `ComfyUI/custom_nodes` then `pip install -r requirements.txt`) Signed-off-by: David Feltell --- requirements.txt | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 requirements.txt diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e3f7cd0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ + openassetio>=1.0.0 + openassetio-mediacreation>=1.0.0a12 From 52e47fe185cab7f6f90257ddf232d1ee15d45012 Mon Sep 17 00:00:00 2001 From: David Feltell Date: Thu, 4 Sep 2025 20:15:02 +0100 Subject: [PATCH 08/10] [Docs] Update README Signed-off-by: David Feltell --- README.md | 144 +++++++++++++++++++++++++++++++----------------------- 1 file changed, 84 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index fdb1246..abb962f 100644 --- a/README.md +++ b/README.md @@ -1,88 +1,112 @@ # OpenAssetIO-ComfyUI -OpenAssetIO ingress and egress +## What -> [!NOTE] -> This projected was created with -> a [cookiecutter](https://github.com/Comfy-Org/cookiecutter-comfy-extension) template. It helps you -> start -> writing custom nodes without worrying about the Python setup. +Custom [ComfyUI](https://comfy.org) nodes for resolving and publishing +assets directly from a workflow via +[OpenAssetIO](https://docs.openassetio.org/OpenAssetIO/). -## Quickstart +## Why -1. Install [ComfyUI](https://docs.comfy.org/get_started). -1. Install [ComfyUI-Manager](https://github.com/ltdrdata/ComfyUI-Manager) -1. Look up this extension in ComfyUI-Manager. If you are installing manually, clone this repository - under `ComfyUI/custom_nodes`. -1. Restart ComfyUI. +This project allows ComfyUI to leverage the abilities of +OpenAssetIO-enabled asset management systems, such as versioning, +dependency tracking, and collaboration. -# Features +For example, if the asset manager supports a meta-version of "latest", +then the workflow inputs can be updated without having to edit the +workflow or move files around. -- A list of features +Then, when the workflow completes, the output can be published back to +the asset manager, which typically creates a new version/revision +(rather than overwriting), and makes the output available for review and +for use by downstream tools. -## Develop +## Features -To install the dev dependencies and pre-commit (will run the ruff hook), do: +- _OpenAssetIO Resolve Image_: An alternative to the built-in + _Load Image_ node that resolves an OpenAssetIO entity reference to an + image. -```bash -cd openassetio-comfyui -pip install -e .[dev] -pre-commit install -``` +- _OpenAssetIO Publish Image_: An alternative to the built-in _Save + Image_ node that publishes the output of a workflow to an OpenAssetIO + entity reference. + +## Requirements + +The plugin is known to work with -The `-e` flag above will result in a "live" install, in the sense that any changes you make to your -node extension will automatically be -picked up the next time you run ComfyUI. +- Python 3.11 +- [ComfyUI](https://comfy.org) 0.3.57 +- [OpenAssetIO](https://github.com/OpenAssetIO/OpenAssetIO) 1.0.0 +- [OpenAssetIO-MediaCreation](https://github.com/OpenAssetIO/OpenAssetIO-MediaCreation) + 1.0.0-alpha.12 -## Publish to Github +## Installation -Install Github Desktop or follow -these [instructions](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent) -for ssh. +Install [ComfyUI](https://docs.comfy.org/get_started). -1. Create a Github repository that matches the directory name. -2. Push the files to Git +Clone this repository under `ComfyUI/custom_nodes`. + +From the repository root, install dependencies ``` -git add . -git commit -m "project scaffolding" -git push +pip install -r requirements.txt ``` -## Writing custom nodes +Ensure the ComfyUI execution environment is configured correctly for an +OpenAssetIO host application. See the +[OpenAssetIO documentation](https://docs.openassetio.org/OpenAssetIO/runtime_configuration.html) +for general instructions on host application configuration. -An example custom node is located in [node.py](src/openassetio-comfyui/nodes.py). To learn more, -read -the [docs](https://docs.comfy.org/essentials/custom_node_overview). +In particular, ensure the `OPENASSETIO_DEFAULT_CONFIG` environment +variable contains a path to a valid OpenAssetIO configuration file. -## Tests +## Development -This repo contains unit tests written in Pytest in the `tests/` directory. It is recommended to -unit test your custom node. +To install the dev dependencies and pre-commit (will run the +[Ruff](https://docs.astral.sh/ruff/) hook), from the repository root run -- [build-pipeline.yml](.github/workflows/build-pipeline.yml) will run pytest and linter on any open - PRs -- [validate.yml](.github/workflows/validate.yml) will - run [node-diff](https://github.com/Comfy-Org/node-diff) to check for breaking changes +```bash +pip install -e .[dev] +pre-commit install +``` -## Publishing to Registry +> Note that installing this project to the Python environment has no +> effect on ComfyUI, since it loads plugins from the `custom_nodes` +> directory. However, installing the package helps with IDE code +> completion and linting; and of course ensures test/lint dependencies +> are installed. + +### Running Tests + +This project contains unit tests written in +[pytest](https://docs.pytest.org/en/stable/) in the `tests` directory. +To run the tests, from the repository root run + +```bash +pytest tests +``` + +### Linting + +The project makes use of the [Ruff](https://docs.astral.sh/ruff/) +linter, configured through the `pyproject.toml` file. To run Ruff, from +the repository root run + +```bash +ruff check . +``` -If you wish to share this custom node with others in the community, you can publish it to the -registry. We've already auto-populated some -fields in `pyproject.toml` under `tool.comfy`, but please double-check that they are correct. +## License -You need to make an account on https://registry.comfy.org and create an API key token. +Apache-2.0 - See [LICENSE](./LICENSE) file for details. -- [ ] Go to the [registry](https://registry.comfy.org). Login and create a publisher id (everything - after the `@` sign on your registry - profile). -- [ ] Add the publisher id into the pyproject.toml file. -- [ ] Create an api key on the Registry for publishing from - Github. [Instructions](https://docs.comfy.org/registry/publishing#create-an-api-key-for-publishing). -- [ ] Add it to your Github Repository Secrets as `REGISTRY_ACCESS_TOKEN`. +## Contributing -A Github action will run on every git push. You can also run the Github action manually. Full -instructions [here](https://docs.comfy.org/registry/publishing). Join -our [discord](https://discord.com/invite/comfyorg) if you have any -questions! +Please feel free to contribute pull requests or issues. Note that +contributions will require signing a CLA. +See the OpenAssetIO contribution docs for how to structure +[commit messages](https://github.com/OpenAssetIO/OpenAssetIO/blob/main/doc/contributing/COMMITS.md), +the [pull request process](https://github.com/OpenAssetIO/OpenAssetIO/blob/main/doc/contributing/PULL_REQUESTS.md), +and [coding style guide](https://github.com/OpenAssetIO/OpenAssetIO/blob/main/doc/contributing/CODING_STYLE.md). From ab9ad28d0476f03a4289dd62486ce402d8cd3c2f Mon Sep 17 00:00:00 2001 From: David Feltell Date: Fri, 5 Sep 2025 16:12:06 +0100 Subject: [PATCH 09/10] [CI] Disable backward compatibility check At the moment, we have no history to diff against, resulting in an error > Error loading nodes: Could not find __init__.py in base_repo This commit should be reverted once the initial node implementation has been merged to main. Signed-off-by: David Feltell --- .github/workflows/{validate.yml => validate.yml.disabled} | 1 + 1 file changed, 1 insertion(+) rename .github/workflows/{validate.yml => validate.yml.disabled} (82%) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml.disabled similarity index 82% rename from .github/workflows/validate.yml rename to .github/workflows/validate.yml.disabled index b273847..21d89b9 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml.disabled @@ -2,6 +2,7 @@ # Copyright (c) 2025 The Foundry Visionmongers Ltd # SPDX-License-Identifier: Apache-2.0 +# TODO(DF): Re-enable once initial PR merged to set a baseline. name: Validate backwards compatibility on: From e793b74687a1871dd92876853a824da168f7f609 Mon Sep 17 00:00:00 2001 From: David Feltell Date: Fri, 5 Sep 2025 15:59:44 +0100 Subject: [PATCH 10/10] [CI] Update CI config to work with project Pin the Python version to 3.11, since OpenAssetIO is currently unavailable on PyPI for 3.12 (i.e. only currently supports VFX Reference Platform CY23/24). Checkout ComfyUI and add to PYTHONPATH for tests, since we make use of its internal utility libraries. We also make use of some of the dependencies of ComfyUI (e.g. numpy), so install ComfyUI's dependencies into the environment. Signed-off-by: David Feltell --- ...{build-pipeline.yml => build_pipeline.yml} | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) rename .github/workflows/{build-pipeline.yml => build_pipeline.yml} (66%) diff --git a/.github/workflows/build-pipeline.yml b/.github/workflows/build_pipeline.yml similarity index 66% rename from .github/workflows/build-pipeline.yml rename to .github/workflows/build_pipeline.yml index 32c49b9..68adf8f 100644 --- a/.github/workflows/build-pipeline.yml +++ b/.github/workflows/build_pipeline.yml @@ -6,21 +6,28 @@ name: CI build on: pull_request: - branches: - - master - - main + workflow_dispatch: + jobs: - build: + test: + name: Test and Lint runs-on: ${{ matrix.os }} env: PYTHONIOENCODING: "utf8" strategy: matrix: os: [ ubuntu-latest ] - python-version: [ "3.12" ] + python-version: [ "3.11" ] steps: - uses: actions/checkout@v4 + + - uses: actions/checkout@v4 + name: Checkout ComfyUI + with: + repository: comfyanonymous/ComfyUI + ref: v0.3.57 + path: comfyui - name: Set up Python uses: actions/setup-python@v5 with: @@ -29,9 +36,12 @@ jobs: run: | python -m pip install --upgrade pip pip install .[dev] - - name: Run Linting - run: | - ruff check . + pip install -r comfyui/requirements.txt - name: Run Tests run: | pytest tests/ + env: + PYTHONPATH: ./comfyui + - name: Run Linting + run: | + ruff check .