Files
WJLUOXIAO-XB_ToolBox/nodes_pipeline.py
T

992 lines
49 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os
import torch
import numpy as np
import gc
from PIL import Image, ImageOps
import nodes
import comfy.samplers
import comfy.model_management
import folder_paths
from .nodes_wan_vae import XB_WanFirstLastFrameToVideo, XB_WanImageToVideo, XB_WanInfiniteTalkToVideo_Single
def _refresh_models():
"""清空 GPU 缓存但不卸载模型(BlockSwap 分块模型卸载后重新加载会崩溃)"""
torch.cuda.synchronize()
torch.cuda.empty_cache()
gc.collect()
# ============================================================
# XB_Wan_ParamBus — Wan 视频参数总线
# ============================================================
class XB_Wan_ParamBus:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model_high": ("MODEL",),
"model_low": ("MODEL",),
"clip": ("CLIP",),
"vae": ("VAE",),
"negative_prompt": ("STRING", {"multiline": True, "default": "vivid colors, overexposed, static, blurry details..."}),
"width": ("INT", {"forceInput": True}),
"height": ("INT", {"forceInput": True}),
"length": ("INT", {"forceInput": True}),
"fps": ("FLOAT", {"forceInput": True}),
# --- VAE 时空分块与重叠参数 ---
"vae_encode_tile_size": ("INT", {"default": 64, "min": 64, "max": 3840, "step": 32}),
"vae_decode_tile_size": ("INT", {"default": 64, "min": 64, "max": 3840, "step": 32}),
"spatial_overlap": ("INT", {"default": 32, "min": 0, "max": 3840, "step": 32}),
"temporal_chunk_size": ("INT", {"default": 64, "min": 0, "max": 8192, "step": 4}),
"temporal_overlap": ("INT", {"default": 8, "min": 0, "max": 8192, "step": 4}),
"steps": ("INT", {"default": 4, "min": 1, "max": 100}),
"high_noise_steps": ("INT", {"default": 2, "min": 1, "max": 50}),
"cfg": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 10.0, "step": 0.1}),
"sampler_name": (comfy.samplers.KSAMPLER_NAMES, ),
"scheduler": (comfy.samplers.SCHEDULER_NAMES, ),
"seed": ("INT", {"default": 123456789}),
"cut_first_frame": ("BOOLEAN", {"default": True, "label_on": "Yes (Relay Deduplication)", "label_off": "No (Keep First Frame)"}),
"trim_head_frames": ("INT", {"default": 1, "min": 1, "max": 8192, "step": 1}),
},
"optional": {
"clip_vision": ("CLIP_VISION",),
"scale_method": (["lanczos", "bilinear", "bicubic", "nearest-exact", "area"], {"default": "lanczos"}),
"crop_mode": (["center", "disabled"], {"default": "center"}),
}
}
RETURN_TYPES = ("WAN_BUS",)
RETURN_NAMES = ("📦 WAN_BUS (Dynamic Bus)",)
FUNCTION = "pack_bus"
CATEGORY = "XB_ToolBox/Pipeline"
def pack_bus(self, **kwargs):
import comfy.utils
cv = kwargs.get("clip_vision")
ref = kwargs.get("start_image") # 首尾帧/图生视频用 start_image 做视觉参考
if cv is not None and ref is not None:
w, h = kwargs["width"], kwargs["height"]
method = kwargs.get("scale_method", "lanczos")
crop = kwargs.get("crop_mode", "center")
scaled = comfy.utils.common_upscale(ref.movedim(-1, 1), w, h, method, crop).movedim(1, -1)
kwargs["clip_vision_output"], = nodes.CLIPVisionEncode().encode(cv, scaled, "center")
print(f"👁️ [XB-BOX] CLIP视觉编码 ({ref.shape[1]}×{ref.shape[2]} → {w}×{h}, {method}/{crop})")
else:
kwargs["clip_vision_output"] = None
return (kwargs,)
# ============================================================
# XB_Wan_RelayNode — Wan 首尾帧接力点
# ============================================================
class XB_Wan_RelayNode:
@classmethod
def INPUT_TYPES(cls):
input_dir = folder_paths.get_input_directory()
files = [f for f in os.listdir(input_dir) if os.path.isfile(os.path.join(input_dir, f))] if os.path.exists(input_dir) else []
files = sorted(files)
if not files:
files = ["[Folder empty_Please connect or upload]"]
return {
"required": {
"wan_bus": ("WAN_BUS",),
"start_image": ("IMAGE",),
"positive_prompt": ("STRING", {"multiline": True, "default": "Describe the specific action for this segment..."}),
"end_image_file": (files, {"image_upload": True})
},
"optional": {
"prev_video": ("IMAGE",),
"opt_end_image": ("IMAGE",)
}
}
@classmethod
def VALIDATE_INPUTS(cls, **kwargs):
return True
RETURN_TYPES = ("WAN_BUS", "IMAGE", "IMAGE")
RETURN_NAMES = ("📦 WAN_BUS (Pass to next)", "🖼️ Current End Image (Connect to next Start Image)", "🎞️ Accumulated Video Stream")
FUNCTION = "execute_relay"
CATEGORY = "XB_ToolBox/Pipeline"
def execute_relay(self, wan_bus, start_image, positive_prompt, end_image_file, opt_end_image=None, prev_video=None):
print("\n🏃 [XB-BOX] Executing First/Last frame constraint relay task...")
b = wan_bus
seed = b["seed"]
cut_first_frame = b.get("cut_first_frame", True)
trim_head_frames = b.get("trim_head_frames", 1)
encode_tile = b.get("vae_encode_tile_size", b.get("vae_tile_size", 64))
decode_tile = b.get("vae_decode_tile_size", b.get("vae_tile_size", 64))
spat_overlap = b.get("spatial_overlap", 32)
temp_chunk = b.get("temporal_chunk_size", 64)
temp_overlap = b.get("temporal_overlap", 8)
if opt_end_image is not None:
end_image = opt_end_image
print(f"🖼️ [XB-BOX] Endpoint connection detected, prioritizing opt_end_image payload.")
else:
if end_image_file == "[Folder empty_Please connect or upload]":
raise ValueError("🚨 [XB-BOX] End frame missing! Connect image to opt_end_image or upload via panel!")
image_path = folder_paths.get_annotated_filepath(end_image_file)
if not os.path.exists(image_path):
raise ValueError(f"🚨 [XB-BOX] Image file not found: {image_path}")
i = Image.open(image_path)
i = ImageOps.exif_transpose(i)
image_data = i.convert("RGB")
image_data = np.array(image_data).astype(np.float32) / 255.0
end_image = torch.from_numpy(image_data)[None,]
pos_cond, = nodes.CLIPTextEncode().encode(b["clip"], positive_prompt)
neg_cond, = nodes.CLIPTextEncode().encode(b["clip"], b["negative_prompt"])
cv_output = b.get("clip_vision_output")
pos, neg, latent = XB_WanFirstLastFrameToVideo().process(
positive=pos_cond, negative=neg_cond, vae=b["vae"],
clip_vision_start_image=cv_output, clip_vision_end_image=None,
start_image=start_image, end_image=end_image,
width=b["width"], height=b["height"], length=b["length"],
batch_size=1, vae_tile_size=encode_tile,
scale_method=b.get("scale_method", "lanczos"),
crop_mode=b.get("crop_mode", "center"),
)
print(f"🔥 [XB-BOX] Samping Layer 1 (0 -> {b['high_noise_steps']} steps)...")
latent_high, = nodes.KSamplerAdvanced().sample(
model=b["model_high"], add_noise="enable", noise_seed=seed,
steps=b["steps"], cfg=b["cfg"], sampler_name=b["sampler_name"], scheduler=b["scheduler"],
positive=pos, negative=neg, latent_image=latent,
start_at_step=0, end_at_step=b["high_noise_steps"], return_with_leftover_noise="enable"
)
print(f"❄️ [XB-BOX] Samping Layer 2 ({b['high_noise_steps']} -> completion)...")
latent_low, = nodes.KSamplerAdvanced().sample(
model=b["model_low"], add_noise="disable", noise_seed=seed,
steps=b["steps"], cfg=b["cfg"], sampler_name=b["sampler_name"], scheduler=b["scheduler"],
positive=pos, negative=neg, latent_image=latent_high,
start_at_step=b["high_noise_steps"], end_at_step=10000, return_with_leftover_noise="disable"
)
decoded_image, = nodes.VAEDecode().decode(
samples=latent_low,
vae=b["vae"],
)
is_4d = len(decoded_image.shape) == 4
frame_dim = 0 if is_4d else 1
total_frames = decoded_image.shape[frame_dim]
if cut_first_frame and total_frames > 1:
safe_trim = min(trim_head_frames, total_frames - 1)
if safe_trim > 0:
if is_4d:
decoded_image = decoded_image[safe_trim:, :, :, :]
else:
decoded_image = decoded_image[:, safe_trim:, :, :, :]
last_frame = decoded_image[-1:, :, :, :] if is_4d else decoded_image[:, -1:, :, :, :]
concat_dim = 0 if is_4d else 1
if prev_video is not None:
final_video = torch.cat([prev_video, decoded_image], dim=concat_dim)
else:
final_video = decoded_image
_refresh_models()
return (wan_bus, last_frame, final_video)
# ============================================================
# XB_Wan_InfiniteRelayNode — Wan 无限长接力点
# ============================================================
class XB_Wan_InfiniteRelayNode:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"wan_bus": ("WAN_BUS",),
"start_image": ("IMAGE",),
"positive_prompt": ("STRING", {"multiline": True, "default": "Describe the specific action for this segment..."}),
},
"optional": {
"prev_video": ("IMAGE",)
}
}
@classmethod
def VALIDATE_INPUTS(cls, **kwargs):
return True
RETURN_TYPES = ("WAN_BUS", "IMAGE", "IMAGE")
RETURN_NAMES = ("📦 WAN_BUS (Pass to next)", "🖼️ Current End Image (Connect to next Start Image)", "🎞️ Accumulated Video Stream")
FUNCTION = "execute_relay"
CATEGORY = "XB_ToolBox/Pipeline"
def execute_relay(self, wan_bus, start_image, positive_prompt, prev_video=None):
print("\n🏃‍♀️ [XB-BOX] Executing Image-to-Video Infinite pipeline generation task...")
b = wan_bus
seed = b["seed"]
cut_first_frame = b.get("cut_first_frame", True)
trim_head_frames = b.get("trim_head_frames", 1)
encode_tile = b.get("vae_encode_tile_size", b.get("vae_tile_size", 64))
decode_tile = b.get("vae_decode_tile_size", b.get("vae_tile_size", 64))
spat_overlap = b.get("spatial_overlap", 32)
temp_chunk = b.get("temporal_chunk_size", 64)
temp_overlap = b.get("temporal_overlap", 8)
pos_cond, = nodes.CLIPTextEncode().encode(b["clip"], positive_prompt)
neg_cond, = nodes.CLIPTextEncode().encode(b["clip"], b["negative_prompt"])
cv_output = b.get("clip_vision_output")
pos, neg, latent = XB_WanImageToVideo().process(
positive=pos_cond, negative=neg_cond, vae=b["vae"],
clip_vision_output=cv_output, start_image=start_image,
width=b["width"], height=b["height"], length=b["length"],
batch_size=1, vae_tile_size=encode_tile,
scale_method=b.get("scale_method", "lanczos"),
crop_mode=b.get("crop_mode", "center"),
)
latent_high, = nodes.KSamplerAdvanced().sample(
model=b["model_high"], add_noise="enable", noise_seed=seed,
steps=b["steps"], cfg=b["cfg"], sampler_name=b["sampler_name"], scheduler=b["scheduler"],
positive=pos, negative=neg, latent_image=latent,
start_at_step=0, end_at_step=b["high_noise_steps"], return_with_leftover_noise="enable"
)
latent_low, = nodes.KSamplerAdvanced().sample(
model=b["model_low"], add_noise="disable", noise_seed=seed,
steps=b["steps"], cfg=b["cfg"], sampler_name=b["sampler_name"], scheduler=b["scheduler"],
positive=pos, negative=neg, latent_image=latent_high,
start_at_step=b["high_noise_steps"], end_at_step=10000, return_with_leftover_noise="disable"
)
decoded_image, = nodes.VAEDecode().decode(
samples=latent_low,
vae=b["vae"],
)
is_4d = len(decoded_image.shape) == 4
frame_dim = 0 if is_4d else 1
total_frames = decoded_image.shape[frame_dim]
if cut_first_frame and total_frames > 1:
safe_trim = min(trim_head_frames, total_frames - 1)
if safe_trim > 0:
if is_4d:
decoded_image = decoded_image[safe_trim:, :, :, :]
else:
decoded_image = decoded_image[:, safe_trim:, :, :, :]
last_frame = decoded_image[-1:, :, :, :] if is_4d else decoded_image[:, -1:, :, :, :]
concat_dim = 0 if is_4d else 1
if prev_video is not None:
final_video = torch.cat([prev_video, decoded_image], dim=concat_dim)
else:
final_video = decoded_image
_refresh_models()
return (wan_bus, last_frame, final_video)
# ============================================================
# XB_Video_Merger — 视频拼接器
# ============================================================
class XB_Video_Merger:
@classmethod
def INPUT_TYPES(cls):
inputs = {"required": {}, "optional": {}}
for i in range(1, 11):
inputs["optional"][f"video_{i}"] = ("IMAGE",)
return inputs
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("🎞️ Full Long Video",)
FUNCTION = "merge_videos"
CATEGORY = "XB_ToolBox/Pipeline"
def merge_videos(self, **kwargs):
videos = [kwargs.get(f"video_{i}") for i in range(1, 11) if kwargs.get(f"video_{i}") is not None]
if not videos:
raise ValueError("🚨 [XB-BOX] Merge failed: At least one video must be connected!")
concat_dim = 0 if len(videos[0].shape) == 4 else 1
ref_shape = list(videos[0].shape)
for i, vid in enumerate(videos):
for d in range(len(ref_shape)):
if d != concat_dim and vid.shape[d] != ref_shape[d]:
raise ValueError(
f"🚨 [XB-BOX] Merge failed: Video segment {i+1} shape {list(vid.shape)} "
f"does not match first segment shape {ref_shape} on dimension {d} "
f"(expected {ref_shape[d]}, got {vid.shape[d]})"
)
return (torch.cat(videos, dim=concat_dim),)
# ============================================================
# XB_StoryboardSlicer — 分镜切片器
# ============================================================
class XB_StoryboardSlicer:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"mode": (["3-Grid (1x3 Horizontal)", "3-Grid (3x1 Vertical)", "4-Grid (2x2)", "6-Grid (2x3 Horizontal)", "6-Grid (3x2 Vertical)", "9-Grid (3x3)"], {"default": "4-Grid (2x2)"}),
"crop_margin": ("FLOAT", {"default": 0.00, "min": 0.00, "max": 0.45, "step": 0.01}),
}
}
RETURN_TYPES = ("IMAGE",) * 9
RETURN_NAMES = ("Img1", "Img2", "Img3", "Img4", "Img5", "Img6", "Img7", "Img8", "Img9")
FUNCTION = "slice_image"
CATEGORY = "XB_ToolBox/Pipeline"
def slice_image(self, image, mode, crop_margin):
if "1x3" in mode: rows, cols = 1, 3
elif "3x1" in mode: rows, cols = 3, 1
elif "2x2" in mode: rows, cols = 2, 2
elif "2x3" in mode: rows, cols = 2, 3
elif "3x2" in mode: rows, cols = 3, 2
elif "3x3" in mode: rows, cols = 3, 3
else: rows, cols = 2, 2
batch_size, h, w, channels = image.shape
slice_h, slice_w = h // rows, w // cols
crop_y, crop_x = int(slice_h * crop_margin), int(slice_w * crop_margin)
sliced_images = []
for r in range(rows):
for c in range(cols):
sy, ey = r * slice_h + crop_y, (r + 1) * slice_h - crop_y
sx, ex = c * slice_w + crop_x, (c + 1) * slice_w - crop_x
if sy >= ey: ey = sy + 1
if sx >= ex: ex = sx + 1
sliced_images.append(image[:, sy:ey, sx:ex, :])
placeholder_h = max(1, slice_h - crop_y * 2)
placeholder_w = max(1, slice_w - crop_x * 2)
while len(sliced_images) < 9:
sliced_images.append(torch.zeros((1, placeholder_h, placeholder_w, 3), dtype=torch.float32))
return tuple(sliced_images)
# ============================================================
# XB_WanAnimate_ParamBus — Wan Animate 动作迁移参数总线
# ============================================================
class XB_WanAnimate_ParamBus:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model": ("MODEL",),
"clip": ("CLIP",),
"vae": ("VAE",),
"negative_prompt": ("STRING", {"multiline": True, "default": "vivid colors, overexposed, static, blurry details..."}),
"width": ("INT", {"default": 480, "min": 260, "step": 16}),
"height": ("INT", {"default": 832, "step": 16}),
"total_frames": ("INT", {"default": 0, "min": 0, "max": 999999, "tooltip": "0=自动从源视频取长度;>0=强制截断到此帧数"}),
"fps": ("FLOAT", {"default": 16.0}),
# --- VAE 时空分块与重叠参数 ---
"vae_encode_tile_size": ("INT", {"default": 320, "step": 32}),
"vae_decode_tile_size": ("INT", {"default": 320, "step": 32}),
"spatial_overlap": ("INT", {"default": 32, "step": 32}),
"temporal_chunk_size": ("INT", {"default": 64, "step": 4}),
"temporal_overlap": ("INT", {"default": 8, "step": 4}),
# --- 采样与控制 ---
"steps": ("INT", {"default": 20, "min": 1, "max": 100}),
"cfg": ("FLOAT", {"default": 5.0, "step": 0.1}),
"sampler_name": (comfy.samplers.KSAMPLER_NAMES, ),
"scheduler": (comfy.samplers.SCHEDULER_NAMES, ),
"seed": ("INT", {"default": 123456789}),
"continue_motion_max_frames": ("INT", {"default": 5, "min": 0, "max": 16}),
# --- 显存清理 ---
"cleanup": (["不做任何清理", "单次缓存清理", "双次缓存清理", "卸载显存模型"], {"default": "双次缓存清理"}),
# --- CLIP视觉 ---
"clip_vision": ("CLIP_VISION",),
},
"optional": {
"global_ref_image": ("IMAGE",),
"pose_video": ("IMAGE",),
"face_video": ("IMAGE",),
"background_video": ("IMAGE",),
"character_mask": ("MASK",),
"scale_method": (["lanczos", "bilinear", "bicubic", "nearest-exact", "area"], {"default": "lanczos"}),
"crop_mode": (["center", "disabled"], {"default": "center"}),
}
}
RETURN_TYPES = ("WAN_ANIMATE_BUS",)
RETURN_NAMES = ("📦 WAN_ANIMATE_BUS",)
FUNCTION = "pack_bus"
CATEGORY = "XB_ToolBox/Pipeline"
def pack_bus(self, **kwargs):
kwargs["current_offset"] = 0
cv = kwargs.get("clip_vision")
ref = kwargs.get("global_ref_image")
if cv is not None and ref is not None:
w, h = kwargs.get("width", 480), kwargs.get("height", 832)
m = kwargs.get("scale_method", "lanczos")
c = kwargs.get("crop_mode", "center")
scaled = comfy.utils.common_upscale(ref.movedim(-1, 1), w, h, m, c).movedim(1, -1)
kwargs["clip_vision_output"], = nodes.CLIPVisionEncode().encode(cv, scaled, "center")
print(f"👁️ [XB-BOX] Animate CLIP视觉编码 ({ref.shape[1]}×{ref.shape[2]} → {w}×{h}, {m}/{c})")
return (kwargs,)
# ============================================================
# XB_WanAnimate_RelayNode — Wan Animate 无限接力点
# ============================================================
class XB_WanAnimate_RelayNode:
@classmethod
def INPUT_TYPES(cls):
input_dir = folder_paths.get_input_directory()
files = [f for f in os.listdir(input_dir) if os.path.isfile(os.path.join(input_dir, f))] if os.path.exists(input_dir) else []
files = sorted(files)
if not files:
files = ["[Folder empty_Please connect or upload]"]
return {
"required": {
"wan_animate_bus": ("WAN_ANIMATE_BUS",),
"segment_length": ("INT", {"default": 81, "min": 1, "max": 8192, "step": 1}),
"positive_prompt": ("STRING", {"multiline": True, "default": "Describe the scene..."}),
"use_local_ref_image": (["继承总线全局图", "独立参考图"], {"default": "继承总线全局图"}),
"ref_image_file": (files, {"image_upload": True}),
},
"optional": {
"prev_video": ("IMAGE",),
}
}
@classmethod
def VALIDATE_INPUTS(cls, **kwargs):
return True
RETURN_TYPES = ("WAN_ANIMATE_BUS", "IMAGE")
RETURN_NAMES = ("📦 WAN_ANIMATE_BUS (传给下段)", "🎞️ 累加视频流")
FUNCTION = "execute_relay"
CATEGORY = "XB_ToolBox/Pipeline"
def execute_relay(self, wan_animate_bus, segment_length, positive_prompt, use_local_ref_image, ref_image_file, prev_video=None, opt_ref_image=None):
b = wan_animate_bus.copy()
current_offset = b.get("current_offset", 0)
seed = b.get("seed", 123456789)
source_video = b.get("pose_video") if b.get("pose_video") is not None else b.get("face_video")
total_source_frames = float('inf')
# 优先使用总线 total_frames,其次用源视频长度
bus_total = b.get("total_frames", 0)
if bus_total > 0:
total_source_frames = bus_total
elif source_video is not None:
total_source_frames = source_video.shape[0] if len(source_video.shape) == 4 else source_video.shape[1]
remaining_frames = max(0, total_source_frames - current_offset)
cont_max = b.get("continue_motion_max_frames", 5)
if remaining_frames <= 0:
print(f"\n⏭️ [XB-BOX] 动作视频已耗尽 (已跑到 {current_offset} 帧 / 总 {total_source_frames} 帧)。当前接力点直接跳过!")
fallback_video = prev_video if prev_video is not None else torch.zeros((1, b.get("height", 832), b.get("width", 480), 3))
return (b, fallback_video)
if remaining_frames < segment_length:
# 最后一段:trim 会吃掉 continue_motion 帧,需补回;再对齐 4N+1
if current_offset == 0:
need_raw = remaining_frames # 第一段无 trim
else:
need_raw = remaining_frames + cont_max
actual_length = ((need_raw + 2) // 4) * 4 + 1
if actual_length < 1:
print(f"⏭️ [XB-BOX] 剩余 {remaining_frames} 帧不足以生成最小段,跳过")
fallback_video = prev_video if prev_video is not None else torch.zeros((1, b.get("height", 832), b.get("width", 480), 3))
return (b, fallback_video)
print(f"\n⚠️ [XB-BOX] 最后一段: 剩余 {remaining_frames} → need_raw={need_raw} → {actual_length} 帧 (4N+1)")
else:
actual_length = segment_length
print(f"\n🏃‍♀️ [XB-BOX] Executing Wan Animate Relay task... Target segment length: {actual_length}")
is_local = (use_local_ref_image == "独立参考图" or
use_local_ref_image is True or
use_local_ref_image == 1)
if is_local:
if not ref_image_file or ref_image_file == "[Folder empty_Please connect or upload]":
raise ValueError("🚨 [XB-BOX] 已开启独立参考图,但未选择图片文件!")
image_path = folder_paths.get_annotated_filepath(ref_image_file)
if not os.path.exists(image_path):
raise ValueError(f"🚨 [XB-BOX] 图片文件未找到: {image_path}")
i = Image.open(image_path)
i = ImageOps.exif_transpose(i)
image_data = i.convert("RGB")
image_data = np.array(image_data).astype(np.float32) / 255.0
ref_image = torch.from_numpy(image_data)[None,]
cv_model = b.get("clip_vision")
if cv_model is None:
raise ValueError("🚨 [XB-BOX] 使用独立参考图需要总线连接 clip_vision 模型!")
# CLIP 编码用总线缩放参数,不做预缩放(XB_WanAnimateToVideo 内部自己处理 VAE 编码)
w, h = b.get("width", 480), b.get("height", 832)
sm = b.get("scale_method", "lanczos")
cm = b.get("crop_mode", "center")
clip_img = comfy.utils.common_upscale(ref_image.movedim(-1, 1), w, h, sm, cm).movedim(1, -1)
clip_vision_output, = nodes.CLIPVisionEncode().encode(cv_model, clip_img, "center")
print("🖼️ [XB-BOX] 使用独立参考图(含独立CLIP视觉编码)。")
else:
ref_image = b.get("global_ref_image")
if ref_image is None:
raise ValueError("🚨 [XB-BOX] 缺少参考图!请开启独立参考图选择图片,或在总线上连接 global_ref_image。")
clip_vision_output = b.get("clip_vision_output")
print("🌐 [XB-BOX] 使用总线全局参考图。")
prompt_text = positive_prompt.strip() if positive_prompt.strip() else b.get("positive_prompt", "")
pos_cond, = nodes.CLIPTextEncode().encode(b["clip"], prompt_text)
neg_cond, = nodes.CLIPTextEncode().encode(b["clip"], b.get("negative_prompt", ""))
continue_motion = None
cont_max_frames = b.get("continue_motion_max_frames", 5)
if is_local:
cont_max_frames = 0 # 独立参考图时关闭衔接帧,避免CLIP视觉特征冲突
if prev_video is not None and cont_max_frames > 0:
is_4d = len(prev_video.shape) == 4
if is_4d:
continue_motion = prev_video[-cont_max_frames:, :, :, :]
else:
continue_motion = prev_video[:, -cont_max_frames:, :, :, :]
try:
from .nodes_wan import XB_WanAnimateToVideo
except ImportError:
raise ImportError("🚨 [XB-BOX] 找不到 XB_WanAnimateToVideo 底座节点,请检查 nodes_wan.py 文件目录!")
animate_core = XB_WanAnimateToVideo()
func_name = getattr(XB_WanAnimateToVideo, "FUNCTION", "process")
func = getattr(animate_core, func_name)
kwargs = {
"positive": pos_cond,
"negative": neg_cond,
"vae": b["vae"],
"clip_vision_output": clip_vision_output,
"reference_image": ref_image,
"face_video": b.get("face_video"),
"pose_video": b.get("pose_video"),
"background_video": b.get("background_video"),
"character_mask": b.get("character_mask"),
"continue_motion": continue_motion,
"width": b["width"],
"height": b["height"],
"length": actual_length,
"batch_size": 1,
"continue_motion_max_frames": cont_max_frames,
"video_frame_offset": current_offset,
"vae_tile_size": b.get("vae_encode_tile_size", 320)
}
pos, neg, latent, trim_latent, trim_image, new_offset = func(**kwargs)
model_obj = b["model"]
if hasattr(model_obj, "model") and hasattr(model_obj, "offload_device"):
model_obj.model.to(model_obj.offload_device)
comfy.model_management.soft_empty_cache()
torch.cuda.synchronize()
torch.cuda.empty_cache()
gc.collect()
print(f"🔥 [XB-BOX] Animate Single-Pass Sampling ({b['steps']} steps)...")
latent_sampled, = nodes.KSampler().sample(
model=b["model"],
seed=seed,
steps=b["steps"],
cfg=b["cfg"],
sampler_name=b["sampler_name"],
scheduler=b["scheduler"],
positive=pos,
negative=neg,
latent_image=latent,
denoise=1.0
)
trim_latent_val = trim_latent if trim_latent is not None else 0
if trim_latent_val > 0:
latent_sampled["samples"] = latent_sampled["samples"][:, :, trim_latent_val:, :, :]
try:
from .nodes_rocm import XB_ROCmVAEDecodeTemporal
except ImportError:
raise ImportError("🚨 [XB-BOX] 找不到 XB_ROCmVAEDecodeTemporal,请确保 nodes_rocm.py 未被删除!")
cleanup_mode = b.get("cleanup", "双次缓存清理")
decoder = XB_ROCmVAEDecodeTemporal()
decoded_image, = decoder.go(
samples=latent_sampled,
vae=b["vae"],
tile=b.get("vae_decode_tile_size", 320),
overlap=b.get("spatial_overlap", 32),
t_tile=b.get("temporal_chunk_size", 64),
t_overlap=b.get("temporal_overlap", 8),
cleanup=cleanup_mode
)
is_4d = len(decoded_image.shape) == 4
concat_dim = 0 if is_4d else 1
# 像素层裁切重叠过渡帧(trim_image 对应 continue_motion_max_frames)
trim_px = int(trim_image) if trim_image else 0
if trim_px > 0 and trim_px < decoded_image.shape[concat_dim]:
if is_4d:
decoded_image = decoded_image[trim_px:, :, :, :]
else:
decoded_image = decoded_image[:, trim_px:, :, :, :]
print(f"✂️ [XB-BOX] 像素裁切重叠: {trim_px} 帧")
if prev_video is not None:
final_video = torch.cat([prev_video, decoded_image], dim=concat_dim)
else:
final_video = decoded_image
actual_out = decoded_image.shape[concat_dim]
b["current_offset"] = current_offset + actual_out
print(f"✅ [XB-BOX] Animate Relay Complete. trim_lat={trim_latent_val} trim_px={trim_px} out={actual_out}f total={final_video.shape[concat_dim]}f")
return (b, final_video)
# ============================================================
# XB_WanInfiniteTalk_ParamBus — InfiniteTalk 无限对口型参数总线
# ============================================================
class XB_WanInfiniteTalk_ParamBus:
"""
集中管理所有共享参数。内置音频编码器 + CLIP 视觉编码。
接力点只需传正面提示词 + 分段帧数 + 音频,其余全从总线走。
"""
_SCALE_METHODS = ["bilinear", "bicubic", "lanczos", "nearest-exact", "area"]
_CROP_MODES = ["center", "disabled"]
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
# --- 模型 ---
"model": ("MODEL",),
"model_patch": ("MODEL_PATCH",),
"clip": ("CLIP",),
"vae": ("VAE",),
"audio_encoder": ("AUDIO_ENCODER",),
# --- CLIP视觉 ---
"clip_vision": ("CLIP_VISION",),
# --- 基础参数 ---
"negative_prompt": ("STRING", {"multiline": True, "default": "色调艳丽,过曝,静态,细节模糊不清,字幕,风格,作品,画作,画面,静止,整体发灰,最差质量,低质量,JPEG压缩残留,丑陋的,残缺的,多余的手指,画得不好的手部,画得不好的脸部,畸形的,毁容的,形态畸形的肢体,手指融合,静止不动的画面,杂乱的背景,三条腿,背景人很多,倒着走"}),
"width": ("INT", {"default": 832, "min": 260, "max": 8192, "step": 16}),
"height": ("INT", {"default": 480, "min": 16, "max": 8192, "step": 16}),
"total_frames": ("INT", {"default": 0, "min": 0, "max": 999999, "tooltip": "总线音频模式:音频切片节点输出的总帧数,耗尽后自动停止接力。0=独立音频模式"}),
"fps": ("FLOAT", {"default": 25.0, "min": 1.0, "max": 120.0, "step": 1.0}),
# --- VAE 分块 ---
"vae_encode_tile_size": ("INT", {"default": 256, "min": 0, "max": 3840, "step": 32}),
"vae_decode_tile_size": ("INT", {"default": 192, "min": 0, "max": 3840, "step": 32}),
"spatial_overlap": ("INT", {"default": 32, "min": 0, "max": 3840, "step": 32}),
"temporal_chunk_size": ("INT", {"default": 96, "min": 0, "max": 8192, "step": 4}),
"temporal_overlap": ("INT", {"default": 4, "min": 0, "max": 8192, "step": 4}),
# --- 采样 ---
"steps": ("INT", {"default": 4, "min": 1, "max": 100}),
"cfg": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 10.0, "step": 0.1}),
"sampler_name": (comfy.samplers.KSAMPLER_NAMES,),
"scheduler": (comfy.samplers.SCHEDULER_NAMES,),
"seed": ("INT", {"default": 123456789}),
# --- InfiniteTalk ---
"motion_frame_count": ("INT", {"default": 9, "min": 1, "max": 33, "step": 1}),
"audio_scale": ("FLOAT", {"default": 1.0, "min": -10.0, "max": 10.0, "step": 0.01}),
# --- 缩放 ---
"scale_method": (cls._SCALE_METHODS, {"default": "lanczos"}),
"crop_mode": (cls._CROP_MODES, {"default": "center"}),
# --- 清理 ---
"cleanup": (["不做任何清理", "单次缓存清理", "双次缓存清理", "卸载显存模型"], {"default": "双次缓存清理"}),
},
"optional": {
"start_image": ("IMAGE",),
"audio": ("AUDIO", {"tooltip": "总线音频模式:接入长音频,总线一次性编码,接力点自动分段"}),
},
}
RETURN_TYPES = ("WAN_INFINITETALK_BUS",)
RETURN_NAMES = ("📦 WAN_INFINITETALK_BUS",)
FUNCTION = "pack_bus"
CATEGORY = "XB_ToolBox/Pipeline"
def pack_bus(self, **kwargs):
# CLIP 视觉编码:先缩放到视频尺寸,再按 crop 模式处理
cv = kwargs.get("clip_vision")
ref = kwargs.get("start_image")
if cv is not None and ref is not None:
w, h = kwargs["width"], kwargs["height"]
method = kwargs.get("scale_method", "lanczos")
crop = kwargs.get("crop_mode", "center")
scaled = comfy.utils.common_upscale(ref.movedim(-1, 1), w, h, method, crop).movedim(1, -1)
kwargs["clip_vision_output"], = nodes.CLIPVisionEncode().encode(cv, scaled, "center")
print(f"👁️ [XB-BOX] CLIP视觉编码: {ref.shape[1]}×{ref.shape[2]} → {w}×{h} ({method}/{crop})")
else:
kwargs["clip_vision_output"] = None
# --- 总线音频模式:接入长音频 + 总帧数则一次性编码 ---
audio_input = kwargs.get("audio")
total_frames = kwargs.get("total_frames", 0)
if audio_input is not None and total_frames > 0:
ae_cls = nodes.NODE_CLASS_MAPPINGS.get("AudioEncoderEncode")
if ae_cls is None:
raise ImportError("AudioEncoderEncode not found")
ae = ae_cls()
kwargs["_encoded_audio"], = getattr(ae, ae.FUNCTION)(kwargs["audio_encoder"], audio_input)
kwargs["_raw_audio"] = audio_input # 保留原始波形用于输出拼接
kwargs["_total_frames"] = total_frames
kwargs["_accumulated_frames"] = 0
kwargs["_bus_audio_mode"] = True
print(f"🔊 [XB-BOX] 总线音频模式:已编码长音频,总目标 {total_frames} 帧 ({total_frames/kwargs['fps']:.1f}s)")
else:
kwargs["_bus_audio_mode"] = False
kwargs["_previous_frames"] = None
kwargs["_global_frame_offset"] = 0
return (kwargs,)
# ============================================================
# XB_WanInfiniteTalk_RelayNode — InfiniteTalk 无限对口型接力点
# ============================================================
class XB_WanInfiniteTalk_RelayNode:
"""
每个接力点完成:CLIPTextEncode → WanInfiniteTalkToVideo_Single → KSampler → VAEDecode → trim → 累加。
音频独立输入 + 自动裁剪重叠 + 累加输出,与视频帧对齐。
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"wan_infinitetalk_bus": ("WAN_INFINITETALK_BUS",),
"positive_prompt": ("STRING", {"multiline": True, "default": ""}),
"segment_length": ("INT", {"default": 81, "min": 1, "max": 8192, "step": 4}),
},
"optional": {
"prev_video": ("IMAGE",),
"prev_audio": ("AUDIO",),
"audio": ("AUDIO",),
}
}
RETURN_TYPES = ("WAN_INFINITETALK_BUS", "IMAGE", "AUDIO")
RETURN_NAMES = ("📦 WAN_INFINITETALK_BUS (传给下段)", "🎞️ 累加视频流", "🔊 累加音频流")
FUNCTION = "execute_relay"
CATEGORY = "XB_ToolBox/Pipeline"
def execute_relay(self, wan_infinitetalk_bus, positive_prompt, segment_length,
prev_video=None, prev_audio=None, audio=None):
b = wan_infinitetalk_bus.copy()
motion = b.get("motion_frame_count", 9)
fps = b.get("fps", 25.0)
bus_audio_mode = b.get("_bus_audio_mode", False)
# ================================================================
# 🛡️ 模式判断 & 帧数计算
# ================================================================
if bus_audio_mode:
# --- 模式1:总线音频模式 ---
total_frames = b["_total_frames"]
accumulated = b["_accumulated_frames"]
remaining = total_frames - accumulated
if remaining <= 0:
print(f"\n⏭️ [XB-BOX] 总线帧数已耗尽 ({accumulated}/{total_frames}),跳过接力点")
fb_video = prev_video if prev_video is not None else torch.zeros((1, b["height"], b["width"], 3))
fb_audio = prev_audio if prev_audio is not None else {"waveform": torch.zeros((1, 1)), "sample_rate": 44100}
return (b, fb_video, fb_audio)
if remaining < segment_length:
# 最后一段:trim 会吃掉 motion 帧,需补回;再对齐 4N+1
if accumulated == 0:
need_raw = remaining # 第一段无 trim
else:
need_raw = remaining + motion
actual_length = ((need_raw + 2) // 4) * 4 + 1
if actual_length < 1:
print(f"⏭️ [XB-BOX] 剩余 {remaining} 帧不足以生成最小段,跳过")
fb_video = prev_video if prev_video is not None else torch.zeros((1, b["height"], b["width"], 3))
fb_audio = prev_audio if prev_audio is not None else {"waveform": torch.zeros((1, 1)), "sample_rate": 44100}
return (b, fb_video, fb_audio)
print(f"\n⚠️ [XB-BOX] 最后一段: 剩余 {remaining} → need_raw={need_raw} → {actual_length} 帧 (4N+1)")
else:
actual_length = segment_length
encoded_audio = b["_encoded_audio"]
raw_audio = b.get("_raw_audio")
use_segment_audio = False # 用 offset 方式裁剪长音频
print(f"\n🔊 [XB-BOX] 总线音频接力: 偏移 {accumulated} 帧, 生成长度 {actual_length} 帧 ({accumulated}/{total_frames})")
else:
# --- 模式2:独立音频模式 ---
if audio is None:
print("\n⏭️ [XB-BOX] 无音频输入,跳过接力点")
fb_video = prev_video if prev_video is not None else torch.zeros((1, b["height"], b["width"], 3))
fb_audio = prev_audio if prev_audio is not None else {"waveform": torch.zeros((1, 1)), "sample_rate": 44100}
return (b, fb_video, fb_audio)
actual_length = segment_length
encoded_audio = None # 下面编码
raw_audio = audio
use_segment_audio = True # 每段独立音频从头开始
print(f"\n🎵 [XB-BOX] 独立音频接力: 生成长度 {actual_length} 帧")
# --- 🛡️ 帧数对齐:Wan 模型要求 length = 4n+1 ---
raw_length = actual_length
aligned_length = ((actual_length + 2) // 4) * 4 + 1
if aligned_length != raw_length:
print(f"⚠️ [XB-BOX] 帧数对齐: {raw_length} → {aligned_length} (4n+1)")
# 总线模式下不补音频(已编码),独立模式下补静音
if not bus_audio_mode and audio is not None:
pad_frames = aligned_length - raw_length
pad_sec = pad_frames / fps
sr = audio["sample_rate"]
pad_samples = int(pad_sec * sr)
pad_wf = torch.zeros((audio["waveform"].shape[0], pad_samples), dtype=audio["waveform"].dtype, device=audio["waveform"].device)
raw_audio = {"waveform": torch.cat([audio["waveform"], pad_wf], dim=-1), "sample_rate": sr}
actual_length = aligned_length
# --- 音频编码(独立模式) ---
if not bus_audio_mode:
ae_cls = nodes.NODE_CLASS_MAPPINGS.get("AudioEncoderEncode")
if ae_cls is None:
raise ImportError("AudioEncoderEncode not found")
ae = ae_cls()
encoded_audio, = getattr(ae, ae.FUNCTION)(b["audio_encoder"], raw_audio)
# --- 提示词 ---
pos_cond, = nodes.CLIPTextEncode().encode(b["clip"], positive_prompt)
neg_cond, = nodes.CLIPTextEncode().encode(b["clip"], b["negative_prompt"])
# --- InfiniteTalk 底座 ---
prev = b.get("_previous_frames")
# 🛡️ 总线模式:previous_frames 只含最后一段,补齐到累积帧数,
# 让 _process_infinite_talk_audio 的 frame_offset 算出正确的全局 audio_start
if bus_audio_mode and prev is not None:
accumulated = b["_accumulated_frames"]
target_len = accumulated + motion
cd_pad = 0 if len(prev.shape) == 4 else 1
if prev.shape[cd_pad] < target_len:
pad_shape = list(prev.shape)
pad_shape[cd_pad] = target_len - prev.shape[cd_pad]
dummy = torch.zeros(pad_shape, device=prev.device, dtype=prev.dtype)
prev = torch.cat([dummy, prev], dim=cd_pad)
core = XB_WanInfiniteTalkToVideo_Single()
# 🛡️ 保存模型原始状态,之后完整恢复,防止补丁累积导致质量下滑
model_ref = b["model"]
_saved_wrappers = dict(model_ref.wrappers) if hasattr(model_ref, "wrappers") else {}
_saved_obj_patches = dict(model_ref.object_patches) if hasattr(model_ref, "object_patches") else {}
_saved_trans_opts = dict(model_ref.model_options.get("transformer_options", {})) if hasattr(model_ref, "model_options") else {}
model_out, pos, neg, latent, trim = core.process(
model=model_ref, model_patch=b["model_patch"],
positive=pos_cond, negative=neg_cond, vae=b["vae"],
width=b["width"], height=b["height"], length=actual_length,
audio_encoder_output_1=encoded_audio,
motion_frame_count=motion, audio_scale=b["audio_scale"],
vae_tile_size=b["vae_encode_tile_size"],
clip_vision_output=b.get("clip_vision_output"),
start_image=b.get("start_image"), previous_frames=prev,
segment_audio=use_segment_audio,
scale_method=b.get("scale_method", "lanczos"),
crop_mode=b.get("crop_mode", "center"),
)
# --- 采样 ---
latent_out, = nodes.KSampler().sample(
model=model_out, seed=b["seed"],
steps=b["steps"], cfg=b["cfg"],
sampler_name=b["sampler_name"], scheduler=b["scheduler"],
positive=pos, negative=neg, latent_image=latent, denoise=1.0,
)
# --- 完整恢复模型原始状态(不只是删 key,防止残留引用累积) ---
if hasattr(model_out, "wrappers"):
model_out.wrappers.clear()
model_out.wrappers.update(_saved_wrappers)
if hasattr(model_out, "object_patches"):
model_out.object_patches.clear()
model_out.object_patches.update(_saved_obj_patches)
if hasattr(model_out, "model_options"):
model_out.model_options["transformer_options"] = _saved_trans_opts
# --- 解码 ---
decoded, = nodes.VAEDecode().decode(samples=latent_out, vae=b["vae"])
# --- 视频裁剪累加 ---
is4d = len(decoded.shape) == 4
cd = 0 if is4d else 1
total = decoded.shape[cd]
b["_previous_frames"] = decoded
trim_val = int(trim) if trim else 0
trim_val = min(trim_val, total - 1) if trim_val < total else 0
cut = decoded[trim_val:] if is4d else decoded[:, trim_val:]
final_video = torch.cat([prev_video, cut], dim=cd) if prev_video is not None else cut
# --- 音频裁剪累加 ---
ats = trim_val / fps if trim_val else 0.0
if bus_audio_mode:
# 总线模式:从原始长音频中裁出当前段
raw_wf = raw_audio["waveform"] if raw_audio else torch.zeros((1, 1))
raw_sr = raw_audio["sample_rate"] if raw_audio else 44100
seg_start_sample = int(b["_accumulated_frames"] / fps * raw_sr)
seg_end_sample = int((b["_accumulated_frames"] + cut.shape[cd]) / fps * raw_sr)
seg_end_sample = min(seg_end_sample, raw_wf.shape[-1])
cut_audio = {"waveform": raw_wf[..., seg_start_sample:seg_end_sample], "sample_rate": raw_sr} if seg_end_sample > seg_start_sample else None
else:
cut_audio = raw_audio
if ats > 0 and raw_audio:
wf = raw_audio["waveform"]
sr = raw_audio["sample_rate"]
n = int(ats * sr)
if n < wf.shape[-1]:
cut_audio = {"waveform": wf[..., n:], "sample_rate": sr}
if prev_audio is None:
final_audio = cut_audio
elif cut_audio is None:
final_audio = prev_audio
else:
pa, ca = prev_audio, cut_audio
if pa["sample_rate"] != ca["sample_rate"]:
import torchaudio
ca_wf = torchaudio.functional.resample(ca["waveform"], ca["sample_rate"], pa["sample_rate"])
ca = {"waveform": ca_wf, "sample_rate": pa["sample_rate"]}
# 🛡️ 设备对齐 + 声道对齐: 防止 torch.cat 因设备/声道不一致崩溃
pa_wf = pa["waveform"]
ca_wf = ca["waveform"].to(pa_wf.device)
if pa_wf.dim() >= 1 and ca_wf.dim() >= 1 and pa_wf.shape[:-1] != ca_wf.shape[:-1]:
# 单声道 → 双声道 或反之时对齐
if pa_wf.shape[0] == 1:
pa_wf = pa_wf.repeat(ca_wf.shape[0], *([1] * (pa_wf.dim() - 1)))
elif ca_wf.shape[0] == 1:
ca_wf = ca_wf.repeat(pa_wf.shape[0], *([1] * (ca_wf.dim() - 1)))
final_audio = {"waveform": torch.cat([pa_wf, ca_wf], dim=-1),
"sample_rate": pa["sample_rate"]}
b["_global_frame_offset"] = b.get("_global_frame_offset", 0) + cut.shape[cd]
if bus_audio_mode:
b["_accumulated_frames"] += cut.shape[cd]
remaining_after = b["_total_frames"] - b["_accumulated_frames"]
print(f"🎵 [XB-BOX] Relay done: {total}→{cut.shape[cd]}f (trim {trim_val}), "
f"acc={final_video.shape[cd]}f, 总线进度 {b['_accumulated_frames']}/{b['_total_frames']} (剩余 {remaining_after})")
else:
print(f"🎵 [XB-BOX] Relay done: {total}→{cut.shape[cd]}f (trim {trim_val}), "
f"acc={final_video.shape[cd]}f, video_dur={final_video.shape[cd]/fps:.1f}s")
_refresh_models()
return (b, final_video, final_audio)