diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..10dbcd5 --- /dev/null +++ b/.flake8 @@ -0,0 +1,8 @@ +# .flake8 +[flake8] +max-line-length = 127 +# extend-ignore = E203, W503 +# Add plugins if you installed them via additional_dependencies: +# select = C,E,F,W,B,B950 # Example: using flake8-bugbear (B, B950) +# docstring-convention = google +# ... other flake8 or plugin settings diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..fe37c8b --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +custom: ['https://www.paypal.com/donate/?hosted_button_id=K2T86GDTTMRPL'] diff --git a/.github/workflows/publish_comfy_registry_action.yml b/.github/workflows/publish_comfy_registry_action.yml new file mode 100644 index 0000000..be0b02f --- /dev/null +++ b/.github/workflows/publish_comfy_registry_action.yml @@ -0,0 +1,20 @@ +name: Publish to Comfy registry +on: + workflow_dispatch: + push: + branches: + - main + paths: + - "pyproject.toml" + +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/.gitignore b/.gitignore new file mode 100644 index 0000000..d95843e --- /dev/null +++ b/.gitignore @@ -0,0 +1,198 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# 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/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the enitre vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Cursor +# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to +# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data +# refer to https://docs.cursor.com/context/ignore-files +.cursorignore +.cursorindexingignore + +*.dst +*.epr +.*~ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..621d23a --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,66 @@ +# .pre-commit-config.yaml + +# Optional: Set a minimum pre-commit version +# min_pre_commit_version: '2.9.0' + +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 # Use the latest stable version + hooks: + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace + # - id: check-added-large-files # Optional: good for catching accidental large file commits + +- repo: https://github.com/pycqa/flake8 + rev: 7.0.0 # Use the latest stable version of flake8 + hooks: + - id: flake8 + # Optional: specify arguments for flake8 + # args: ['--max-line-length=88', '--extend-ignore=E203'] + # It's often better to configure flake8 via .flake8, setup.cfg, or pyproject.toml + # so it's consistent whether run via pre-commit or manually. + additional_dependencies: [ + # Add any flake8 plugins you use here, e.g.: + # 'flake8-bugbear', + # 'flake8-comprehensions', + # 'flake8-docstrings', + # 'pep8-naming' + ] + +- repo: https://github.com/codespell-project/codespell + rev: v2.2.6 # Use the latest stable version + hooks: + - id: codespell + args: [ + # "--ignore-words-list=your,custom,words,here", + # Or point to a file: + # "--ignore-words=.codespellignore", + "--skip=*.json,*.lock,*.svg,*.css,*.html", # Skip file types less likely to need it + # "--check-filenames", + # "--check-hidden" + ] + # You can create a .codespellignore file with one word per line for words to ignore. + + +# --- Use a "local" hook for mypy --- +- repo: local + hooks: + - id: mypy + name: mypy + # The command to execute. It's expected to be found in the PATH + # of the environment where you run `git commit`. + entry: mypy + args: ["--explicit-package-bases", "source/"] + # 'system' tells pre-commit to find the command in the current environment + # instead of building an isolated one. + language: system + # Specify which files to run on. + types: [python] + pass_filenames: false + # You might still need args if not configured elsewhere, but a mypy.ini is better. + # args: [--config-file=mypy.ini] + # `pass_filenames: false` can be useful here if you want mypy to analyze the + # whole project as configured in mypy.ini, rather than just the changed files. + # Try without it first. + # pass_filenames: false diff --git a/README.md b/README.md new file mode 100644 index 0000000..490f51e --- /dev/null +++ b/README.md @@ -0,0 +1,95 @@ +# ComfyUI Image Miscellaneous Nodes 🎨 + +This repository provides a set of custom nodes for ComfyUI focused on image manipulation. +Currently we just have an image downloader node, but I didn't wanted to put it with +my audio nodes. + + +## ⚙️ Main features + +✅ No extra dependencies, we use the same modules as ComfyUI + +✅ Warnings and errors visible in the browser, configurable debug information in the console + + +## 📜 Table of Contents + +- 🚀 [Installation](#-installation) +- 📦 [Dependencies](#-dependencies) +- 🖼️ [Examples](#️-examples) +- ✨ [Nodes](#-extra-nodes) + - [1. Image Download and Load](#1-image-download-and-load) +- 📝 [Usage Notes](#-usage-notes) +- 📜 [Project History](#-project-history) +- ⚖️ [License](#️-license) +- 🙏 [Attributions](#-attributions) + +## ✨ Nodes + +### 1. Image Download and Load + - **Display Name:** `Image Download and Load` + - **Internal Name:** `SET_ImageDownload` + - **Category:** `image/io` + - **Description:** Downloads an image file from a URL into the `ComfyUI/input/` directory if it's not already there, and then loads it as an image and mask. This is perfect for creating self-contained, shareable workflows with example image. + - **Inputs:** + - `image_bypass` (IMAGE, Optional): If an image is provided here it will be used for the output. You can connect a `Load Image` node here, if the connected node is muted (bypassed) we download the file, otherwise we use the image from the `Load Image` node. + - `mask_bypass` (MASK, Optional): This input complements `image_bypass`. + - `base_url` (STRING): The URL of the directory containing the image file. + - `filename` (STRING): The name of the image file to download (e.g., photo.jpg, art.png). + - **Output:** + - `image` (IMAGE): The loaded image. + - `alpha_mask` (MASK): The alpha mask for the loaded image. + - **Behavior Details:** + - **Caching:** The node checks the `ComfyUI/input/` folder first. If the file with the specified `filename` already exists, the download is skipped. + - **Bypass:** If only one of `image_bypass` and `mask_bypass` is connected the other will be assumed to be empty. You should connect both or avoid using the output corresponding to the unconnected input. + + +## 🚀 Installation + +You can install the nodes from the ComfyUI nodes manager, the name is *Image Misc*, or just do it manually: + +1. Clone this repository into your `ComfyUI/custom_nodes/` directory: + ```bash + cd ComfyUI/custom_nodes/ + git clone https://github.com/set-soft/ComfyUI-ImageMisc ComfyUI-ImageMisc + ``` +2. Restart ComfyUI. + +The nodes should then appear under the "image/io" category in the "Add Node" menu. + + +## 📦 Dependencies + +- PyTorch: Installed by ComfyUI +- Requests (optional): Usually an indirect ComfyUI dependency. If installed it will be used for downloads, it should be more robust than then built-in `urllib`, used as fallback. +- Colorama (optional): Might help to get colored log messages on some terminals. We use ANSI escape sequences when it isn't installed. + + +## 🖼️ Examples + +Once installed the examples are available in the ComfyUI workflow templates, in the *Image Misc* section (or ComfyUI-ImageMisc). + +- [image_download.json](example_workflows/image_download.json): Shows how to use the image downloader node. + + +## 📝 Usage Notes + +- **Logging:** 🔊 The nodes use Python's `logging` module. Debug messages can be helpful for understanding the transformations being applied. + You can control log verbosity through ComfyUI's startup arguments (e.g., `--preview-method auto --verbose DEBUG` for more detailed ComfyUI logs + which might also affect custom node loggers if they are configured to inherit levels). The logger name used is "ImageMisc". + You can force debugging level for these nodes defining the `IMAGEMISC_NODES_DEBUG` environment variable to `1`. + + +## 📜 Project History + +- 1.0.0 2025-07-21: Initial release + - Just the download image. + + +## ⚖️ License + +[GPL-3.0](LICENSE) + +## 🙏 Attributions + +- Good part of the initial code and this README was generated using Gemini 2.5 Pro. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..b4bd949 --- /dev/null +++ b/__init__.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2025 Salvador E. Tropea +# Copyright (c) 2025 Instituto Nacional de Tecnologïa Industrial +# License: GPL-3.0 +# Project: ComfyUI-ImageMisc +from .source.nodes import nodes_img +import inspect +import logging +from .source.nodes.utils.misc import NODES_NAME + +init_logger = logging.getLogger(NODES_NAME + ".__init__") + +NODE_CLASS_MAPPINGS = {} +NODE_DISPLAY_NAME_MAPPINGS = {} + +for name, obj in inspect.getmembers(nodes_img): + # We skip nodes imported from the ComfyUI main nodes + if not inspect.isclass(obj) or not hasattr(obj, "INPUT_TYPES") or obj.__module__ == "nodes": + continue + assert hasattr(obj, "UNIQUE_NAME"), f"No name for {obj.__name__}" + NODE_CLASS_MAPPINGS[obj.UNIQUE_NAME] = obj + NODE_DISPLAY_NAME_MAPPINGS[obj.UNIQUE_NAME] = obj.DISPLAY_NAME + +init_logger.info(f"Registering {len(NODE_CLASS_MAPPINGS)} node(s).") +init_logger.debug(f"{list(NODE_DISPLAY_NAME_MAPPINGS.values())}") + +WEB_DIRECTORY = "./js" +__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] diff --git a/example_workflows/image_download.jpg b/example_workflows/image_download.jpg new file mode 100644 index 0000000..0ed93ae Binary files /dev/null and b/example_workflows/image_download.jpg differ diff --git a/example_workflows/image_download.json b/example_workflows/image_download.json new file mode 100644 index 0000000..b2a317b --- /dev/null +++ b/example_workflows/image_download.json @@ -0,0 +1 @@ +{"id":"63a02d67-42e7-4217-9d52-28b9c456c35b","revision":0,"last_node_id":5,"last_link_id":4,"nodes":[{"id":4,"type":"MaskPreview","pos":[1457.7564697265625,1385.4490966796875],"size":[273.6515197753906,258],"flags":{},"order":4,"mode":0,"inputs":[{"localized_name":"mask","name":"mask","type":"MASK","link":4}],"outputs":[],"properties":{"cnr_id":"comfy-core","ver":"0.3.43","Node name for S&R":"MaskPreview"},"widgets_values":[],"color":"#222","bgcolor":"#000"},{"id":3,"type":"PreviewImage","pos":[1461.320068359375,1076.2701416015625],"size":[273.6515197753906,258],"flags":{},"order":3,"mode":0,"inputs":[{"localized_name":"images","name":"images","type":"IMAGE","link":3}],"outputs":[],"properties":{"cnr_id":"comfy-core","ver":"0.3.43","Node name for S&R":"PreviewImage"},"widgets_values":[],"color":"#222","bgcolor":"#000"},{"id":1,"type":"SET_ImageDownload","pos":[1077.0606689453125,1075.701416015625],"size":[270,314],"flags":{},"order":2,"mode":0,"inputs":[{"localized_name":"image_bypass","name":"image_bypass","shape":7,"type":"IMAGE","link":1},{"localized_name":"mask_bypass","name":"mask_bypass","shape":7,"type":"MASK","link":2},{"localized_name":"base_url","name":"base_url","type":"STRING","widget":{"name":"base_url"},"link":null},{"localized_name":"filename","name":"filename","type":"STRING","widget":{"name":"filename"},"link":null}],"outputs":[{"localized_name":"image","name":"image","type":"IMAGE","links":[3]},{"localized_name":"alpha_mask","name":"alpha_mask","type":"MASK","links":[4]}],"properties":{"aux_id":"set-soft/ComfyUI-ImageMisc","ver":"3ab5664ee7057737c3215db19b078afbcead7a08","Node name for S&R":"SET_ImageDownload"},"widgets_values":["https://raw.githubusercontent.com/set-soft/AudioSeparation/refs/heads/main/example_workflows/","audioseparation_logo.jpg"],"color":"#2a363b","bgcolor":"#3f5159"},{"id":2,"type":"LoadImage","pos":[727.1275024414062,1075.3787841796875],"size":[274.080078125,314],"flags":{},"order":0,"mode":4,"inputs":[{"localized_name":"image","name":"image","type":"COMBO","widget":{"name":"image"},"link":null},{"localized_name":"choose file to upload","name":"upload","type":"IMAGEUPLOAD","widget":{"name":"upload"},"link":null}],"outputs":[{"localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","links":[1]},{"localized_name":"MASK","name":"MASK","type":"MASK","links":[2]}],"properties":{"cnr_id":"comfy-core","ver":"0.3.43","Node name for S&R":"LoadImage"},"widgets_values":["example.png","image"],"color":"#222","bgcolor":"#000"},{"id":5,"type":"MarkdownNote","pos":[733.544921875,821.4869384765625],"size":[989.1290283203125,186.43255615234375],"flags":{},"order":1,"mode":0,"inputs":[],"outputs":[],"properties":{},"widgets_values":["# If you \"unbypass\" the *Load Image* node you'll get the image and mask from it\n# When the inputs are not connected or come from a bypassed node you get the downloaded image\n# Images are downloaded to the ComfyUI's *input* directory, so they become available to be selected in the *Load Image* node. Just press R to refresh the nodes.\n# To \"bypass\" a node just select it and choose the icon with an arrow around a circle.\n"],"color":"#432","bgcolor":"#653"}],"links":[[1,2,0,1,0,"IMAGE"],[2,2,1,1,1,"MASK"],[3,1,0,3,0,"IMAGE"],[4,1,1,4,0,"MASK"]],"groups":[],"config":{},"extra":{"ds":{"scale":0.8432169278238246,"offset":[-188.01505304476098,-782.3508645860205]}},"version":0.4} \ No newline at end of file diff --git a/js/toast_handler.js b/js/toast_handler.js new file mode 100644 index 0000000..652f204 --- /dev/null +++ b/js/toast_handler.js @@ -0,0 +1,32 @@ +// Copyright (c) 2025 Salvador E. Tropea +// Copyright (c) 2025 Instituto Nacional de Tecnología Industrial +// License: GPLv3 +// Project: ComfyUI-ImageMisc + +// This script adds an event named "set-imagemisc-toast" +// Used to notify the user in the GUI using the Toast API + +import { app } from "/scripts/app.js"; + +// Register a new extension +app.registerExtension({ + name: "SET.ImageMisc.ToastHandler", // Unique name + + // The setup function is executed when the extension is loaded + setup() { + // Add a listener for our custom event + app.api.addEventListener("set-imagemisc-toast", (event) => { + // The data from Python is in event.detail + const { message, summary, severity } = event.detail; + + // Use the ComfyUI toast API to show the message + // app.ui.toast.addMessage is the modern way to do this + app.extensionManager.toast.add({ + severity: severity, + summary: summary, + detail: message, + life: 6000 + }); + }); + }, +}); diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..d888af5 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,22 @@ +[mypy] +# Basic configuration +python_version = 3.11 +warn_return_any = True +warn_unused_ignores = True +mypy_path = source/ + +# This is often needed when starting out, especially with libraries that lack stubs +ignore_missing_imports = True + +# You can get stricter over time by removing ignore_missing_imports +# and setting flags like these: +# disallow_untyped_defs = True +# disallow_any_unimported = True + +# Tell mypy to ignore errors from third-party libraries if they are not typed +[mypy-torch.*] +ignore_missing_imports = True +[mypy-torchaudio.*] +ignore_missing_imports = True +[mypy-pytest.*] +ignore_missing_imports = True diff --git a/source/__init__.py b/source/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/source/nodes/__init__.py b/source/nodes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/source/nodes/nodes_img.py b/source/nodes/nodes_img.py new file mode 100644 index 0000000..55271f0 --- /dev/null +++ b/source/nodes/nodes_img.py @@ -0,0 +1,129 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2025 Salvador E. Tropea +# Copyright (c) 2025 Instituto Nacional de Tecnologïa Industrial +# License: GPL-3.0 +# Project: ComfyUI-ImageMisc +# From code generated by Gemini 2.5 Pro +import os +import torch +from typing import Optional +from .utils.logger import main_logger +from .utils.downloader import download_model +from .utils.comfy_notification import send_toast_notification +try: + from folder_paths import get_input_directory # To get the ComfyUI input directory +except ModuleNotFoundError: + # No ComfyUI, this is a test environment + def get_input_directory(): + return "" +try: + # We need to import the built-in LoadImage class for ImageDownload + from nodes import LoadImage # type: ignore[attr-defined] + has_load_image = True +except Exception: + has_load_image = False + +logger = main_logger +BASE_CATEGORY = "image" +IO_CATEGORY = "io" + + +if has_load_image: + class ImageDownload: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "base_url": ("STRING", { + "default": + "https://raw.githubusercontent.com/set-soft/AudioSeparation/refs/heads/main/example_workflows/", + "tooltip": "The base URL where the image file is located." + }), + "filename": ("STRING", { + "default": "audioseparation_logo.jpg", + "tooltip": "The name of the image file to download (e.g., photo.jpg, art.png)." + }), + }, + "optional": { + "image_bypass": ("IMAGE", { + "tooltip": "If this image is present will be used instead of the downloaded one" + }), + "mask_bypass": ("MASK", {"tooltip": "If this mask is present will be used instead of the downloaded one"}), + } + } + + RETURN_TYPES = ("IMAGE", "MASK") + RETURN_NAMES = ("image", "alpha_mask") + FUNCTION = "load_or_download_image" + CATEGORY = BASE_CATEGORY + "/" + IO_CATEGORY + DESCRIPTION = ("Downloads an image to ComfyUI's 'input' directory if it doesn't exist, then loads it using the " + "built-in LoadImage logic.") + UNIQUE_NAME = "SET_ImageDownload" + DISPLAY_NAME = "Image Download and Load" + OUTPUT_NODE = True + + def load_or_download_image(self, base_url: str, filename: str, image_bypass: Optional[torch.Tensor] = None, + mask_bypass: Optional[torch.Tensor] = None): + # If we have something at the bypass inputs use it + if image_bypass is not None or mask_bypass is not None: + if image_bypass is None: + # Just a mask + assert mask_bypass is not None, "This should not be possible if image_bypass is None" # For mypy + image_bypass = torch.zeros(mask_bypass.shape + (3,), dtype=torch.float32, device="cpu") + logger.warning("ImageDownload: Returning an empty image") + elif mask_bypass is None: + # This is ComfyUI behavior when we don't have transparency + mask_bypass = torch.zeros((64, 64), dtype=torch.float32, device="cpu").unsqueeze(0) + logger.warning("ImageDownload: Returning an empty mask") + return (image_bypass, mask_bypass) + + save_dir = get_input_directory() + local_filepath = os.path.join(save_dir, filename) + + if not os.path.exists(local_filepath): + logger.info(f"File '{filename}' not found locally. Attempting to download.") + + if not base_url.endswith('/'): + base_url += '/' + download_url = base_url + filename + send_toast_notification(f"Downloading `{filename}`", "Download") + + try: + download_model(url=download_url, save_dir=save_dir, file_name=filename, kind="image") + except Exception as e: + logger.error(f"Download failed for {download_url}: {e}", exc_info=True) + raise + send_toast_notification("Finished downloading", "Download", 'success') + else: + logger.info(f"Found existing file, skipping download: '{local_filepath}'") + + # --- REUSE ComfyUI's LoadImage LOGIC --- + try: + # Instantiate the built-in LoadImage node + loader_instance = LoadImage() + + # The LoadImage node's `load_image` method expects the filename as passed + # by the ComfyUI widget, which is just the filename. It internally + # resolves the path using folder_paths. + + logger.debug(f"Calling built-in LoadImage.load_image() with filename: '{filename}'") + + # Call the method and return its result directly + result = loader_instance.load_image(filename) + downloaded_file = { + "images": [{ + "filename": filename, + "subfolder": "", + "type": "input" # We stored the file in the "input" folder + }] + } + return {"ui": downloaded_file, "result": result} + + except Exception as e: + logger.error(f"Failed to load image '{filename}' using built-in LoadImage node: {e}", exc_info=True) + # Re-raise to make the error visible in ComfyUI + raise IOError(f"Could not load the image file '{filename}' using the standard loader. " + "It may be corrupt or in an unsupported format.") from e +else: + logger.error("Failed to import ComfyUI `LoadImage`, please fill an issue here: " + "https://github.com/set-soft/ComfyUI-ImageMisc/issues") diff --git a/source/nodes/utils/__init__.py b/source/nodes/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/source/nodes/utils/ansi.py b/source/nodes/utils/ansi.py new file mode 100644 index 0000000..5e7d0cc --- /dev/null +++ b/source/nodes/utils/ansi.py @@ -0,0 +1,113 @@ +# Copyright Jonathan Hartley 2013. BSD 3-Clause license, see LICENSE file. +''' +This module generates ANSI character codes to printing colors to terminals. +See: http://en.wikipedia.org/wiki/ANSI_escape_code +''' +import sys +import os + +CSI = '\033[' +OSC = '\033]' +BEL = '\a' +is_a_tty = sys.stderr.isatty() and os.name == 'posix' + + +def code_to_chars(code): + return CSI + str(code) + 'm' if is_a_tty else '' + + +def set_title(title): + return OSC + '2;' + title + BEL + + +def clear_screen(mode=2): + return CSI + str(mode) + 'J' + + +def clear_line(mode=2): + return CSI + str(mode) + 'K' + + +class AnsiCodes(object): + def __init__(self): + # the subclasses declare class attributes which are numbers. + # Upon instantiation we define instance attributes, which are the same + # as the class attributes but wrapped with the ANSI escape sequence + for name in dir(self): + if not name.startswith('_'): + value = getattr(self, name) + setattr(self, name, code_to_chars(value)) + + +class AnsiCursor(object): + def UP(self, n=1): + return CSI + str(n) + 'A' + + def DOWN(self, n=1): + return CSI + str(n) + 'B' + + def FORWARD(self, n=1): + return CSI + str(n) + 'C' + + def BACK(self, n=1): + return CSI + str(n) + 'D' + + def POS(self, x=1, y=1): + return CSI + str(y) + ';' + str(x) + 'H' + + +class AnsiFore(AnsiCodes): + BLACK = 30 + RED = 31 + GREEN = 32 + YELLOW = 33 + BLUE = 34 + MAGENTA = 35 + CYAN = 36 + WHITE = 37 + RESET = 39 + + # These are fairly well supported, but not part of the standard. + LIGHTBLACK_EX = 90 + LIGHTRED_EX = 91 + LIGHTGREEN_EX = 92 + LIGHTYELLOW_EX = 93 + LIGHTBLUE_EX = 94 + LIGHTMAGENTA_EX = 95 + LIGHTCYAN_EX = 96 + LIGHTWHITE_EX = 97 + + +class AnsiBack(AnsiCodes): + BLACK = 40 + RED = 41 + GREEN = 42 + YELLOW = 43 + BLUE = 44 + MAGENTA = 45 + CYAN = 46 + WHITE = 47 + RESET = 49 + + # These are fairly well supported, but not part of the standard. + LIGHTBLACK_EX = 100 + LIGHTRED_EX = 101 + LIGHTGREEN_EX = 102 + LIGHTYELLOW_EX = 103 + LIGHTBLUE_EX = 104 + LIGHTMAGENTA_EX = 105 + LIGHTCYAN_EX = 106 + LIGHTWHITE_EX = 107 + + +class AnsiStyle(AnsiCodes): + BRIGHT = 1 + DIM = 2 + NORMAL = 22 + RESET_ALL = 0 + + +Fore = AnsiFore() +Back = AnsiBack() +Style = AnsiStyle() +Cursor = AnsiCursor() diff --git a/source/nodes/utils/comfy_notification.py b/source/nodes/utils/comfy_notification.py new file mode 100644 index 0000000..e279e0b --- /dev/null +++ b/source/nodes/utils/comfy_notification.py @@ -0,0 +1,47 @@ +# Copyright (c) 2025 Salvador E. Tropea +# Copyright (c) 2025 Instituto Nacional de Tecnología Industrial +# License: GPLv3 +# Project: ComfyUI-ImageMisc +# +# ComfyUI Toast API messages +# Original code from Gemini 2.5 Pro, which was really outdated +# Took ideas from Easy Use nodes and looking at ComfyUI code +import logging +from typing import Optional +# ComfyUI imports +try: + from server import PromptServer + with_comfy = True +except Exception: + with_comfy = False +# Local imports +from .misc import NODES_NAME + +logger = logging.getLogger(f"{NODES_NAME}.comfy_notification") + + +def send_toast_notification(message: str, summary: str = "Warning", severity: str = "warn", sid: Optional[str] = None): + """ + Sends a toast notification event to the ComfyUI client. + + Args: + message (str): The message content of the toast. + severity (str): The type of toast. Can be 'success' | 'info' | 'warn' | 'error' | 'secondary' | 'contrast' + summary (str): Short explanation + sid (str, optional): The session ID of the client to send to. + If None, broadcasts to all clients. Defaults to None. + """ + if not with_comfy: + return + try: + PromptServer.instance.send_sync( + "set-imagemisc-toast", # This is our custom event name + { + 'message': message, + 'summary': summary, + 'severity': severity + }, + sid + ) + except Exception as e: + logger.error(f"when trying to use ComfyUI PromptServer: {e}") diff --git a/source/nodes/utils/downloader.py b/source/nodes/utils/downloader.py new file mode 100644 index 0000000..67e413c --- /dev/null +++ b/source/nodes/utils/downloader.py @@ -0,0 +1,206 @@ +# Copyright (c) 2025 Salvador E. Tropea +# Copyright (c) 2025 Instituto Nacional de Tecnología Industrial +# License: GPLv3 +# Project: ComfyUI-AudioSeparation, ComfyUI-AudioBatch and ComfyUI-ImageMisc +# +# Model downloader w/TQDM and ComfyUI progress +# Original code from Gemini 2.5 Pro +import logging +import os +# Requests is better than the core Python urllib, and is a really common package +# But we don't really need it. Lets make it optional: +try: + import requests + with_requests = True +except Exception: + with_requests = False +import urllib +from tqdm import tqdm +# ComfyUI imports +try: + import comfy.utils + with_comfy = True +except Exception: + with_comfy = False +# Local imports +from .misc import NODES_NAME + +logger = logging.getLogger(f"{NODES_NAME}.downloader") + + +def download_model_requests(url: str, save_dir: str, file_name: str): + """ + Downloads a file from a URL with progress bars for both console and ComfyUI. + + Args: + url (str): The direct download URL for the file. + save_dir (str): The directory where the file will be saved. + file_name (str): The name of the file to be saved on disk. + """ + full_path = os.path.join(save_dir, file_name) + + # Ensure the save directory exists + os.makedirs(save_dir, exist_ok=True) + try: + # Use a streaming request to handle large files and get content length + with requests.get(url, stream=True, timeout=10) as r: + r.raise_for_status() # Raise an exception for bad status codes (4xx or 5xx) + + # Get total file size from headers + total_size_in_bytes = int(r.headers.get('content-length', 0)) + block_size = 1024 # 1 Kibibyte + + # --- Setup Progress Bars --- + # Console progress bar using tqdm + progress_bar_console = tqdm( + total=total_size_in_bytes, + unit='iB', + unit_scale=True, + desc=f"Downloading {file_name}" + ) + + # ComfyUI progress bar + progress_bar_ui = comfy.utils.ProgressBar(total_size_in_bytes) if with_comfy else None + + # --- Download Loop --- + downloaded_size = 0 + with open(full_path, 'wb') as f: + for chunk in r.iter_content(chunk_size=block_size): + if chunk: # filter out keep-alive new chunks + chunk_size = len(chunk) + + # Update console progress bar + progress_bar_console.update(chunk_size) + + # Update ComfyUI progress bar + downloaded_size += chunk_size + if progress_bar_ui: + progress_bar_ui.update(chunk_size) # ProgressBar takes absolute value, but update is incremental + + # Write chunk to file + f.write(chunk) + + # --- Cleanup --- + progress_bar_console.close() + + # Final check to see if download was complete + if total_size_in_bytes != 0 and progress_bar_console.n != total_size_in_bytes: + logger.error("Download failed: Size mismatch.") + # Optional: remove partial file + # os.remove(full_path) + raise IOError(f"Download failed for {file_name}. Expected {total_size_in_bytes} but got " + f"{progress_bar_console.n}") + + return full_path + + except requests.exceptions.RequestException as e: + logger.error(f"Network error while downloading {file_name}: {e}") + # Clean up partial file if it exists + if os.path.exists(full_path): + try: + os.remove(full_path) + except OSError: + pass + raise + except Exception as e: + logger.error(f"An error occurred during download: {e}") + if os.path.exists(full_path): + try: + os.remove(full_path) + except OSError: + pass + raise + + +# A simple version implemented using the Python urllib +class Downloader: + def __init__(self, model_path, model_name): + self.model_path = model_path + self.model_name = model_name + self.model_full_name = os.path.join(self.model_path, self.model_name) + # Ensure the directory for the model_path exists before __init__ if used elsewhere + # or create it at the start of download_model + + # A TQDM helper class for urlretrieve reporthook + # This is a common pattern for this use case. + class TqdmUpTo(tqdm): + """ + Provides `update_to(block_num, block_size, total_size)` + and updates the TQDM bar. + """ + def __init__(self, unit, unit_scale, unit_divisor, miniters, desc): + super().__init__(unit=unit, unit_scale=unit_scale, unit_divisor=unit_divisor, miniters=miniters, desc=desc) + self.ui_bar = None + self.total = None + + def update_to(self, block_num=1, block_size=1, total_size=None): + """ + block_num : int, optional + Number of blocks transferred so far [default: 1]. + block_size : int, optional + Size of each block (in tqdm units) [default: 1]. + total_size : int, optional + Total size (in tqdm units). If [default: None] remains unchanged. + """ + if total_size is not None and self.total is None: + self.total = total_size + # ComfyUI progress bar + if self.ui_bar is None and with_comfy: + self.ui_bar = comfy.utils.ProgressBar(total_size) + # self.update() will take the *difference* from the last call. + # So we pass the number of new blocks * block_size. + # Since block_num is cumulative, we calculate the new amount. + chunk_size = block_num * block_size - self.n + self.update(chunk_size) # self.n is current progress + if self.ui_bar: + self.ui_bar.update(chunk_size) # ProgressBar takes absolute value, but update is incremental + + def download_model(self, url: str): + try: + # Ensure the directory exists + # Use or '.' for current dir if dirname is empty + os.makedirs(self.model_path or '.', exist_ok=True) + + # Get filename for tqdm description + filename = self.model_name + + # Use TqdmUpTo as a context manager + with self.TqdmUpTo(unit='iB', unit_scale=True, unit_divisor=1024, miniters=1, + desc=f"Downloading {filename}") as t: + # urlretrieve(url, filename=None, reporthook=None, data=None) + # reporthook is called with (block_num, block_size, total_size) + urllib.request.urlretrieve(url, self.model_full_name, reporthook=t.update_to) + # The 'with' statement ensures t.close() is called. + + return filename + + except urllib.error.URLError as e: # More specific exception for network issues + # Clean up partially downloaded file if an error occurs + if os.path.exists(self.model_full_name): + os.remove(self.model_full_name) + raise Exception(f"An error occurred while downloading the model (URL Error): {e.reason} from {url}") + + except Exception as e: + # Clean up partially downloaded file if an error occurs + if os.path.exists(self.model_full_name): + os.remove(self.model_full_name) + raise Exception(f"An unexpected error occurred while downloading the model: {e}") + + +def download_model_urllib(url: str, save_dir: str, file_name: str): + return Downloader(save_dir, file_name).download_model(url) + + +def download_model(url: str, save_dir: str, file_name: str, force_urllib: bool = False, kind: str = "model"): + logger.info(f"Downloading {kind}: {file_name}") + logger.info(f"Source URL: {url}") + full_name = os.path.join(save_dir, file_name) + logger.info(f"Destination: {full_name}") + + if with_requests and not force_urllib: + download_model_requests(url, save_dir, file_name) + else: + download_model_urllib(url, save_dir, file_name) + + logger.info(f"Successfully downloaded {full_name}") + return full_name diff --git a/source/nodes/utils/logger.py b/source/nodes/utils/logger.py new file mode 100644 index 0000000..63d659d --- /dev/null +++ b/source/nodes/utils/logger.py @@ -0,0 +1,136 @@ +# Copyright (c) 2025 Salvador E. Tropea +# Copyright (c) 2025 Instituto Nacional de Tecnologïa Industrial +# License: GPL-3.0 +# Project: ComfyUI-ImageMisc +from __future__ import annotations # Good practice +import logging +import os +import sys +from typing import Any, Callable +from .misc import NODES_NAME, NODES_DEBUG_VAR +from .comfy_notification import send_toast_notification + + +# 1. Initialize variables with the `Any` type. +# This tells mypy not to make assumptions about their specific class. +Fore: Any +Back: Any +Style: Any + +# 2. Perform the runtime import logic as before. +try: + from colorama import init as colorama_init, Fore, Back, Style + colorama_init() +except ImportError: + # If colorama is not available, import our fallback. + # mypy will now allow this assignment because the variables were declared as Any. + from .ansi import Fore, Back, Style + + +class CustomFormatter(logging.Formatter): + """Logging Formatter to add colors""" + + def __init__(self): + super(logging.Formatter, self).__init__() + white = Fore.WHITE + Style.BRIGHT + yellow = Fore.YELLOW + Style.BRIGHT + red = Fore.RED + Style.BRIGHT + red_alarm = Fore.RED + Back.WHITE + Style.BRIGHT + cyan = Fore.CYAN + Style.BRIGHT + reset = Style.RESET_ALL + # format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s " + # "(%(filename)s:%(lineno)d)" + format = f"[{NODES_NAME} %(levelname)s] %(message)s (%(name)s - %(filename)s:%(lineno)d)" + format_simple = f"[{NODES_NAME}] %(message)s" + + self.FORMATS = { + logging.DEBUG: cyan + format + reset, + logging.INFO: white + format_simple + reset, + logging.WARNING: yellow + format + reset, + logging.ERROR: red + format + reset, + logging.CRITICAL: red_alarm + format + reset + } + + def format(self, record): + log_fmt = self.FORMATS.get(record.levelno) + formatter = logging.Formatter(log_fmt) + return formatter.format(record) + + +def on_log_error_or_warning(record: logging.LogRecord) -> None: + """ + This function is called whenever a log with level WARNING or higher is emitted. + The 'record' object contains all information about the log event. + """ + if record.levelno == logging.WARNING: + summary = "Warning" + severity = "warn" + else: + summary = "Error" + severity = "error" + send_toast_notification(record.getMessage(), summary=summary, severity=severity) + + +class WarningAndErrorFilter(logging.Filter): + """ + A custom log filter that intercepts logs of a certain level. + """ + def __init__(self, callback: Callable, level: int = logging.WARNING): + """ + Initializes the filter. + + Args: + callback: The function to call when a log record meets the level criteria. + level: The minimum level to trigger the callback. + """ + super().__init__() + self._callback = callback + self._level = level + + def filter(self, record: logging.LogRecord) -> bool: + """ + This method is called for every log record. + """ + # Check if the log level is WARNING or higher + if record.levelno >= self._level: + self._callback(record) + + # Always return True to ensure the log is always processed + # by the handlers after this filter. + return True + + +# Create a new logger +logger = logging.getLogger(NODES_NAME) +logger.propagate = False + +# Add the custom filter to the logger. +logger.addFilter(WarningAndErrorFilter(callback=on_log_error_or_warning)) + +# Add handler if we don't have one. +if not logger.handlers: + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter(CustomFormatter()) + logger.addHandler(handler) + +# ###################### +# Logger setup +# ###################### +# 1. Determine the ComfyUI global log level (influenced by --verbose) +main_logger = logger +comfy_root_logger = logging.getLogger('comfy') +effective_comfy_level = logging.getLogger().getEffectiveLevel() +# 2. Check our custom environment variable for more verbosity +try: + nodes_debug_env = int(os.environ.get(NODES_DEBUG_VAR, "0")) +except ValueError: + nodes_debug_env = 0 +# 3. Set node's logger level +if nodes_debug_env: + main_logger.setLevel(logging.DEBUG - (nodes_debug_env - 1)) + final_level_str = f"DEBUG (due to {NODES_DEBUG_VAR}={nodes_debug_env})" +else: + main_logger.setLevel(effective_comfy_level) + final_level_str = logging.getLevelName(effective_comfy_level) + " (matching ComfyUI global)" +_initial_setup_logger = logging.getLogger(NODES_NAME + ".setup") # A temporary logger for this message +_initial_setup_logger.debug(f"{NODES_NAME} logger level set to: {final_level_str}") diff --git a/source/nodes/utils/misc.py b/source/nodes/utils/misc.py new file mode 100644 index 0000000..9fcc976 --- /dev/null +++ b/source/nodes/utils/misc.py @@ -0,0 +1,6 @@ +# Copyright (c) 2025 Salvador E. Tropea +# Copyright (c) 2025 Instituto Nacional de Tecnologïa Industrial +# License: GPL-3.0 +# Project: ComfyUI-ImageMisc +NODES_NAME = "ImageMisc" +NODES_DEBUG_VAR = NODES_NAME.upper() + "_NODES_DEBUG"