diff --git a/__init__.py b/__init__.py index 3515384..4a7facb 100644 --- a/__init__.py +++ b/__init__.py @@ -12,6 +12,7 @@ from .fal_seedance_video import FalAPI_seedance_video from .string_lower import StringLower from .multi_alpha_composite import MultiAlphaComposite from .flux_kontext_inpaint_conditioning import FluxKontextInpaintingConditioning +from .load_image_batch_fun import LoadImageBatchFun WEB_DIRECTORY = "./js" NODE_CLASS_MAPPINGS = { @@ -27,7 +28,8 @@ NODE_CLASS_MAPPINGS = { "FalAPI_seedance_video": FalAPI_seedance_video, "StringLower": StringLower, "MultiAlphaComposite": MultiAlphaComposite, - "FluxKontextInpaintingConditioning": FluxKontextInpaintingConditioning + "FluxKontextInpaintingConditioning": FluxKontextInpaintingConditioning, + "LoadImageBatchFun": LoadImageBatchFun } NODE_DISPLAY_NAME_MAPPINGS = { "LayeredInfiniteZoom": "Layered Infinite Zoom", @@ -42,7 +44,8 @@ NODE_DISPLAY_NAME_MAPPINGS = { "FalAPI_seedance_video": "Fal API Seedance Video", "StringLower": "String to Lowercase", "MultiAlphaComposite": "Multi Alpha Composite", - "FluxKontextInpaintingConditioning": "No Fun Flux Kontext Inpaint Conditioning" + "FluxKontextInpaintingConditioning": "No Fun Flux Kontext Inpaint Conditioning", + "LoadImageBatchFun": "Load Image Batch (Fun)" } __all__ = [ diff --git a/__pycache__/__init__.cpython-312.pyc b/__pycache__/__init__.cpython-312.pyc index 166e870..b54ecaf 100644 Binary files a/__pycache__/__init__.cpython-312.pyc and b/__pycache__/__init__.cpython-312.pyc differ diff --git a/__pycache__/custom_name_selector.cpython-312.pyc b/__pycache__/custom_name_selector.cpython-312.pyc index f527d8f..f4316fb 100644 Binary files a/__pycache__/custom_name_selector.cpython-312.pyc and b/__pycache__/custom_name_selector.cpython-312.pyc differ diff --git a/__pycache__/fal_kling_video.cpython-312.pyc b/__pycache__/fal_kling_video.cpython-312.pyc new file mode 100644 index 0000000..4ba16a2 Binary files /dev/null and b/__pycache__/fal_kling_video.cpython-312.pyc differ diff --git a/__pycache__/fal_recraft_upscale.cpython-312.pyc b/__pycache__/fal_recraft_upscale.cpython-312.pyc new file mode 100644 index 0000000..b6789c4 Binary files /dev/null and b/__pycache__/fal_recraft_upscale.cpython-312.pyc differ diff --git a/__pycache__/fal_seedance_video.cpython-312.pyc b/__pycache__/fal_seedance_video.cpython-312.pyc new file mode 100644 index 0000000..74e9201 Binary files /dev/null and b/__pycache__/fal_seedance_video.cpython-312.pyc differ diff --git a/__pycache__/flux_kontext_inpaint_conditioning.cpython-312.pyc b/__pycache__/flux_kontext_inpaint_conditioning.cpython-312.pyc new file mode 100644 index 0000000..afb0d83 Binary files /dev/null and b/__pycache__/flux_kontext_inpaint_conditioning.cpython-312.pyc differ diff --git a/__pycache__/iframe_view.cpython-312.pyc b/__pycache__/iframe_view.cpython-312.pyc index 1a5c4ea..e4aa989 100644 Binary files a/__pycache__/iframe_view.cpython-312.pyc and b/__pycache__/iframe_view.cpython-312.pyc differ diff --git a/__pycache__/ksampler_fun.cpython-312.pyc b/__pycache__/ksampler_fun.cpython-312.pyc index e816d6a..964e01e 100644 Binary files a/__pycache__/ksampler_fun.cpython-312.pyc and b/__pycache__/ksampler_fun.cpython-312.pyc differ diff --git a/__pycache__/layered_infinite_zoom.cpython-312.pyc b/__pycache__/layered_infinite_zoom.cpython-312.pyc index d02303a..4a4125c 100644 Binary files a/__pycache__/layered_infinite_zoom.cpython-312.pyc and b/__pycache__/layered_infinite_zoom.cpython-312.pyc differ diff --git a/__pycache__/load_image_batch_fun.cpython-312.pyc b/__pycache__/load_image_batch_fun.cpython-312.pyc new file mode 100644 index 0000000..ba4cad4 Binary files /dev/null and b/__pycache__/load_image_batch_fun.cpython-312.pyc differ diff --git a/__pycache__/multi_alpha_composite.cpython-312.pyc b/__pycache__/multi_alpha_composite.cpython-312.pyc new file mode 100644 index 0000000..6d0374e Binary files /dev/null and b/__pycache__/multi_alpha_composite.cpython-312.pyc differ diff --git a/__pycache__/queue_counter_reset.cpython-312.pyc b/__pycache__/queue_counter_reset.cpython-312.pyc index 31b7bce..695d3c5 100644 Binary files a/__pycache__/queue_counter_reset.cpython-312.pyc and b/__pycache__/queue_counter_reset.cpython-312.pyc differ diff --git a/__pycache__/replicate_Flux_Fill.cpython-312.pyc b/__pycache__/replicate_Flux_Fill.cpython-312.pyc index 5ee81f8..719f341 100644 Binary files a/__pycache__/replicate_Flux_Fill.cpython-312.pyc and b/__pycache__/replicate_Flux_Fill.cpython-312.pyc differ diff --git a/__pycache__/replicate_Flux_Pro_Ultra.cpython-312.pyc b/__pycache__/replicate_Flux_Pro_Ultra.cpython-312.pyc index eb0427a..a6a33f7 100644 Binary files a/__pycache__/replicate_Flux_Pro_Ultra.cpython-312.pyc and b/__pycache__/replicate_Flux_Pro_Ultra.cpython-312.pyc differ diff --git a/__pycache__/string_lower.cpython-312.pyc b/__pycache__/string_lower.cpython-312.pyc new file mode 100644 index 0000000..b7a60e5 Binary files /dev/null and b/__pycache__/string_lower.cpython-312.pyc differ diff --git a/fal_seedance_video.py b/fal_seedance_video.py index ad1185e..8bc6b78 100644 --- a/fal_seedance_video.py +++ b/fal_seedance_video.py @@ -3,11 +3,15 @@ import time import requests import tempfile import json +import random import numpy as np import torch +import asyncio +import folder_paths from io import BytesIO from PIL import Image import fal_client +from comfy_api.input_impl import VideoFromFile class FalAPI_seedance_video: @classmethod @@ -85,62 +89,37 @@ class FalAPI_seedance_video: } } - RETURN_TYPES = ("STRING", "STRING",) - RETURN_NAMES = ("video_path", "generation_info",) + RETURN_TYPES = ("VIDEO", "STRING", "STRING",) + RETURN_NAMES = ("video", "video_path", "generation_info",) FUNCTION = "generate_video" CATEGORY = "FAL" def __init__(self): - # Directory structure for saving generated videos - self.output_dir = "output/API/FAL/seedance-video" - self.metadata_dir = os.path.join(self.output_dir, "metadata") - - os.makedirs(self.output_dir, exist_ok=True) + # Use ComfyUI's temp directory for temporary video storage + self.temp_dir = folder_paths.get_temp_directory() + self.metadata_dir = os.path.join(self.temp_dir, "seedance_metadata") os.makedirs(self.metadata_dir, exist_ok=True) - def get_next_number(self): + def get_temp_filename(self): """ - Looks at existing .mp4/.mov files and picks the next integer - filename index (e.g. 001, 002, etc.). + Generate a unique temporary filename using timestamp and random component. """ - valid_exts = {".mp4", ".mov", ".avi"} - files = [f for f in os.listdir(self.output_dir) - if os.path.splitext(f)[1].lower() in valid_exts] - if not files: - return 1 + timestamp = int(time.time() * 1000) + random_id = random.randint(1000, 9999) + return f"seedance_{timestamp}_{random_id}.mp4" - numbers = [] - for file_name in files: - base, _ = os.path.splitext(file_name) - try: - numbers.append(int(base)) - except ValueError: - pass - - if numbers: - return max(numbers) + 1 - else: - return 1 + def save_video_and_metadata(self, video_content, generation_info, filename): + """ + Saves the generated video to temp directory, plus metadata as a .json. + """ + filepath = os.path.join(self.temp_dir, filename) - def create_filename(self, number): - """ - Zero-padded filenames ending in .mp4 (e.g., '001.mp4'). - """ - return f"{number:03d}.mp4" - - def save_video_and_metadata(self, video_content, generation_info, number): - """ - Saves the generated video as .mp4, plus metadata as a .json. - """ - filename = self.create_filename(number) - filepath = os.path.join(self.output_dir, filename) - - # Save video content + # Save video content to temp directory with open(filepath, 'wb') as f: f.write(video_content) - # Create metadata filename (001_metadata.json) - metadata_filename = f"{number:03d}_metadata.json" + # Create metadata filename based on video filename + metadata_filename = f"{os.path.splitext(filename)[0]}_metadata.json" metadata_filepath = os.path.join(self.metadata_dir, metadata_filename) # Write out metadata @@ -158,10 +137,11 @@ class FalAPI_seedance_video: except Exception as e: print(f"[Seedance Video] Error in queue update: {e}") - def generate_video(self, api_token, image, prompt, aspect_ratio, resolution, + async def generate_video(self, api_token, image, prompt, aspect_ratio, resolution, duration, camera_fixed, enable_safety_checker, seed=-1): """ Generate video using fal-ai/bytedance/seedance/v1/pro/image-to-video + (Async execution for parallel processing) """ print(f"[Seedance Video] Starting video generation process...") @@ -201,18 +181,18 @@ class FalAPI_seedance_video: except Exception as e: print(f"[Seedance Video] fal_client check failed: {str(e)}") - # 2) Convert ComfyUI image tensor to temporary file + # 2) Convert ComfyUI image tensor to temporary file (run in thread pool) try: - temp_file_path = self.tensor_to_tempfile(image) + temp_file_path = await asyncio.to_thread(self.tensor_to_tempfile, image) print(f"[Seedance Video] Created temp file: {temp_file_path}") except Exception as e: print(f"[Seedance Video] Error creating temp file: {str(e)}") raise - # 3) Upload image to FAL storage and get URL + # 3) Upload image to FAL storage and get URL (run in thread pool) try: print(f"[Seedance Video] Uploading image to FAL storage...") - image_url = fal_client.upload_file(temp_file_path) + image_url = await asyncio.to_thread(fal_client.upload_file, temp_file_path) print(f"[Seedance Video] Image uploaded successfully: {image_url}") except Exception as e: print(f"[Seedance Video] Error uploading file: {str(e)}") @@ -240,9 +220,10 @@ class FalAPI_seedance_video: print(f"[Seedance Video] Starting video generation...") - # 5) Call fal_client.subscribe() + # 5) Call fal_client.subscribe() (run in thread pool for async execution) try: - result = fal_client.subscribe( + result = await asyncio.to_thread( + fal_client.subscribe, "fal-ai/bytedance/seedance/v1/pro/image-to-video", arguments=arguments, with_logs=True, @@ -270,14 +251,14 @@ class FalAPI_seedance_video: video_url = video_data["url"] seed_used = result.get("seed", "unknown") - # 8) Download the generated video + # 8) Download the generated video (run in thread pool) print(f"[Seedance Video] Downloading video from: {video_url}") - resp = requests.get(video_url) + resp = await asyncio.to_thread(requests.get, video_url) if resp.status_code != 200: raise ConnectionError(f"Failed to download video. HTTP {resp.status_code}") - # 9) Save the video & metadata - number = self.get_next_number() + # 9) Save the video to temp directory & metadata (run in thread pool) + temp_filename = self.get_temp_filename() generation_info = { "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"), @@ -297,22 +278,27 @@ class FalAPI_seedance_video: "fal_result": result } - video_path, metadata_path = self.save_video_and_metadata(resp.content, generation_info, number) - print(f"[Seedance Video] Saved video -> {video_path}") + video_path, metadata_path = await asyncio.to_thread( + self.save_video_and_metadata, resp.content, generation_info, temp_filename + ) + print(f"[Seedance Video] Saved temp video -> {video_path}") print(f"[Seedance Video] Saved metadata -> {metadata_path}") - # 10) Return the video path & metadata JSON string - return (video_path, json.dumps(generation_info, indent=2)) + # 10) Create VideoFromFile object for ComfyUI's VIDEO type + video_output = VideoFromFile(video_path) + + # 11) Return the video object, video path string & metadata JSON string + return (video_output, video_path, json.dumps(generation_info, indent=2)) except Exception as e: - # Return empty path and an error message in JSON + # Return None for video, empty path and an error message in JSON error_info = { "error": f"Seedance video generation failed: {str(e)}", "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"), "model": "fal-ai/bytedance/seedance/v1/pro/image-to-video" } print(f"[Seedance Video] Error: {str(e)}") - return (empty_path, json.dumps(error_info, indent=2)) + return (None, empty_path, json.dumps(error_info, indent=2)) def tensor_to_tempfile(self, tensor): """ diff --git a/load_image_batch_fun.py b/load_image_batch_fun.py new file mode 100644 index 0000000..0b514a8 --- /dev/null +++ b/load_image_batch_fun.py @@ -0,0 +1,229 @@ +import os +import numpy as np +import torch +from PIL import Image, ImageOps, ImageSequence +import hashlib +import folder_paths + +class LoadImageBatchFun: + """ + A fixed version of Load Image Batch that supports multiple instances in the same workflow. + Each instance maintains its own state to prevent conflicts. + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "mode": (["single_image", "incremental_image", "random"],), + "index": ("INT", {"default": 0, "min": 0, "max": 150000, "step": 1}), + "label": ("STRING", {"default": "Batch"}), + "path": ("STRING", {"default": "", "multiline": False}), + "pattern": ("STRING", {"default": "*", "multiline": False}), + "allow_RGBA_output": (["false", "true"],), + }, + } + + RETURN_TYPES = ("IMAGE", "STRING", "INT") + RETURN_NAMES = ("image", "filename", "count") + FUNCTION = "load_batch_images" + CATEGORY = "image" + + def __init__(self): + """ + Initialize instance-specific variables to prevent shared state issues. + This is the key fix for supporting multiple instances. + """ + self.current_images = [] + self.current_filenames = [] + self.current_index = 0 + self.last_hash = None + self.cached_images = [] + self.cached_filenames = [] + + def load_batch_images(self, mode, index, label, path, pattern, allow_RGBA_output): + """ + Load images from a directory path based on the specified mode. + """ + # Validate path + if not path or path.strip() == "": + raise ValueError("Path cannot be empty") + + # Expand path and validate + path = os.path.expanduser(path) + if not os.path.exists(path): + raise ValueError(f"Path does not exist: {path}") + + if not os.path.isdir(path): + raise ValueError(f"Path is not a directory: {path}") + + # Generate a hash for path/pattern to determine if we need to reload images + # Don't include index/mode in this hash so we can reuse cached images + cache_hash = hashlib.md5( + f"{path}_{pattern}_{allow_RGBA_output}".encode() + ).hexdigest() + + # Load images if cache is invalid or parameters changed + if self.last_hash != cache_hash or not self.cached_images: + print(f"[Load Image Batch Fun] Loading images from: {path}") + self.cached_images, self.cached_filenames = self._load_images_from_path( + path, pattern, allow_RGBA_output + ) + self.last_hash = cache_hash + self.current_index = 0 + + if not self.cached_images: + raise ValueError(f"No images found in path: {path} with pattern: {pattern}") + + total_images = len(self.cached_images) + print(f"[Load Image Batch Fun] Total images found: {total_images}") + + # Select image based on mode + if mode == "single_image": + # Use the specified index, wrap around if needed + selected_index = index % total_images + selected_image = self.cached_images[selected_index] + selected_filename = self.cached_filenames[selected_index] + print(f"[Load Image Batch Fun] Single image mode - selected index: {selected_index}") + + elif mode == "incremental_image": + # Use current index and increment + selected_index = self.current_index % total_images + selected_image = self.cached_images[selected_index] + selected_filename = self.cached_filenames[selected_index] + self.current_index += 1 + print(f"[Load Image Batch Fun] Incremental mode - selected index: {selected_index}, next: {self.current_index}") + + elif mode == "random": + # Random selection + import random + selected_index = random.randint(0, total_images - 1) + selected_image = self.cached_images[selected_index] + selected_filename = self.cached_filenames[selected_index] + print(f"[Load Image Batch Fun] Random mode - selected index: {selected_index}") + + else: + raise ValueError(f"Invalid mode: {mode}") + + return (selected_image, selected_filename, total_images) + + def _load_images_from_path(self, path, pattern, allow_RGBA_output): + """ + Load all images from the specified path matching the pattern. + Returns lists of image tensors and filenames. + """ + import glob + + # Get all files matching pattern + if pattern == "*": + search_pattern = os.path.join(path, "*") + else: + search_pattern = os.path.join(path, pattern) + + files = glob.glob(search_pattern) + + # Filter for image extensions + image_extensions = {'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.tiff', '.tif'} + image_files = [ + f for f in files + if os.path.isfile(f) and os.path.splitext(f.lower())[1] in image_extensions + ] + + # Sort files for consistent ordering + image_files.sort() + + if not image_files: + return [], [] + + print(f"[Load Image Batch Fun] Found {len(image_files)} image files") + + images = [] + filenames = [] + + for img_path in image_files: + try: + img = self._load_image(img_path, allow_RGBA_output) + images.append(img) + filenames.append(os.path.basename(img_path)) + except Exception as e: + print(f"[Load Image Batch Fun] Error loading {img_path}: {str(e)}") + continue + + return images, filenames + + def _load_image(self, image_path, allow_RGBA_output): + """ + Load a single image and convert it to the ComfyUI tensor format. + """ + img = Image.open(image_path) + + # Handle animated images (take first frame) + if hasattr(img, 'is_animated') and img.is_animated: + img = ImageSequence.Iterator(img)[0] + + # Handle EXIF orientation + img = ImageOps.exif_transpose(img) + + # Convert to RGB or RGBA based on settings + if allow_RGBA_output == "true" and img.mode == "RGBA": + # Keep RGBA + img_array = np.array(img).astype(np.float32) / 255.0 + else: + # Convert to RGB + if img.mode != "RGB": + img = img.convert("RGB") + img_array = np.array(img).astype(np.float32) / 255.0 + + # Convert to tensor with shape (1, H, W, C) + img_tensor = torch.from_numpy(img_array).unsqueeze(0) + + return img_tensor + + @classmethod + def IS_CHANGED(cls, mode, index, label, path, pattern, allow_RGBA_output): + """ + This tells ComfyUI when to re-execute the node. + We return a hash of the parameters to detect changes. + """ + # For single_image mode, include index in the hash so changing index triggers re-execution + # For incremental_image mode, always return a new value so it increments each time + # For random mode, always return a new value so it randomizes each time + + if mode == "incremental_image" or mode == "random": + # Always execute for these modes + import time + return str(time.time()) + else: + # For single_image mode, return hash of all parameters including index + param_string = f"{mode}_{index}_{label}_{path}_{pattern}_{allow_RGBA_output}" + return hashlib.md5(param_string.encode()).hexdigest() + + @classmethod + def VALIDATE_INPUTS(cls, mode, index, label, path, pattern, allow_RGBA_output): + """ + Validate inputs before execution. + """ + if not path or path.strip() == "": + return "Path cannot be empty" + + expanded_path = os.path.expanduser(path) + if not os.path.exists(expanded_path): + return f"Path does not exist: {path}" + + if not os.path.isdir(expanded_path): + return f"Path is not a directory: {path}" + + return True + + +# Register with ComfyUI +NODE_CLASS_MAPPINGS = { + "LoadImageBatchFun": LoadImageBatchFun +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "LoadImageBatchFun": "Load Image Batch (Fun)" +} + +__all__ = ["LoadImageBatchFun"] + diff --git a/queue_counter_reset.py b/queue_counter_reset.py index c7fcd43..dae465b 100644 --- a/queue_counter_reset.py +++ b/queue_counter_reset.py @@ -14,6 +14,8 @@ class DynamicQueueCounter: # Widget shows stop as 8 "stop": ("FLOAT", {"default": 8.0}), "step": ("FLOAT", {"default": 1.0}), + # Toggle to reset counter to start + "reset": ("BOOLEAN", {"default": False}), } } @@ -27,16 +29,25 @@ class DynamicQueueCounter: # Always return NaN so that ComfyUI re-runs this node each prompt. return float("NaN") - def node(self, start, stop, step): + def node(self, start, stop, step, reset): display_offset = 1.0 # Adjust widget values for internal calculation. # (The displayed value is normally calculated as internal_value + display_offset.) internal_start = float(start) - display_offset internal_stop = float(stop) - display_offset + # Initialize counter if this is the first run if not hasattr(self, "counter"): self.counter = internal_start + self.last_reset = reset print("DynamicQueueCounter: Initialized counter =", self.counter) + else: + # Check if user toggled the reset button + if reset != self.last_reset: + # Reset toggle state changed, reset counter to start + self.counter = internal_start + self.last_reset = reset + print(f"DynamicQueueCounter: Reset toggled - counter reset to {self.counter} (display: {internal_start + display_offset})") if step == 0: display_current = self.counter + display_offset