Files
WJLUOXIAO-XB_ToolBox/nodes_pipeline.py
T
2026-05-28 22:48:50 +08:00

596 lines
28 KiB
Python

import os
import torch
import numpy as np
from PIL import Image, ImageOps
import nodes
import comfy.samplers
import folder_paths
from .nodes_wan_vae import XB_WanFirstLastFrameToVideo, XB_WanImageToVideo
# ==============================================================================
# 1. 视频参数总线 (全面解耦 VAE 编码与解码的时空分块参数)
# ==============================================================================
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}), # 🚀 新增:精确去重帧数控制
}
}
RETURN_TYPES = ("WAN_BUS",)
RETURN_NAMES = ("📦 WAN_BUS (Dynamic Bus)",)
FUNCTION = "pack_bus"
CATEGORY = "XB_ToolBox/Pipeline"
def pack_bus(self, **kwargs):
return (kwargs,)
# ==============================================================================
# 2. 🏃首尾帧接力点
# ==============================================================================
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)
# 🔄 向后兼容:旧工作流只有 vae_tile_size,新工作流分离了编解码参数
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"])
pos, neg, latent = XB_WanFirstLastFrameToVideo().process(
positive=pos_cond, negative=neg_cond, vae=b["vae"],
clip_vision_start_image=None, 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
)
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.VAEDecodeTiled().decode(
samples=latent_low,
vae=b["vae"],
tile_size=decode_tile,
overlap=spat_overlap,
temporal_size=temp_chunk,
temporal_overlap=temp_overlap
)
is_4d = len(decoded_image.shape) == 4
frame_dim = 0 if is_4d else 1
total_frames = decoded_image.shape[frame_dim]
# 🛡️ 安全钳:trim_head_frames 不能超过实际帧数,至少保留 1 帧
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
return (wan_bus, last_frame, final_video)
# ==============================================================================
# 3. 🏃‍♀️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)
# 🔄 向后兼容:旧工作流只有 vae_tile_size,新工作流分离了编解码参数
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"])
pos, neg, latent = XB_WanImageToVideo().process(
positive=pos_cond, negative=neg_cond, vae=b["vae"],
clip_vision_output=None, start_image=start_image,
width=b["width"], height=b["height"], length=b["length"],
batch_size=1, vae_tile_size=encode_tile
)
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.VAEDecodeTiled().decode(
samples=latent_low,
vae=b["vae"],
tile_size=decode_tile,
overlap=spat_overlap,
temporal_size=temp_chunk,
temporal_overlap=temp_overlap
)
is_4d = len(decoded_image.shape) == 4
frame_dim = 0 if is_4d else 1
total_frames = decoded_image.shape[frame_dim]
# 🛡️ 安全钳:trim_head_frames 不能超过实际帧数,至少保留 1 帧
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
return (wan_bus, last_frame, final_video)
# ==============================================================================
# 4. 视频拼接与分镜切片
# ==============================================================================
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),)
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)
# ==============================================================================
# 6. 🎬 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, "step": 16}),
"height": ("INT", {"default": 832, "step": 16}),
"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",),
}
}
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:
kwargs["clip_vision_output"], = nodes.CLIPVisionEncode().encode(cv, ref, "center")
return (kwargs,)
# ==============================================================================
# 7. 🏃‍♀️ Wan Animate 无限接力点 (完全复刻首尾帧接力点的 UI 交互模式)
# ==============================================================================
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)
# ====================================================================
# 🛡️ 防呆 1:帧数智能对齐与越界跳过
# ====================================================================
source_video = b.get("pose_video") if b.get("pose_video") is not None else b.get("face_video")
total_source_frames = float('inf')
if 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)
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)
actual_length = min(segment_length, remaining_frames)
if actual_length < segment_length:
print(f"\n⚠️ [XB-BOX] 帧数截断触发:节点请求 {segment_length} 帧,源动作视频仅剩 {actual_length} 帧。已自动对齐缩减!")
else:
print(f"\n🏃‍♀️ [XB-BOX] Executing Wan Animate Relay task... Target segment length: {actual_length}")
# ====================================================================
# 🎨 开关控制:ON=独立选图, OFF=继承总线全局图
# ====================================================================
# 判断是否使用独立参考图(兼容字符串/布尔/索引)
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,]
# 用本地图重新编码 CLIP 视觉特征,完全替代总线的 clip_vision_output
cv_model = b.get("clip_vision")
if cv_model is None:
raise ValueError("🚨 [XB-BOX] 使用独立参考图需要总线连接 clip_vision 模型!")
local_clip_vision, = nodes.CLIPVisionEncode().encode(
cv_model, ref_image, "center"
)
clip_vision_output = local_clip_vision
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)
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:, :, :]
# ====================================================================
# 🚀 核弹升级:ROCm 专属时空解码 (cleanup 从总线继承)
# ====================================================================
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
if prev_video is not None:
final_video = torch.cat([prev_video, decoded_image], dim=concat_dim)
else:
final_video = decoded_image
b["current_offset"] = new_offset
print(f"✅ [XB-BOX] Animate Relay Complete. Next temporal offset shifted to: Frame {new_offset}")
return (b, final_video)