| import os
|
| import torch
|
| import numpy as np
|
| import json
|
| from PIL import Image, ImageSequence
|
| from PIL.PngImagePlugin import PngInfo
|
| import folder_paths
|
| from server import PromptServer
|
| import re
|
|
|
|
|
| class ImageBatchSaver:
|
| INPUT_IS_LIST = True
|
| FUNCTION = "save"
|
| CATEGORY = "Batch Process"
|
| RETURN_TYPES = ()
|
| RETURN_NAMES = ()
|
| OUTPUT_NODE = True
|
|
|
| ALLOWED_EXT = [".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tiff", ".gif"]
|
|
|
| @classmethod
|
| def IS_CHANGED(cls, **kwargs):
|
|
|
| import time
|
|
|
| return time.time()
|
|
|
| @classmethod
|
| def INPUT_TYPES(cls):
|
| return {
|
| "required": {},
|
| "optional": {
|
| "images": ("IMAGE",),
|
| "contents": ("STRING", {"forceInput": True}),
|
| "output_path": ("STRING", {"default": ""}),
|
| "filename_prefix": ("STRING", {"default": "IMG"}),
|
| "filename_delimiter": ("STRING", {"default": "_"}),
|
| "filename_suffix": ("STRING", {"default": ""}),
|
| "extension": (
|
| ["png", "jpg", "jpeg", "webp", "bmp", "tiff", "gif"],
|
| {"default": "png"},
|
| ),
|
| "filename_number_padding": (
|
| "INT",
|
| {"default": 4, "min": 1, "max": 9, "step": 1},
|
| ),
|
| "filename_number": (
|
| ["off", "start", "end"],
|
| {"default": "end"},
|
| ),
|
| "embeded_workflow": ("BOOLEAN", {"default": True}),
|
| "append_frames": (
|
| "BOOLEAN",
|
| {
|
| "label_on": "true",
|
| "label_off": "false",
|
| "default": False,
|
| "defaultInput": False,
|
| "tooltip": "Append new frames to existing file with same base name. Useful for accumulating frames across loop iterations to reduce memory usage.",
|
| },
|
| ),
|
| "start_index": (
|
| "INT",
|
| {
|
| "default": 0,
|
| "min": 0,
|
| "max": 999999,
|
| "step": 1,
|
| "tooltip": "Start index (1-based) for frames to save. Use 0 to save all frames, or set 1 for first frame, 2 for second frame, etc.",
|
| },
|
| ),
|
| "end_index": (
|
| "INT",
|
| {
|
| "default": 0,
|
| "min": 0,
|
| "max": 999999,
|
| "step": 1,
|
| "tooltip": "End index (inclusive, 1-based) for frames to save. Use 0 to save all remaining frames, or set >0 to limit range.",
|
| },
|
| ),
|
| },
|
| "hidden": {
|
| "node_id": "UNIQUE_ID",
|
| "prompt": "PROMPT",
|
| "extra_pnginfo": "EXTRA_PNGINFO",
|
| },
|
| }
|
|
|
| def save(
|
| self,
|
| images=None,
|
| contents=None,
|
| output_path="",
|
| filename_prefix="IMG",
|
| filename_delimiter="_",
|
| filename_suffix="",
|
| extension="png",
|
| filename_number_padding=4,
|
| filename_number="end",
|
| append_frames=False,
|
| start_index=0,
|
| end_index=0,
|
| embeded_workflow=True,
|
| node_id=None,
|
| prompt=None,
|
| extra_pnginfo=None,
|
| ):
|
|
|
| extension = self._get_first_or_default(extension, "png")
|
| filename_prefix = self._get_first_or_default(filename_prefix, "IMG")
|
| output_path = self._get_first_or_default(output_path, "")
|
| filename_number_padding = self._get_first_or_default(filename_number_padding, 4)
|
| filename_delimiter = self._get_first_or_default(filename_delimiter, "_")
|
| filename_suffix = self._get_first_or_default(filename_suffix, "")
|
| filename_suffix = filename_suffix.strip("'[]")
|
| filename_number = self._get_first_or_default(filename_number, "end")
|
| append_frames = self._get_first_or_default(append_frames, False)
|
| start_index = self._get_first_or_default(start_index, 0)
|
| end_index = self._get_first_or_default(end_index, 0)
|
| embeded_workflow = self._get_first_or_default(embeded_workflow, True)
|
|
|
|
|
| counter_start = filename_number in ["start"]
|
| counter_end = filename_number in ["end"]
|
|
|
| if extension not in ["png", "jpg", "jpeg", "webp", "bmp", "tiff", "gif"]:
|
| raise ValueError(f"Invalid extension: {extension}")
|
|
|
| if filename_number_padding < 1:
|
| raise ValueError(
|
| f"filename_number_padding must be at least 1, got {filename_number_padding}"
|
| )
|
|
|
|
|
| if images is not None:
|
| processed_images = []
|
| for img in images if isinstance(images, list) else [images]:
|
| if (
|
| isinstance(img, torch.Tensor)
|
| and img.dim() == 4
|
| and img.shape[0] > 1
|
| ):
|
| batch_size = img.shape[0]
|
| for i in range(batch_size):
|
| processed_images.append(img[i].unsqueeze(0))
|
| else:
|
| processed_images.append(img)
|
| images = processed_images
|
| image_count = len(images)
|
| else:
|
| images = []
|
| image_count = len(contents) if contents is not None else 1
|
|
|
|
|
| output_path = self._normalize_input(output_path, image_count)
|
|
|
|
|
| filename_prefix = self._normalize_input(filename_prefix, image_count)
|
|
|
|
|
| contents = (
|
| self._normalize_input(contents, image_count)
|
| if contents is not None
|
| else None
|
| )
|
|
|
|
|
| output_dir = folder_paths.get_output_directory()
|
|
|
|
|
| saved_files = []
|
| for idx, (prefix, path) in enumerate(zip(filename_prefix, output_path)):
|
| try:
|
| final_output_path = self._get_output_path(output_dir, path)
|
| os.makedirs(final_output_path, exist_ok=True)
|
|
|
| original_filename = os.path.splitext(os.path.basename(prefix))[0]
|
| base_filename = self._generate_filename(
|
| prefix=original_filename,
|
| suffix=filename_suffix,
|
| padding=filename_number_padding,
|
| counter_start=counter_start,
|
| counter_end=counter_end,
|
| delimiter=filename_delimiter,
|
| final_output_path=final_output_path,
|
| )
|
|
|
|
|
| if images and idx < len(images):
|
| image_to_save = images[idx]
|
|
|
|
|
| if (
|
| isinstance(image_to_save, torch.Tensor)
|
| and image_to_save.dim() == 4
|
| ):
|
|
|
| num_frames = image_to_save.shape[0]
|
| actual_start = (start_index - 1) if start_index > 0 else 0
|
| actual_end = (
|
| (end_index - 1) if end_index > 0 else (num_frames - 1)
|
| )
|
|
|
|
|
| actual_start = max(0, min(actual_start, num_frames - 1))
|
| actual_end = max(actual_start, min(actual_end, num_frames - 1))
|
|
|
| if actual_start <= actual_end and num_frames > 0:
|
| image_to_save = image_to_save[actual_start : actual_end + 1]
|
| else:
|
| image_to_save = image_to_save[0:1]
|
|
|
|
|
| if append_frames:
|
|
|
| original_extension = extension
|
| if extension.lower() not in ["gif", "tiff", "webp"]:
|
|
|
| extension = "gif"
|
| print(
|
| f"Info: {original_extension} format doesn't support multiple frames. "
|
| f"Auto-converting to GIF format for append_frames feature."
|
| )
|
|
|
|
|
| base_name_no_counter = self._get_base_name_without_counter(
|
| original_filename, filename_suffix, filename_delimiter
|
| )
|
| existing_path = self._find_existing_file(
|
| final_output_path,
|
| base_name_no_counter,
|
| extension,
|
| counter_start,
|
| counter_end,
|
| filename_delimiter,
|
| )
|
|
|
|
|
| if not existing_path and original_extension != extension:
|
| existing_path = self._find_existing_file(
|
| final_output_path,
|
| base_name_no_counter,
|
| original_extension,
|
| counter_start,
|
| counter_end,
|
| filename_delimiter,
|
| )
|
|
|
| if existing_path:
|
| print(
|
| f"Info: Found existing file with {original_extension} extension. "
|
| f"Will convert to {extension} format for multi-frame support."
|
| )
|
|
|
| if existing_path and os.path.exists(existing_path):
|
|
|
| existing_frames = self._load_image_frames(existing_path)
|
| if existing_frames is not None:
|
|
|
| if image_to_save.dim() == 3:
|
| image_to_save = image_to_save.unsqueeze(0)
|
| if existing_frames.dim() == 3:
|
| existing_frames = existing_frames.unsqueeze(0)
|
| image_to_save = torch.cat(
|
| [existing_frames, image_to_save], dim=0
|
| )
|
|
|
|
|
| if original_extension != extension:
|
|
|
| base_path = os.path.splitext(existing_path)[0]
|
| full_path = f"{base_path}.{extension}"
|
| else:
|
| full_path = existing_path
|
|
|
| num_new_frames = (
|
| image_to_save.shape[0] - existing_frames.shape[0]
|
| )
|
| print(
|
| f"Appending {num_new_frames} frame(s) to file: {full_path} "
|
| f"(total: {image_to_save.shape[0]} frames)"
|
| )
|
| else:
|
|
|
| full_path = os.path.join(
|
| final_output_path, f"{base_filename}.{extension}"
|
| )
|
| else:
|
|
|
| full_path = os.path.join(
|
| final_output_path, f"{base_filename}.{extension}"
|
| )
|
| else:
|
|
|
| full_path = os.path.join(
|
| final_output_path, f"{base_filename}.{extension}"
|
| )
|
|
|
| self._save_image_tensor(
|
| image_to_save,
|
| full_path,
|
| embed_workflow=embeded_workflow,
|
| prompt=prompt,
|
| extra_pnginfo=extra_pnginfo,
|
| extension=extension,
|
| )
|
| saved_files.append(full_path)
|
| print(f"Saved image: {full_path}")
|
| elif not images:
|
|
|
| full_path = os.path.join(
|
| final_output_path, f"{base_filename}.{extension}"
|
| )
|
| saved_files.append(full_path)
|
| print(f"Generated path: {full_path}")
|
|
|
|
|
| if contents and idx < len(contents):
|
| content_path = os.path.join(
|
| final_output_path, f"{base_filename}.txt"
|
| )
|
| with open(content_path, "w", encoding="utf-8") as f:
|
| f.write(str(contents[idx]).strip())
|
| saved_files.append(content_path)
|
| print(f"Saved content: {content_path}")
|
|
|
| except Exception as e:
|
| print(f"Error saving file {idx+1}: {str(e)}")
|
|
|
| self._update_progress(node_id, idx + 1, image_count)
|
|
|
| return ()
|
|
|
| def _save_image_tensor(
|
| self, tensor, path, embed_workflow, prompt, extra_pnginfo, extension
|
| ):
|
| try:
|
|
|
| if tensor.is_cuda:
|
| tensor = tensor.cpu()
|
|
|
|
|
| if tensor.dim() == 4 and tensor.shape[0] > 1:
|
| self._save_multi_frame_tensor(
|
| tensor, path, embed_workflow, prompt, extra_pnginfo, extension
|
| )
|
| return
|
|
|
|
|
| if tensor.dim() == 4:
|
| tensor = tensor.squeeze(0)
|
|
|
|
|
| if tensor.dtype == torch.uint8:
|
|
|
| img_array = tensor.numpy()
|
| else:
|
|
|
| img_array = np.clip(255.0 * tensor.numpy(), 0, 255).astype(np.uint8)
|
|
|
|
|
| if img_array.ndim == 3:
|
|
|
| if img_array.shape[0] == 3 or img_array.shape[0] == 1:
|
|
|
| img_array = np.transpose(img_array, (1, 2, 0))
|
| elif img_array.shape[2] == 3 or img_array.shape[2] == 1:
|
|
|
| pass
|
| else:
|
|
|
|
|
| if img_array.shape[0] == 1 and img_array.shape[1] == 1:
|
|
|
| img_array = img_array.reshape(img_array.shape[2], 1, 1)
|
| img_array = np.transpose(img_array, (1, 0, 2))
|
| elif img_array.shape[1] == 1:
|
|
|
| img_array = np.transpose(img_array, (0, 2, 1))
|
|
|
|
|
| if img_array.ndim == 2:
|
|
|
| img_array = np.expand_dims(img_array, axis=2)
|
| elif img_array.ndim == 1:
|
|
|
|
|
| size = int(np.sqrt(len(img_array)))
|
| if size * size == len(img_array):
|
| img_array = img_array.reshape(size, size, 1)
|
| else:
|
| raise ValueError(
|
| f"Cannot reshape 1D array of length {len(img_array)} to image"
|
| )
|
|
|
|
|
| if img_array.shape[2] == 1:
|
|
|
| img_array = img_array.squeeze(
|
| 2
|
| )
|
| elif img_array.shape[2] > 3:
|
|
|
| img_array = img_array[:, :, :3]
|
| elif img_array.shape[2] == 2:
|
|
|
| img_array = np.concatenate([img_array, img_array[:, :, :1]], axis=2)
|
|
|
|
|
| if img_array.ndim == 2:
|
|
|
| img = Image.fromarray(img_array, mode="L")
|
| elif img_array.ndim == 3 and img_array.shape[2] == 3:
|
|
|
| img = Image.fromarray(img_array, mode="RGB")
|
| else:
|
|
|
| if img_array.ndim == 2:
|
| img = Image.fromarray(img_array, mode="L").convert("RGB")
|
| else:
|
| img = Image.fromarray(img_array[:, :, :3], mode="RGB")
|
|
|
| except Exception as e:
|
| print(f"Error processing tensor with shape {tensor.shape}: {str(e)}")
|
|
|
| try:
|
| tensor_np = tensor.cpu().numpy()
|
| if tensor_np.dtype != np.uint8:
|
| tensor_np = np.clip(255.0 * tensor_np, 0, 255).astype(np.uint8)
|
|
|
|
|
| while tensor_np.ndim < 2:
|
| tensor_np = np.expand_dims(tensor_np, axis=0)
|
| while tensor_np.ndim > 3:
|
| tensor_np = tensor_np.squeeze(0)
|
|
|
| if tensor_np.ndim == 2:
|
| img = Image.fromarray(tensor_np, mode="L").convert("RGB")
|
| elif tensor_np.ndim == 3:
|
| if tensor_np.shape[0] == 3 or tensor_np.shape[0] == 1:
|
| tensor_np = np.transpose(tensor_np, (1, 2, 0))
|
| if tensor_np.shape[2] == 1:
|
| tensor_np = tensor_np.squeeze(2)
|
| img = Image.fromarray(tensor_np, mode="L").convert("RGB")
|
| else:
|
| img = Image.fromarray(tensor_np[:, :, :3], mode="RGB")
|
| else:
|
| raise ValueError(f"Cannot convert tensor with shape {tensor.shape}")
|
| except Exception as e2:
|
| raise ValueError(
|
| f"Failed to save image: {str(e)}, fallback also failed: {str(e2)}"
|
| )
|
|
|
| if embed_workflow:
|
| if extension.lower() == "webp":
|
| exif_data = img.getexif()
|
| if prompt is not None:
|
| prompt_str = json.dumps(prompt)
|
| exif_data[0x010F] = "Prompt:" + prompt_str
|
| if extra_pnginfo is not None:
|
| if isinstance(extra_pnginfo, list):
|
| merged_extra = {}
|
| for item in extra_pnginfo:
|
| if isinstance(item, dict):
|
| merged_extra.update(item)
|
| extra_pnginfo = merged_extra
|
| if isinstance(extra_pnginfo, dict):
|
| workflow_metadata = json.dumps(extra_pnginfo)
|
| exif_data[0x010E] = "Workflow:" + workflow_metadata
|
| exif_bytes = exif_data.tobytes()
|
| img.save(path, exif=exif_bytes)
|
| else:
|
| metadata = PngInfo()
|
| if prompt is not None:
|
| metadata.add_text("prompt", json.dumps(prompt))
|
| if extra_pnginfo is not None:
|
| if isinstance(extra_pnginfo, list):
|
| merged_extra = {}
|
| for item in extra_pnginfo:
|
| if isinstance(item, dict):
|
| merged_extra.update(item)
|
| extra_pnginfo = merged_extra
|
| if isinstance(extra_pnginfo, dict):
|
| for key in extra_pnginfo:
|
| metadata.add_text(key, json.dumps(extra_pnginfo[key]))
|
| img.save(path, pnginfo=metadata)
|
| else:
|
| img.save(path)
|
|
|
| def _save_multi_frame_tensor(
|
| self, tensor, path, embed_workflow, prompt, extra_pnginfo, extension
|
| ):
|
| """Save a 4D tensor (batch, height, width, channels) as multi-frame image"""
|
| frames = []
|
| for i in range(tensor.shape[0]):
|
| frame = tensor[i]
|
|
|
|
|
| if frame.is_cuda:
|
| frame = frame.cpu()
|
|
|
|
|
| if frame.dtype == torch.uint8:
|
| frame_np = frame.numpy()
|
| else:
|
| frame_np = np.clip(255.0 * frame.numpy(), 0, 255).astype(np.uint8)
|
|
|
|
|
| if frame_np.ndim == 3:
|
|
|
| if frame_np.shape[0] == 3 or frame_np.shape[0] == 1:
|
|
|
| frame_np = np.transpose(frame_np, (1, 2, 0))
|
| elif frame_np.shape[2] == 3 or frame_np.shape[2] == 1:
|
|
|
| pass
|
| else:
|
|
|
| if frame_np.shape[0] == 1:
|
| frame_np = np.transpose(frame_np, (1, 2, 0))
|
| elif frame_np.ndim == 2:
|
|
|
| frame_np = np.expand_dims(frame_np, axis=2)
|
|
|
|
|
| if frame_np.ndim == 2:
|
|
|
| pil_img = Image.fromarray(frame_np, mode="L")
|
| elif frame_np.ndim == 3:
|
| if frame_np.shape[2] == 1:
|
|
|
| pil_img = Image.fromarray(frame_np.squeeze(2), mode="L")
|
| elif frame_np.shape[2] == 3:
|
|
|
| pil_img = Image.fromarray(frame_np, mode="RGB")
|
| else:
|
|
|
| pil_img = Image.fromarray(frame_np[:, :, :3], mode="RGB")
|
| else:
|
| raise ValueError(
|
| f"Unsupported frame shape: {frame.shape} -> {frame_np.shape}"
|
| )
|
|
|
| frames.append(pil_img)
|
|
|
|
|
| first_frame = frames[0]
|
| if embed_workflow:
|
| if extension.lower() == "webp":
|
| exif_data = first_frame.getexif()
|
| if prompt is not None:
|
| prompt_str = json.dumps(prompt)
|
| exif_data[0x010F] = "Prompt:" + prompt_str
|
| if extra_pnginfo is not None:
|
| if isinstance(extra_pnginfo, list):
|
| merged_extra = {}
|
| for item in extra_pnginfo:
|
| if isinstance(item, dict):
|
| merged_extra.update(item)
|
| extra_pnginfo = merged_extra
|
| if isinstance(extra_pnginfo, dict):
|
| workflow_metadata = json.dumps(extra_pnginfo)
|
| exif_data[0x010E] = "Workflow:" + workflow_metadata
|
| exif_bytes = exif_data.tobytes()
|
| first_frame.save(
|
| path,
|
| save_all=True,
|
| append_images=frames[1:] if len(frames) > 1 else [],
|
| duration=100,
|
| loop=0,
|
| exif=exif_bytes,
|
| )
|
| elif extension.lower() == "gif":
|
| first_frame.save(
|
| path,
|
| save_all=True,
|
| append_images=frames[1:] if len(frames) > 1 else [],
|
| duration=100,
|
| loop=0,
|
| )
|
| elif extension.lower() == "tiff":
|
| metadata = PngInfo()
|
| if prompt is not None:
|
| metadata.add_text("prompt", json.dumps(prompt))
|
| if extra_pnginfo is not None:
|
| if isinstance(extra_pnginfo, list):
|
| merged_extra = {}
|
| for item in extra_pnginfo:
|
| if isinstance(item, dict):
|
| merged_extra.update(item)
|
| extra_pnginfo = merged_extra
|
| if isinstance(extra_pnginfo, dict):
|
| for key in extra_pnginfo:
|
| metadata.add_text(key, json.dumps(extra_pnginfo[key]))
|
| first_frame.save(
|
| path,
|
| save_all=True,
|
| append_images=frames[1:] if len(frames) > 1 else [],
|
| pnginfo=metadata,
|
| )
|
| else:
|
|
|
| metadata = PngInfo()
|
| if prompt is not None:
|
| metadata.add_text("prompt", json.dumps(prompt))
|
| if extra_pnginfo is not None:
|
| if isinstance(extra_pnginfo, list):
|
| merged_extra = {}
|
| for item in extra_pnginfo:
|
| if isinstance(item, dict):
|
| merged_extra.update(item)
|
| extra_pnginfo = merged_extra
|
| if isinstance(extra_pnginfo, dict):
|
| for key in extra_pnginfo:
|
| metadata.add_text(key, json.dumps(extra_pnginfo[key]))
|
| first_frame.save(path, pnginfo=metadata)
|
| print(
|
| f"Warning: {extension} doesn't support multiple frames. Only first frame saved."
|
| )
|
| else:
|
| if extension.lower() in ["gif", "webp"]:
|
| first_frame.save(
|
| path,
|
| save_all=True,
|
| append_images=frames[1:] if len(frames) > 1 else [],
|
| duration=100,
|
| loop=0,
|
| )
|
| elif extension.lower() == "tiff":
|
| first_frame.save(
|
| path,
|
| save_all=True,
|
| append_images=frames[1:] if len(frames) > 1 else [],
|
| )
|
| else:
|
| first_frame.save(path)
|
| print(
|
| f"Warning: {extension} doesn't support multiple frames. Only first frame saved."
|
| )
|
|
|
| def _get_base_name_without_counter(self, prefix, suffix, delimiter):
|
| """Generate base filename without counter"""
|
| parts = [str(prefix)]
|
| if suffix and suffix.strip():
|
| parts.append(str(suffix))
|
| return delimiter.join(parts)
|
|
|
| def _find_existing_file(
|
| self, directory, base_name, extension, counter_start, counter_end, delimiter
|
| ):
|
| """Find existing file with same base name (ignoring counter)"""
|
| if not os.path.exists(directory):
|
| return None
|
|
|
| existing_files = os.listdir(directory)
|
| base_pattern = re.escape(base_name)
|
|
|
|
|
| if counter_start and counter_end:
|
|
|
| pattern_start = re.compile(
|
| rf"^(\d+){re.escape(delimiter)}{base_pattern}\.{re.escape(extension)}$"
|
| )
|
| pattern_end = re.compile(
|
| rf"^{base_pattern}{re.escape(delimiter)}(\d+)\.{re.escape(extension)}$"
|
| )
|
| elif counter_start:
|
| pattern_start = re.compile(
|
| rf"^(\d+){re.escape(delimiter)}{base_pattern}\.{re.escape(extension)}$"
|
| )
|
| pattern_end = None
|
| elif counter_end:
|
| pattern_start = None
|
| pattern_end = re.compile(
|
| rf"^{base_pattern}{re.escape(delimiter)}(\d+)\.{re.escape(extension)}$"
|
| )
|
| else:
|
|
|
| exact_name = f"{base_name}.{extension}"
|
| exact_path = os.path.join(directory, exact_name)
|
| if os.path.exists(exact_path):
|
| return exact_path
|
| return None
|
|
|
|
|
| matching_files = []
|
| for file in existing_files:
|
| if not file.endswith(f".{extension}"):
|
| continue
|
| file_base = os.path.splitext(file)[0]
|
|
|
| match = None
|
| if pattern_start:
|
| match = pattern_start.match(file_base)
|
| if not match and pattern_end:
|
| match = pattern_end.match(file_base)
|
|
|
| if match:
|
| matching_files.append((file, int(match.group(1))))
|
|
|
| if matching_files:
|
|
|
| matching_files.sort(key=lambda x: x[1], reverse=True)
|
| return os.path.join(directory, matching_files[0][0])
|
|
|
| return None
|
|
|
| def _load_image_frames(self, path):
|
| """Load an image file and return all frames as a tensor"""
|
| try:
|
| img = Image.open(path)
|
| frames = []
|
|
|
|
|
| try:
|
| for frame in ImageSequence.Iterator(img):
|
| frame = frame.convert("RGB")
|
| img_array = np.array(frame).astype(np.float32) / 255.0
|
| frames.append(torch.from_numpy(img_array))
|
| except Exception:
|
|
|
| img = img.convert("RGB")
|
| img_array = np.array(img).astype(np.float32) / 255.0
|
| frames.append(torch.from_numpy(img_array))
|
|
|
| if len(frames) == 0:
|
| return None
|
|
|
| if len(frames) == 1:
|
| return frames[0]
|
| else:
|
|
|
| return torch.stack(frames, dim=0)
|
| except Exception as e:
|
| print(f"Error loading image frames from {path}: {str(e)}")
|
| return None
|
|
|
| def _get_first_or_default(self, value, default):
|
| if isinstance(value, list):
|
| for v in value:
|
| if isinstance(v, type(default)):
|
| return v
|
| return default
|
| return value if value is not None else default
|
|
|
| def _normalize_input(self, input_data, count):
|
| if input_data is None:
|
| return [None] * count
|
| if not isinstance(input_data, list):
|
| return [input_data] * count
|
| return input_data + [input_data[-1]] * (count - len(input_data))
|
|
|
| def _get_output_path(self, base_dir, user_path):
|
| if not user_path or str(user_path).lower() in ["none", "."]:
|
| return base_dir
|
| return (
|
| os.path.join(base_dir, user_path)
|
| if not os.path.isabs(str(user_path))
|
| else user_path
|
| )
|
|
|
| def _generate_filename(
|
| self,
|
| prefix,
|
| suffix,
|
| padding,
|
| counter_start,
|
| counter_end,
|
| delimiter,
|
| final_output_path,
|
| ):
|
| existing_files = os.listdir(final_output_path)
|
|
|
| if suffix.strip():
|
| pattern_start = re.compile(
|
| rf"^(\d+){re.escape(delimiter)}{re.escape(prefix)}{re.escape(delimiter)}{re.escape(suffix)}$"
|
| )
|
| pattern_end = re.compile(
|
| rf"^{re.escape(prefix)}{re.escape(delimiter)}{re.escape(suffix)}{re.escape(delimiter)}(\d+)$"
|
| )
|
| else:
|
| pattern_start = re.compile(
|
| rf"^(\d+){re.escape(delimiter)}{re.escape(prefix)}$"
|
| )
|
| pattern_end = re.compile(
|
| rf"^{re.escape(prefix)}{re.escape(delimiter)}(\d+)$"
|
| )
|
|
|
| numbers = []
|
| for file in existing_files:
|
| file_base = os.path.splitext(file)[0]
|
| if counter_start and not counter_end:
|
| match = pattern_start.match(file_base)
|
| elif counter_end and not counter_start:
|
| match = pattern_end.match(file_base)
|
| elif counter_start and counter_end:
|
| match_start = pattern_start.match(file_base)
|
| match_end = pattern_end.match(file_base)
|
| match = match_start or match_end
|
| if match_start and match_end:
|
| numbers.extend([int(match_start.group(1)), int(match_end.group(1))])
|
| continue
|
| else:
|
| match = None
|
| if match:
|
| numbers.append(int(match.group(1)))
|
|
|
| counter = max(numbers) + 1 if numbers else 1
|
| parts = []
|
| if counter_start:
|
| parts.append(f"{counter:0{padding}d}")
|
| parts.append(str(prefix))
|
| if suffix.strip():
|
| parts.append(str(suffix))
|
| if counter_end:
|
| parts.append(f"{counter:0{padding}d}")
|
|
|
| return delimiter.join(parts)
|
|
|
| def _update_progress(self, node_id, current, total):
|
| if node_id:
|
| PromptServer.instance.send_sync(
|
| "progress", {"node": node_id, "value": current, "max": total}
|
| )
|
|
|