seedance video - image batch custom and reset on queue counter
This commit is contained in:
+5
-2
@@ -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.
Binary file not shown.
+46
-60
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user