Files
2025-11-17 20:47:51 +08:00

229 lines
10 KiB
Python

# ComfyUI-PainterLongVideo/nodes.py
import torch
import comfy.utils
import comfy.model_management
class PainterLongVideo:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"positive": ("CONDITIONING", ),
"negative": ("CONDITIONING", ),
"vae": ("VAE", ),
"width": ("INT", {"default": 832, "min": 16, "max": 8192, "step": 16}),
"height": ("INT", {"default": 480, "min": 16, "max": 8192, "step": 16}),
"length": ("INT", {"default": 81, "min": 1, "max": 1000, "step": 1}),
"batch_size": ("INT", {"default": 1, "min": 1, "max": 4096}),
"motion_frames": ("INT", {"default": 5, "min": 1, "max": 20}),
"motion_amplitude": ("FLOAT", {"default": 1.15, "min": 1.0, "max": 2.0, "step": 0.05}),
},
"optional": {
"previous_video": ("IMAGE",), # 现在是可选
"initial_reference_image": ("IMAGE",),
"clip_vision_output": ("CLIP_VISION_OUTPUT",),
"start_image": ("IMAGE",), # 首帧
"end_image": ("IMAGE",), # 尾帧
}
}
RETURN_TYPES = ("CONDITIONING", "CONDITIONING", "LATENT")
RETURN_NAMES = ("positive", "negative", "latent")
FUNCTION = "execute"
CATEGORY = "video/painter"
DISPLAY_NAME = "PainterLongVideo"
def execute(
self,
positive,
negative,
vae,
width,
height,
length,
batch_size,
motion_frames,
motion_amplitude=1.15,
previous_video=None,
initial_reference_image=None,
clip_vision_output=None,
start_image=None,
end_image=None,
):
device = comfy.model_management.intermediate_device()
latent_timesteps = ((length - 1) // 4) + 1
latent = torch.zeros([batch_size, 16, latent_timesteps, height // 8, width // 8], device=device)
# ---------- 1. 收集开关 ----------
has_prev = previous_video is not None
has_start = start_image is not None
has_end = end_image is not None
# ---------- 2. 无输入直接报错 ----------
if not has_prev and not has_start and not has_end:
raise RuntimeError("PainterLongVideo: 必须接入 previous_video、start_image、end_image 中的至少一个!")
# ---------- 3. 构造统一的 image / mask ----------
# 默认值:灰色画布
image = torch.full((length, height, width, 3), 0.5, device=device, dtype=torch.float32)
mask = torch.ones((1, 1, latent_timesteps * 4, height // 8, width // 8), device=device, dtype=torch.float32)
# ---------- 4. 按优先级分支 ----------
if has_start or has_end:
# 分支 A/B:只要接了 start 或 end,就以「首尾帧」逻辑为准
if has_start:
start_img = comfy.utils.common_upscale(
start_image[:length].movedim(-1, 1), width, height, "bilinear", "center"
).movedim(1, -1)
actual_start_len = min(start_img.shape[0], length)
image[:actual_start_len] = start_img[:actual_start_len]
mask[:, :, :actual_start_len + 3] = 0.0 # 保护首帧(含缓冲)
else:
# 没给 start,但给了 end → 把 previous_video 最后一帧当首帧
if has_prev:
last_frame = previous_video[-1:].clone()
last_frame_resized = comfy.utils.common_upscale(
last_frame.movedim(-1, 1), width, height, "bilinear", "center"
).movedim(1, -1)
image[0] = last_frame_resized[0]
mask[:, :, :1 + 3] = 0.0
# 如果既没有 start 也没有 prev,却走到这里,说明只接了 end,也允许继续
if has_end:
end_img = comfy.utils.common_upscale(
end_image[-length:].movedim(-1, 1), width, height, "bilinear", "center"
).movedim(1, -1)
actual_end_len = min(end_img.shape[0], length)
image[-actual_end_len:] = end_img[-actual_end_len:]
mask[:, :, -actual_end_len:] = 0.0
concat_latent_image = vae.encode(image[:, :, :, :3])
mask = mask.view(1, mask.shape[2] // 4, 4, mask.shape[3], mask.shape[4]).transpose(1, 2)
# 运动参考:优先用 previous_video(如果给了)
ref_motion_latent = None
if has_prev and previous_video.shape[0] >= 2:
ref_motion = previous_video[-min(73, previous_video.shape[0]):].clone()
ref_motion = comfy.utils.common_upscale(
ref_motion.movedim(-1, 1), width, height, "bilinear", "center"
).movedim(1, -1)
if ref_motion.shape[0] < 73:
gray_fill = torch.ones([73, height, width, 3], device=device, dtype=ref_motion.dtype) * 0.5
gray_fill[-ref_motion.shape[0]:] = ref_motion
ref_motion = gray_fill
ref_motion_latent_temp = vae.encode(ref_motion[:, :, :, :3])
ref_motion_latent = ref_motion_latent_temp[:, :, -19:]
else:
# 分支 C:只接了 previous_video,走原逻辑
last_frame = previous_video[-1:].clone()
last_frame_resized = comfy.utils.common_upscale(
last_frame.movedim(-1, 1), width, height, "bilinear", "center"
).movedim(1, -1)
image_seq = torch.full(
(length, height, width, last_frame_resized.shape[-1]),
0.5, device=last_frame_resized.device, dtype=last_frame_resized.dtype
)
image_seq[0] = last_frame_resized[0]
concat_latent_image = vae.encode(image_seq[:, :, :, :3])
mask = torch.ones((1, 1, latent_timesteps, height // 8, width // 8),
device=device, dtype=last_frame_resized.dtype)
mask[:, :, 0] = 0.0
# 运动幅度增强
if motion_amplitude > 1.0:
base_latent = concat_latent_image[:, :, 0:1]
gray_latent = concat_latent_image[:, :, 1:]
diff = gray_latent - base_latent
diff_mean = diff.mean(dim=(1, 3, 4), keepdim=True)
diff_centered = diff - diff_mean
scaled_latent = base_latent + diff_centered * motion_amplitude + diff_mean
scaled_latent = torch.clamp(scaled_latent, -6, 6)
concat_latent_image = torch.cat([base_latent, scaled_latent], dim=2)
# 运动参考帧
ref_motion = previous_video[-motion_frames:].clone()
if ref_motion.shape[0] > 73:
ref_motion = ref_motion[-73:]
ref_motion_resized = comfy.utils.common_upscale(
ref_motion.movedim(-1, 1), width, height, "bilinear", "center"
).movedim(1, -1)
if ref_motion_resized.shape[0] < 73:
gray_fill = torch.ones([73, height, width, 3], device=ref_motion_resized.device,
dtype=ref_motion_resized.dtype) * 0.5
gray_fill[-ref_motion_resized.shape[0]:] = ref_motion_resized
ref_motion_resized = gray_fill
ref_motion_latent_temp = vae.encode(ref_motion_resized[:, :, :, :3])
ref_motion_latent = ref_motion_latent_temp[:, :, -19:]
# ---------- 5. 构造 reference_latents ----------
ref_latents = []
# (a) 上一段最后一帧(始终添加)
last_frame_for_ref = previous_video[-1:] if has_prev else image[0:1]
last_frame_for_ref = comfy.utils.common_upscale(
last_frame_for_ref.movedim(-1, 1), width, height, "bilinear", "center"
).movedim(1, -1)
last_latent = vae.encode(last_frame_for_ref[:, :, :, :3])
ref_latents.append(last_latent)
# (b) 初始参考图(如有)
if initial_reference_image is not None:
init_img = initial_reference_image[:1]
init_img_resized = comfy.utils.common_upscale(
init_img.movedim(-1, 1), width, height, "bilinear", "center"
).movedim(1, -1)
init_latent = vae.encode(init_img_resized[:, :, :, :3])
ref_latents.append(init_latent)
# ---------- 6. conditioning 注入 ----------
def inject_conditioning(cond, values_dict):
new_cond = []
for c_tensor, c_dict in cond:
new_dict = c_dict.copy()
new_dict.update(values_dict)
new_cond.append([c_tensor, new_dict])
return new_cond
def append_conditioning(cond, key, value_list):
new_cond = []
for c_tensor, c_dict in cond:
new_dict = c_dict.copy()
if key in new_dict:
new_dict[key] = new_dict[key] + value_list
else:
new_dict[key] = value_list
new_cond.append([c_tensor, new_dict])
return new_cond
shared_values = {
"concat_latent_image": concat_latent_image,
"concat_mask": mask,
}
if ref_motion_latent is not None:
shared_values["reference_motion"] = ref_motion_latent
pos_out = inject_conditioning(positive, shared_values)
neg_out = inject_conditioning(negative, shared_values)
pos_out = append_conditioning(pos_out, "reference_latents", ref_latents)
neg_ref_latents = [torch.zeros_like(r) for r in ref_latents]
neg_out = append_conditioning(neg_out, "reference_latents", neg_ref_latents)
if clip_vision_output is not None:
pos_out = inject_conditioning(pos_out, {"clip_vision_output": clip_vision_output})
neg_out = inject_conditioning(neg_out, {"clip_vision_output": clip_vision_output})
latent_out = {"samples": latent}
return (pos_out, neg_out, latent_out)
NODE_CLASS_MAPPINGS = {
"PainterLongVideo": PainterLongVideo
}
NODE_DISPLAY_NAME_MAPPINGS = {
"PainterLongVideo": "PainterLongVideo"
}