[Added] The image downloader node

This commit is contained in:
Salvador E. Tropea
2025-07-21 08:48:35 -03:00
parent 3ab5664ee7
commit 502589a9a1
20 changed files with 1108 additions and 0 deletions
+8
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
custom: ['https://www.paypal.com/donate/?hosted_button_id=K2T86GDTTMRPL']
@@ -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 }}
+198
View File
@@ -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
.*~
+66
View File
@@ -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
+95
View File
@@ -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.
+28
View File
@@ -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']
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+1
View File
@@ -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}
+32
View File
@@ -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
});
});
},
});
+22
View File
@@ -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
View File
View File
+129
View File
@@ -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")
View File
+113
View File
@@ -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()
+47
View File
@@ -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}")
+206
View File
@@ -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
+136
View File
@@ -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}")
+6
View File
@@ -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"