from inspect import cleandoc import os import glob try: from comfy.comfy_types.node_typing import IO, ComfyNodeABC except: class IO: BOOLEAN = "BOOLEAN" INT = "INT" FLOAT = "FLOAT" STRING = "STRING" NUMBER = "FLOAT,INT" IMAGE = "IMAGE" MASK = "MASK" ANY = "*" ComfyNodeABC = object try: from folder_paths import get_input_directory, get_output_directory except: def get_input_directory(): return "./" get_output_directory = get_input_directory def _require_numpy(): try: import numpy as np return np except ModuleNotFoundError as e: raise ModuleNotFoundError( "basic_data_handling: Missing dependency 'numpy'. It seems your ComfyUI installation is faulty." "Only for development purposes: Install it with `pip install .[dev] numpy torch pillow` or `pip install numpy`." ) from e def _require_torch(): try: import torch return torch except ModuleNotFoundError as e: raise ModuleNotFoundError( "basic_data_handling: Missing dependency 'torch'. It seems your ComfyUI installation is faulty." "Only for development purposes: Install it with `pip install .[dev] numpy torch pillow` or `pip install torch`." ) from e def _require_pillow(): try: from PIL import Image, ImageOps return Image, ImageOps except ModuleNotFoundError as e: raise ModuleNotFoundError( "basic_data_handling: Missing dependency 'pillow'. It seems your ComfyUI installation is faulty." "Only for development purposes: Install it with `pip install .[dev] numpy torch pillow` or `pip install pillow`." ) from e # helper functions: def load_image_helper(path: str): """Helper function to load an image from a path""" Image, ImageOps = _require_pillow() try: import pillow_jxl # noqa: F401 - imported but unused, kept for JPEG XL support except ModuleNotFoundError: pass if not os.path.exists(path): return None # Open and process the image try: img = Image.open(path) img = ImageOps.exif_transpose(img) return img except Exception: return None def extract_mask_from_alpha(img): """Extract a mask from the alpha channel of an image""" np = _require_numpy() torch = _require_torch() if 'A' in img.getbands(): alpha = np.array(img.getchannel('A')).astype(np.float32) / 255.0 mask_tensor = 1.0 - torch.from_numpy(alpha) elif img.mode == 'P' and 'transparency' in img.info: alpha = np.array(img.convert('RGBA').getchannel('A')).astype(np.float32) / 255.0 mask_tensor = 1.0 - torch.from_numpy(alpha) else: # Create a blank mask if no alpha channel mask_tensor = torch.zeros((img.height, img.width), dtype=torch.float32) # Add batch dimension to mask mask_tensor = mask_tensor.unsqueeze(0) return mask_tensor def extract_mask_from_greyscale(img): """Extract a mask from a greyscale image or the red channel of an RGB image""" np = _require_numpy() torch = _require_torch() if img.mode == 'L': # Image is already greyscale gray = np.array(img).astype(np.float32) / 255.0 elif img.mode == 'RGB' or img.mode == 'RGBA': # Use the red channel of RGB or RGBA gray = np.array(img.getchannel('R')).astype(np.float32) / 255.0 else: # Convert to greyscale if it's another format gray_img = img.convert('L') gray = np.array(gray_img).astype(np.float32) / 255.0 # Convert to tensor and invert (white pixels in image = transparent in mask) mask_tensor = 1.0 - torch.from_numpy(gray) # Add batch dimension mask_tensor = mask_tensor.unsqueeze(0) return mask_tensor # the nodes: class PathAbspath(ComfyNodeABC): """ Returns the absolute path of a file or directory. This node takes a path and returns its absolute (full) path by resolving any relative path components and symbolic links. """ @classmethod def INPUT_TYPES(cls): return { "required": { "path": (IO.STRING, {"default": ""}), } } RETURN_TYPES = (IO.STRING,) RETURN_NAMES = ("absolute path",) CATEGORY = "Basic/Path" DESCRIPTION = cleandoc(__doc__ or "") FUNCTION = "get_abspath" def get_abspath(self, path: str) -> tuple[str]: return (os.path.abspath(path),) class PathBasename(ComfyNodeABC): """ Returns the base name of a path. This node extracts the filename component from a path, removing any directory information. """ @classmethod def INPUT_TYPES(cls): return { "required": { "path": (IO.STRING, {"default": ""}), } } RETURN_TYPES = (IO.STRING,) RETURN_NAMES = ("basename",) CATEGORY = "Basic/Path" DESCRIPTION = cleandoc(__doc__ or "") FUNCTION = "get_basename" def get_basename(self, path: str) -> tuple[str]: return (os.path.basename(path),) class PathCommonPrefix(ComfyNodeABC): """ Finds the common prefix of multiple paths. This node returns the longest common leading component of the given paths. """ @classmethod def INPUT_TYPES(cls): return { "required": { "path1": (IO.STRING, {"default": ""}), }, "optional": { "path2": (IO.STRING, {"default": ""}), } } RETURN_TYPES = (IO.STRING,) RETURN_NAMES = ("common prefix",) CATEGORY = "Basic/Path" DESCRIPTION = cleandoc(__doc__ or "") FUNCTION = "get_common_prefix" def get_common_prefix(self, path1: str, path2: str = "") -> tuple[str]: paths = [p for p in [path1, path2] if p] return (os.path.commonprefix(paths),) class PathDirname(ComfyNodeABC): """ Returns the directory name of a path. This node extracts the directory component from a path, removing the filename. """ @classmethod def INPUT_TYPES(cls): return { "required": { "path": (IO.STRING, {"default": ""}), } } RETURN_TYPES = (IO.STRING,) RETURN_NAMES = ("dirname",) CATEGORY = "Basic/Path" DESCRIPTION = cleandoc(__doc__ or "") FUNCTION = "get_dirname" def get_dirname(self, path: str) -> tuple[str]: return (os.path.dirname(path),) class PathExists(ComfyNodeABC): """ Checks if a path exists in the filesystem. This node returns True if the path exists (either as a file or a directory), and False otherwise. """ @classmethod def INPUT_TYPES(cls): return { "required": { "path": (IO.STRING, {"default": ""}), } } RETURN_TYPES = (IO.BOOLEAN,) RETURN_NAMES = ("exists",) CATEGORY = "Basic/Path" DESCRIPTION = cleandoc(__doc__ or "") FUNCTION = "check_exists" def check_exists(self, path: str) -> tuple[bool]: return (os.path.exists(path),) class PathExpandVars(ComfyNodeABC): """ Expands environment variables in a path. This node replaces environment variables in a path with their values. For example, $HOME or ${HOME} on Unix, or %USERPROFILE% on Windows. """ @classmethod def INPUT_TYPES(cls): return { "required": { "path": (IO.STRING, {"default": ""}), } } RETURN_TYPES = (IO.STRING,) RETURN_NAMES = ("expanded path",) CATEGORY = "Basic/Path" DESCRIPTION = cleandoc(__doc__ or "") FUNCTION = "expand_vars" def expand_vars(self, path: str) -> tuple[str]: return (os.path.expandvars(path),) class PathGetCwd(ComfyNodeABC): """ Returns the current working directory. This node returns the current working directory as an absolute path. """ @classmethod def INPUT_TYPES(cls): return {"required": {}} RETURN_TYPES = (IO.STRING,) RETURN_NAMES = ("current directory",) CATEGORY = "Basic/Path" DESCRIPTION = cleandoc(__doc__ or "") FUNCTION = "get_cwd" def get_cwd(self) -> tuple[str]: return (os.getcwd(),) class PathGetExtension(ComfyNodeABC): """ Returns the extension of a file. This node extracts the file extension from a path, including the dot (e.g., '.txt'). """ @classmethod def INPUT_TYPES(cls): return { "required": { "path": (IO.STRING, {"default": ""}), } } RETURN_TYPES = (IO.STRING,) RETURN_NAMES = ("extension",) CATEGORY = "Basic/Path" DESCRIPTION = cleandoc(__doc__ or "") FUNCTION = "get_extension" def get_extension(self, path: str) -> tuple[str]: return (os.path.splitext(path)[1],) class PathGetSize(ComfyNodeABC): """ Returns the size of a file in bytes. This node returns the size in bytes of the file at the given path. Raises an error if the path doesn't exist or isn't a file. """ @classmethod def INPUT_TYPES(cls): return { "required": { "path": (IO.STRING, {"default": ""}), } } RETURN_TYPES = (IO.INT,) RETURN_NAMES = ("size (bytes)",) CATEGORY = "Basic/Path" DESCRIPTION = cleandoc(__doc__ or "") FUNCTION = "get_size" def get_size(self, path: str) -> tuple[int]: if not os.path.exists(path): raise FileNotFoundError(f"Basic data handling: Path does not exist: {path}") if not os.path.isfile(path): raise ValueError(f"Basic data handling: Path is not a file: {path}") return (os.path.getsize(path),) class PathGlob(ComfyNodeABC): """ Finds paths matching a pattern. This node returns a list of paths matching the given pattern. The pattern follows shell-style wildcard rules: * - matches any number of characters ? - matches a single character [seq] - matches any character in seq [!seq] - matches any character not in seq """ @classmethod def INPUT_TYPES(cls): return { "required": { "pattern": (IO.STRING, {"default": "*.txt"}), }, "optional": { "recursive": (IO.BOOLEAN, {"default": False}), } } RETURN_TYPES = (IO.STRING,) RETURN_NAMES = ("matching paths",) CATEGORY = "Basic/Path" DESCRIPTION = cleandoc(__doc__ or "") FUNCTION = "glob_paths" OUTPUT_IS_LIST = (True,) # Class variable to store the last matched paths _last_matched_paths = {} @classmethod def IS_CHANGED(s, pattern: str, recursive: bool = False): # Get current paths current_paths = glob.glob(pattern, recursive=recursive) # Create a key for this specific pattern and recursive setting key = f"{pattern}_{recursive}" # If we haven't seen this pattern before, store it and trigger recalculation if key not in s._last_matched_paths: s._last_matched_paths[key] = current_paths return float("NaN") # Compare with previous paths previous_paths = s._last_matched_paths[key] if previous_paths != current_paths: # Update stored paths and trigger recalculation s._last_matched_paths[key] = current_paths return float("NaN") # No changes, return a consistent value import hashlib m = hashlib.md5() m.update(str(current_paths).encode()) return m.hexdigest() def glob_paths(self, pattern: str, recursive: bool = False) -> tuple[list[str]]: return (glob.glob(pattern, recursive=recursive),) class PathIsAbsolute(ComfyNodeABC): """ Checks if a path is absolute. This node returns True if the path is absolute (begins at the root directory), and False if it's relative. """ @classmethod def INPUT_TYPES(cls): return { "required": { "path": (IO.STRING, {"default": ""}), } } RETURN_TYPES = (IO.BOOLEAN,) RETURN_NAMES = ("is absolute",) CATEGORY = "Basic/Path" DESCRIPTION = cleandoc(__doc__ or "") FUNCTION = "check_is_absolute" def check_is_absolute(self, path: str) -> tuple[bool]: return (os.path.isabs(path),) class PathIsDir(ComfyNodeABC): """ Checks if a path points to a directory. This node returns True if the path exists and is a directory, and False otherwise. """ @classmethod def INPUT_TYPES(cls): return { "required": { "path": (IO.STRING, {"default": ""}), } } RETURN_TYPES = (IO.BOOLEAN,) RETURN_NAMES = ("is dir",) CATEGORY = "Basic/Path" DESCRIPTION = cleandoc(__doc__ or "") FUNCTION = "check_is_dir" def check_is_dir(self, path: str) -> tuple[bool]: return (os.path.isdir(path),) class PathIsFile(ComfyNodeABC): """ Checks if a path points to a file. This node returns True if the path exists and is a regular file, and False otherwise. """ @classmethod def INPUT_TYPES(cls): return { "required": { "path": (IO.STRING, {"default": ""}), } } RETURN_TYPES = (IO.BOOLEAN,) RETURN_NAMES = ("is file",) CATEGORY = "Basic/Path" DESCRIPTION = cleandoc(__doc__ or "") FUNCTION = "check_is_file" def check_is_file(self, path: str) -> tuple[bool]: return (os.path.isfile(path),) class PathJoin(ComfyNodeABC): """ Joins multiple path components into a single path. This node takes multiple path components and joins them intelligently to form a single path. It handles directory separators correctly for the operating system. """ @classmethod def INPUT_TYPES(cls): return { "required": { "path1": (IO.STRING, {"default": ""}), }, "optional": { "path2": (IO.STRING, {"default": ""}), } } RETURN_TYPES = (IO.STRING,) RETURN_NAMES = ("path",) CATEGORY = "Basic/Path" DESCRIPTION = cleandoc(__doc__ or "") FUNCTION = "join_paths" def join_paths(self, path1: str, path2: str = "") -> tuple[str]: paths = [p for p in [path1, path2] if p] return (str(os.path.join(*paths)),) class PathListDir(ComfyNodeABC): """ Lists the contents of a directory. This node returns a list of files and directories in the specified path. If 'files_only' is True, it only returns files. If 'dirs_only' is True, it only returns directories. If both are False, it returns all contents. """ @classmethod def INPUT_TYPES(cls): return { "required": { "path": (IO.STRING, {"default": ""}), }, "optional": { "files_only": (IO.BOOLEAN, {"default": False}), "dirs_only": (IO.BOOLEAN, {"default": False}), } } RETURN_TYPES = (IO.STRING,) RETURN_NAMES = ("entries",) CATEGORY = "Basic/Path" DESCRIPTION = cleandoc(__doc__ or "") FUNCTION = "list_directory" OUTPUT_IS_LIST = (True,) def list_directory(self, path: str, files_only: str = False, dirs_only: str = False) -> tuple[list[str]]: if not path: path = os.getcwd() if not os.path.exists(path): raise FileNotFoundError(f"Directory does not exist: {path}") if not os.path.isdir(path): raise NotADirectoryError(f"Basic data handling: Path is not a directory: {path}") entries = os.listdir(path) if files_only: entries = [e for e in entries if os.path.isfile(os.path.join(path, e))] elif dirs_only: entries = [e for e in entries if os.path.isdir(os.path.join(path, e))] return (entries,) class PathNormalize(ComfyNodeABC): """ Normalizes a path. This node normalizes a path by collapsing redundant separators, resolving up-level references, and converting to the correct separator for the operating system. """ @classmethod def INPUT_TYPES(cls): return { "required": { "path": (IO.STRING, {"default": ""}), } } RETURN_TYPES = (IO.STRING,) RETURN_NAMES = ("normalized path",) CATEGORY = "Basic/Path" DESCRIPTION = cleandoc(__doc__ or "") FUNCTION = "normalize_path" def normalize_path(self, path: str) -> tuple[str]: return (os.path.normpath(path),) class PathSetExtension(ComfyNodeABC): """ Sets the file extension for a path. This node replaces the current extension in a path with a new one. The extension should include the dot (e.g., '.jpg'). """ @classmethod def INPUT_TYPES(cls): return { "required": { "path": (IO.STRING, {}), "extension": (IO.STRING, {"default": ".txt"}), } } RETURN_TYPES = (IO.STRING,) RETURN_NAMES = ("path",) CATEGORY = "Basic/Path" DESCRIPTION = cleandoc(__doc__ or "") FUNCTION = "set_extension" def set_extension(self, path: str, extension: str) -> tuple[str]: # Make sure extension starts with a dot if not extension.startswith('.') and extension: extension = '.' + extension root, _ = os.path.splitext(path) return (root + extension,) class PathRelative(ComfyNodeABC): """ Returns a relative path. This node computes a relative path from the 'start' path to the 'path'. If 'start' is not provided, the current working directory is used. """ @classmethod def INPUT_TYPES(cls): return { "required": { "path": (IO.STRING, {"default": ""}), }, "optional": { "start": (IO.STRING, {"default": ""}), } } RETURN_TYPES = (IO.STRING,) RETURN_NAMES = ("relative path",) CATEGORY = "Basic/Path" DESCRIPTION = cleandoc(__doc__ or "") FUNCTION = "get_relative_path" def get_relative_path(self, path: str, start: str = "") -> tuple[str]: if not start: start = os.getcwd() return (os.path.relpath(path, start),) class PathSplit(ComfyNodeABC): """ Splits a path into directory and filename components. This node takes a path and returns a tuple containing the directory path and the filename. """ @classmethod def INPUT_TYPES(cls): return { "required": { "path": (IO.STRING, {"default": ""}), } } RETURN_TYPES = (IO.STRING, IO.STRING) RETURN_NAMES = ("directory", "filename") CATEGORY = "Basic/Path" DESCRIPTION = cleandoc(__doc__ or "") FUNCTION = "split_path" def split_path(self, path: str) -> tuple[str, str]: return os.path.split(path) class PathSplitExt(ComfyNodeABC): """ Splits a path into name and extension components. This node takes a path and returns a tuple containing the path without the extension and the extension (including the dot). """ @classmethod def INPUT_TYPES(cls): return { "required": { "path": (IO.STRING, {"default": ""}), } } RETURN_TYPES = (IO.STRING, IO.STRING) RETURN_NAMES = ("path without ext", "extension") CATEGORY = "Basic/Path" DESCRIPTION = cleandoc(__doc__ or "") FUNCTION = "split_ext" def split_ext(self, path: str) -> tuple[str, str]: return os.path.splitext(path) class PathLoadStringFile(ComfyNodeABC): """ Loads a text file in UTF-8 encoding and returns its content as a STRING without any further processing. """ @classmethod def INPUT_TYPES(cls): return { "required": { "path": (IO.STRING, {"default": ""}), }, } RETURN_TYPES = (IO.STRING, IO.BOOLEAN) RETURN_NAMES = ("text", "exists") CATEGORY = "Basic/Path" DESCRIPTION = cleandoc(__doc__ or "") FUNCTION = "load_text" @classmethod def IS_CHANGED(cls, path): try: if os.path.exists(path): return os.path.getmtime(path) except Exception: pass return float("NaN") # Return NaN if file doesn't exist or can't access modification time def load_text(self, path: str): exists = os.path.exists(path) if not exists: return ("", False) try: with open(path, "r", encoding="utf-8") as f: text = f.read() return (text, True) except Exception: return ("", False) class PathLoadImageRGB(ComfyNodeABC): """ Loads an image from a file path and returns only the RGB channels. This node loads an image from the specified path and processes it to return only the RGB channels as a tensor, ignoring any alpha channel. """ @classmethod def INPUT_TYPES(cls): return { "required": { "path": (IO.STRING, {"default": ""}), }, } RETURN_TYPES = (IO.IMAGE, IO.BOOLEAN) RETURN_NAMES = ("image", "exists") CATEGORY = "Basic/Path" DESCRIPTION = cleandoc(__doc__ or "") FUNCTION = "load_image_rgb" @classmethod def IS_CHANGED(cls, path): try: if os.path.exists(path): return os.path.getmtime(path) except Exception: pass return float("NaN") # Return NaN if file doesn't exist or can't access modification time def load_image_rgb(self, path: str): import numpy as np import torch img = load_image_helper(path) if img is None: # Create an empty 1x1 image empty_tensor = torch.zeros((1, 1, 1, 3), dtype=torch.float32) return (empty_tensor, False) # Convert to RGB (removing alpha if present) img_rgb = img.convert("RGB") # Convert to tensor format expected by ComfyUI image_tensor = np.array(img_rgb).astype(np.float32) / 255.0 image_tensor = torch.from_numpy(image_tensor)[None,] return (image_tensor, True) class PathLoadImageRGBA(ComfyNodeABC): """ Loads an image from a file path and returns RGB channels and Alpha as a mask. This node loads an image from the specified path and processes it to return the RGB channels as a tensor and the Alpha channel as a mask tensor. If the image has no alpha channel, a blank mask is returned. """ @classmethod def INPUT_TYPES(cls): return { "required": { "path": (IO.STRING, {"default": ""}), }, } RETURN_TYPES = (IO.IMAGE, IO.MASK, IO.BOOLEAN) RETURN_NAMES = ("image", "mask", "exists") CATEGORY = "Basic/Path" DESCRIPTION = cleandoc(__doc__ or "") FUNCTION = "load_image_rgba" @classmethod def IS_CHANGED(cls, path): try: if os.path.exists(path): return os.path.getmtime(path) except Exception: pass return float("NaN") # Return NaN if file doesn't exist or can't access modification time def load_image_rgba(self, path: str): import numpy as np import torch img = load_image_helper(path) if img is None: # Create empty 1x1 image and mask empty_image = torch.zeros((1, 1, 1, 3), dtype=torch.float32) empty_mask = torch.zeros((1, 1, 1), dtype=torch.float32) return (empty_image, empty_mask, False) # Convert to RGB for the image img_rgb = img.convert("RGB") # Convert to tensor format expected by ComfyUI image_tensor = np.array(img_rgb).astype(np.float32) / 255.0 image_tensor = torch.from_numpy(image_tensor)[None,] # Extract alpha channel as mask mask_tensor = extract_mask_from_alpha(img) return (image_tensor, mask_tensor, True) class PathLoadMaskFromAlpha(ComfyNodeABC): """ Loads a mask from the alpha channel of an image. This node loads an image from the specified path and extracts the alpha channel to use as a mask. If the image has no alpha channel, a blank mask is returned. """ @classmethod def INPUT_TYPES(cls): return { "required": { "path": (IO.STRING, {"default": ""}), }, } RETURN_TYPES = (IO.MASK, IO.BOOLEAN) RETURN_NAMES = ("mask", "exists") CATEGORY = "Basic/Path" DESCRIPTION = cleandoc(__doc__ or "") FUNCTION = "load_mask_from_alpha" @classmethod def IS_CHANGED(cls, path): try: if os.path.exists(path): return os.path.getmtime(path) except Exception: pass return float("NaN") # Return NaN if file doesn't exist or can't access modification time def load_mask_from_alpha(self, path: str): import torch img = load_image_helper(path) if img is None: # Return empty 1x1 mask empty_mask = torch.zeros((1, 1, 1), dtype=torch.float32) return (empty_mask, False) mask_tensor = extract_mask_from_alpha(img) return (mask_tensor, True) class PathLoadMaskFromGreyscale(ComfyNodeABC): """ Loads a mask from a greyscale image or the red channel of an RGB image. This node loads an image from the specified path and creates a mask from it. If the image is greyscale, the intensity is used directly. If the image is RGB, the red channel is used. """ @classmethod def INPUT_TYPES(cls): return { "required": { "path": (IO.STRING, {"default": ""}), }, "optional": { "invert": (IO.BOOLEAN, {"default": False}), }, } RETURN_TYPES = (IO.MASK, IO.BOOLEAN) RETURN_NAMES = ("mask", "exists") CATEGORY = "Basic/Path" DESCRIPTION = cleandoc(__doc__ or "") FUNCTION = "load_mask_from_greyscale" @classmethod def IS_CHANGED(cls, path): try: if os.path.exists(path): return os.path.getmtime(path) except Exception: pass return float("NaN") # Return NaN if file doesn't exist or can't access modification time def load_mask_from_greyscale(self, path: str, invert: bool = False): import torch img = load_image_helper(path) if img is None: # Return empty 1x1 mask empty_mask = torch.zeros((1, 1, 1), dtype=torch.float32) return (empty_mask, False) mask_tensor = extract_mask_from_greyscale(img) # Optionally invert the mask (1.0 - mask) if invert: mask_tensor = 1.0 - mask_tensor return (mask_tensor, True) class PathSaveStringFile(ComfyNodeABC): """ Saves a string to a text file. This node takes a string and saves it to the specified path as a text file. Optionally, you can choose to create the directory if it doesn't exist. If 'append' is True, the text is appended to an existing file instead of overwriting it. """ @classmethod def INPUT_TYPES(cls): return { "required": { "text": (IO.STRING, {"default": ""}), "path": (IO.STRING, {"default": ""}), }, "optional": { "create_dirs": (IO.BOOLEAN, {"default": True}), "append": (IO.BOOLEAN, {"default": False}), "encoding": (IO.STRING, {"default": "utf-8"}), } } RETURN_TYPES = (IO.BOOLEAN,) RETURN_NAMES = ("success",) CATEGORY = "Basic/Path" DESCRIPTION = cleandoc(__doc__ or "") FUNCTION = "save_text" OUTPUT_NODE = True def save_text(self, text: str, path: str, create_dirs: bool = True, append: bool = False, encoding: str = "utf-8"): if not path: print("Basic data handling: Save failed - no path specified") return (False,) try: # Create directories if needed directory = os.path.dirname(path) if directory and create_dirs and not os.path.exists(directory): os.makedirs(directory) mode = "a" if append else "w" with open(path, mode, encoding=encoding) as f: f.write(text) action = "appended" if append else "saved" print(f"Basic data handling: Successfully {action} text to {path}") return (True,) except Exception as e: print(f"Basic data handling: Error saving text file: {e}") return (False,) def compose_prompt_text(prompt: str, negative_prompt: str) -> str: """ Build the generation-parameter text embedded into saved images. Follows the Stable Diffusion WebUI convention: the (optional) positive prompt is written first, followed by an optional ``Negative prompt:`` line:: Negative prompt: Returns an empty string when neither value is provided, in which case no metadata is embedded into the file. """ lines = [] if prompt.strip(): lines.append(prompt.strip()) if negative_prompt.strip(): lines.append(f"Negative prompt: {negative_prompt.strip()}") return "\n".join(lines) def build_png_info(metadata_text: str): """ Wrap ``metadata_text`` in a Pillow ``PngInfo`` container under the standard ``parameters`` text-chunk key so it can be embedded in a PNG file. Returns ``None`` when there is no text to embed (or Pillow's PNG metadata support is unavailable), in which case the image should be saved without extra metadata. """ if not metadata_text: return None try: from PIL import PngImagePlugin except ModuleNotFoundError: return None pnginfo = PngImagePlugin.PngInfo() pnginfo.add_text("parameters", metadata_text) return pnginfo def build_image_exif(metadata_text: str, include_description: bool = True): """ Build an EXIF block that stores ``metadata_text`` for formats without a native text chunk (JPEG, WEBP, JXL). The payload is written into the EXIF ``UserComment`` field (tag 0x9286) of the Exif IFD as ``UNICODE\0`` + UTF-16-BE, which matches what Stable Diffusion WebUI / piexif based readers expect. When ``include_description`` is true, the payload is also written as UTF-8 into the EXIF ``ImageDescription`` field (tag 0x010E) of IFD0. Returns the EXIF bytes (starting with the ``Exif\0\0`` marker), or ``None`` when there is no text to embed. """ if not metadata_text: return None try: from PIL import ExifTags except ModuleNotFoundError: return None Image, _ = _require_pillow() exif = Image.Exif() if include_description: exif[0x010E] = metadata_text.encode("utf-8") exif.get_ifd(ExifTags.IFD.Exif)[0x9286] = b"UNICODE\x00" + metadata_text.encode("utf-16-be") return exif.tobytes() def build_xmp_packet(metadata_text: str) -> bytes: """ Build an XMP packet storing ``metadata_text`` in the Dublin Core ``dc:description`` tag, as expected for JPEG XL ``xml `` boxes. """ from xml.sax.saxutils import escape body = escape(metadata_text) packet = ( '\n' '\n' '\n' '\n' '' + body + '\n' '\n' '\n' '\n' '' ) return packet.encode("utf-8") def metadata_save_kwargs(metadata_text: str, fmt: str) -> dict: """ Return the extra keyword arguments that embed ``metadata_text`` when saving an image in the (lower-case) format ``fmt``. Returns an empty dict when there is no text to embed or when the format cannot carry text metadata. """ if not metadata_text: return {} if fmt == "png": return {"pnginfo": build_png_info(metadata_text)} if fmt in ("jpg", "jpeg"): exif = build_image_exif(metadata_text, include_description=True) return {"exif": exif} if exif is not None else {} if fmt in ("webp", "jxl"): exif = build_image_exif(metadata_text, include_description=False) kwargs = {"exif": exif} if exif is not None else {} if fmt == "jxl": # EXIF and XMP boxes are only available in the JXL container format kwargs["use_container"] = True kwargs["xmp"] = build_xmp_packet(metadata_text) return kwargs return {} class PathSaveImageRGB(ComfyNodeABC): """ Saves an image to a file. This node takes an image tensor and saves it to the specified path. Supports various image formats like PNG, JPG, WEBP, JXL (if pillow-jxl is installed), etc. When ``prompt`` and/or ``negative_prompt`` are provided, they are embedded into the saved image as ``parameters`` metadata: in the PNG text chunk, in the EXIF ``UserComment`` (and ``ImageDescription`` for JPEG) fields, and in the EXIF + XMP boxes for JPEG XL. Formats that cannot carry text metadata ignore the prompts. """ @classmethod def INPUT_TYPES(cls): return { "required": { "images": (IO.IMAGE,), "path": (IO.STRING, {"default": ""}), }, "optional": { "format": (IO.STRING, {"default": "png"}), "quality": (IO.INT, {"default": 95, "min": 1, "max": 100}), "create_dirs": (IO.BOOLEAN, {"default": True}), "prompt": (IO.STRING, {"default": ""}), "negative_prompt": (IO.STRING, {"default": ""}), } } RETURN_TYPES = (IO.BOOLEAN,) RETURN_NAMES = ("success",) CATEGORY = "Basic/Path" DESCRIPTION = cleandoc(__doc__ or "") FUNCTION = "save_image" OUTPUT_NODE = True def save_image(self, images, path: str, format: str = "png", quality: int = 95, create_dirs: bool = True, prompt: str = "", negative_prompt: str = ""): if not path: print("Basic data handling: Save failed - no path specified") return (False,) # If the path doesn't have an extension or it doesn't match the format, add it if not path.lower().endswith(f".{format.lower()}"): path = f"{path}.{format.lower()}" try: import numpy as np from PIL import Image # Check if pillow_jxl is available for JXL support has_jxl_support = False try: import pillow_jxl # noqa: F401 - imported but unused, kept for JPEG XL support has_jxl_support = True except ModuleNotFoundError: # pillow_jxl is not installed if format.lower() == "jxl": print("Basic data handling: JPEG XL format requested but pillow_jxl module is not installed. " "Please install it with 'pip install pillow-jxl-plugin'.") return (False,) # Create directories if needed directory = os.path.dirname(path) if directory and create_dirs and not os.path.exists(directory): os.makedirs(directory) # Convert from tensor format back to PIL Image # Extract the first image from the batch i = 0 img_tensor = images[i].cpu().numpy() # Convert to uint8 format for PIL img_np = (img_tensor * 255).astype(np.uint8) # Create PIL image pil_img = Image.fromarray(img_np) # Compose the prompt metadata to embed into the saved file metadata_text = compose_prompt_text(prompt, negative_prompt) fmt = format.lower() # Save the image, embedding prompt metadata where the format supports it if fmt == "jpg" or fmt == "jpeg": pil_img.save(path, format="JPEG", quality=quality, **metadata_save_kwargs(metadata_text, fmt)) elif fmt == "webp": pil_img.save(path, format="WEBP", quality=quality, **metadata_save_kwargs(metadata_text, fmt)) elif fmt == "jxl" and has_jxl_support: # JPEG XL specific options pil_img.save(path, format="JXL", quality=quality, **metadata_save_kwargs(metadata_text, fmt)) elif fmt == "png": pil_img.save(path, format="PNG", **metadata_save_kwargs(metadata_text, fmt)) else: if metadata_text: print("Basic data handling: Prompt metadata is not supported for this format; skipping it.") pil_img.save(path, format=format.upper()) print(f"Basic data handling: Successfully saved image to {path}") return (True,) except Exception as e: print(f"Basic data handling: Error saving image: {e}") return (False,) class PathSaveImageRGBA(ComfyNodeABC): """ Saves an image with a mask to a file with transparency. This node takes an image tensor and a mask tensor and saves them to the specified path as an image with transparency, where the mask defines the alpha channel. When ``prompt`` and/or ``negative_prompt`` are provided, they are embedded into the saved image as ``parameters`` metadata: in the PNG text chunk, in the EXIF ``UserComment`` (and ``ImageDescription`` for JPEG) fields, and in the EXIF + XMP boxes for JPEG XL. Formats that cannot carry text metadata ignore the prompts. """ @classmethod def INPUT_TYPES(cls): return { "required": { "images": (IO.IMAGE,), "mask": (IO.MASK,), "path": (IO.STRING, {"default": ""}), }, "optional": { "format": (IO.STRING, {"default": "png"}), "quality": (IO.INT, {"default": 95, "min": 1, "max": 100}), "invert_mask": (IO.BOOLEAN, {"default": False}), "create_dirs": (IO.BOOLEAN, {"default": True}), "prompt": (IO.STRING, {"default": ""}), "negative_prompt": (IO.STRING, {"default": ""}), } } RETURN_TYPES = (IO.BOOLEAN,) RETURN_NAMES = ("success",) CATEGORY = "Basic/Path" DESCRIPTION = cleandoc(__doc__ or "") FUNCTION = "save_image_with_mask" OUTPUT_NODE = True def save_image_with_mask(self, images, mask, path: str, format: str = "png", quality: int = 95, invert_mask: bool = False, create_dirs: bool = True, prompt: str = "", negative_prompt: str = ""): if not path: print("Basic data handling: Save failed - no path specified") return (False,) # Check format compatibility - needs to support alpha channel if format.lower() in ["jpg", "jpeg"]: print("Basic data handling: JPEG format doesn't support transparency. Using PNG instead.") format = "png" # If the path doesn't have an extension or it doesn't match the format, add it if not path.lower().endswith(f".{format.lower()}"): path = f"{path}.{format.lower()}" try: import numpy as np from PIL import Image # Check if pillow_jxl is available for JXL support has_jxl_support = False try: import pillow_jxl # noqa: F401 - imported but unused, kept for JPEG XL support has_jxl_support = True except ModuleNotFoundError: # pillow_jxl is not installed if format.lower() == "jxl": print("Basic data handling: JPEG XL format requested but pillow_jxl module is not installed. " "Please install it with 'pip install pillow-jxl-plugin'.") return (False,) # Create directories if needed directory = os.path.dirname(path) if directory and create_dirs and not os.path.exists(directory): os.makedirs(directory) # Convert from tensor format back to PIL Image # Extract the first image from the batch i = 0 img_tensor = images[i].cpu().numpy() mask_tensor = mask[i].cpu() # Invert the mask if needed (1.0 becomes transparent, 0.0 becomes opaque) if invert_mask: mask_tensor = 1.0 - mask_tensor # Convert to alpha channel (0-255) alpha_np = (255.0 * (1.0 - mask_tensor.numpy())).astype(np.uint8) # Convert to uint8 format for PIL img_np = (img_tensor * 255).astype(np.uint8) # Create PIL image (RGB) pil_img = Image.fromarray(img_np) # Create alpha channel image (avoid deprecated 'mode' kwarg in Pillow 13+) alpha_img = Image.fromarray(alpha_np).convert("L") # Convert to RGBA and add alpha channel pil_img_rgba = pil_img.convert("RGBA") pil_img_rgba.putalpha(alpha_img) # Compose the prompt metadata to embed into the saved file metadata_text = compose_prompt_text(prompt, negative_prompt) fmt = format.lower() # Save the image, embedding prompt metadata where the format supports it if fmt == "webp": pil_img_rgba.save(path, format="WEBP", quality=quality, **metadata_save_kwargs(metadata_text, fmt)) elif fmt == "jxl" and has_jxl_support: # JPEG XL supports alpha channel pil_img_rgba.save(path, format="JXL", quality=quality, **metadata_save_kwargs(metadata_text, fmt)) elif fmt == "png": pil_img_rgba.save(path, format="PNG", **metadata_save_kwargs(metadata_text, fmt)) else: if metadata_text: print("Basic data handling: Prompt metadata is not supported for this format; skipping it.") pil_img_rgba.save(path, format=format.upper()) print(f"Basic data handling: Successfully saved image with mask to {path}") return (True,) except Exception as e: print(f"Basic data handling: Error saving image with mask: {e}") return (False,) class PathInputDir(ComfyNodeABC): """ Returns the ComfyUI input path. This is where input images are usually stored when using ComfyUI """ @classmethod def INPUT_TYPES(cls): return {} RETURN_TYPES = (IO.STRING,) RETURN_NAMES = ("input_path",) CATEGORY = "Basic/Path" DESCRIPTION = cleandoc(__doc__ or "") FUNCTION = "execute" DISPLAY_NAME = "Get ComfyUI Input Path" def execute(self) -> tuple[str]: return (get_input_directory(),) class PathOutputDir(ComfyNodeABC): """ Returns the ComfyUI output path. This is where output images are usually stored when using ComfyUI """ @classmethod def INPUT_TYPES(cls): return {} RETURN_TYPES = (IO.STRING,) RETURN_NAMES = ("output_path",) CATEGORY = "Basic/Path" DESCRIPTION = cleandoc(__doc__ or "") FUNCTION = "execute" DISPLAY_NAME = "Get ComfyUI Output Path" def execute(self) -> tuple[str]: return (get_output_directory(),) NODE_CLASS_MAPPINGS = { "Basic data handling: PathAbspath": PathAbspath, "Basic data handling: PathBasename": PathBasename, "Basic data handling: PathCommonPrefix": PathCommonPrefix, "Basic data handling: PathDirname": PathDirname, "Basic data handling: PathExists": PathExists, "Basic data handling: PathExpandVars": PathExpandVars, "Basic data handling: PathGetCwd": PathGetCwd, "Basic data handling: PathGetExtension": PathGetExtension, "Basic data handling: PathSetExtension": PathSetExtension, "Basic data handling: PathGetSize": PathGetSize, "Basic data handling: PathGlob": PathGlob, "Basic data handling: PathInputDir": PathInputDir, "Basic data handling: PathIsAbsolute": PathIsAbsolute, "Basic data handling: PathIsDir": PathIsDir, "Basic data handling: PathIsFile": PathIsFile, "Basic data handling: PathJoin": PathJoin, "Basic data handling: PathListDir": PathListDir, "Basic data handling: PathNormalize": PathNormalize, "Basic data handling: PathOutputDir": PathOutputDir, "Basic data handling: PathRelative": PathRelative, "Basic data handling: PathSplit": PathSplit, "Basic data handling: PathSplitExt": PathSplitExt, "Basic data handling: PathLoadStringFile": PathLoadStringFile, "Basic data handling: PathLoadImageRGB": PathLoadImageRGB, "Basic data handling: PathLoadImageRGBA": PathLoadImageRGBA, "Basic data handling: PathLoadMaskFromAlpha": PathLoadMaskFromAlpha, "Basic data handling: PathLoadMaskFromGreyscale": PathLoadMaskFromGreyscale, "Basic data handling: PathSaveStringFile": PathSaveStringFile, "Basic data handling: PathSaveImageRGB": PathSaveImageRGB, "Basic data handling: PathSaveImageRGBA": PathSaveImageRGBA, } NODE_DISPLAY_NAME_MAPPINGS = { "Basic data handling: PathAbspath": "abspath", "Basic data handling: PathBasename": "basename", "Basic data handling: PathCommonPrefix": "common prefix", "Basic data handling: PathDirname": "dirname", "Basic data handling: PathExists": "exists", "Basic data handling: PathExpandVars": "expand vars", "Basic data handling: PathGetCwd": "get current working directory", "Basic data handling: PathGetExtension": "get extension", "Basic data handling: PathSetExtension": "set extension", "Basic data handling: PathGetSize": "get size", "Basic data handling: PathGlob": "glob", "Basic data handling: PathInputDir": "input dir", "Basic data handling: PathIsAbsolute": "is absolute", "Basic data handling: PathIsDir": "is dir", "Basic data handling: PathIsFile": "is file", "Basic data handling: PathJoin": "join", "Basic data handling: PathListDir": "list dir", "Basic data handling: PathNormalize": "normalize", "Basic data handling: PathOutputDir": "output dir", "Basic data handling: PathRelative": "relative", "Basic data handling: PathSplit": "split", "Basic data handling: PathSplitExt": "splitext", "Basic data handling: PathLoadStringFile": "load STRING from file", "Basic data handling: PathLoadImageRGB": "load IMAGE from file (RGB)", "Basic data handling: PathLoadImageRGBA": "load IMAGE+MASK from file (RGBA)", "Basic data handling: PathLoadMaskFromAlpha": "load MASK from alpha channel", "Basic data handling: PathLoadMaskFromGreyscale": "load MASK from greyscale/red", "Basic data handling: PathSaveStringFile": "save STRING to file", "Basic data handling: PathSaveImageRGB": "save IMAGE to file", "Basic data handling: PathSaveImageRGBA": "save IMAGE+MASK to file", }