Add batch-aware nodes

This commit is contained in:
stuttlepress
2026-02-06 04:05:52 -06:00
parent fcf4b2f858
commit 03ec569f50
9 changed files with 330 additions and 35 deletions
+61 -9
View File
@@ -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)
+5 -3
View File
@@ -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']
Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

+16 -7
View File
@@ -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):
+1 -1
View File
@@ -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 = []
+88
View File
@@ -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"
}
+4 -15
View File
@@ -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"
}
}
+155
View File
@@ -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"
}