seedance video - image batch custom and reset on queue counter

This commit is contained in:
nofunstudio
2025-10-16 00:53:46 -06:00
parent fab9b95bd3
commit 6cc08e4c44
19 changed files with 292 additions and 63 deletions
+5 -2
View File
@@ -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__ = [
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+46 -60
View File
@@ -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):
"""
+229
View File
@@ -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"]
+12 -1
View File
@@ -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