| import logging |
| import os |
| import json |
|
|
| import av |
| import numpy as np |
| import torch |
| from PIL import Image |
| from typing_extensions import override |
|
|
| import folder_paths |
| import node_helpers |
| from comfy_api.latest import ComfyExtension, io, Input, InputImpl, Types |
|
|
|
|
| def load_and_process_images(image_files, input_dir): |
| """Utility function to load and process a list of images. |
| |
| Args: |
| image_files: List of image filenames |
| input_dir: Base directory containing the images |
| resize_method: How to handle images of different sizes ("None", "Stretch", "Crop", "Pad") |
| |
| Returns: |
| torch.Tensor: Batch of processed images |
| """ |
| if not image_files: |
| raise ValueError("No valid images found in input") |
|
|
| output_images = [] |
|
|
| for file in image_files: |
| image_path = os.path.join(input_dir, file) |
| img = node_helpers.pillow(Image.open, image_path) |
|
|
| if img.mode == "I": |
| img = img.point(lambda i: i * (1 / 255)) |
| img = img.convert("RGB") |
| img_array = np.array(img).astype(np.float32) / 255.0 |
| img_tensor = torch.from_numpy(img_array)[None,] |
| output_images.append(img_tensor) |
|
|
| return output_images |
|
|
|
|
| def secure_subfolder_path(base_dir, folder_name): |
| """Resolve folder_name inside base_dir, rejecting anything that escapes it. |
| |
| Blocks '..', absolute paths, drive letters and symlink escapes using the |
| same realpath containment check as the core file endpoints. |
| """ |
| target = os.path.abspath(os.path.join(base_dir, folder_name)) |
| if not folder_paths.is_within_directory(base_dir, target): |
| raise ValueError(f"Invalid folder name {folder_name!r}: resolves outside of {base_dir}") |
| return target |
|
|
|
|
| def list_dataset_folders(): |
| """Relative paths of dataset folders found under all dataset roots. |
| |
| Any subfolder containing a metadata.json or *.safetensors shard counts as |
| a dataset; the walk doesn't descend into matched folders. |
| |
| Symlinked directories are followed, but symlink loops are avoided. |
| """ |
| found = set() |
|
|
| for root in folder_paths.get_folder_paths("datasets"): |
| if not os.path.isdir(root): |
| continue |
|
|
| root = os.path.abspath(root) |
| seen_dirs = set() |
|
|
| for dirpath, subdirs, filenames in os.walk(root, followlinks=True): |
| try: |
| st = os.stat(dirpath) |
| except OSError: |
| subdirs[:] = [] |
| continue |
|
|
| dir_key = (st.st_dev, st.st_ino) |
| if dir_key in seen_dirs: |
| subdirs[:] = [] |
| continue |
|
|
| seen_dirs.add(dir_key) |
|
|
| if dirpath != root and ( |
| "metadata.json" in filenames |
| or any(f.endswith(".safetensors") for f in filenames) |
| ): |
| found.add(os.path.relpath(dirpath, root).replace(os.sep, "/")) |
| subdirs[:] = [] |
| continue |
|
|
| kept_subdirs = [] |
| for name in subdirs: |
| child = os.path.join(dirpath, name) |
| try: |
| child_st = os.stat(child) |
| except OSError: |
| continue |
|
|
| child_key = (child_st.st_dev, child_st.st_ino) |
| if child_key not in seen_dirs: |
| kept_subdirs.append(name) |
|
|
| subdirs[:] = kept_subdirs |
|
|
| return sorted(found) |
|
|
|
|
| def get_dataset_save_dir(folder_name): |
| """Resolve the folder to save a new dataset into, inside the default root. |
| |
| The folder is not created here; callers makedirs after validation. |
| """ |
| root = folder_paths.get_folder_paths("datasets")[0] |
| target = secure_subfolder_path(root, folder_name) |
| if os.path.realpath(target) == os.path.realpath(root): |
| raise ValueError("folder_name must name a subfolder of the datasets directory, e.g. 'my_dataset'.") |
| return target |
|
|
|
|
| def get_dataset_dir(folder_name): |
| """Find an existing dataset folder by relative name across all dataset roots.""" |
| roots = folder_paths.get_folder_paths("datasets") |
| for root in roots: |
| target = secure_subfolder_path(root, folder_name) |
| if os.path.realpath(target) == os.path.realpath(root): |
| raise ValueError("folder_name must name a subfolder of the datasets directory, e.g. 'my_dataset'.") |
| if os.path.isdir(target): |
| return target |
| raise ValueError(f"Dataset folder {folder_name!r} not found in: {', '.join(roots)}") |
|
|
|
|
| VALID_VIDEO_EXTENSIONS = [".mp4", ".avi", ".mov", ".webm", ".mkv", ".flv"] |
|
|
|
|
| def _decode_selected_frames(video: Input.Video, indices: list[int]) -> Input.Video: |
| """Decode only the requested frame indices from a video. |
| |
| Opens the underlying container once, decodes frames in presentation order, |
| keeps only the ones whose index is in ``indices``, and returns the result |
| wrapped in a VideoFromComponents so it still satisfies the VideoInput |
| contract for downstream nodes. |
| """ |
| indices_sorted = sorted(set(indices)) |
| max_idx = indices_sorted[-1] |
| source = video.get_stream_source() |
|
|
| frames_by_idx: dict[int, torch.Tensor] = {} |
| with av.open(source, mode="r") as container: |
| stream = container.streams.video[0] |
| wanted = set(indices_sorted) |
| for frame_idx, frame in enumerate(container.decode(stream)): |
| if frame_idx in wanted: |
| img = frame.to_ndarray(format="rgb24") |
| frames_by_idx[frame_idx] = torch.from_numpy(img.copy()).float() / 255.0 |
| if frame_idx >= max_idx: |
| break |
|
|
| stacked = torch.stack([frames_by_idx[i] for i in indices]) |
| return InputImpl.VideoFromComponents( |
| Types.VideoComponents(images=stacked, frame_rate=video.get_frame_rate()) |
| ) |
|
|
|
|
| class LoadImageDataSetFromFolderNode(io.ComfyNode): |
| @classmethod |
| def define_schema(cls): |
| return io.Schema( |
| node_id="LoadImageDataSetFromFolder", |
| search_aliases=["load folder", "load from folder", "load dataset", "load images", "import dataset"], |
| display_name="Load Image (from Folder)", |
| category="image", |
| description="Load a dataset of images from a specified folder and return a list of images. Supported formats: PNG, JPG, JPEG, WEBP.", |
| is_experimental=True, |
| inputs=[ |
| io.Combo.Input( |
| "folder", |
| options=folder_paths.get_input_subfolders(), |
| tooltip="The folder to load images from.", |
| ) |
| ], |
| outputs=[ |
| io.Image.Output( |
| display_name="images", |
| is_output_list=True, |
| tooltip="List of loaded images", |
| ) |
| ], |
| ) |
|
|
| @classmethod |
| def execute(cls, folder): |
| sub_input_dir = secure_subfolder_path(folder_paths.get_input_directory(), folder) |
| valid_extensions = [".png", ".jpg", ".jpeg", ".webp"] |
| image_files = [ |
| f |
| for f in os.listdir(sub_input_dir) |
| if any(f.lower().endswith(ext) for ext in valid_extensions) |
| ] |
| output_tensor = load_and_process_images(image_files, sub_input_dir) |
| return io.NodeOutput(output_tensor) |
|
|
|
|
| class LoadImageTextDataSetFromFolderNode(io.ComfyNode): |
| @classmethod |
| def define_schema(cls): |
| return io.Schema( |
| node_id="LoadImageTextDataSetFromFolder", |
| search_aliases=["load folder", "load from folder", "load dataset", "load images", "import dataset"], |
| display_name="Load Image-Text (from Folder)", |
| category="image", |
| description="Load a dataset of pairs of images and text captions from a specified folder and return them as a list. Supported formats: PNG, JPG, JPEG, WEBP.", |
| is_experimental=True, |
| inputs=[ |
| io.Combo.Input( |
| "folder", |
| options=folder_paths.get_input_subfolders(), |
| tooltip="The folder to load images and text captions from.", |
| ) |
| ], |
| outputs=[ |
| io.Image.Output( |
| display_name="images", |
| is_output_list=True, |
| tooltip="List of loaded images", |
| ), |
| io.String.Output( |
| display_name="texts", |
| is_output_list=True, |
| tooltip="List of text captions", |
| ), |
| ], |
| ) |
|
|
| @classmethod |
| def execute(cls, folder): |
| logging.info(f"Loading images from folder: {folder}") |
|
|
| sub_input_dir = secure_subfolder_path(folder_paths.get_input_directory(), folder) |
| valid_extensions = [".png", ".jpg", ".jpeg", ".webp"] |
|
|
| image_files = [] |
| for item in os.listdir(sub_input_dir): |
| path = os.path.join(sub_input_dir, item) |
| if any(item.lower().endswith(ext) for ext in valid_extensions): |
| image_files.append(path) |
| elif os.path.isdir(path): |
| |
| repeat = 1 |
| if item.split("_")[0].isdigit(): |
| repeat = int(item.split("_")[0]) |
| image_files.extend( |
| [ |
| os.path.join(path, f) |
| for f in os.listdir(path) |
| if any(f.lower().endswith(ext) for ext in valid_extensions) |
| ] |
| * repeat |
| ) |
|
|
| caption_file_path = [ |
| f.replace(os.path.splitext(f)[1], ".txt") for f in image_files |
| ] |
| captions = [] |
| for caption_file in caption_file_path: |
| caption_path = os.path.join(sub_input_dir, caption_file) |
| if os.path.exists(caption_path): |
| with open(caption_path, "r", encoding="utf-8") as f: |
| caption = f.read().strip() |
| captions.append(caption) |
| else: |
| captions.append("") |
|
|
| output_tensor = load_and_process_images(image_files, sub_input_dir) |
|
|
| logging.info(f"Loaded {len(output_tensor)} images from {sub_input_dir}.") |
| return io.NodeOutput(output_tensor, captions) |
|
|
|
|
| class LoadVideoDataSetFromFolderNode(io.ComfyNode): |
| @classmethod |
| def define_schema(cls): |
| return io.Schema( |
| node_id="LoadVideoDataSetFromFolder", |
| search_aliases=["load folder", "load from folder", "load dataset", "load videos", "import dataset"], |
| display_name="Load Video (from Folder)", |
| category="video", |
| description="Load a dataset of videos from a specified folder and return a list of videos. Supported formats: MP4, AVI, MOV, WEBM, MKV, FLV.", |
| is_experimental=True, |
| inputs=[ |
| io.Combo.Input( |
| "folder", |
| options=folder_paths.get_input_subfolders(), |
| tooltip="The folder containing video files.", |
| ), |
| ], |
| outputs=[ |
| io.Video.Output( |
| display_name="videos", |
| is_output_list=True, |
| tooltip="Lazy video references; frames are decoded only when needed downstream.", |
| ), |
| ], |
| ) |
|
|
| @classmethod |
| def execute(cls, folder): |
| sub_input_dir = secure_subfolder_path(folder_paths.get_input_directory(), folder) |
| video_files = sorted([ |
| f for f in os.listdir(sub_input_dir) |
| if any(f.lower().endswith(ext) for ext in VALID_VIDEO_EXTENSIONS) |
| ]) |
|
|
| if not video_files: |
| raise ValueError(f"No video files found in {sub_input_dir}") |
|
|
| videos = [InputImpl.VideoFromFile(os.path.join(sub_input_dir, f)) for f in video_files] |
| logging.info(f"Loaded {len(videos)} lazy video references from {sub_input_dir}") |
| return io.NodeOutput(videos) |
|
|
|
|
| class LoadVideoTextDataSetFromFolderNode(io.ComfyNode): |
| @classmethod |
| def define_schema(cls): |
| return io.Schema( |
| node_id="LoadVideoTextDataSetFromFolder", |
| search_aliases=["load folder", "load from folder", "load dataset", "load videos", "import dataset"], |
| display_name="Load Video-Text (from Folder)", |
| category="video", |
| description="Load a dataset of pairs of videos and text captions from a specified folder and return them as a list. Supported formats: MP4, AVI, MOV, WEBM, MKV, FLV.", |
| is_experimental=True, |
| inputs=[ |
| io.Combo.Input( |
| "folder", |
| options=folder_paths.get_input_subfolders(), |
| tooltip="The folder containing video files and .txt captions.", |
| ), |
| ], |
| outputs=[ |
| io.Video.Output( |
| display_name="videos", |
| is_output_list=True, |
| tooltip="Lazy video references; frames are decoded only when needed downstream.", |
| ), |
| io.String.Output( |
| display_name="texts", |
| is_output_list=True, |
| tooltip="List of text captions.", |
| ), |
| ], |
| ) |
|
|
| @classmethod |
| def execute(cls, folder): |
| sub_input_dir = secure_subfolder_path(folder_paths.get_input_directory(), folder) |
|
|
| video_files = [] |
| for item in sorted(os.listdir(sub_input_dir)): |
| path = os.path.join(sub_input_dir, item) |
| if any(item.lower().endswith(ext) for ext in VALID_VIDEO_EXTENSIONS): |
| video_files.append(path) |
| elif os.path.isdir(path): |
| |
| repeat = 1 |
| if item.split("_")[0].isdigit(): |
| repeat = int(item.split("_")[0]) |
| video_files.extend([ |
| os.path.join(path, f) |
| for f in sorted(os.listdir(path)) |
| if any(f.lower().endswith(ext) for ext in VALID_VIDEO_EXTENSIONS) |
| ] * repeat) |
|
|
| if not video_files: |
| raise ValueError(f"No video files found in {sub_input_dir}") |
|
|
| captions = [] |
| for vf in video_files: |
| caption_path = os.path.splitext(vf)[0] + ".txt" |
| if os.path.exists(caption_path): |
| with open(caption_path, "r", encoding="utf-8") as f: |
| captions.append(f.read().strip()) |
| else: |
| captions.append("") |
|
|
| videos = [InputImpl.VideoFromFile(vf) for vf in video_files] |
| logging.info(f"Loaded {len(videos)} lazy video references with captions from {sub_input_dir}") |
| return io.NodeOutput(videos, captions) |
|
|
|
|
| def save_images_to_folder(image_list, output_dir, prefix="image", overwrite=True): |
| """Utility function to save a list of image tensors to disk. |
| |
| Args: |
| image_list: List of image tensors (each [1, H, W, C] or [H, W, C] or [C, H, W]) |
| output_dir: Directory to save images to |
| prefix: Filename prefix |
| |
| Returns: |
| List of saved filenames |
| """ |
| os.makedirs(output_dir, exist_ok=True) |
| saved_files = [] |
|
|
| for idx, img_tensor in enumerate(image_list): |
| |
| if isinstance(img_tensor, torch.Tensor): |
| |
| if img_tensor.dim() == 4 and img_tensor.shape[0] == 1: |
| img_tensor = img_tensor.squeeze(0) |
|
|
| |
| if img_tensor.dim() == 3 and img_tensor.shape[0] in [1, 3, 4]: |
| if ( |
| img_tensor.shape[0] <= 4 |
| and img_tensor.shape[1] > 4 |
| and img_tensor.shape[2] > 4 |
| ): |
| img_tensor = img_tensor.permute(1, 2, 0) |
|
|
| |
| img_array = img_tensor.cpu().numpy() |
| img_array = np.clip(img_array * 255.0, 0, 255).astype(np.uint8) |
|
|
| |
| img = Image.fromarray(img_array) |
| else: |
| raise ValueError(f"Expected torch.Tensor, got {type(img_tensor)}") |
|
|
| |
| if overwrite: |
| filename = f"{prefix}_{idx:05d}.png" |
| else: |
| _, _, counter, _, resolved_prefix = folder_paths.get_save_image_path(prefix, output_dir) |
| filename = f"{resolved_prefix}_{counter:05}_{idx:05d}.png" |
| filepath = os.path.join(output_dir, filename) |
| img.save(filepath) |
| saved_files.append(filename) |
|
|
| return saved_files |
|
|
|
|
| class SaveImageDataSetToFolderNode(io.ComfyNode): |
| @classmethod |
| def define_schema(cls): |
| return io.Schema( |
| node_id="SaveImageDataSetToFolder", |
| search_aliases=["save folder", "save to folder", "save dataset", "save images", "export dataset"], |
| display_name="Save Image (to Folder) (DEPRECATED)", |
| category="image", |
| description="Save a dataset of images to a specified folder. Supported formats: PNG.", |
| is_experimental=True, |
| is_output_node=True, |
| is_input_list=True, |
| inputs=[ |
| io.Image.Input("images", tooltip="List of images to save."), |
| io.String.Input( |
| "folder_name", |
| default="dataset", |
| tooltip="Name of the folder to save images to (inside output directory).", |
| ), |
| io.String.Input( |
| "filename_prefix", |
| default="image", |
| tooltip="Prefix for saved image filenames.", |
| advanced=True, |
| ), |
| io.Combo.Input( |
| "mode", |
| default="overwrite", |
| options=["overwrite", "increment"], |
| tooltip="Whether to overwrite existing files or increment filenames to avoid overwriting." |
| ), |
| ], |
| outputs=[], |
| is_deprecated=True, |
| ) |
|
|
| @classmethod |
| def execute(cls, images, folder_name, filename_prefix, mode): |
| |
| folder_name = folder_name[0] |
| filename_prefix = filename_prefix[0] |
| mode = mode[0] |
|
|
| output_dir = secure_subfolder_path(folder_paths.get_output_directory(), folder_name) |
| saved_files = save_images_to_folder(images, output_dir, filename_prefix, mode=='overwrite') |
|
|
| logging.info(f"Saved {len(saved_files)} images to {output_dir}.") |
| return io.NodeOutput() |
|
|
|
|
| class SaveImageTextDataSetToFolderNode(io.ComfyNode): |
| @classmethod |
| def define_schema(cls): |
| return io.Schema( |
| node_id="SaveImageTextDataSetToFolder", |
| search_aliases=["save folder", "save to folder", "save dataset", "save images", "save text", "export dataset"], |
| display_name="Save Image-Text (to Folder)", |
| category="image", |
| description="Save a dataset of pairs of images and text captions to a specified folder. Images are saved as PNG files and captions are saved as TXT files with the same filename_prefix.", |
| is_experimental=True, |
| is_output_node=True, |
| is_input_list=True, |
| inputs=[ |
| io.Image.Input("images", tooltip="List of images to save."), |
| io.String.Input("texts", |
| optional=True, |
| force_input=True, |
| tooltip="List of text captions to save." |
| ), |
| io.String.Input( |
| "folder_name", |
| default="dataset", |
| tooltip="Name of the folder to save images to (inside output directory).", |
| ), |
| io.String.Input( |
| "filename_prefix", |
| default="image", |
| tooltip="Prefix for saved image filenames.", |
| advanced=True, |
| ), |
| io.Combo.Input( |
| "mode", |
| default="overwrite", |
| options=["overwrite", "increment"], |
| tooltip="Whether to overwrite existing files or increment filenames to avoid overwriting." |
| ), |
| ], |
| outputs=[], |
| ) |
|
|
| @classmethod |
| def execute(cls, images, folder_name, filename_prefix, mode, texts=None): |
| |
| folder_name = folder_name[0] |
| filename_prefix = filename_prefix[0] |
| mode = mode[0] |
|
|
| output_dir = secure_subfolder_path(folder_paths.get_output_directory(), folder_name) |
| saved_files = save_images_to_folder(images, output_dir, filename_prefix, mode=='overwrite') |
|
|
| |
| if texts: |
| for idx, (filename, caption) in enumerate(zip(saved_files, texts)): |
| caption_filename = filename.replace(".png", ".txt") |
| caption_path = os.path.join(output_dir, caption_filename) |
| with open(caption_path, "w", encoding="utf-8") as f: |
| f.write(caption) |
|
|
| logging.info(f"Saved {len(saved_files)} images and captions to {output_dir}.") |
| return io.NodeOutput() |
|
|
|
|
| |
|
|
|
|
| def tensor_to_pil(img_tensor): |
| """Convert tensor to PIL Image.""" |
| if img_tensor.dim() == 4 and img_tensor.shape[0] == 1: |
| img_tensor = img_tensor.squeeze(0) |
| img_array = (img_tensor.cpu().numpy() * 255).clip(0, 255).astype(np.uint8) |
| return Image.fromarray(img_array) |
|
|
|
|
| def pil_to_tensor(img): |
| """Convert PIL Image to tensor.""" |
| img_array = np.array(img).astype(np.float32) / 255.0 |
| return torch.from_numpy(img_array)[None,] |
|
|
|
|
| |
|
|
|
|
| class ImageProcessingNode(io.ComfyNode): |
| """Base class for image processing nodes that operate on images. |
| |
| Child classes should set: |
| node_id: Unique node identifier (required) |
| search_aliases: List of search aliases (optional) |
| display_name: Display name (optional, defaults to node_id) |
| description: Node description (optional) |
| extra_inputs: List of additional io.Input objects beyond "images" (optional) |
| is_group_process: None (auto-detect), True (group), or False (individual) (optional) |
| is_output_list: True (list output) or False (single output) (optional, default True) |
| is_deprecated: True if the node is deprecated (optional, default False) |
| |
| Child classes must implement ONE of: |
| _process(cls, image, **kwargs) -> tensor (for single-item processing) |
| _group_process(cls, images, **kwargs) -> list[tensor] (for group processing) |
| """ |
|
|
| node_id = None |
| search_aliases = [] |
| display_name = None |
| description = None |
| extra_inputs = [] |
| is_group_process = None |
| is_output_list = None |
| is_deprecated = False |
| @classmethod |
| def _detect_processing_mode(cls): |
| """Detect whether this node uses group or individual processing. |
| |
| Returns: |
| bool: True if group processing, False if individual processing |
| """ |
| |
| if cls.is_group_process is not None: |
| return cls.is_group_process |
|
|
| |
| base_class = ImageProcessingNode |
|
|
| |
| process_definer = None |
| for klass in cls.__mro__: |
| if "_process" in klass.__dict__: |
| process_definer = klass |
| break |
|
|
| |
| group_definer = None |
| for klass in cls.__mro__: |
| if "_group_process" in klass.__dict__: |
| group_definer = klass |
| break |
|
|
| |
| has_process = process_definer is not None and process_definer is not base_class |
| has_group = group_definer is not None and group_definer is not base_class |
|
|
| if has_process and has_group: |
| raise ValueError( |
| f"{cls.__name__}: Cannot override both _process and _group_process. " |
| "Override only one, or set is_group_process explicitly." |
| ) |
| if not has_process and not has_group: |
| raise ValueError( |
| f"{cls.__name__}: Must override either _process or _group_process" |
| ) |
|
|
| return has_group |
|
|
| @classmethod |
| def _ensure_image_list(cls, images): |
| """Normalize to a flat list of [1, H, W, C] tensors.""" |
| if isinstance(images, torch.Tensor): |
| if images.ndim != 4: |
| raise ValueError(f"Expected 4D image tensor, got shape {tuple(images.shape)}") |
| return [images[i:i+1] for i in range(images.shape[0])] |
|
|
| flat = [] |
| for item in images: |
| if not isinstance(item, torch.Tensor) or item.ndim != 4: |
| raise ValueError(f"Expected 4D image tensor, got {type(item).__name__} shape {getattr(item, 'shape', None)}") |
| flat.extend([item[i:i+1] for i in range(item.shape[0])]) |
| return flat |
|
|
| @classmethod |
| def define_schema(cls): |
| if cls.node_id is None: |
| raise NotImplementedError(f"{cls.__name__} must set node_id class variable") |
|
|
| is_group = cls._detect_processing_mode() |
|
|
| |
| |
| |
| output_is_list = ( |
| cls.is_output_list if cls.is_output_list is not None else is_group |
| ) |
|
|
| inputs = [ |
| io.Image.Input( |
| "images", |
| tooltip=( |
| "List of images to process." if is_group else "Image to process." |
| ), |
| ) |
| ] |
| inputs.extend(cls.extra_inputs) |
|
|
| return io.Schema( |
| node_id=cls.node_id, |
| search_aliases=cls.search_aliases, |
| display_name=cls.display_name or cls.node_id, |
| category=cls.category, |
| description=cls.description, |
| is_experimental=True, |
| is_input_list=is_group, |
| inputs=inputs, |
| outputs=[ |
| io.Image.Output( |
| display_name="images", |
| is_output_list=output_is_list, |
| tooltip="Processed images", |
| ) |
| ], |
| ) |
|
|
| @classmethod |
| def execute(cls, images, **kwargs): |
| """Execute the node. Routes to _process or _group_process based on mode. |
| |
| For individual processing (_process), automatically handles multi-frame |
| inputs (video tensors [T, H, W, C]) by applying _process per-frame and |
| concatenating the results. This allows all spatial transform nodes to |
| work with video without modification. Nodes that natively handle batched |
| tensors (e.g. pure tensor math) can set per_frame_process = False to |
| skip the per-frame loop. |
| """ |
| is_group = cls._detect_processing_mode() |
|
|
| if is_group: |
| images = cls._ensure_image_list(images) |
|
|
| |
| params = {} |
| for k, v in kwargs.items(): |
| if isinstance(v, list) and len(v) == 1: |
| params[k] = v[0] |
| else: |
| params[k] = v |
|
|
| if is_group: |
| |
| result = cls._group_process(images, **params) |
| else: |
| |
| |
| |
| if images.shape[0] > 1 and getattr(cls, 'per_frame_process', True): |
| results = [] |
| for i in range(images.shape[0]): |
| frame_result = cls._process(images[i:i + 1], **params) |
| results.append(frame_result) |
| result = torch.cat(results, dim=0) |
| else: |
| result = cls._process(images, **params) |
|
|
| return io.NodeOutput(result) |
|
|
| @classmethod |
| def _process(cls, image, **kwargs): |
| """Override this method for single-item processing. |
| |
| Args: |
| image: tensor - Single image tensor |
| **kwargs: Additional parameters (already extracted from lists) |
| |
| Returns: |
| tensor - Processed image |
| """ |
| raise NotImplementedError(f"{cls.__name__} must implement _process method") |
|
|
| @classmethod |
| def _group_process(cls, images, **kwargs): |
| """Override this method for group processing. |
| |
| Args: |
| images: list[tensor] - List of image tensors |
| **kwargs: Additional parameters (already extracted from lists) |
| |
| Returns: |
| list[tensor] - Processed images |
| """ |
| raise NotImplementedError( |
| f"{cls.__name__} must implement _group_process method" |
| ) |
|
|
|
|
| class TextProcessingNode(io.ComfyNode): |
| """Base class for text processing nodes that operate on texts. |
| |
| Child classes should set: |
| node_id: Unique node identifier (required) |
| search_aliases: List of search aliases (optional) |
| display_name: Display name (optional, defaults to node_id) |
| description: Node description (optional) |
| extra_inputs: List of additional io.Input objects beyond "texts" (optional) |
| is_group_process: None (auto-detect), True (group), or False (individual) (optional) |
| is_output_list: True (list output) or False (single output) (optional, default True) |
| is_deprecated: True if the node is deprecated (optional, default False) |
| |
| Child classes must implement ONE of: |
| _process(cls, text, **kwargs) -> str (for single-item processing) |
| _group_process(cls, texts, **kwargs) -> list[str] (for group processing) |
| """ |
|
|
| node_id = None |
| search_aliases = [] |
| display_name = None |
| description = None |
| extra_inputs = [] |
| is_group_process = None |
| is_output_list = None |
| is_deprecated = False |
| @classmethod |
| def _detect_processing_mode(cls): |
| """Detect whether this node uses group or individual processing. |
| |
| Returns: |
| bool: True if group processing, False if individual processing |
| """ |
| |
| if cls.is_group_process is not None: |
| return cls.is_group_process |
|
|
| |
| base_class = TextProcessingNode |
|
|
| |
| process_definer = None |
| for klass in cls.__mro__: |
| if "_process" in klass.__dict__: |
| process_definer = klass |
| break |
|
|
| |
| group_definer = None |
| for klass in cls.__mro__: |
| if "_group_process" in klass.__dict__: |
| group_definer = klass |
| break |
|
|
| |
| has_process = process_definer is not None and process_definer is not base_class |
| has_group = group_definer is not None and group_definer is not base_class |
|
|
| if has_process and has_group: |
| raise ValueError( |
| f"{cls.__name__}: Cannot override both _process and _group_process. " |
| "Override only one, or set is_group_process explicitly." |
| ) |
| if not has_process and not has_group: |
| raise ValueError( |
| f"{cls.__name__}: Must override either _process or _group_process" |
| ) |
|
|
| return has_group |
|
|
| @classmethod |
| def define_schema(cls): |
| if cls.node_id is None: |
| raise NotImplementedError(f"{cls.__name__} must set node_id class variable") |
|
|
| is_group = cls._detect_processing_mode() |
|
|
| inputs = [ |
| io.String.Input( |
| "texts", |
| tooltip="List of texts to process." if is_group else "Text to process.", |
| ) |
| ] |
| inputs.extend(cls.extra_inputs) |
|
|
| return io.Schema( |
| node_id=cls.node_id, |
| display_name=cls.display_name or cls.node_id, |
| category="text", |
| is_experimental=True, |
| is_input_list=is_group, |
| inputs=inputs, |
| outputs=[ |
| io.String.Output( |
| display_name="texts", |
| is_output_list=cls.is_output_list, |
| tooltip="Processed texts", |
| ) |
| ], |
| ) |
|
|
| @classmethod |
| def execute(cls, texts, **kwargs): |
| """Execute the node. Routes to _process or _group_process based on mode.""" |
| is_group = cls._detect_processing_mode() |
|
|
| |
| params = {} |
| for k, v in kwargs.items(): |
| if isinstance(v, list) and len(v) == 1: |
| params[k] = v[0] |
| else: |
| params[k] = v |
|
|
| if is_group: |
| |
| result = cls._group_process(texts, **params) |
| else: |
| |
| result = cls._process(texts, **params) |
|
|
| |
| if cls.is_output_list: |
| |
| return io.NodeOutput(result if is_group else [result]) |
| else: |
| |
| return io.NodeOutput([result]) |
|
|
| @classmethod |
| def _process(cls, text, **kwargs): |
| """Override this method for single-item processing. |
| |
| Args: |
| text: str - Single text string |
| **kwargs: Additional parameters (already extracted from lists) |
| |
| Returns: |
| str - Processed text |
| """ |
| raise NotImplementedError(f"{cls.__name__} must implement _process method") |
|
|
| @classmethod |
| def _group_process(cls, texts, **kwargs): |
| """Override this method for group processing. |
| |
| Args: |
| texts: list[str] - List of text strings |
| **kwargs: Additional parameters (already extracted from lists) |
| |
| Returns: |
| list[str] - Processed texts |
| """ |
| raise NotImplementedError( |
| f"{cls.__name__} must implement _group_process method" |
| ) |
|
|
|
|
| |
|
|
|
|
| class ResizeImagesByShorterEdgeNode(ImageProcessingNode): |
| node_id = "ResizeImagesByShorterEdge" |
| display_name = "Resize Images by Shorter Edge (DEPRECATED)" |
| category = "image/transform" |
| description = "Resize images so that the shorter edge matches the specified dimension while preserving aspect ratio." |
| is_deprecated = True |
| extra_inputs = [ |
| io.Int.Input( |
| "shorter_edge", |
| default=512, |
| min=1, |
| max=8192, |
| tooltip="Target dimension for the shorter edge.", |
| ), |
| ] |
|
|
| @classmethod |
| def _process(cls, image, shorter_edge): |
| img = tensor_to_pil(image) |
| w, h = img.size |
| if w < h: |
| new_w = shorter_edge |
| new_h = int(h * (shorter_edge / w)) |
| else: |
| new_h = shorter_edge |
| new_w = int(w * (shorter_edge / h)) |
| img = img.resize((new_w, new_h), Image.Resampling.LANCZOS) |
| return pil_to_tensor(img) |
|
|
|
|
| class ResizeImagesByLongerEdgeNode(ImageProcessingNode): |
| node_id = "ResizeImagesByLongerEdge" |
| display_name = "Resize Images by Longer Edge (DEPRECATED)" |
| category = "image/transform" |
| description = "Resize images so that the longer edge matches the specified dimension while preserving aspect ratio." |
| is_deprecated = True |
| extra_inputs = [ |
| io.Int.Input( |
| "longer_edge", |
| default=1024, |
| min=1, |
| max=8192, |
| tooltip="Target dimension for the longer edge.", |
| ), |
| ] |
|
|
| @classmethod |
| def _process(cls, image, longer_edge): |
| resized_images = [] |
| for image_i in image: |
| img = tensor_to_pil(image_i) |
| w, h = img.size |
| if w > h: |
| new_w = longer_edge |
| new_h = int(h * (longer_edge / w)) |
| else: |
| new_h = longer_edge |
| new_w = int(w * (longer_edge / h)) |
| img = img.resize((new_w, new_h), Image.Resampling.LANCZOS) |
| resized_images.append(pil_to_tensor(img)) |
| return torch.cat(resized_images, dim=0) |
|
|
|
|
| class CenterCropImagesNode(ImageProcessingNode): |
| node_id = "CenterCropImages" |
| search_aliases=["crop", "cut", "trim"] |
| display_name="Crop Image (Center)" |
| category="image/transform" |
| description = "Center crop an image to the specified dimensions." |
| extra_inputs = [ |
| io.Int.Input("width", default=512, min=1, max=8192, tooltip="Crop width."), |
| io.Int.Input("height", default=512, min=1, max=8192, tooltip="Crop height."), |
| ] |
|
|
| @classmethod |
| def _process(cls, image, width, height): |
| img = tensor_to_pil(image) |
| left = max(0, (img.width - width) // 2) |
| top = max(0, (img.height - height) // 2) |
| right = min(img.width, left + width) |
| bottom = min(img.height, top + height) |
| img = img.crop((left, top, right, bottom)) |
| return pil_to_tensor(img) |
|
|
|
|
| class RandomCropImagesNode(ImageProcessingNode): |
| node_id = "RandomCropImages" |
| search_aliases=["crop", "cut", "trim"] |
| display_name = "Crop Image (Random)" |
| category="image/transform" |
| description = "Randomly crop an image to the specified dimensions." |
|
|
| extra_inputs = [ |
| io.Int.Input("width", default=512, min=1, max=8192, tooltip="Crop width."), |
| io.Int.Input("height", default=512, min=1, max=8192, tooltip="Crop height."), |
| io.Int.Input( |
| "seed", default=0, min=0, max=0xFFFFFFFFFFFFFFFF, tooltip="Random seed." |
| ), |
| ] |
|
|
| @classmethod |
| def _process(cls, image, width, height, seed): |
| np.random.seed(seed % (2**32 - 1)) |
| img = tensor_to_pil(image) |
| max_left = max(0, img.width - width) |
| max_top = max(0, img.height - height) |
| left = np.random.randint(0, max_left + 1) if max_left > 0 else 0 |
| top = np.random.randint(0, max_top + 1) if max_top > 0 else 0 |
| right = min(img.width, left + width) |
| bottom = min(img.height, top + height) |
| img = img.crop((left, top, right, bottom)) |
| return pil_to_tensor(img) |
|
|
|
|
| class NormalizeImagesNode(ImageProcessingNode): |
| node_id = "NormalizeImages" |
| search_aliases=["normalize", "normalize colors"] |
| display_name = "Normalize Image Colors" |
| category = "image/color" |
| description = "Normalize images using mean and standard deviation." |
| per_frame_process = False |
| extra_inputs = [ |
| io.Float.Input( |
| "mean", |
| default=0.5, |
| min=0.0, |
| max=1.0, |
| tooltip="Mean value for normalization.", |
| advanced=True, |
| ), |
| io.Float.Input( |
| "std", |
| default=0.5, |
| min=0.001, |
| max=1.0, |
| tooltip="Standard deviation for normalization.", |
| advanced=True, |
| ), |
| ] |
|
|
| @classmethod |
| def _process(cls, image, mean, std): |
| return (image - mean) / std |
|
|
|
|
| class AdjustBrightnessNode(ImageProcessingNode): |
| node_id = "AdjustBrightness" |
| search_aliases=["brightness"] |
| display_name = "Adjust Brightness" |
| category="image/adjustments" |
| description = "Adjust the brightness of an image." |
| per_frame_process = False |
| extra_inputs = [ |
| io.Float.Input( |
| "factor", |
| default=1.0, |
| min=0.0, |
| max=2.0, |
| tooltip="Brightness factor. 1.0 = no change, <1.0 = darker, >1.0 = brighter.", |
| ), |
| ] |
|
|
| @classmethod |
| def _process(cls, image, factor): |
| return (image * factor).clamp(0.0, 1.0) |
|
|
|
|
| class AdjustContrastNode(ImageProcessingNode): |
| node_id = "AdjustContrast" |
| search_aliases=["contrast"] |
| display_name = "Adjust Contrast" |
| category="image/adjustments" |
| description = "Adjust the contrast of an image." |
| per_frame_process = False |
| extra_inputs = [ |
| io.Float.Input( |
| "factor", |
| default=1.0, |
| min=0.0, |
| max=2.0, |
| tooltip="Contrast factor. 1.0 = no change, <1.0 = less contrast, >1.0 = more contrast.", |
| ), |
| ] |
|
|
| @classmethod |
| def _process(cls, image, factor): |
| return ((image - 0.5) * factor + 0.5).clamp(0.0, 1.0) |
|
|
|
|
| class ShuffleDatasetNode(ImageProcessingNode): |
| node_id = "ShuffleDataset" |
| search_aliases=["shuffle", "randomize", "mix"] |
| display_name = "Shuffle Images List" |
| category = "image/batch" |
| description = "Randomly shuffle the order of images in a list." |
| is_group_process = True |
| extra_inputs = [ |
| io.Int.Input( |
| "seed", default=0, min=0, max=0xFFFFFFFFFFFFFFFF, tooltip="Random seed." |
| ), |
| ] |
|
|
| @classmethod |
| def _group_process(cls, images, seed): |
| np.random.seed(seed % (2**32 - 1)) |
| indices = np.random.permutation(len(images)) |
| return [images[i] for i in indices] |
|
|
|
|
| class ShuffleImageTextDatasetNode(io.ComfyNode): |
| """Special node that shuffles both images and texts together.""" |
|
|
| @classmethod |
| def define_schema(cls): |
| return io.Schema( |
| node_id="ShuffleImageTextDataset", |
| search_aliases=["shuffle", "randomize", "mix"], |
| display_name = "Shuffle Pairs of Image-Text", |
| category = "image/batch", |
| description = "Randomly shuffle the order of pairs of image-text in a list.", |
| is_experimental=True, |
| is_input_list=True, |
| inputs=[ |
| io.Image.Input("images", tooltip="List of images to shuffle."), |
| io.String.Input("texts", tooltip="List of texts to shuffle.", force_input=True), |
| io.Int.Input( |
| "seed", |
| default=0, |
| min=0, |
| max=0xFFFFFFFFFFFFFFFF, |
| tooltip="Random seed.", |
| ), |
| ], |
| outputs=[ |
| io.Image.Output( |
| display_name="images", |
| is_output_list=True, |
| tooltip="Shuffled images", |
| ), |
| io.String.Output( |
| display_name="texts", is_output_list=True, tooltip="Shuffled texts" |
| ), |
| ], |
| ) |
|
|
| @classmethod |
| def execute(cls, images, texts, seed): |
| seed = seed[0] |
| np.random.seed(seed % (2**32 - 1)) |
| indices = np.random.permutation(len(images)) |
| shuffled_images = [images[i] for i in indices] |
| shuffled_texts = [texts[i] for i in indices] |
| return io.NodeOutput(shuffled_images, shuffled_texts) |
|
|
|
|
| |
|
|
|
|
| class VideoFrameSampleNode(io.ComfyNode): |
| """Sample a fixed number of frames from a video using various strategies. |
| |
| For contiguous strategies ("head"/"tail") the result is a fully lazy |
| VideoInput (no frames decoded). For non-contiguous strategies |
| ("uniform"/"random") only the selected indices are decoded. |
| """ |
|
|
| @classmethod |
| def define_schema(cls): |
| return io.Schema( |
| node_id="VideoFrameSample", |
| search_aliases=["sample frames", "extract frames"], |
| display_name="Sample Video Frame", |
| category="video", |
| description="Sample a fixed number of frames from a video using various strategies.", |
| is_experimental=True, |
| inputs=[ |
| io.Video.Input("video", tooltip="Input video."), |
| io.Int.Input( |
| "num_frames", |
| default=16, |
| min=1, |
| max=9999, |
| tooltip="Number of frames to sample.", |
| ), |
| io.Combo.Input( |
| "strategy", |
| options=["uniform", "head", "tail", "random"], |
| default="uniform", |
| tooltip="uniform: evenly spaced, head: first N, tail: last N, random: random sorted.", |
| ), |
| io.Int.Input( |
| "seed", |
| default=0, |
| min=0, |
| max=0xFFFFFFFFFFFFFFFF, |
| tooltip="Random seed (only used with 'random' strategy).", |
| ), |
| ], |
| outputs=[ |
| io.Video.Output(display_name="video", tooltip="Sampled video."), |
| ], |
| ) |
|
|
| @classmethod |
| def execute(cls, video, num_frames, strategy, seed): |
| total_frames = video.get_frame_count() |
| num_frames = min(num_frames, total_frames) |
| fps = float(video.get_frame_rate()) |
|
|
| if strategy == "head": |
| return io.NodeOutput( |
| video.as_trimmed(0.0, num_frames / fps, strict_duration=False) |
| ) |
| if strategy == "tail": |
| start_t = (total_frames - num_frames) / fps |
| return io.NodeOutput( |
| video.as_trimmed(start_t, num_frames / fps, strict_duration=False) |
| ) |
|
|
| if strategy == "uniform": |
| if num_frames == 1: |
| indices = [total_frames // 2] |
| else: |
| indices = [round(i * (total_frames - 1) / (num_frames - 1)) for i in range(num_frames)] |
| elif strategy == "random": |
| rng = np.random.RandomState(seed % (2**32 - 1)) |
| indices = sorted(rng.choice(total_frames, size=num_frames, replace=False).tolist()) |
| else: |
| raise ValueError(f"Unknown strategy: {strategy}") |
|
|
| return io.NodeOutput(_decode_selected_frames(video, indices)) |
|
|
|
|
| class VideoTemporalCropNode(io.ComfyNode): |
| """Crop a continuous range of frames from a video (fully lazy).""" |
|
|
| @classmethod |
| def define_schema(cls): |
| return io.Schema( |
| node_id="VideoTemporalCrop", |
| search_aliases=["crop", "crop video", "temporal crop", "truncate video"], |
| display_name="Crop Video (Temporal)", |
| category="video/transform", |
| description="Crop a continuous range of frames from a video.", |
| is_experimental=True, |
| inputs=[ |
| io.Video.Input("video", tooltip="Input video."), |
| io.Int.Input( |
| "start_frame", |
| default=0, |
| min=0, |
| max=99999, |
| tooltip="Starting frame index.", |
| ), |
| io.Int.Input( |
| "length", |
| default=16, |
| min=1, |
| max=99999, |
| tooltip="Number of frames to keep.", |
| ), |
| ], |
| outputs=[ |
| io.Video.Output(display_name="video", tooltip="Cropped video (lazy)."), |
| ], |
| ) |
|
|
| @classmethod |
| def execute(cls, video, start_frame, length): |
| total_frames = video.get_frame_count() |
| fps = float(video.get_frame_rate()) |
| start_frame = min(start_frame, max(total_frames - 1, 0)) |
| length = min(length, total_frames - start_frame) |
| return io.NodeOutput( |
| video.as_trimmed(start_frame / fps, length / fps, strict_duration=False) |
| ) |
|
|
|
|
| class VideoRandomTemporalCropNode(io.ComfyNode): |
| """Randomly crop a continuous range of frames from a video (fully lazy).""" |
|
|
| @classmethod |
| def define_schema(cls): |
| return io.Schema( |
| node_id="VideoRandomTemporalCrop", |
| search_aliases=["crop", "crop video", "temporal crop", "truncate video", "random crop"], |
| display_name="Crop Video (Temporal Random)", |
| category="video/transform", |
| description="Randomly crop a continuous range of frames from a video.", |
| is_experimental=True, |
| inputs=[ |
| io.Video.Input("video", tooltip="Input video."), |
| io.Int.Input( |
| "length", |
| default=16, |
| min=1, |
| max=99999, |
| tooltip="Number of frames to keep.", |
| ), |
| io.Int.Input( |
| "seed", |
| default=0, |
| min=0, |
| max=0xFFFFFFFFFFFFFFFF, |
| tooltip="Random seed.", |
| ), |
| ], |
| outputs=[ |
| io.Video.Output(display_name="video", tooltip="Cropped video (lazy)."), |
| ], |
| ) |
|
|
| @classmethod |
| def execute(cls, video, length, seed): |
| total_frames = video.get_frame_count() |
| fps = float(video.get_frame_rate()) |
| length = min(length, total_frames) |
| max_start = total_frames - length |
| rng = np.random.RandomState(seed % (2**32 - 1)) |
| start = rng.randint(0, max_start + 1) if max_start > 0 else 0 |
| return io.NodeOutput( |
| video.as_trimmed(start / fps, length / fps, strict_duration=False) |
| ) |
|
|
|
|
| class ShuffleVideoDatasetNode(io.ComfyNode): |
| """Randomly shuffle the order of videos in the dataset.""" |
|
|
| @classmethod |
| def define_schema(cls): |
| return io.Schema( |
| node_id="ShuffleVideoDataset", |
| search_aliases=["shuffle", "randomize", "mix"], |
| display_name="Shuffle Videos List", |
| category="video/batch", |
| description="Randomly shuffle the order of videos in a list.", |
| is_experimental=True, |
| is_input_list=True, |
| inputs=[ |
| io.Video.Input("videos", tooltip="List of videos to shuffle."), |
| io.Int.Input( |
| "seed", default=0, min=0, max=0xFFFFFFFFFFFFFFFF, tooltip="Random seed." |
| ), |
| ], |
| outputs=[ |
| io.Video.Output( |
| display_name="videos", |
| is_output_list=True, |
| tooltip="Shuffled videos", |
| ), |
| ], |
| ) |
|
|
| @classmethod |
| def execute(cls, videos, seed): |
| seed = seed[0] if isinstance(seed, list) else seed |
| np.random.seed(seed % (2**32 - 1)) |
| indices = np.random.permutation(len(videos)) |
| return io.NodeOutput([videos[i] for i in indices]) |
|
|
|
|
| class ShuffleVideoTextDatasetNode(io.ComfyNode): |
| """Shuffle videos and their captions together, preserving pairs.""" |
|
|
| @classmethod |
| def define_schema(cls): |
| return io.Schema( |
| node_id="ShuffleVideoTextDataset", |
| search_aliases=["shuffle", "randomize", "mix"], |
| display_name="Shuffle Pairs of Video-Text", |
| category="dataset/video", |
| description="Randomly shuffle the order of pairs of video-text in a list.", |
| is_experimental=True, |
| is_input_list=True, |
| inputs=[ |
| io.Video.Input("videos", tooltip="List of videos to shuffle."), |
| io.String.Input("texts", tooltip="List of texts to shuffle."), |
| io.Int.Input( |
| "seed", |
| default=0, |
| min=0, |
| max=0xFFFFFFFFFFFFFFFF, |
| tooltip="Random seed.", |
| ), |
| ], |
| outputs=[ |
| io.Video.Output( |
| display_name="videos", |
| is_output_list=True, |
| tooltip="Shuffled videos", |
| ), |
| io.String.Output( |
| display_name="texts", |
| is_output_list=True, |
| tooltip="Shuffled texts", |
| ), |
| ], |
| ) |
|
|
| @classmethod |
| def execute(cls, videos, texts, seed): |
| seed = seed[0] if isinstance(seed, list) else seed |
| np.random.seed(seed % (2**32 - 1)) |
| indices = np.random.permutation(len(videos)) |
| return io.NodeOutput( |
| [videos[i] for i in indices], |
| [texts[i] for i in indices], |
| ) |
|
|
|
|
| |
|
|
|
|
| class TextToLowercaseNode(TextProcessingNode): |
| node_id = "TextToLowercase" |
| search_aliases=["lowercase"] |
| display_name = "Convert Text to Lowercase (DEPRECATED)" |
| category = "text" |
| description = "Convert text to lowercase." |
| is_deprecated = True |
|
|
| @classmethod |
| def _process(cls, text): |
| return text.lower() |
|
|
|
|
| class TextToUppercaseNode(TextProcessingNode): |
| node_id = "TextToUppercase" |
| search_aliases=["uppercase"] |
| display_name = "Convert Text to Uppercase (DEPRECATED)" |
| category = "text" |
| description = "Convert text to uppercase." |
| is_deprecated = True |
|
|
| @classmethod |
| def _process(cls, text): |
| return text.upper() |
|
|
|
|
| class TruncateTextNode(TextProcessingNode): |
| node_id = "TruncateText" |
| search_aliases=["truncate", "cut", "shorten"] |
| display_name = "Truncate Text" |
| category = "text" |
| description = "Truncate text to a maximum length." |
| extra_inputs = [ |
| io.Int.Input( |
| "max_length", default=77, min=1, max=10000, tooltip="Maximum text length." |
| ), |
| ] |
|
|
| @classmethod |
| def _process(cls, text, max_length): |
| return text[:max_length] |
|
|
|
|
| class AddTextPrefixNode(TextProcessingNode): |
| node_id = "AddTextPrefix" |
| display_name = "Add Text Prefix (DEPRECATED)" |
| category = "text" |
| description = "Add a prefix to all texts." |
| is_deprecated = True |
| extra_inputs = [ |
| io.String.Input("prefix", default="", tooltip="Prefix to add."), |
| ] |
|
|
| @classmethod |
| def _process(cls, text, prefix): |
| return prefix + text |
|
|
|
|
| class AddTextSuffixNode(TextProcessingNode): |
| node_id = "AddTextSuffix" |
| display_name = "Add Text Suffix (DEPRECATED)" |
| category = "text" |
| description = "Add a suffix to all texts." |
| is_deprecated = True |
| extra_inputs = [ |
| io.String.Input("suffix", default="", tooltip="Suffix to add."), |
| ] |
|
|
| @classmethod |
| def _process(cls, text, suffix): |
| return text + suffix |
|
|
|
|
| class ReplaceTextNode(TextProcessingNode): |
| node_id = "ReplaceText" |
| display_name = "Replace Text (DEPRECATED)" |
| category = "text" |
| description = "Replace text in all texts." |
| is_deprecated = True |
| extra_inputs = [ |
| io.String.Input("find", default="", tooltip="Text to find."), |
| io.String.Input("replace", default="", tooltip="Text to replace with."), |
| ] |
|
|
| @classmethod |
| def _process(cls, text, find, replace): |
| return text.replace(find, replace) |
|
|
|
|
| class StripWhitespaceNode(TextProcessingNode): |
| node_id = "StripWhitespace" |
| display_name = "Strip Whitespace (DEPRECATED)" |
| category = "text" |
| description = "Strip leading and trailing whitespace from all texts." |
| is_deprecated = True |
|
|
| @classmethod |
| def _process(cls, text): |
| return text.strip() |
|
|
|
|
| |
|
|
|
|
| class ImageDeduplicationNode(ImageProcessingNode): |
| """Remove duplicate or very similar images from a list using perceptual hashing.""" |
|
|
| node_id = "ImageDeduplication" |
| search_aliases=["deduplicate", "remove duplicates", "similarity filter"] |
| display_name = "Deduplicate Images" |
| category = "image/batch" |
| description = "Remove duplicate or very similar images from a list." |
| is_group_process = True |
| extra_inputs = [ |
| io.Float.Input( |
| "similarity_threshold", |
| default=0.95, |
| min=0.0, |
| max=1.0, |
| tooltip="Similarity threshold (0-1). Higher means more similar. Images above this threshold are considered duplicates.", |
| advanced=True, |
| ), |
| ] |
|
|
| @classmethod |
| def _group_process(cls, images, similarity_threshold): |
| """Remove duplicate images using perceptual hashing.""" |
| if len(images) == 0: |
| return [] |
|
|
| |
| def compute_hash(img_tensor): |
| """Compute a simple perceptual hash by resizing to 8x8 and comparing to average.""" |
| img = tensor_to_pil(img_tensor) |
| |
| img_small = img.resize((8, 8), Image.Resampling.LANCZOS).convert("L") |
| |
| pixels = list(img_small.getdata()) |
| |
| avg = sum(pixels) / len(pixels) |
| |
| hash_bits = "".join("1" if p > avg else "0" for p in pixels) |
| return hash_bits |
|
|
| def hamming_distance(hash1, hash2): |
| """Compute Hamming distance between two hash strings.""" |
| return sum(c1 != c2 for c1, c2 in zip(hash1, hash2)) |
|
|
| |
| hashes = [compute_hash(img) for img in images] |
|
|
| |
| keep_indices = [] |
| for i in range(len(images)): |
| is_duplicate = False |
| for j in keep_indices: |
| |
| distance = hamming_distance(hashes[i], hashes[j]) |
| similarity = 1.0 - (distance / 64.0) |
| if similarity >= similarity_threshold: |
| is_duplicate = True |
| logging.info( |
| f"Image {i} is similar to image {j} (similarity: {similarity:.3f}), skipping" |
| ) |
| break |
|
|
| if not is_duplicate: |
| keep_indices.append(i) |
|
|
| |
| unique_images = [images[i] for i in keep_indices] |
| logging.info( |
| f"Deduplication: kept {len(unique_images)} out of {len(images)} images" |
| ) |
| return unique_images |
|
|
|
|
| class ImageGridNode(ImageProcessingNode): |
| """Combine multiple images into a single grid/collage.""" |
|
|
| node_id = "ImageGrid" |
| search_aliases=["grid", "collage", "combine"] |
| display_name = "Make Image Grid" |
| category="image/batch" |
| description = "Arrange multiple images into a grid layout." |
| is_group_process = True |
| is_output_list = False |
| extra_inputs = [ |
| io.Int.Input( |
| "columns", |
| default=4, |
| min=1, |
| max=20, |
| tooltip="Number of columns in the grid.", |
| ), |
| io.Int.Input( |
| "cell_width", |
| default=256, |
| min=32, |
| max=2048, |
| tooltip="Width of each cell in the grid.", |
| advanced=True, |
| ), |
| io.Int.Input( |
| "cell_height", |
| default=256, |
| min=32, |
| max=2048, |
| tooltip="Height of each cell in the grid.", |
| advanced=True, |
| ), |
| io.Int.Input( |
| "padding", default=4, min=0, max=50, tooltip="Padding between images.", advanced=True |
| ), |
| ] |
|
|
| @classmethod |
| def _group_process(cls, images, columns, cell_width, cell_height, padding): |
| """Arrange images into a grid.""" |
| if len(images) == 0: |
| raise ValueError("Cannot create grid from empty image list") |
|
|
| |
| num_images = len(images) |
| rows = (num_images + columns - 1) // columns |
|
|
| |
| grid_width = columns * cell_width + (columns - 1) * padding |
| grid_height = rows * cell_height + (rows - 1) * padding |
|
|
| |
| grid = Image.new("RGB", (grid_width, grid_height), (0, 0, 0)) |
|
|
| |
| for idx, img_tensor in enumerate(images): |
| row = idx // columns |
| col = idx % columns |
|
|
| |
| img = tensor_to_pil(img_tensor) |
| img = img.resize((cell_width, cell_height), Image.Resampling.LANCZOS) |
|
|
| |
| x = col * (cell_width + padding) |
| y = row * (cell_height + padding) |
|
|
| |
| grid.paste(img, (x, y)) |
|
|
| logging.info( |
| f"Created {columns}x{rows} grid with {num_images} images ({grid_width}x{grid_height})" |
| ) |
| return pil_to_tensor(grid) |
|
|
|
|
| class MergeImageListsNode(ImageProcessingNode): |
| """Merge multiple image lists into a single list.""" |
|
|
| node_id = "MergeImageLists" |
| search_aliases=["list", "merge list", "make list"] |
| display_name = "Merge Image Lists (DEPRECATED)" |
| category = "image/batch" |
| description = "Concatenate multiple image lists into one." |
| is_group_process = True |
| is_deprecated = True |
|
|
| @classmethod |
| def _group_process(cls, images): |
| """Simply return the images list (already merged by input handling).""" |
| |
| |
| logging.info(f"Merged image list contains {len(images)} images") |
| return images |
|
|
|
|
| class MergeTextListsNode(TextProcessingNode): |
| """Merge multiple text lists into a single list.""" |
|
|
| node_id = "MergeTextLists" |
| display_name = "Merge Text Lists (DEPRECATED)" |
| category = "text" |
| description = "Concatenate multiple text lists into one." |
| is_group_process = True |
| is_deprecated = True |
|
|
| @classmethod |
| def _group_process(cls, texts): |
| """Simply return the texts list (already merged by input handling).""" |
| |
| |
| logging.info(f"Merged text list contains {len(texts)} texts") |
| return texts |
|
|
|
|
| |
|
|
|
|
| class ResolutionBucket(io.ComfyNode): |
| """Bucket latents and conditions by resolution for efficient batch training.""" |
|
|
| @classmethod |
| def define_schema(cls): |
| return io.Schema( |
| node_id="ResolutionBucket", |
| search_aliases=["bucket by resolution", "group by resolution", "batch by resolution"], |
| display_name="Resolution Bucket", |
| category="model/training", |
| description="Group latents and conditionings into buckets", |
| is_experimental=True, |
| is_input_list=True, |
| inputs=[ |
| io.Latent.Input( |
| "latents", |
| tooltip="List of latent dicts to bucket by resolution.", |
| ), |
| io.Conditioning.Input( |
| "conditioning", |
| tooltip="List of conditioning lists (must match latents length).", |
| ), |
| ], |
| outputs=[ |
| io.Latent.Output( |
| display_name="latents", |
| is_output_list=True, |
| tooltip="List of batched latent dicts, one per resolution bucket.", |
| ), |
| io.Conditioning.Output( |
| display_name="conditioning", |
| is_output_list=True, |
| tooltip="List of condition lists, one per resolution bucket.", |
| ), |
| ], |
| ) |
|
|
| @classmethod |
| def execute(cls, latents, conditioning): |
| |
| |
|
|
| |
| if len(latents) != len(conditioning): |
| raise ValueError( |
| f"Number of latents ({len(latents)}) does not match number of conditions ({len(conditioning)})." |
| ) |
|
|
| |
| flat_latents = [] |
| flat_conditions = [] |
|
|
| for latent_dict, cond in zip(latents, conditioning): |
| samples = latent_dict["samples"] |
| batch_size = samples.shape[0] |
|
|
| |
| for i in range(batch_size): |
| flat_latents.append(samples[i]) |
| flat_conditions.append(cond[i]) |
|
|
| |
| buckets = {} |
|
|
| for latent, cond in zip(flat_latents, flat_conditions): |
| |
| h, w = latent.shape[-2], latent.shape[-1] |
| key = (h, w) |
|
|
| if key not in buckets: |
| buckets[key] = {"latents": [], "conditions": []} |
|
|
| buckets[key]["latents"].append(latent) |
| buckets[key]["conditions"].append(cond) |
|
|
| |
| output_latents = [] |
| output_conditions = [] |
|
|
| for (h, w), bucket_data in buckets.items(): |
| |
| stacked_latents = torch.stack(bucket_data["latents"], dim=0) |
| output_latents.append({"samples": stacked_latents}) |
|
|
| |
| output_conditions.append(bucket_data["conditions"]) |
|
|
| logging.info( |
| f"Resolution bucket ({h}x{w}): {len(bucket_data['latents'])} samples" |
| ) |
|
|
| logging.info(f"Created {len(buckets)} resolution buckets from {len(flat_latents)} samples") |
| return io.NodeOutput(output_latents, output_conditions) |
|
|
|
|
| class MakeTrainingDataset(io.ComfyNode): |
| """Encode images with VAE and texts with CLIP to create a training dataset.""" |
| @classmethod |
| def define_schema(cls): |
| return io.Schema( |
| node_id="MakeTrainingDataset", |
| search_aliases=["encode dataset"], |
| display_name="Make Training Dataset", |
| category="model/training", |
| description="Encode images with VAE and texts with CLIP to create a training dataset of latents and conditionings.", |
| is_experimental=True, |
| is_input_list=True, |
| inputs=[ |
| io.Image.Input("images", tooltip="List of images to encode."), |
| io.Vae.Input( |
| "vae", tooltip="VAE model for encoding images to latents." |
| ), |
| io.Clip.Input( |
| "clip", tooltip="CLIP model for encoding text to conditioning." |
| ), |
| io.String.Input( |
| "texts", |
| optional=True, |
| tooltip="List of text captions. Can be length n (matching images), 1 (repeated for all), or omitted (uses empty string).", |
| force_input=True |
| ), |
| ], |
| outputs=[ |
| io.Latent.Output( |
| display_name="latents", |
| is_output_list=True, |
| tooltip="List of latent dicts", |
| ), |
| io.Conditioning.Output( |
| display_name="conditioning", |
| is_output_list=True, |
| tooltip="List of conditioning lists", |
| ), |
| ], |
| ) |
|
|
| @classmethod |
| def execute(cls, images, vae, clip, texts=None): |
| |
| vae = vae[0] |
| clip = clip[0] |
|
|
| |
| num_images = len(images) |
|
|
| if texts is None or len(texts) == 0: |
| |
| texts = [""] |
|
|
| if len(texts) == 1 and num_images > 1: |
| |
| texts = texts * num_images |
| elif len(texts) != num_images: |
| raise ValueError( |
| f"Number of texts ({len(texts)}) does not match number of images ({num_images}). " |
| f"Text list should have length {num_images}, 1, or 0." |
| ) |
|
|
| |
| logging.info(f"Encoding {num_images} images with VAE...") |
| latents_list = [] |
| for img_tensor in images: |
| |
| latent_tensor = vae.encode(img_tensor[:, :, :, :3]) |
| latents_list.append({"samples": latent_tensor}) |
|
|
| |
| logging.info(f"Encoding {len(texts)} texts with CLIP...") |
| conditioning_list = [] |
| for text in texts: |
| if text == "": |
| cond = clip.encode_from_tokens_scheduled(clip.tokenize("")) |
| else: |
| tokens = clip.tokenize(text) |
| cond = clip.encode_from_tokens_scheduled(tokens) |
| conditioning_list.append(cond) |
|
|
| logging.info( |
| f"Created dataset with {len(latents_list)} latents and {len(conditioning_list)} conditioning." |
| ) |
| return io.NodeOutput(latents_list, conditioning_list) |
|
|
|
|
| class SaveTrainingDataset(io.ComfyNode): |
| """Save encoded training dataset (latents + conditioning) to disk.""" |
| @classmethod |
| def define_schema(cls): |
| return io.Schema( |
| node_id="SaveTrainingDataset", |
| search_aliases=["export dataset", "save dataset"], |
| display_name="Save Training Dataset", |
| category="model/training", |
| description="Save encoded training dataset (latents + conditioning) to disk for efficient loading during training.", |
| is_experimental=True, |
| is_output_node=True, |
| is_input_list=True, |
| inputs=[ |
| io.Latent.Input( |
| "latents", |
| tooltip="List of latent dicts from MakeTrainingDataset.", |
| ), |
| io.Conditioning.Input( |
| "conditioning", |
| tooltip="List of conditioning lists from MakeTrainingDataset.", |
| ), |
| io.String.Input( |
| "folder_name", |
| default="training_dataset", |
| tooltip="Name of folder to save the dataset into, inside the datasets directory. Subfolders like 'project/run1' are allowed.", |
| ), |
| io.Int.Input( |
| "shard_size", |
| default=1000, |
| min=1, |
| max=100000, |
| tooltip="Number of samples per shard file.", |
| advanced=True, |
| ), |
| ], |
| outputs=[], |
| ) |
|
|
| @classmethod |
| def execute(cls, latents, conditioning, folder_name, shard_size): |
| |
| folder_name = folder_name[0] |
| shard_size = shard_size[0] |
|
|
| |
| |
|
|
| |
| if len(latents) != len(conditioning): |
| raise ValueError( |
| f"Number of latents ({len(latents)}) does not match number of conditions ({len(conditioning)}). " |
| f"Something went wrong in dataset preparation." |
| ) |
|
|
| |
| output_dir = get_dataset_save_dir(folder_name) |
| os.makedirs(output_dir, exist_ok=True) |
|
|
| |
| num_samples = len(latents) |
| num_shards = (num_samples + shard_size - 1) // shard_size |
|
|
| logging.info( |
| f"Saving {num_samples} samples to {num_shards} shards in {output_dir}..." |
| ) |
|
|
| |
| for shard_idx in range(num_shards): |
| start_idx = shard_idx * shard_size |
| end_idx = min(start_idx + shard_size, num_samples) |
|
|
| |
| shard_data = { |
| "latents": latents[start_idx:end_idx], |
| "conditioning": conditioning[start_idx:end_idx], |
| } |
|
|
| |
| shard_filename = f"shard_{shard_idx:04d}.pkl" |
| shard_path = os.path.join(output_dir, shard_filename) |
|
|
| with open(shard_path, "wb") as f: |
| torch.save(shard_data, f) |
|
|
| logging.info( |
| f"Saved shard {shard_idx + 1}/{num_shards}: {shard_filename} ({end_idx - start_idx} samples)" |
| ) |
|
|
| |
| metadata = { |
| "num_samples": num_samples, |
| "num_shards": num_shards, |
| "shard_size": shard_size, |
| } |
| metadata_path = os.path.join(output_dir, "metadata.json") |
| with open(metadata_path, "w") as f: |
| json.dump(metadata, f, indent=2) |
|
|
| logging.info(f"Successfully saved {num_samples} samples to {output_dir}.") |
| return io.NodeOutput() |
|
|
|
|
| class LoadTrainingDataset(io.ComfyNode): |
| """Load encoded training dataset from disk.""" |
| @classmethod |
| def define_schema(cls): |
| return io.Schema( |
| node_id="LoadTrainingDataset", |
| search_aliases=["import dataset", "training data"], |
| display_name="Load Training Dataset", |
| category="model/training", |
| description="Load encoded training dataset (latents + conditioning) from disk for use in training.", |
| is_experimental=True, |
| inputs=[ |
| io.Combo.Input( |
| "folder_name", |
| options=list_dataset_folders(), |
| tooltip="Saved dataset to load, from the datasets directory.", |
| ), |
| ], |
| outputs=[ |
| io.Latent.Output( |
| display_name="latents", |
| is_output_list=True, |
| tooltip="List of latent dicts", |
| ), |
| io.Conditioning.Output( |
| display_name="conditioning", |
| is_output_list=True, |
| tooltip="List of conditioning lists", |
| ), |
| ], |
| ) |
|
|
| @classmethod |
| def execute(cls, folder_name): |
| |
| dataset_dir = get_dataset_dir(folder_name) |
|
|
| |
| shard_files = sorted( |
| [ |
| f |
| for f in os.listdir(dataset_dir) |
| if f.startswith("shard_") and f.endswith(".pkl") |
| ] |
| ) |
|
|
| if not shard_files: |
| raise ValueError(f"No shard files found in {dataset_dir}") |
|
|
| logging.info(f"Loading {len(shard_files)} shards from {dataset_dir}...") |
|
|
| |
| all_latents = [] |
| all_conditioning = [] |
|
|
| for shard_file in shard_files: |
| shard_path = os.path.join(dataset_dir, shard_file) |
|
|
| with open(shard_path, "rb") as f: |
| shard_data = torch.load(f, weights_only=True) |
|
|
| all_latents.extend(shard_data["latents"]) |
| all_conditioning.extend(shard_data["conditioning"]) |
|
|
| logging.info(f"Loaded {shard_file}: {len(shard_data['latents'])} samples") |
|
|
| logging.info( |
| f"Successfully loaded {len(all_latents)} samples from {dataset_dir}." |
| ) |
| return io.NodeOutput(all_latents, all_conditioning) |
|
|
|
|
| |
|
|
|
|
| class DatasetExtension(ComfyExtension): |
| @override |
| async def get_node_list(self) -> list[type[io.ComfyNode]]: |
| return [ |
| |
| LoadImageDataSetFromFolderNode, |
| LoadImageTextDataSetFromFolderNode, |
| SaveImageDataSetToFolderNode, |
| SaveImageTextDataSetToFolderNode, |
| |
| LoadVideoDataSetFromFolderNode, |
| LoadVideoTextDataSetFromFolderNode, |
| |
| ResizeImagesByShorterEdgeNode, |
| ResizeImagesByLongerEdgeNode, |
| CenterCropImagesNode, |
| RandomCropImagesNode, |
| NormalizeImagesNode, |
| AdjustBrightnessNode, |
| AdjustContrastNode, |
| ShuffleDatasetNode, |
| ShuffleImageTextDatasetNode, |
| |
| VideoFrameSampleNode, |
| VideoTemporalCropNode, |
| VideoRandomTemporalCropNode, |
| ShuffleVideoDatasetNode, |
| ShuffleVideoTextDatasetNode, |
| |
| TextToLowercaseNode, |
| TextToUppercaseNode, |
| TruncateTextNode, |
| AddTextPrefixNode, |
| AddTextSuffixNode, |
| ReplaceTextNode, |
| StripWhitespaceNode, |
| |
| ImageDeduplicationNode, |
| ImageGridNode, |
| MergeImageListsNode, |
| MergeTextListsNode, |
| |
| MakeTrainingDataset, |
| SaveTrainingDataset, |
| LoadTrainingDataset, |
| ResolutionBucket, |
| ] |
|
|
|
|
| async def comfy_entrypoint() -> DatasetExtension: |
| return DatasetExtension() |
|
|