import logging
logger = logging.getLogger(__name__)
import os
import json

import av
import numpy as np
import torch
from PIL import Image
from typing_extensions import override

from comfy.cmd import folder_paths
from comfy 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)  # follows symlinks
            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)  # follows symlinks
                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):
        logger.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):
                # Support kohya-ss/sd-scripts folder structure
                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)

        logger.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):
                # Support kohya-ss/sd-scripts folder structure: {repeat}_{desc}/
                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):
        # Handle different tensor shapes
        if isinstance(img_tensor, torch.Tensor):
            # Remove batch dimension if present [1, H, W, C] -> [H, W, C]
            if img_tensor.dim() == 4 and img_tensor.shape[0] == 1:
                img_tensor = img_tensor.squeeze(0)

            # If tensor is [C, H, W], permute to [H, W, C]
            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)

            # Convert to numpy and scale to 0-255
            img_array = img_tensor.cpu().numpy()
            img_array = np.clip(img_array * 255.0, 0, 255).astype(np.uint8)

            # Convert to PIL Image
            img = Image.fromarray(img_array)
        else:
            raise ValueError(f"Expected torch.Tensor, got {type(img_tensor)}")

        # Save image
        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,  # Receive images as list
            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,  # This node is redundant and superseded by existing Save Image nodes where the target folder can be specified in the filename_prefix
        )

    @classmethod
    def execute(cls, images, folder_name, filename_prefix, mode):
        # Extract scalar values
        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')

        logger.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,  # Receive both images and texts as lists
            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):
        # Extract scalar values
        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')

        # Save captions
        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)

        logger.info(f"Saved {len(saved_files)} images and captions to {output_dir}.")
        return io.NodeOutput()


# ========== Helper Functions for Transform Nodes ==========


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,]


# ========== Base Classes for Transform Nodes ==========


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  # None = auto-detect, True/False = explicit
    is_output_list = None  # None = auto-detect based on processing mode
    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
        """
        # Explicit setting takes precedence
        if cls.is_group_process is not None:
            return cls.is_group_process

        # Check which method is overridden by looking at the defining class in MRO
        base_class = ImageProcessingNode

        # Find which class in MRO defines _process
        process_definer = None
        for klass in cls.__mro__:
            if "_process" in klass.__dict__:
                process_definer = klass
                break

        # Find which class in MRO defines _group_process
        group_definer = None
        for klass in cls.__mro__:
            if "_group_process" in klass.__dict__:
                group_definer = klass
                break

        # Check what was overridden (not defined in base class)
        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()

        # Auto-detect is_output_list if not explicitly set
        # Single processing: False (backend collects results into list)
        # Group processing: True by default (can be False for single-output nodes)
        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,  # True for group, False for individual
            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)

        # Extract scalar values from lists for parameters
        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:
            # Group processing: images is list, call _group_process
            result = cls._group_process(images, **params)
        else:
            # Individual processing: images is single item, call _process
            # Auto-loop over frames for multi-frame inputs (video [T, H, W, C])
            # so that PIL-based spatial transforms work per-frame automatically.
            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  # None = auto-detect, True/False = explicit
    is_output_list = None  # None = auto-detect based on processing mode
    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
        """
        # Explicit setting takes precedence
        if cls.is_group_process is not None:
            return cls.is_group_process

        # Check which method is overridden by looking at the defining class in MRO
        base_class = TextProcessingNode

        # Find which class in MRO defines _process
        process_definer = None
        for klass in cls.__mro__:
            if "_process" in klass.__dict__:
                process_definer = klass
                break

        # Find which class in MRO defines _group_process
        group_definer = None
        for klass in cls.__mro__:
            if "_group_process" in klass.__dict__:
                group_definer = klass
                break

        # Check what was overridden (not defined in base class)
        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,  # True for group, False for individual
            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()

        # Extract scalar values from lists for parameters
        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:
            # Group processing: texts is list, call _group_process
            result = cls._group_process(texts, **params)
        else:
            # Individual processing: texts is single item, call _process
            result = cls._process(texts, **params)

        # Wrap result based on is_output_list
        if cls.is_output_list:
            # Result should already be a list (or will be for individual)
            return io.NodeOutput(result if is_group else [result])
        else:
            # Single output - wrap in list for NodeOutput
            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"
        )


# ========== Image Transform Nodes ==========


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  # This node is superseded by Resize Image/Mask with resize_type = scale shorter dimension
    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  # This node is superseded by Resize Image/Mask with resize_type = scale longer dimension
    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  # Pure tensor math, handles any batch size
    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  # Pure tensor math, handles any batch size
    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  # Pure tensor math, handles any batch size
    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  # Requires full list to shuffle
    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]  # Extract scalar
        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)


# ========== Video Processing Nodes ==========


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],
        )


# ========== Text Transform Nodes ==========


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  # This node is superseded by the Convert Text Case node

    @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  # This node is superseded by the Convert Text Case node

    @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  # This node is superseded by the Concatenate Text node
    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  # This node is superseded by the Concatenate Text node
    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  # This node is superseded by the other Replace Text node
    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  # This node is superseded by the Trim Text node

    @classmethod
    def _process(cls, text):
        return text.strip()


# ========== Group Processing Example Nodes ==========


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  # Requires full list to compare images
    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 []

        # Compute simple perceptual hash for each image
        def compute_hash(img_tensor):
            """Compute a simple perceptual hash by resizing to 8x8 and comparing to average."""
            img = tensor_to_pil(img_tensor)
            # Resize to 8x8
            img_small = img.resize((8, 8), Image.Resampling.LANCZOS).convert("L")
            # Get pixels
            pixels = list(img_small.getdata())
            # Compute average
            avg = sum(pixels) / len(pixels)
            # Create hash (1 if above average, 0 otherwise)
            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))

        # Compute hashes for all images
        hashes = [compute_hash(img) for img in images]

        # Find duplicates
        keep_indices = []
        for i in range(len(images)):
            is_duplicate = False
            for j in keep_indices:
                # Compare hashes
                distance = hamming_distance(hashes[i], hashes[j])
                similarity = 1.0 - (distance / 64.0)  # 64 bits total
                if similarity >= similarity_threshold:
                    is_duplicate = True
                    logger.info(
                        f"Image {i} is similar to image {j} (similarity: {similarity:.3f}), skipping"
                    )
                    break

            if not is_duplicate:
                keep_indices.append(i)

        # Return only unique images
        unique_images = [images[i] for i in keep_indices]
        logger.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  # Requires full list to create grid
    is_output_list = False  # Outputs single grid image
    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")

        # Calculate grid dimensions
        num_images = len(images)
        rows = (num_images + columns - 1) // columns  # Ceiling division

        # Calculate total grid size
        grid_width = columns * cell_width + (columns - 1) * padding
        grid_height = rows * cell_height + (rows - 1) * padding

        # Create blank grid
        grid = Image.new("RGB", (grid_width, grid_height), (0, 0, 0))

        # Place images
        for idx, img_tensor in enumerate(images):
            row = idx // columns
            col = idx % columns

            # Convert to PIL and resize to cell size
            img = tensor_to_pil(img_tensor)
            img = img.resize((cell_width, cell_height), Image.Resampling.LANCZOS)

            # Calculate position
            x = col * (cell_width + padding)
            y = row * (cell_height + padding)

            # Paste into grid
            grid.paste(img, (x, y))

        logger.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  # Receives images as list
    is_deprecated = True  # This node is superseded by the Create List node

    @classmethod
    def _group_process(cls, images):
        """Simply return the images list (already merged by input handling)."""
        # When multiple list inputs are connected, they're concatenated
        # For now, this is a simple pass-through
        logger.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  # Receives texts as list
    is_deprecated = True  # This node is superseded by the Create List node

    @classmethod
    def _group_process(cls, texts):
        """Simply return the texts list (already merged by input handling)."""
        # When multiple list inputs are connected, they're concatenated
        # For now, this is a simple pass-through
        logger.info(f"Merged text list contains {len(texts)} texts")
        return texts


# ========== Training Dataset Nodes ==========


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):
        # latents: list[{"samples": tensor}] where tensor is (B, C, H, W), typically B=1
        # conditioning: list[list[cond]]

        # Validate lengths match
        if len(latents) != len(conditioning):
            raise ValueError(
                f"Number of latents ({len(latents)}) does not match number of conditions ({len(conditioning)})."
            )

        # Flatten latents and conditions to individual samples
        flat_latents = []  # list of (C, H, W) tensors
        flat_conditions = []  # list of condition lists

        for latent_dict, cond in zip(latents, conditioning):
            samples = latent_dict["samples"]  # (B, C, H, W)
            batch_size = samples.shape[0]

            # cond is a list of conditions with length == batch_size
            for i in range(batch_size):
                flat_latents.append(samples[i])  # (C, H, W)
                flat_conditions.append(cond[i])  # single condition

        # Group by resolution (H, W)
        buckets = {}  # (H, W) -> {"latents": list, "conditions": list}

        for latent, cond in zip(flat_latents, flat_conditions):
            # latent shape is (..., H, W) (B, C, H, W) or (B, T, C, H ,W)
            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)

        # Convert buckets to output format
        output_latents = []  # list[{"samples": tensor}] where tensor is (Bi, ..., H, W)
        output_conditions = []  # list[list[cond]] where each inner list has Bi conditions

        for (h, w), bucket_data in buckets.items():
            # Stack latents into batch: list of (..., H, W) -> (Bi, ..., H, W)
            stacked_latents = torch.stack(bucket_data["latents"], dim=0)
            output_latents.append({"samples": stacked_latents})

            # Conditions stay as list of condition lists
            output_conditions.append(bucket_data["conditions"])

            logger.info(
                f"Resolution bucket ({h}x{w}): {len(bucket_data['latents'])} samples"
            )

        logger.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,  # images and texts as lists
            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):
        # Extract scalars (vae and clip are single values wrapped in lists)
        vae = vae[0]
        clip = clip[0]

        # Handle text list
        num_images = len(images)

        if texts is None or len(texts) == 0:
            # Treat as [""] for unconditional training
            texts = [""]

        if len(texts) == 1 and num_images > 1:
            # Repeat single text for all images
            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."
            )

        # Encode images with VAE
        logger.info(f"Encoding {num_images} images with VAE...")
        latents_list = []  # list[{"samples": tensor}]
        for img_tensor in images:
            # img_tensor is [1, H, W, 3]
            latent_tensor = vae.encode(img_tensor[:, :, :, :3])
            latents_list.append({"samples": latent_tensor})

        # Encode texts with CLIP
        logger.info(f"Encoding {len(texts)} texts with CLIP...")
        conditioning_list = []  # list[list[cond]]
        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)

        logger.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,  # Receive lists
            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):
        # Extract scalars
        folder_name = folder_name[0]
        shard_size = shard_size[0]

        # latents: list[{"samples": tensor}]
        # conditioning: list[list[cond]]

        # Validate lengths match
        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."
            )

        # Create output directory (inside the datasets root, traversal-safe)
        output_dir = get_dataset_save_dir(folder_name)
        os.makedirs(output_dir, exist_ok=True)

        # Prepare data pairs
        num_samples = len(latents)
        num_shards = (num_samples + shard_size - 1) // shard_size  # Ceiling division

        logger.info(
            f"Saving {num_samples} samples to {num_shards} shards in {output_dir}..."
        )

        # Save data in shards
        for shard_idx in range(num_shards):
            start_idx = shard_idx * shard_size
            end_idx = min(start_idx + shard_size, num_samples)

            # Get shard data (list of latent dicts and conditioning lists)
            shard_data = {
                "latents": latents[start_idx:end_idx],
                "conditioning": conditioning[start_idx:end_idx],
            }

            # Save shard
            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)

            logger.info(
                f"Saved shard {shard_idx + 1}/{num_shards}: {shard_filename} ({end_idx - start_idx} samples)"
            )

        # Save metadata
        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)

        logger.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):
        # Get dataset directory (searched across all dataset roots, traversal-safe)
        dataset_dir = get_dataset_dir(folder_name)

        # Find all shard files
        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}")

        logger.info(f"Loading {len(shard_files)} shards from {dataset_dir}...")

        # Load all shards
        all_latents = []  # list[{"samples": tensor}]
        all_conditioning = []  # list[list[cond]]

        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"])

            logger.info(f"Loaded {shard_file}: {len(shard_data['latents'])} samples")

        logger.info(
            f"Successfully loaded {len(all_latents)} samples from {dataset_dir}."
        )
        return io.NodeOutput(all_latents, all_conditioning)


# ========== Extension Setup ==========


class DatasetExtension(ComfyExtension):
    @override
    async def get_node_list(self) -> list[type[io.ComfyNode]]:
        return [
            # Data loading/saving nodes
            LoadImageDataSetFromFolderNode,
            LoadImageTextDataSetFromFolderNode,
            SaveImageDataSetToFolderNode,
            SaveImageTextDataSetToFolderNode,
            # Video data loading nodes
            LoadVideoDataSetFromFolderNode,
            LoadVideoTextDataSetFromFolderNode,
            # Image transform nodes (auto-handle video via per-frame processing)
            ResizeImagesByShorterEdgeNode,
            ResizeImagesByLongerEdgeNode,
            CenterCropImagesNode,
            RandomCropImagesNode,
            NormalizeImagesNode,
            AdjustBrightnessNode,
            AdjustContrastNode,
            ShuffleDatasetNode,
            ShuffleImageTextDatasetNode,
            # Video processing nodes (lazy VideoInput in/out)
            VideoFrameSampleNode,
            VideoTemporalCropNode,
            VideoRandomTemporalCropNode,
            ShuffleVideoDatasetNode,
            ShuffleVideoTextDatasetNode,
            # Text transform nodes
            TextToLowercaseNode,
            TextToUppercaseNode,
            TruncateTextNode,
            AddTextPrefixNode,
            AddTextSuffixNode,
            ReplaceTextNode,
            StripWhitespaceNode,
            # Group processing examples
            ImageDeduplicationNode,
            ImageGridNode,
            MergeImageListsNode,
            MergeTextListsNode,
            # Training dataset nodes
            MakeTrainingDataset,
            SaveTrainingDataset,
            LoadTrainingDataset,
            ResolutionBucket,
        ]


async def comfy_entrypoint() -> DatasetExtension:
    return DatasetExtension()
