diff --git a/README.md b/README.md index 4ea5e0a..ef57a72 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,9 @@ # Wan VACE Prep -ComfyUI nodes for preparing videos for Wan VACE generation - handles transitions and extensions with automatic control video generation. +ComfyUI nodes for Wan VACE workflows. Control video and mask creation for transitions and extensions, plus workflow utilites. ## Quick Start - -**What it does:** Automates the tedious parts of VACE workflows by generating control videos and masks for smooth video transitions and extensions. - -**Install via ComfyUI Manager:** Search for "Wan VACE Prep" → Install → Restart +**Install via ComfyUI Manager:** Search for "Wan VACE Prep" **Or clone this repository:** ```bash @@ -45,6 +42,60 @@ For smoothly joining two video clips together. Builds VACE controls for the tran --- +### Wan VACE Prep Batch +Batch-aware version of Wan VACE Prep for processing multiple video pairs. Handles first/last iteration edge cases. + +![Wan VACE Prep Batch Node](assets/comfyui-wan-vace-prep-batch.png) + +**Parameters:** +|Parameter|Default|Description| +|-|-|-| +|video_1| |First video in the pair (IMAGE type)| +|video_2| |Second video in the pair (IMAGE type)| +|is_first|false|Set true for first iteration (index=0) - includes full beginning of video_1 in start_images| +|is_last|false|Set true for last iteration - includes full ending of video_2 in end_images| +|context_frames|8|Reference frames from each video edge that VACE uses for interpolation. These frames guide the model and are preserved in the output. Must be a multiple of 4.| +|replace_frames|8|Number of frames at each transition edge to discard and regenerate. These create the actual transition blend zone. Must be a multiple of 4.| +|new_frames|0|Number of completely new frames to generate between the two clips, extending the transition duration. Must be 0 or a multiple of 4.| +|debug|false|Log diagnostic information to the console| + +**Outputs:** +|Output|Description| +|-|-| +|control_video|VACE control video input (context frames + placeholder for generation)| +|control_mask|VACE control mask input (masks generation region)| +|width, height, length|Control video dimensions| +|start_images|Video segment from video_1 to preserve (excludes transition region)| +|end_images|Video segment from video_2 to preserve (only populated on last iteration)| + +--- + +### Wan VACE Batch Context +Establishes iteration context for batch video processing workflows. Manages file paths, iteration tracking, and provides first/last flags for proper handling of video sequence boundaries. + +![Wan VACE Batch Context Node](assets/comfyui-wan-vace-batch-context.png) + +**Parameters:** +|Parameter|Default|Description| +|-|-|-| +|input_list| |List of video filenames to process (STRING, force input)| +|input_dir| |Directory containing input videos| +|project_name|.|Project name - workflow files created under ComfyUI/output/project_name. Use period (.) for no project name.| +|index|0|Current iteration index (0-based). Valid range: 0 to (number of videos - 2)| +|debug|false|Log iteration details to the console| + +**Outputs:** +|Output|Description| +|-|-| +|work_dir|Working directory path for intermediate files| +|workfile_prefix|Filename prefix for this iteration's work files| +|is_first|True if this is the first iteration (index=0)| +|is_last|True if this is the last iteration| +|video_1_filename|Full path to first video in current pair| +|video_2_filename|Full path to second video in current pair| + +-- + ### Wan VACE Extend For smoothly extending a video. Context frames from before the chosen extension point are used to build a control video for VACE conditioning. @@ -76,11 +127,12 @@ For smoothly extending a video. Context frames from before the chosen extension --- ### Load Videos From Folder (Simple) -Load all videos from a folder. A simplified answer to KJNodes' LoadVideosFromFolder, which depends on VideoHelperSuite and can have problems in some environments. +Load all videos from a folder for batch processing. A simplified, dependency-free alternative to KJNodes' LoadVideosFromFolder (which depends on VideoHelperSuite and can have problems in some environments). - - Formats: webm, mp4, mkv, gif, mov - - All videos must have identical resolution - - No external dependencies +- **Formats:** webm, mp4, mkv, gif, mov +- All videos must have identical resolution +- No external dependencies + ![Load Videos From Folder (Simple) Node](assets/load-videos-from-folder-simple.png) diff --git a/__init__.py b/__init__.py index ef05e4c..58252b7 100644 --- a/__init__.py +++ b/__init__.py @@ -1,11 +1,13 @@ """ -Wan VACE Prep - Custom node for preparing videos for Wan VACE generation +Wan VACE Prep - Custom nodes for preparing videos for Wan VACE generation """ from .wan_vace_prep import NODE_CLASS_MAPPINGS as PREP_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as PREP_DISPLAY +from .wan_vace_prep_batch import NODE_CLASS_MAPPINGS as PREP_BATCH_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as PREP_BATCH_DISPLAY from .wan_vace_extend import NODE_CLASS_MAPPINGS as EXTEND_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as EXTEND_DISPLAY from .load_videos_simple import NODE_CLASS_MAPPINGS as LOAD_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as LOAD_DISPLAY +from .wan_vace_batch_context import NODE_CLASS_MAPPINGS as CONTEXT_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as CONTEXT_DISPLAY -NODE_CLASS_MAPPINGS = {**PREP_MAPPINGS, **EXTEND_MAPPINGS, **LOAD_MAPPINGS} -NODE_DISPLAY_NAME_MAPPINGS = {**PREP_DISPLAY, **EXTEND_DISPLAY, **LOAD_DISPLAY} +NODE_CLASS_MAPPINGS = {**PREP_MAPPINGS, **EXTEND_MAPPINGS, **LOAD_MAPPINGS, **PREP_BATCH_MAPPINGS, **CONTEXT_MAPPINGS} +NODE_DISPLAY_NAME_MAPPINGS = {**PREP_DISPLAY, **EXTEND_DISPLAY, **LOAD_DISPLAY, **PREP_BATCH_DISPLAY, **CONTEXT_DISPLAY} __all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] \ No newline at end of file diff --git a/assets/comfyui-wan-vace-batch-context.png b/assets/comfyui-wan-vace-batch-context.png new file mode 100644 index 0000000..b2acd9b Binary files /dev/null and b/assets/comfyui-wan-vace-batch-context.png differ diff --git a/assets/comfyui-wan-vace-prep-batch.png b/assets/comfyui-wan-vace-prep-batch.png new file mode 100644 index 0000000..b2acd9b Binary files /dev/null and b/assets/comfyui-wan-vace-prep-batch.png differ diff --git a/load_videos_simple.py b/load_videos_simple.py index 470faa0..4428916 100644 --- a/load_videos_simple.py +++ b/load_videos_simple.py @@ -10,7 +10,11 @@ class LoadVideosFromFolderSimple: return { "required": { "folder_path": ("STRING", {"default": ""}), - }, + "debug": ("BOOLEAN", { + "default": False, + "tooltip": "Log some details to the console" + }), + } } RETURN_TYPES = ["IMAGE"] @@ -25,7 +29,7 @@ class LoadVideosFromFolderSimple: - All videos must have identical resolution """ - def load_videos(self, folder_path): + def load_videos(self, folder_path, debug): folder_path = folder_path.strip().strip('"').strip("'") if not os.path.isdir(folder_path): @@ -39,12 +43,14 @@ class LoadVideosFromFolderSimple: f"Supported formats: {', '.join(self.VIDEO_EXTENSIONS)}" ) - print(f"Loading {len(video_files)} videos from {folder_path}") + if debug: + print(f"Loading {len(video_files)} videos from {folder_path}") all_frames = [] expected_shape = None for idx, video_path in enumerate(video_files): - print(f"[{idx+1}/{len(video_files)}]: {os.path.basename(video_path)}", end=" ... ") + if debug: + print(f"[{idx+1}/{len(video_files)}]: {os.path.basename(video_path)}", end=" ... ") frames = self._load_video_frames(video_path) @@ -60,13 +66,16 @@ class LoadVideosFromFolderSimple: ) all_frames.append(frames) - print(f"{frames.shape[0]} frames") + if debug: + print(f"{frames.shape[0]} frames") # Concatenate all frames - print(f"\nConcatenating {len(video_files)} videos...") + if debug: + print(f"\nConcatenating {len(video_files)} videos...") output = torch.cat(all_frames, dim=0) - print(f"Done!\n") + if debug: + print(f"Done\n") return (output,) def _get_video_files(self, folder_path): diff --git a/pyproject.toml b/pyproject.toml index e2465e5..f565f66 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "comfyui-wan-vace-prep" description = "ComfyUI nodes for smooth video transitions and extensions using Wan VACE." -version = "1.0.8" +version = "1.0.9" license = { text = "MIT" } dependencies = [] diff --git a/wan_vace_batch_context.py b/wan_vace_batch_context.py new file mode 100644 index 0000000..0b75088 --- /dev/null +++ b/wan_vace_batch_context.py @@ -0,0 +1,88 @@ +class WanVACEBatchContext: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "input_list": ("STRING", {"forceInput": True}), + "input_dir": ("STRING", { + "default": "", + "tooltip": "Directory containing input videos" + }), + "project_name": ("STRING", { + "default": ".", + "tooltip": "Project name - workflow files will be created under ComfyUI/output/project_name. Use period (.) for no project name." + }), + "index": ("INT", { + "default": 0, + "min": 0, + "tooltip": "Current iteration index (0 based)" + }), + "debug": ("BOOLEAN", { + "default": False, + "tooltip": "Log some details to the console" + }), + } + } + + RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING", "BOOLEAN", "BOOLEAN") + RETURN_NAMES = ("work_dir", "workfile_prefix", "video_1_filename", "video_2_filename", "is_first", "is_last") + FUNCTION = "setup_context" + CATEGORY = "video/VACE" + DESCRIPTION = "Establishes iteration context for batch video processing." + INPUT_IS_LIST = True + + def setup_context(self, **kwargs): + input_dir = kwargs.get('input_dir', [""])[0] + input_list = kwargs.get('input_list', []) + project_name = kwargs.get('project_name', [""])[0] + index = kwargs.get('index', [0])[0] + debug = kwargs.get('debug', [False])[0] + + # Validate input list + list_length = len(input_list) + + if list_length < 2: + raise ValueError( + f"Need at least 2 videos to create transitions, found {list_length}" + ) + + # Validate index bounds + max_index = list_length - 2 + if index < 0 or index > max_index: + raise ValueError( + f"Index {index} out of range (valid: 0-{max_index} for {list_length} videos)" + ) + + # Construct paths + work_dir = f"{project_name}/vace-work" + padded_index = f"{index:03d}" + workfile_prefix = f"{work_dir}/index{padded_index}" + + # Set iteration flags + is_first = (index == 0) + is_last = (index == max_index) + + # Extract filenames + video_1_filename = f"{input_dir}/{input_list[index]}" + video_2_filename = f"{input_dir}/{input_list[index + 1]}" + + if debug: + print(f"\n=== VACE Batch Context ===") + print(f"Index: {index} (videos {index+1}-{index+2} of {list_length})") + print(f"{'[FIRST]' if is_first else ''} {'[LAST]' if is_last else ''}") + print(f"Input directory: {input_dir}") + print(f"Video 1: {input_list[index]}") + print(f"Video 2: {input_list[index + 1]}") + print(f"Work prefix: {workfile_prefix}") + print(f"=========================\n") + + return (work_dir, workfile_prefix, video_1_filename, video_2_filename, is_first, is_last) + + +NODE_CLASS_MAPPINGS = { + "WanVACEBatchContext": WanVACEBatchContext +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "WanVACEBatchContext": "Wan VACE Batch Context" +} \ No newline at end of file diff --git a/wan_vace_prep.py b/wan_vace_prep.py index 100dd69..e7f0738 100644 --- a/wan_vace_prep.py +++ b/wan_vace_prep.py @@ -28,10 +28,6 @@ class WanVACEPrep: "step": 4, "tooltip": "Number of new transition frames to generate, in addition to the replace_frames (multiple of 4)." }), - "crossfade_mode": ("BOOLEAN", { - "default": False, - "tooltip": "Include context frames in start_images and end_images for cross-fade or other use cases." - }), } } @@ -41,7 +37,7 @@ class WanVACEPrep: CATEGORY = "video/VACE" DESCRIPTION = "Generates VACE control video and mask for smooth transitions between two videos using context frames and frame replacement." - def vace_prep(self, video_1, video_2, context_frames, replace_frames, add_frames, crossfade_mode): + def vace_prep(self, video_1, video_2, context_frames, replace_frames, add_frames): height = int(video_1.shape[1]) width = int(video_1.shape[2]) @@ -95,15 +91,8 @@ class WanVACEPrep: mask = torch.zeros((total_frames, height, width), dtype=torch.float32, device=video_1.device) mask[context_frames:context_frames + vace_count] = 1.0 - if crossfade_mode: - if replace_frames > 0: - start_images = video_1[:-replace_frames] - else: - start_images = video_1 - end_images = video_2[replace_frames:] - else: - start_images = video_1[:-(context_frames + replace_frames)] - end_images = video_2[context_frames + replace_frames:] + start_images = video_1[:-(context_frames + replace_frames)] + end_images = video_2[context_frames + replace_frames:] length = int(control_video.shape[0]) @@ -116,4 +105,4 @@ NODE_CLASS_MAPPINGS = { NODE_DISPLAY_NAME_MAPPINGS = { "WanVACEPrep": "Wan VACE Prep" -} +} \ No newline at end of file diff --git a/wan_vace_prep_batch.py b/wan_vace_prep_batch.py new file mode 100644 index 0000000..50c2b63 --- /dev/null +++ b/wan_vace_prep_batch.py @@ -0,0 +1,155 @@ +import torch + +class WanVACEPrepBatch: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "video_1": ("IMAGE",), + "video_2": ("IMAGE",), + "is_first": ("BOOLEAN", { + "default": False, + "tooltip": "True for first iteration (index=0) - includes full beginning of video_1 in start_images." + }), + "is_last": ("BOOLEAN", { + "default": False, + "tooltip": "True for last iteration - includes full ending of video_2 in end_images." + }), + "context_frames": ("INT", { + "default": 8, + "min": 4, + "max": 120, + "step": 4, + "tooltip": "Reference frames from each video edge for VACE interpolation (multiple of 4)." + }), + "replace_frames": ("INT", { + "default": 8, + "min": 0, + "max": 120, + "step": 4, + "tooltip": "Number of frames to regenerate at each transition edge (multiple of 4)." + }), + "new_frames": ("INT", { + "default": 0, + "min": 0, + "max": 240, + "step": 4, + "tooltip": "Number of new transition frames to generate, in addition to the replace_frames (multiple of 4)." + }), + "debug": ("BOOLEAN", { + "default": False, + "tooltip": "Log details to the console" + }), + } + } + + RETURN_TYPES = ("IMAGE", "MASK", "INT", "INT", "INT", "IMAGE", "IMAGE") + RETURN_NAMES = ("control_video", "control_mask", "width", "height", "length", "start_images", "end_images") + FUNCTION = "vace_prep_batch" + CATEGORY = "video/VACE" + DESCRIPTION = "Batch-aware VACE prep that handles first/last iteration edge cases for multi-video processing." + + def vace_prep_batch(self, video_1, video_2, context_frames, replace_frames, new_frames, is_first, is_last, debug=False): + height = int(video_1.shape[1]) + width = int(video_1.shape[2]) + + if debug: + print(f"\n=== Wan VACE Prep Batch ===") + print(f"Video 1: {video_1.shape[0]} frames @ {width}x{height}") + print(f"Video 2: {video_2.shape[0]} frames @ {width}x{height}") + print(f"Flags: {'FIRST ' if is_first else ''}{'LAST' if is_last else 'MIDDLE' if not is_first else ''}") + print(f"Parameters: context={context_frames}, replace={replace_frames}, new={new_frames}") + + if video_2.shape[1] != height or video_2.shape[2] != width: + raise ValueError( + f"Video dimensions must match. " + f"video_1 is {width}x{height}, video_2 is {int(video_2.shape[2])}x{int(video_2.shape[1])}" + ) + + if width % 16 != 0 or height % 16 != 0: + raise ValueError( + f"Video dimensions must be divisible by 16. " + f"Current dimensions: {width}x{height}" + ) + + v1_len = video_1.shape[0] + v2_len = video_2.shape[0] + required_frames = context_frames + replace_frames + + # Validation for video_1 + if is_first: + if v1_len < required_frames: + raise ValueError( + f"First iteration: video_1 needs at least {required_frames} frames " + f"(context_frames + replace_frames), but has {v1_len}" + ) + else: + if v1_len < required_frames * 2: + raise ValueError( + f"Middle/last iteration: video_1 needs at least {required_frames * 2} frames, " + f"but has {v1_len}" + ) + + # Validation for video_2 + if v2_len < required_frames: + raise ValueError( + f"video_2 needs at least {required_frames} frames " + f"(context_frames + replace_frames), but has {v2_len}" + ) + + # Extract context frames for control video/mask + if replace_frames > 0: + v1_context = video_1[-(context_frames + replace_frames):-replace_frames] + v2_context = video_2[replace_frames:context_frames + replace_frames] + else: + v1_context = video_1[-context_frames:] + v2_context = video_2[:context_frames] + + channels = video_1.shape[3] + + # Generate VACE frames placeholder (4n+1 frames) + vace_count = (replace_frames * 2) + new_frames + 1 + vace_frames = torch.full((vace_count, height, width, channels), 0.5, dtype=video_1.dtype, device=video_1.device) + + control_video = torch.cat([v1_context, vace_frames, v2_context], dim=0) + + total_frames = (context_frames * 2) + vace_count + mask = torch.zeros((total_frames, height, width), dtype=torch.float32, device=video_1.device) + mask[context_frames:context_frames + vace_count] = 1.0 + + # Extract start_images (frames to save from video_1) + if is_first: + # Keep everything except last (context + replace) + start_images = video_1[:-required_frames] + else: + # Skip first (context + replace), keep rest except last (context + replace) + start_images = video_1[required_frames:-required_frames] + + # Extract end_images (frames to save from video_2, only on last iteration) + if is_last: + end_images = video_2[required_frames:] + else: + # Return empty batch for non-last iterations + end_images = torch.empty((0, height, width, channels), dtype=video_2.dtype, device=video_2.device) + + length = int(control_video.shape[0]) + + if debug: + print(f"\nOutputs:") + print(f" control_video: {control_video.shape}") + print(f" control_mask: {mask.shape}") + print(f" start_images: {start_images.shape[0]} frames") + print(f" end_images: {end_images.shape[0]} frames") + print(f" VACE output: {total_frames} frames ({context_frames * 2} context + {vace_count} generated") + print(f"===========================\n") + + return (control_video, mask, width, height, length, start_images, end_images) + + +NODE_CLASS_MAPPINGS = { + "WanVACEPrepBatch": WanVACEPrepBatch +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "WanVACEPrepBatch": "Wan VACE Prep Batch" +} \ No newline at end of file