import os import io import math import random from scipy.interpolate import interp1d from numpy import linspace import numpy as np from huggingface_hub import HfApi from datetime import datetime from PIL import Image from PIL.PngImagePlugin import PngInfo import OpenEXR import Imath import folder_paths import torch import builtins from pathlib import Path import cv2 import warnings from .utils import pil2tensor, tensor2pil import logging from comfy.utils import common_upscale from comfy import model_management import sys from os.path import dirname, join # Attempt to import ImagePadKJ from comfyui-kjnodes if available _kj_nodes_path = join(dirname(__file__), "..", "comfyui-kjnodes", "nodes") if _kj_nodes_path not in sys.path: sys.path.append(_kj_nodes_path) try: from image_nodes import ImagePadKJ # type: ignore except Exception as _e: logging.warning( f"โš ๏ธ\u2009 Nilor-Nodes (nilornodes): Could not import ImagePadKJ from comfyui-kjnodes ({_kj_nodes_path}): {_e}" ) BIGMIN = -(2**53 - 1) BIGMAX = 2**53 - 1 category = "Nilor Nodes ๐Ÿ‘บ" subcategories = { "generators": "/Generators", "utilities": "/Utilities", "io": "/IO", } class AnyType(str): """A special class that is always equal in not equal comparisons. Credit to pythongosssss""" def __eq__(self, _) -> bool: return True def __ne__(self, __value: object) -> bool: return False any = AnyType("*") class NilorInterpolatedFloatList: # Generate interpolated float values based on a number of sections def __init__(self): pass @classmethod def INPUT_TYPES(s): # Dictionary that defines input types for each field return { "required": { "number_of_floats": ("INT", {"forceInput": False}), "number_of_sections": ("INT", {"forceInput": False}), "section_number": ("INT", {"forceInput": False}), "interpolation_type": ( ["slinear", "quadratic", "cubic"], {}, ), # Type of interpolation to use }, } # Define return types and names for outputs of the node RETURN_TYPES = ("FLOAT",) RETURN_NAMES = ("floats",) FUNCTION = "generate_float_list" CATEGORY = category + subcategories["generators"] @staticmethod def interpolate_values(start, end, num_points, interp_type): # Linear interpolation between start and end over num_points x = linspace(0, num_points - 1, num_points) y = linspace(start, end, num_points) f = interp1d(x, y, kind=interp_type) return f(x) def generate_float_list( self, number_of_floats, number_of_sections, section_number, interpolation_type ): # Initializes the array with zeros my_floats = [0.0] * number_of_floats # Calculate the length of each portion based on total frames and number of images portion_length = int((number_of_floats - 1) / (number_of_sections - 1)) # Handling the first image (special case for the first segment) if section_number == 1: portion_values = self.interpolate_values( 1, 0, portion_length, interpolation_type ) my_floats[0:portion_length] = portion_values # Handling the last image (special case for the last segment) elif section_number == number_of_sections: portion_values = self.interpolate_values( 0, 1, portion_length, interpolation_type ) start_index = int((number_of_sections - 2) * portion_length) my_floats[start_index:] = portion_values # Handling middle images (general case for dual segments) else: portion_values = np.concatenate( [ self.interpolate_values(0, 1, portion_length, interpolation_type), self.interpolate_values(1, 0, portion_length, interpolation_type), ] ) start_index = int((section_number - 2) * portion_length) end_index = start_index + (2 * portion_length) my_floats[start_index:end_index] = portion_values # Returns the modified list of float values return (my_floats,) class NilorOneMinusFloatList: def __init__(self): pass @classmethod def INPUT_TYPES(s): # Dictionary that defines input types for each field return { "required": { "list_of_floats": ("FLOAT", {"input_is_list": True}), }, } # Define return types and names for outputs of the node RETURN_TYPES = ("FLOAT",) RETURN_NAMES = ("floats",) FUNCTION = "one_minus_float_list" CATEGORY = category + subcategories["generators"] def one_minus_float_list(self, list_of_floats): return ([1 - x for x in list_of_floats],) class NilorRemapFloatList: def __init__(self): pass @classmethod def INPUT_TYPES(s): # Dictionary that defines input types for each field return { "required": { "list_of_floats": ("FLOAT", {"input_is_list": True}), "min_input": ("FLOAT", {"default": 0.0}), "max_input": ("FLOAT", {"default": 1.0}), "min_output": ("FLOAT", {"default": 0.0}), "max_output": ("FLOAT", {"default": 1.0}), }, } # Define return types and names for outputs of the node RETURN_TYPES = ("FLOAT",) RETURN_NAMES = ("remapped_floats",) FUNCTION = "remap_float_list" CATEGORY = category + subcategories["generators"] def remap_float_list( self, list_of_floats, min_input, max_input, min_output, max_output ): # Avoid division by zero if max_input - min_input == 0: raise ValueError( "๐Ÿ›‘\u2009 Nilor-Nodes (RemapFloatList): max_input and min_input cannot be the same value." ) scale = (max_output - min_output) / (max_input - min_input) return ([min_output + (x - min_input) * scale for x in list_of_floats],) class NilorRemapFloatListAutoInput: def __init__(self): pass @classmethod def INPUT_TYPES(s): return { "required": { "list_of_floats": ("FLOAT", {"input_is_list": True}), "min_output": ("FLOAT", {"default": 0.0}), "max_output": ("FLOAT", {"default": 1.0}), }, } # Define return types and names for outputs of the node RETURN_TYPES = ("FLOAT",) RETURN_NAMES = ("remapped_list",) FUNCTION = "remap_float_list_auto_input" CATEGORY = category + subcategories["generators"] def remap_float_list_auto_input(self, list_of_floats, min_output, max_output): min_input = min(list_of_floats) max_input = max(list_of_floats) scale = (max_output - min_output) / (max_input - min_input) return ([min_output + (x - min_input) * scale for x in list_of_floats],) class NilorInverseMapFloatList: def __init__(self): pass @classmethod def INPUT_TYPES(s): return { "required": { "list_of_floats": ("FLOAT", {"input_is_list": True}), }, } RETURN_TYPES = ("FLOAT",) RETURN_NAMES = ("floats",) FUNCTION = "inverse_map_float_list" CATEGORY = category + subcategories["generators"] def inverse_map_float_list(self, list_of_floats): if not list_of_floats: raise ValueError( "๐Ÿ›‘\u2009 Nilor-Nodes (InverseMapFloatList): The input list_of_floats cannot be empty." ) min_input = min(list_of_floats) max_input = max(list_of_floats) return ([min_input + max_input - x for x in list_of_floats],) class NilorIntToListOfBools: def __init__(self): pass @classmethod def INPUT_TYPES(s): # Dictionary that defines input types for each field return { "required": { "number_of_images": ("INT", {"forceInput": False}), }, } # Define return types and names for outputs of the node RETURN_TYPES = ("BOOLEAN",) RETURN_NAMES = ("booleans",) FUNCTION = "boolify" CATEGORY = category + subcategories["generators"] OUTPUT_IS_LIST = (True,) def boolify(self, number_of_images, max_images=10): # Initializes the array with zeros my_bools = [False] * max_images for i in range(max_images): # Set the boolean value to True if the index is less than the number of images my_bools[i] = i < number_of_images return (my_bools,) class NilorListOfInts: def __init__(self): pass @classmethod def INPUT_TYPES(cls): return { "required": { "min": ("INT", {"forceInput": False, "default": 0}), "max": ("INT", {"forceInput": False, "default": 9}), "shuffle": ("BOOLEAN", {"default": False}), # Toggle to randomize order }, } RETURN_TYPES = ("INT",) RETURN_NAMES = ("ints",) FUNCTION = "int_list" CATEGORY = category + subcategories["generators"] OUTPUT_IS_LIST = ( True, ) # Indicates that the output should be processed as a list of individual elements def int_list(self, min=1, max=10, shuffle=False): # Generate the list ints_list = list(range(min, max + 1)) if shuffle: random.shuffle(ints_list) return (ints_list,) class NilorCountImagesInDirectory: def __init__(self): pass @classmethod def INPUT_TYPES(s): # Dictionary that defines input types for each field return { "required": { "directory": ("STRING", {"default": "X://path/to/images"}), }, } # Define return types and names for outputs of the node RETURN_TYPES = ("INT",) RETURN_NAMES = ("int",) FUNCTION = "count_images_in_directory" CATEGORY = category + subcategories["utilities"] INPUT_IS_LIST = False def count_images_in_directory(self, directory): if not os.path.isdir(directory): raise FileNotFoundError( f"๐Ÿ›‘\u2009 Nilor-Nodes (NilorCountImagesInDirectory): Directory '{directory}' cannot be found." ) list_dir = [] list_dir = os.listdir(directory) count = 0 for file in list_dir: if file.endswith(".png") or file.endswith(".jpeg") or file.endswith(".jpg"): count += 1 return [count] class NilorSelectIndexFromList: def __init__(self): pass @classmethod def INPUT_TYPES(cls): return { "required": { "list_of_any": ( any, {"forceInput": False}, ), # Marking as lazy if processing could be deferred "index": ("INT", {"default": 0}), }, } RETURN_TYPES = (any,) RETURN_NAMES = ("any",) FUNCTION = "any_by_index" CATEGORY = category + subcategories["utilities"] INPUT_IS_LIST = True # Treats input list as a whole, rather than processing each item separately OUTPUT_IS_LIST = (False,) # Output is a single element, not a list def any_by_index(self, list_of_any, index=0): # The input is a tensor so we need to unpack one level if isinstance(list_of_any, list) and len(list_of_any) == 1: actual_list = list_of_any[0] else: actual_list = list_of_any # Handle index access safely if isinstance(index, list): index = index[0] # Ensure the index is within bounds if index < 0 or index >= len(actual_list): raise ValueError( "๐Ÿ›‘\u2009 Nilor-Nodes (SelectIndexFromList): Index is outside the bounds of the array." ) # Returns the value at the given index return (actual_list[index],) class NilorSaveEXRArbitrary: def __init__(self): self.output_dir = folder_paths.get_output_directory() self.type = "output" @classmethod def INPUT_TYPES(cls): return { "required": { "channels": ( any, ), # This should match the 'any' type list from List of Any "filename_prefix": ("STRING", {"default": "output"}), }, "hidden": { "prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO", }, } RETURN_TYPES = () FUNCTION = "save_exr_arbitrary" # The execution function CATEGORY = category + subcategories["io"] # INPUT_IS_LIST = True OUTPUT_NODE = True def save_exr_arbitrary( self, channels=None, filename_prefix="output", prompt=None, extra_pnginfo=None ): logging.info( "โ„น๏ธ\u2009 Nilor-Nodes (SaveEXRArbitrary): Running save_exr_arbitrary" ) # print(f"channels: {channels}") # print(f"filename_prefix: {filename_prefix}") actual_channels = channels # actual_channels = channels[0] # Unpack the channels list # filename_prefix = filename_prefix[0] # Unpack the filename_prefix list # check if actual_channels is subscriptable try: actual_channels[0] except TypeError: logging.error( "๐Ÿ›‘\u2009 Nilor-Nodes (SaveEXRArbitrary): actual_channels is not subscriptable" ) return # File path handling useabs = os.path.isabs(filename_prefix) if not useabs: full_output_folder, filename, counter, subfolder, filename_prefix = ( folder_paths.get_save_image_path( filename_prefix, self.output_dir, actual_channels[0].shape[-1], actual_channels[0].shape[-2], ) ) # Determine if the input contains a batch is_batch = ( len(actual_channels[0].shape) == 3 ) # If batch, shape is [batch_size, height, width] if is_batch: batch_size = actual_channels[0].shape[0] else: batch_size = 1 for i in range(batch_size): # Extract each image's channels if is_batch: image_channels = [ tensor[i] for tensor in actual_channels ] # For batch, select i-th image else: image_channels = actual_channels # For single image, use channels as is # Validate each tensor height, width = image_channels[0].shape[-2:] for tensor in image_channels: if tensor.shape[-2:] != (height, width): raise ValueError( "๐Ÿ›‘\u2009 Nilor-Nodes (SaveEXRArbitrary): All input tensors must have the same dimensions" ) # Channel naming default_names = ["R", "G", "B", "A"] + [ f"Channel{j}" for j in range(4, len(image_channels)) ] # Prepare data for EXR writing exr_data = {} for j, tensor in enumerate(image_channels): exr_data[default_names[j]] = tensor.cpu().numpy() # Handle file naming and saving if useabs: writepath = filename_prefix else: file = f"{filename}_{counter:05}_.exr" writepath = os.path.join(full_output_folder, file) counter += 1 # Write EXR file self.write_exr(writepath, exr_data) return filename_prefix def write_exr(self, writepath, exr_data): try: # Determine the height and width from one of the provided channels height, width = list(exr_data.values())[0].shape[:2] # Create the EXR file header with dynamic channel names header = OpenEXR.Header(width, height) header["channels"] = { name: Imath.Channel(Imath.PixelType(Imath.PixelType.FLOAT)) for name in exr_data.keys() } # Create the EXR file exr_file = OpenEXR.OutputFile(writepath, header) # Prepare the data for each channel channel_data = { name: data.astype(np.float32).tobytes() for name, data in exr_data.items() } # Write the channel data to the EXR file exr_file.writePixels(channel_data) exr_file.close() logging.info( f"โœ…\u2009 Nilor-Nodes (SaveEXRArbitrary): EXR file saved successfully to {writepath}" ) except Exception as e: logging.error( f"๐Ÿ›‘\u2009 Nilor-Nodes (SaveEXRArbitrary): Failed to write EXR file: {e}" ) class NilorSaveVideoToHFDataset: def __init__(self) -> None: pass @classmethod def INPUT_TYPES(s): return { "required": { "filename_prefix": ("STRING", {"default": "nilor_save"}), "filenames": ("VHS_FILENAMES",), "hf_auth_token": ("STRING", {"default": "auth_token"}), "repository_id": ("STRING", {"default": "nilor_dataset"}), } } RETURN_TYPES = () FUNCTION = "save_video_to_hf_dataset" OUTPUT_NODE = True CATEGORY = category + subcategories["io"] def save_video_to_hf_dataset( self, filenames, hf_auth_token, repository_id, filename_prefix="nilor_save" ): files = filenames[1] results = list() for path in files: ext = path.split(".")[-1] name = f"{filename_prefix}.{ext}" api = HfApi(token=hf_auth_token) api.upload_file( path_or_fileobj=path, path_in_repo=name, repo_id=repository_id, repo_type="dataset", ) results.append(name) return {"ui": {"string_field": results}} class NilorSaveImageToHFDataset: def __init__(self) -> None: pass @classmethod def INPUT_TYPES(s): return { "required": { "image": ("IMAGE",), "repository_id": ("STRING", {"default": "nilor_dataset"}), "hf_auth_token": ("STRING", {"default": "auth_token"}), "filename_prefix": ("STRING", {"default": "nilor_image"}), }, "hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"}, } RETURN_TYPES = () FUNCTION = "save_image_to_hf_dataset" OUTPUT_NODE = True CATEGORY = category + subcategories["io"] def save_image_to_hf_dataset( self, image, repository_id, hf_auth_token, filename_prefix="nilor_image", prompt=None, extra_pnginfo=None, ): # Save the image to the dataset metadata = PngInfo() metadata.add_text("workflow", "testing, this should be png data") results = list() for i, tensor in enumerate(image): data = 255.0 * tensor.cpu().numpy() img = Image.fromarray(np.clip(data, 0, 255).astype(np.uint8)) img_byte_arr = io.BytesIO() img.save(img_byte_arr, format="PNG", pnginfo=metadata) img_byte_arr = img_byte_arr.getvalue() now = datetime.now() date_string = now.strftime("%Y-%m-%d-%H-%M-%S") image_name = f"{filename_prefix}_{i}_{date_string}.png" api = HfApi(token=hf_auth_token) api.upload_file( path_or_fileobj=img_byte_arr, path_in_repo=image_name, repo_id=repository_id, repo_type="dataset", ) results.append(image_name) return {"ui": {"string_field": results}} class NilorShuffleImageBatch: def __init__(self): pass @classmethod def INPUT_TYPES(s): return { "required": { "images": ("IMAGE",), "seed": ("INT", {"default": 0, "min": 0, "max": BIGMAX, "step": 1}), }, } RETURN_TYPES = ("IMAGE",) RETURN_NAMES = ("images",) FUNCTION = "shuffle_image_batch" CATEGORY = category + subcategories["utilities"] def _check_image_dimensions(self, images): if images.shape[0] == 0: raise ValueError( "๐Ÿ›‘\u2009 Nilor-Nodes (ShuffleImageBatch): Input images tensor is empty." ) # All images in the batch should have the same dimensions if len(images.shape) != 4: raise ValueError( f"๐Ÿ›‘\u2009 Nilor-Nodes (ShuffleImageBatch): Expected 4D tensor (batch, channels, height, width), got shape {images.shape}" ) def shuffle_image_batch(self, images: torch.Tensor, seed): self._check_image_dimensions(images) # Get the number of images in the batch num_images = images.shape[0] # Generate indices and shuffle them torch.manual_seed(seed) indices = torch.randperm(num_images) # Shuffle the images using the indices shuffled_images = images[indices] return (shuffled_images,) class NilorRepeatTrimImageBatch: def __init__(self): pass @classmethod def INPUT_TYPES(s): return { "required": { "images": ("IMAGE",), "count": ("INT", {"default": 1, "min": 1, "max": BIGMAX, "step": 1}), }, } RETURN_TYPES = ("IMAGE",) RETURN_NAMES = ("images",) FUNCTION = "repeat_trim_image_batch" CATEGORY = category + subcategories["utilities"] def _check_image_dimensions(self, images): if images.shape[0] == 0: raise ValueError( "๐Ÿ›‘\u2009 Nilor-Nodes (RepeatTrimImageBatch): Input images tensor is empty." ) # All images in the batch should have the same dimensions if len(images.shape) != 4: raise ValueError( f"๐Ÿ›‘\u2009 Nilor-Nodes (RepeatTrimImageBatch): Expected 4D tensor (batch, channels, height, width), got shape {images.shape}" ) def repeat_trim_image_batch(self, images: torch.Tensor, count): self._check_image_dimensions(images) batch_count = images.size(0) amount = math.ceil(count / batch_count) appended_tensors = (images.repeat(amount, 1, 1, 1),) batched_tensors = torch.cat(appended_tensors, dim=0) trimmed_tensors = batched_tensors[:count] return (trimmed_tensors,) class NilorRepeatShuffleTrimImageBatch: def __init__(self): pass @classmethod def INPUT_TYPES(s): return { "required": { "images": ("IMAGE",), "seed": ("INT", {"default": 0, "min": 0, "max": BIGMAX, "step": 1}), "count": ("INT", {"default": 1, "min": 1, "max": BIGMAX, "step": 1}), }, } RETURN_TYPES = ("IMAGE",) RETURN_NAMES = ("images",) FUNCTION = "repeat_shuffle_trim_image_batch" CATEGORY = category + subcategories["utilities"] def _check_image_dimensions(self, images): if images.shape[0] == 0: raise ValueError( "๐Ÿ›‘\u2009 Nilor-Nodes (RepeatShuffleTrimImageBatch): Input images tensor is empty." ) # All images in the batch should have the same dimensions if len(images.shape) != 4: raise ValueError( f"๐Ÿ›‘\u2009 Nilor-Nodes (RepeatShuffleTrimImageBatch): Expected 4D tensor (batch, channels, height, width), got shape {images.shape}" ) def repeat_shuffle_trim_image_batch(self, images: torch.Tensor, seed, count): self._check_image_dimensions(images) torch.manual_seed(seed) batch_count = images.size(0) amount = math.ceil(count / batch_count) appended_tensors = [] while len(appended_tensors) < count: indices = torch.randperm(batch_count) appended_tensors.append(images[indices]) batched_tensors = torch.cat(appended_tensors, dim=0) trimmed_tensors = batched_tensors[:count] return (trimmed_tensors,) class NilorOutputFilenameString: def __init__(self): pass @classmethod def INPUT_TYPES(s): return { "required": { "client": ("STRING", {"default": "nilor"}), "project": ("STRING", {"default": "research"}), "section": ("STRING", {"default": "test-1"}), "name": ("STRING", {"default": "out-1"}), }, "hidden": { "unique_id": "UNIQUE_ID", "extra_pnginfo": "EXTRA_PNGINFO", }, } RETURN_TYPES = ("STRING",) RETURN_NAMES = ("string",) FUNCTION = "notify" CATEGORY = category + subcategories["utilities"] OUTPUT_NODE = True IS_CHANGED = True def get_time(self, format: str): now = datetime.now() return now.strftime(format) def notify( self, client, project, section, name, unique_id=None, extra_pnginfo=None ): time = self.get_time("%y%m%d-%H%M%S") client = client or "nilor" project = project or "research" section = section or "test-1" name = name or "out-1" text = f"{client}_{project}/{section}/{time}_{section}/{time}_{client}_{project}_{section}_{name}" if unique_id is not None and extra_pnginfo is not None: if not isinstance(extra_pnginfo, list): logging.error( "๐Ÿ›‘\u2009 Nilor-Nodes (OutputFilenameString): extra_pnginfo is not a list" ) elif ( not isinstance(extra_pnginfo[0], dict) or "workflow" not in extra_pnginfo[0] ): logging.error( "๐Ÿ›‘\u2009 Nilor-Nodes (OutputFilenameString): extra_pnginfo[0] is not a dict or missing 'workflow' key" ) else: workflow = extra_pnginfo[0]["workflow"] node = next( (x for x in workflow["nodes"] if str(x["id"]) == str(unique_id[0])), None, ) if node: node["widgets_values"] = [text] # TODO: make this node's text string preview widget work return {"ui": {"text": text}, "result": (text,)} class NilorNFractionsOfInt: def __init__(self): pass @classmethod def INPUT_TYPES(s): return { "required": { "numerator": ("INT", {"default": 10}), "denominator": ("INT", {"default": 2}), "type": (["starts", "ends", "centres", "start + end"], {}), }, } RETURN_TYPES = ("INT",) RETURN_NAMES = ("fractions",) FUNCTION = "n_fractions_of_int" CATEGORY = category + subcategories["utilities"] OUTPUT_IS_LIST = (True,) def n_fractions_of_int(self, numerator, denominator, type): # the number of fractions to generate is the denominator if type == "starts": return ([i * numerator // denominator for i in range(denominator)],) elif type == "ends": return ([(i + 1) * numerator // denominator for i in range(denominator)],) elif type == "centres": return ( [ (i * numerator + numerator // 2) // denominator for i in range(denominator) ], ) elif type == "start + end": return ([i * numerator // (denominator - 1) for i in range(denominator)],) else: raise ValueError( f"๐Ÿ›‘\u2009 Nilor-Nodes (NilorNFractionsOfInt): Unknown type: {type}" ) class NilorWanTileResolution: def __init__(self): pass @classmethod def INPUT_TYPES(cls): return { "required": { "input_width": ( "INT", {"default": 1920, "min": 16, "max": BIGMAX, "step": 1}, ), "input_height": ( "INT", {"default": 1080, "min": 16, "max": BIGMAX, "step": 1}, ), "target_width": ( "INT", {"default": 3840, "min": 16, "max": BIGMAX, "step": 1}, ), "target_height": ( "INT", {"default": 2160, "min": 16, "max": BIGMAX, "step": 1}, ), "size_preference": ( ["largest", "smallest"], {"default": "largest"}, ), } } RETURN_TYPES = ("INT", "INT") RETURN_NAMES = ("tile_width", "tile_height") FUNCTION = "compute_tile_resolution" CATEGORY = category + subcategories["utilities"] MIN_TILE_DIM = 384 MAX_TILE_DIM = 1794 MIN_TILE_AREA = 384 * 384 MAX_TILE_AREA = 1024 * 1024 @staticmethod def _clamp(value, minimum, maximum): return max(minimum, min(value, maximum)) def compute_tile_resolution( self, input_width, input_height, target_width, target_height, size_preference="largest", ): """ Compute (Wt, Ht) tile size (multiples of 16) within [MIN_TILE_DIM, MAX_TILE_DIM] while keeping area between [MIN_TILE_AREA, MAX_TILE_AREA]. Emphasise aspect-ratio fidelity to Wa/Ha while staying within the allowed range. Among options with comparable aspect error, prefer tiles that do not hit clamped bounds, then maximise area and width (or minimise both if size_preference == "smallest"). Assumes Wa, Ha are multiples of 16. """ dims = { "input_width": input_width, "input_height": input_height, "target_width": target_width, "target_height": target_height, } for name, value in dims.items(): if value <= 0: raise ValueError( f"๐Ÿ›‘\u2009 Nilor-Nodes (NilorWanTileResolution): {name} must be a positive integer." ) if input_width % 16 != 0 or input_height % 16 != 0: raise ValueError( "๐Ÿ›‘\u2009 Nilor-Nodes (NilorWanTileResolution): input_width and input_height must be multiples of 16." ) if target_width < self.MIN_TILE_DIM or target_height < self.MIN_TILE_DIM: raise ValueError( "๐Ÿ›‘\u2009 Nilor-Nodes (NilorWanTileResolution): target_width and target_height must be at least the minimum tile size." ) min_blocks = self.MIN_TILE_DIM // 16 max_blocks = self.MAX_TILE_DIM // 16 max_width_blocks = min(max_blocks, target_width // 16) max_height_blocks = min(max_blocks, target_height // 16) if max_width_blocks < min_blocks or max_height_blocks < min_blocks: raise ValueError( "๐Ÿ›‘\u2009 Nilor-Nodes (NilorWanTileResolution): Target dimensions do not allow a tile within the supported range." ) aspect_ratio = input_width / input_height best_score = None best_dimensions = None for height_blocks in range(min_blocks, max_height_blocks + 1): width_blocks = round(aspect_ratio * height_blocks) width_blocks = self._clamp(width_blocks, min_blocks, max_width_blocks) width_px = width_blocks * 16 height_px = height_blocks * 16 area = width_px * height_px if area < self.MIN_TILE_AREA or area > self.MAX_TILE_AREA: # Skip tiles that are too small or too large continue aspect_error = abs((width_blocks / height_blocks) - aspect_ratio) width_hits_bound = int(width_blocks in (min_blocks, max_width_blocks)) height_hits_bound = int(height_blocks in (min_blocks, max_height_blocks)) # Penalise tiles that hit the clamped bounds bound_penalty = width_hits_bound + height_hits_bound # Score tiles based on size preference if size_preference == "smallest": area_score = -area width_score = -width_px else: area_score = area width_score = width_px # Combine scores candidate = (-aspect_error, -bound_penalty, area_score, width_score) if best_score is None or candidate > best_score: # Update best score and dimensions if this candidate is better best_score = candidate best_dimensions = (width_px, height_px) if best_dimensions is None: # If no suitable tile resolution was found, raise an error raise RuntimeError( "๐Ÿ›‘\u2009 Nilor-Nodes (NilorWanTileResolution): Failed to determine a suitable tile resolution." ) return best_dimensions class NilorWanFrameTrim: def __init__(self): pass @classmethod def INPUT_TYPES(s): return { "required": { "images": ("IMAGE",), }, } RETURN_TYPES = ("IMAGE",) RETURN_NAMES = ("images",) FUNCTION = "trim_to_wan_count" CATEGORY = category + subcategories["utilities"] def _validate_images(self, images): if not isinstance(images, torch.Tensor): raise TypeError( "๐Ÿ›‘\u2009 Nilor-Nodes (WanFrameTrim): images must be a torch.Tensor." ) if images.dim() != 4: raise ValueError( f"๐Ÿ›‘\u2009 Nilor-Nodes (WanFrameTrim): Expected 4D tensor (batch, height, width, channels), got shape {tuple(images.shape)}" ) if images.shape[0] == 0: raise ValueError( "๐Ÿ›‘\u2009 Nilor-Nodes (WanFrameTrim): Input images tensor is empty." ) def trim_to_wan_count(self, images: torch.Tensor): self._validate_images(images) batch_count = images.shape[0] # Find the largest m <= batch_count such that m โ‰ก 1 (mod 4) wan_count = batch_count - ((batch_count - 1) % 4) if wan_count <= 0: raise ValueError( "๐Ÿ›‘\u2009 Nilor-Nodes (WanFrameTrim): Unable to compute a valid 4N+1 frame count from input." ) trimmed = images[:wan_count] return (trimmed,) class NilorCategorizeString: def __init__(self): pass @classmethod def INPUT_TYPES(s): return { "required": { "input_string": ("STRING", {"default": ""}), "number_of_categories": ("INT", {"default": 2, "min": 1, "max": 10}), "category_0": ("STRING", {"default": "apple, red fruit"}), "category_1": ("STRING", {"default": "banana, yellow fruit"}), }, "optional": { "category_2": ("STRING", {"default": ""}), "category_3": ("STRING", {"default": ""}), "category_4": ("STRING", {"default": ""}), "category_5": ("STRING", {"default": ""}), "category_6": ("STRING", {"default": ""}), "category_7": ("STRING", {"default": ""}), "category_8": ("STRING", {"default": ""}), "category_9": ("STRING", {"default": ""}), }, } RETURN_TYPES = ("INT",) RETURN_NAMES = ("category_index",) FUNCTION = "categorize_string" CATEGORY = category + subcategories["utilities"] def categorize_string( self, input_string, number_of_categories, category_0, category_1, category_2="", category_3="", category_4="", category_5="", category_6="", category_7="", category_8="", category_9="", ): # Convert input string to lowercase for case-insensitive matching input_string = input_string.lower() # Create categories dictionary from inputs categories = {} all_categories = [ category_0, category_1, category_2, category_3, category_4, category_5, category_6, category_7, category_8, category_9, ] # Only process the number of categories specified for i in range(number_of_categories): if all_categories[i]: # Only add non-empty categories # Split the comma-separated string and clean up whitespace keywords = [k.strip().lower() for k in all_categories[i].split(",")] categories[i] = keywords # Check each category's keywords against the input string for index, keywords in categories.items(): if builtins.any(keyword in input_string for keyword in keywords): return (index,) return (-1,) # Default case if no matches found class NilorRandomString: def __init__(self): pass @classmethod def INPUT_TYPES(s): return { "required": { "multiline_text": ( "STRING", {"default": "option1, option2, option3", "multiline": True}, ), "max_options": ("INT", {"default": 3, "min": 1}), "delimiter": ("STRING", {"default": ","}), "seed": ("INT", {"default": 0, "min": 0, "max": 0xFFFFFFFFFFFFFFFF}), }, } RETURN_TYPES = ("STRING",) RETURN_NAMES = ("chosen_string",) FUNCTION = "choose_random_string" CATEGORY = category + subcategories["utilities"] def choose_random_string(self, multiline_text, max_options, delimiter, seed): import random random.seed(seed) # If the delimiter is literally "\n", use the actual newline character. if delimiter == r"\n" or delimiter == "\\n": actual_delimiter = "\n" else: actual_delimiter = delimiter # Split the input text using the actual delimiter and remove any extra whitespace options = [ item.strip() for item in multiline_text.split(actual_delimiter) if item.strip() ] if not options: raise ValueError( "๐Ÿ›‘\u2009 Nilor-Nodes (NilorRandomString): No valid choices provided." ) # Limit to the first 'max_options' entries if there are more options if len(options) > max_options: options = options[:max_options] chosen = random.choice(options) return (chosen,) class NilorLoadImageByIndex: def __init__(self): pass @classmethod def INPUT_TYPES(s): return { "required": { "image_directory": ( "STRING", {"default": "", "placeholder": "Image Directory"}, ), "seed": ("INT", {"default": 0, "min": 0, "max": 0xFFFFFFFFFFFFFFFF}), "sort_mode": ( ["filename", "creation_time", "modification_time", "size"], {"default": "filename"}, ), "reverse_sort": ("BOOLEAN", {"default": False}), }, } RETURN_TYPES = ("IMAGE", "STRING", "STRING") RETURN_NAMES = ("image", "filename", "filepath") FUNCTION = "load_image_by_index" CATEGORY = category + subcategories["io"] @classmethod def IS_CHANGED(s, image_directory, seed, sort_mode, reverse_sort): return seed def load_image_by_index(self, image_directory, seed, sort_mode, reverse_sort): if not os.path.exists(image_directory): raise FileNotFoundError( f"๐Ÿ›‘\u2009 Nilor-Nodes (NilorLoadImageByIndex): Image directory {image_directory} does not exist" ) # Get list of image files files = [] for f in os.listdir(image_directory): file_path = os.path.join(image_directory, f) if os.path.isfile(file_path) and f.lower().endswith( (".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif") ): files.append(file_path) if not files: raise ValueError( f"๐Ÿ›‘\u2009 Nilor-Nodes (NilorLoadImageByIndex): No image files found in {image_directory}" ) # Sort files based on selected mode if sort_mode == "filename": files.sort() elif sort_mode == "creation_time": files.sort(key=lambda x: os.path.getctime(x)) elif sort_mode == "modification_time": files.sort(key=lambda x: os.path.getmtime(x)) elif sort_mode == "size": files.sort(key=lambda x: os.path.getsize(x)) # Apply reverse sort if requested if reverse_sort: files.reverse() # Get file at index (with wrapping) file_index = seed % len(files) selected_file = files[file_index] # Get filename filename = os.path.basename(selected_file) # Load image using PIL and convert to tensor using our helper function img = Image.open(selected_file) img_tensor = pil2tensor(img) return (img_tensor, filename, selected_file) class NilorExtractFilenameFromPath: def __init__(self): pass @classmethod def INPUT_TYPES(s): return { "required": { "filepath": ("STRING", {"default": ""}), }, } RETURN_TYPES = ("STRING", "STRING") RETURN_NAMES = ("name", "name_with_extension") FUNCTION = "extract_filename" CATEGORY = category + subcategories["utilities"] def extract_filename(self, filepath): # Ensure the input is a valid path if not filepath: raise ValueError( "๐Ÿ›‘\u2009 Nilor-Nodes (ExtractFilenameFromPath): Filepath cannot be empty." ) path = Path(filepath) # Extract filename with and without extension name = path.stem # Filename without extension name_with_extension = path.name # Filename with extension return (name, name_with_extension) class NilorBlurAnalysis: def __init__(self): pass @classmethod def INPUT_TYPES(cls): return { "required": { "images": ("IMAGE",), # Input image batch as a 4D tensor. "block_size": ("INT", {"default": 32, "min": 1, "max": 128, "step": 1}), }, } RETURN_TYPES = ("IMAGE",) RETURN_NAMES = ("blur_analysis",) FUNCTION = "analyze_blur" CATEGORY = category + subcategories["utilities"] def analyze_blur(self, images, block_size): """ Performs blur analysis on each image using OpenCV's Laplacian method. """ # Ensure images is a 4D tensor. if images.dim() != 4: raise ValueError( "๐Ÿ›‘\u2009 Nilor-Nodes (BlurAnalysis): Input images must be a 4D tensor (batch, channels/height, height/width, width/channels)" ) # Detect if using NCHW or NHWC. if images.shape[1] not in (1, 3): if images.shape[-1] in (1, 3): images = images.permute(0, 3, 1, 2) else: raise ValueError( "๐Ÿ›‘\u2009 Nilor-Nodes (BlurAnalysis): Cannot determine image format (expected channel to be 1 or 3)." ) output_images = [] batch_size = images.shape[0] for i in range(batch_size): # Get the i-th image (in NCHW: [channels, height, width]). img_tensor = images[i].cpu() img_np = img_tensor.numpy() # shape: (C, H, W) # Convert to grayscale. if img_np.shape[0] >= 3: gray = 0.299 * img_np[0] + 0.587 * img_np[1] + 0.114 * img_np[2] else: gray = np.squeeze(img_np, axis=0) # shape: (H, W) # Scale from [0, 1] to [0, 255] and convert to uint8. gray = np.clip(gray * 255.0, 0, 255).astype(np.uint8) # Compute Laplacian using a 3x3 kernel. lap = cv2.Laplacian(gray, cv2.CV_64F, ksize=3) abs_lap = np.absolute(lap) # Apply local averaging using cv2.blur with window size = (block_size, block_size). local_edge = cv2.blur(abs_lap, (block_size, block_size)) # Normalize and invert the edge response. max_val = local_edge.max() if max_val > 0: norm_edge = local_edge / max_val else: norm_edge = local_edge blur_map = 1.0 - norm_edge # Scale back to 0-255 and convert to uint8. out_img = (blur_map * 255.0).astype(np.uint8) # Convert the single channel output to a 3-channel image. # This ensures downstream nodes (like MaskFromRGBCMYBW) that index into channels work properly. if out_img.ndim == 2: out_img = np.stack( [out_img, out_img, out_img], axis=-1 ) # shape becomes (H, W, 3) # Convert from PIL image (or numpy array) to tensor. # pil2tensor should create a tensor in a format that downstream nodes expect. output_images.append(pil2tensor(out_img)) # --- # Fix 2: Use torch.stack to preserve the batch dimension. # If each output has shape, say, (H, W, 3), stacking them gives a tensor of shape (B, H, W, 3). return (torch.cat(output_images, dim=0),) class NilorToSparseIndexMethod: def __init__(self): pass @classmethod def INPUT_TYPES(s): return { "required": { "ints": ("INT", {"default": 0}), }, } RETURN_TYPES = ("STRING",) RETURN_NAMES = ("sparse_method",) OUTPUT_IS_LIST = (False,) INPUT_IS_LIST = True FUNCTION = "convert_to_sparse_index_method" CATEGORY = category + subcategories["utilities"] def convert_to_sparse_index_method(self, ints): indexes_str = ",".join(map(str, ints)) return (indexes_str,) class NilorImageResizeV2: upscale_methods = ["nearest-exact", "bilinear", "area", "bicubic", "lanczos"] @classmethod def INPUT_TYPES(s): return { "required": { "image": ("IMAGE",), "width": ("INT", {"default": 512, "min": 0, "max": BIGMAX, "step": 1}), "height": ("INT", {"default": 512, "min": 0, "max": BIGMAX, "step": 1}), "upscale_method": (s.upscale_methods,), "keep_proportion": ( [ "stretch", "resize", "pad", "pad_edge", "pad_edge_pixel", "crop", "pillarbox_blur", ], {"default": False}, ), "pad_color": ("STRING", {"default": "0, 0, 0"}), "crop_position": ( ["center", "top", "bottom", "left", "right"], {"default": "center"}, ), "divisible_by": ( "INT", {"default": 2, "min": 0, "max": 512, "step": 1}, ), }, "optional": { "mask": ("MASK",), "device": (["cpu", "gpu"],), "per_batch": ( "INT", { "default": 16, "min": 0, "max": 4096, "step": 1, "tooltip": "Process images in sub-batches. 0 disables.", }, ), }, "hidden": {"unique_id": "UNIQUE_ID"}, } RETURN_TYPES = ("IMAGE", "INT", "INT", "MASK") RETURN_NAMES = ("IMAGE", "width", "height", "mask") FUNCTION = "resize" CATEGORY = category + subcategories["utilities"] DESCRIPTION = """ Resizes images with optional aspect preservation, padding/cropping, and sub-batching to lower peak memory. """ def resize( self, image, width, height, keep_proportion, upscale_method, divisible_by, pad_color, crop_position, unique_id, device="cpu", mask=None, per_batch=16, ): B, H, W, C = image.shape if device == "gpu": if upscale_method == "lanczos": raise Exception( "๐Ÿ›‘\u2009 Nilor-Nodes (NilorImageResizeV2): Lanczos is not supported on the GPU" ) device = model_management.get_torch_device() else: device = torch.device("cpu") if width == 0: width = W if height == 0: height = H pillarbox_blur = keep_proportion == "pillarbox_blur" if ( keep_proportion == "resize" or keep_proportion.startswith("pad") or pillarbox_blur ): if width == 0 and height != 0: ratio = height / H new_width = round(W * ratio) new_height = height elif height == 0 and width != 0: ratio = width / W new_width = width new_height = round(H * ratio) elif width != 0 and height != 0: ratio = min(width / W, height / H) new_width = round(W * ratio) new_height = round(H * ratio) else: new_width = width new_height = height pad_left = pad_right = pad_top = pad_bottom = 0 if keep_proportion.startswith("pad") or pillarbox_blur: if crop_position == "center": pad_left = (width - new_width) // 2 pad_right = width - new_width - pad_left pad_top = (height - new_height) // 2 pad_bottom = height - new_height - pad_top elif crop_position == "top": pad_left = (width - new_width) // 2 pad_right = width - new_width - pad_left pad_top = 0 pad_bottom = height - new_height elif crop_position == "bottom": pad_left = (width - new_width) // 2 pad_right = width - new_width - pad_left pad_top = height - new_height pad_bottom = 0 elif crop_position == "left": pad_left = 0 pad_right = width - new_width pad_top = (height - new_height) // 2 pad_bottom = height - new_height - pad_top elif crop_position == "right": pad_left = width - new_width pad_right = 0 pad_top = (height - new_height) // 2 pad_bottom = height - new_height - pad_top width = new_width height = new_height if divisible_by > 1: width = width - (width % divisible_by) height = height - (height % divisible_by) if per_batch and B > per_batch: try: bytes_per_elem = image.element_size() est_total_bytes = B * height * width * C * bytes_per_elem est_mb = est_total_bytes / (1024 * 1024) logging.info( f"โ„น๏ธ\u2009 Nilor-Nodes (NilorImageResizeV2) Estimated output ~{est_mb:.2f} MB." ) except: pass def _process_subbatch(in_image, in_mask): out_image = in_image if in_image.device == device else in_image.to(device) out_mask = ( None if in_mask is None else (in_mask if in_mask.device == device else in_mask.to(device)) ) if keep_proportion == "crop": old_height = out_image.shape[-3] old_width = out_image.shape[-2] old_aspect = old_width / old_height new_aspect = width / height if old_aspect > new_aspect: crop_w = round(old_height * new_aspect) crop_h = old_height else: crop_w = old_width crop_h = round(old_width / new_aspect) if crop_position == "center": x = (old_width - crop_w) // 2 y = (old_height - crop_h) // 2 elif crop_position == "top": x = (old_width - crop_w) // 2 y = 0 elif crop_position == "bottom": x = (old_width - crop_w) // 2 y = old_height - crop_h elif crop_position == "left": x = 0 y = (old_height - crop_h) // 2 elif crop_position == "right": x = old_width - crop_w y = (old_height - crop_h) // 2 out_image = out_image.narrow(-2, x, crop_w).narrow(-3, y, crop_h) if out_mask is not None: out_mask = out_mask.narrow(-1, x, crop_w).narrow(-2, y, crop_h) out_image = common_upscale( out_image.movedim(-1, 1), width, height, upscale_method, crop="disabled" ).movedim(1, -1) if out_mask is not None: if upscale_method == "lanczos": out_mask = common_upscale( out_mask.unsqueeze(1).repeat(1, 3, 1, 1), width, height, upscale_method, crop="disabled", ).movedim(1, -1)[:, :, :, 0] else: out_mask = common_upscale( out_mask.unsqueeze(1), width, height, upscale_method, crop="disabled", ).squeeze(1) if (keep_proportion.startswith("pad") or pillarbox_blur) and ( pad_left > 0 or pad_right > 0 or pad_top > 0 or pad_bottom > 0 ): padded_width = width + pad_left + pad_right padded_height = height + pad_top + pad_bottom if divisible_by > 1: width_remainder = padded_width % divisible_by height_remainder = padded_height % divisible_by if width_remainder > 0: extra_width = divisible_by - width_remainder pad_right += extra_width if height_remainder > 0: extra_height = divisible_by - height_remainder pad_bottom += extra_height pad_mode = ( "pillarbox_blur" if pillarbox_blur else ( "edge" if keep_proportion == "pad_edge" else ( "edge_pixel" if keep_proportion == "pad_edge_pixel" else "color" ) ) ) out_image, out_mask = ImagePadKJ.pad( self, out_image, pad_left, pad_right, pad_top, pad_bottom, 0, pad_color, pad_mode, mask=out_mask, ) return out_image, out_mask if per_batch is None or per_batch == 0 or B <= per_batch: out_image, out_mask = _process_subbatch(image, mask) else: chunks = [] mask_chunks = [] if mask is not None else None total_batches = (B + per_batch - 1) // per_batch current_batch = 0 for start_idx in range(0, B, per_batch): current_batch += 1 end_idx = min(start_idx + per_batch, B) sub_img = image[start_idx:end_idx] sub_mask = mask[start_idx:end_idx] if mask is not None else None sub_out_img, sub_out_mask = _process_subbatch(sub_img, sub_mask) chunks.append(sub_out_img.cpu()) if mask is not None: mask_chunks.append( sub_out_mask.cpu() if sub_out_mask is not None else None ) try: logging.info( f"โ„น๏ธ\u2009 Nilor-Nodes (NilorImageResizeV2) Batch {current_batch}/{total_batches} ยท images {end_idx}/{B}" ) except: pass out_image = torch.cat(chunks, dim=0) if mask is not None and any(m is not None for m in mask_chunks): out_mask = torch.cat([m for m in mask_chunks if m is not None], dim=0) else: out_mask = None logging.info(f"โœ…\u2009 Nilor-Nodes (NilorImageResizeV2) All batches complete.") return ( out_image.cpu(), out_image.shape[2], out_image.shape[1], ( out_mask.cpu() if out_mask is not None else torch.zeros( 64, 64, device=torch.device("cpu"), dtype=torch.float32 ) ), ) # Mapping class names to objects for potential export NODE_CLASS_MAPPINGS = { "Nilor Interpolated Float List": NilorInterpolatedFloatList, "Nilor One Minus Float List": NilorOneMinusFloatList, "Nilor Remap Float List": NilorRemapFloatList, "Nilor Remap Float List Auto Input": NilorRemapFloatListAutoInput, "Nilor Inverse Map Float List": NilorInverseMapFloatList, "Nilor Int To List Of Bools": NilorIntToListOfBools, "Nilor List of Ints": NilorListOfInts, "Nilor Count Images In Directory": NilorCountImagesInDirectory, "Nilor Save Image To HF Dataset": NilorSaveImageToHFDataset, "Nilor Save Video To HF Dataset": NilorSaveVideoToHFDataset, "Nilor Select Index From List": NilorSelectIndexFromList, "Nilor Save EXR Arbitrary": NilorSaveEXRArbitrary, "Nilor Shuffle Image Batch": NilorShuffleImageBatch, "Nilor Repeat & Trim Image Batch": NilorRepeatTrimImageBatch, "Nilor Repeat, Shuffle, & Trim Image Batch": NilorRepeatShuffleTrimImageBatch, "Nilor Output Filename String": NilorOutputFilenameString, "Nilor n Fractions of Int": NilorNFractionsOfInt, "Nilor Categorize String": NilorCategorizeString, "Nilor Random String": NilorRandomString, "Nilor Wan Tile Resolution": NilorWanTileResolution, "Nilor Extract Filename from Path": NilorExtractFilenameFromPath, "Nilor Load Image By Index": NilorLoadImageByIndex, "Nilor Blur Analysis": NilorBlurAnalysis, "Nilor To Sparse Index Method": NilorToSparseIndexMethod, "Nilor Image Resize v2": NilorImageResizeV2, "Nilor Wan Frame Trim": NilorWanFrameTrim, } # Mapping nodes to human-readable names NODE_DISPLAY_NAME_MAPPINGS = { "Nilor Interpolated Float List": "๐Ÿ‘บ Interpolated Float List", "Nilor One Minus Float List": "๐Ÿ‘บ One Minus Float List", "Nilor Remap Float List": "๐Ÿ‘บ Remap Float List", "Nilor Remap Float List Auto Input": "๐Ÿ‘บ Remap Float List Auto Input", "Nilor Inverse Map Float List": "๐Ÿ‘บ Inverse Map Float List", "Nilor Int To List Of Bools": "๐Ÿ‘บ Int To List Of Bools", "Nilor List of Ints": "๐Ÿ‘บ List of Ints", "Nilor Count Images In Directory": "๐Ÿ‘บ Count Images In Directory", "Nilor Save Image To HF Dataset": "๐Ÿ‘บ Save Image To HF Dataset", "Nilor Save Video To HF Dataset": "๐Ÿ‘บ Save Video To HF Dataset", "Nilor Select Index From List": "๐Ÿ‘บ Select Index From List", "Nilor Save EXR Arbitrary": "๐Ÿ‘บ Save EXR Arbitrary", "Nilor Shuffle Image Batch": "๐Ÿ‘บ Shuffle Image Batch", "Nilor Repeat & Trim Image Batch": "๐Ÿ‘บ Repeat & Trim Image Batch", "Nilor Repeat, Shuffle, & Trim Image Batch": "๐Ÿ‘บ Repeat, Shuffle, & Trim Image Batch", "Nilor Output Filename String": "๐Ÿ‘บ Output Filename String", "Nilor n Fractions of Int": "๐Ÿ‘บ n Fractions of Int", "Nilor Categorize String": "๐Ÿ‘บ Categorize String", "Nilor Random String": "๐Ÿ‘บ Random String", "Nilor Wan Tile Resolution": "๐Ÿ‘บ Wan Tile Resolution", "Nilor Extract Filename from Path": "๐Ÿ‘บ Extract Filename from Path", "Nilor Load Image By Index": "๐Ÿ‘บ Load Image By Index", "Nilor Blur Analysis": "๐Ÿ‘บ Blur Analysis", "Nilor To Sparse Index Method": "๐Ÿ‘บ To Sparse Index Method", "Nilor Image Resize v2": "๐Ÿ‘บ Resize Image v2", "Nilor Wan Frame Trim": "๐Ÿ‘บ Wan Frame Trim", }