Files
slvslvslv-ComfyUI-SmartHelp…/smart_painter_long_video.py

329 lines
15 KiB
Python

import torch
import comfy.clip_vision
import comfy.model_management
import comfy.utils
class SmartPainterLongVideo:
"""
Smart variation of PainterLongVideo that adds an optional CLIP Vision reference
image (with independent strength) on top of the existing pipeline.
Execute logic is identical to PainterLongVideo. The only addition is an
extra clip_vision_reference_image slot whose CLIP tokens are scaled by
cv_reference_strength and merged with the existing clip_vision_output on
the token axis before being injected into conditioning.
When clip_vision_reference_image is unconnected the node behaves exactly
like the original PainterLongVideo.
"""
@classmethod
def INPUT_TYPES(cls):
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", {
"tooltip": (
"Optional VAE-encoded reference appended to the conditioning's "
"reference_latents list (zero latent on the negative side).\n"
"\n"
"NOTE: Only effective on WAN checkpoints that ship with a ref_conv layer "
"(Fun Control, Animate, SCAIL, HuMo, VACE-merged). "
"Silently ignored by stock WAN 2.2 FLF2V / I2V / T2V and most custom merges. "
"For a reference-image channel that works on any base WAN 2.2 checkpoint, "
"use clip_vision_reference_image below instead.\n"
"\n"
"Leave unconnected to keep the original pipeline unchanged."
),
}),
"clip_vision_output": ("CLIP_VISION_OUTPUT",),
"clip_vision_reference_image": ("CLIP_VISION_OUTPUT", {
"tooltip": (
"Optional extra CLIP Vision slot for an off-screen reference image "
"(subject identity, clothing, object that isn't in start/end frames).\n"
"\n"
"Merged with clip_vision_output by concatenating their token sequences "
"along the token axis. Feeds the model's img_emb cross-attention pathway — "
"the only non-keyframe image-guidance channel available on stock WAN 2.2 "
"base checkpoints (no VACE / no ref_conv required).\n"
"\n"
"Provides semantic / style / identity guidance. NOT pixel-faithful — "
"good for 'keep the jacket this color', weak for 'reproduce this exact logo'.\n"
"\n"
"Leave unconnected to keep the original pipeline unchanged."
),
}),
"cv_reference_strength": ("FLOAT", {
"default": 1.0, "min": 0.0, "max": 3.0, "step": 0.05,
"tooltip": (
"Only active when clip_vision_reference_image is connected.\n"
"\n"
"Scales the reference image's CLIP token magnitudes before they are merged "
"with clip_vision_output. Higher values make the reference dominate the "
"cross-attention, lower values let the existing CLIP guidance win.\n"
"\n"
"0.0 = reference disabled (same as leaving clip_vision_reference_image "
"unconnected).\n"
"1.0 = equal weight with clip_vision_output tokens.\n"
"2.0-3.0 = reference boosted (useful if the reference subject is being "
"ignored by the model).\n"
"\n"
"Defaults to 1.0."
),
}),
"start_image": ("IMAGE",),
"end_image": ("IMAGE",),
},
}
RETURN_TYPES = ("CONDITIONING", "CONDITIONING", "LATENT")
RETURN_NAMES = ("positive", "negative", "latent")
FUNCTION = "execute"
CATEGORY = "SmartHelperNodes/Wan"
DISPLAY_NAME = "Smart Painter Long Video"
DESCRIPTION = (
"Smart Painter Long Video: long-form WAN video conditioning with optional CLIP Vision "
"reference image and independent strength.\n"
"Identical execute logic to PainterLongVideo, plus an extra clip_vision_reference_image "
"slot for off-screen subject/style guidance through the model's img_emb cross-attention."
)
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,
clip_vision_reference_image=None,
cv_reference_strength=1.0,
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("SmartPainterLongVideo: 必须接入 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)
scaled_reference = self._scale_clip_vision(
clip_vision_reference_image, cv_reference_strength,
)
merged_clip_vision = self._merge_clip_vision(clip_vision_output, scaled_reference)
if merged_clip_vision is not None:
pos_out = inject_conditioning(pos_out, {"clip_vision_output": merged_clip_vision})
neg_out = inject_conditioning(neg_out, {"clip_vision_output": merged_clip_vision})
latent_out = {"samples": latent}
return (pos_out, neg_out, latent_out)
# ------------------------------------------------------------------
@staticmethod
def _merge_clip_vision(*outputs):
valid = [o for o in outputs if o is not None]
if not valid:
return None
if len(valid) == 1:
return valid[0]
states = torch.cat([o.penultimate_hidden_states for o in valid], dim=-2)
result = comfy.clip_vision.Output()
result.penultimate_hidden_states = states
return result
# ------------------------------------------------------------------
@staticmethod
def _scale_clip_vision(output, strength):
if output is None or strength <= 0.0:
return None
if abs(strength - 1.0) < 1e-6:
return output
scaled = comfy.clip_vision.Output()
scaled.penultimate_hidden_states = output.penultimate_hidden_states * strength
for attr in ("image_embeds", "last_hidden_state", "mm_projected", "image_sizes"):
if hasattr(output, attr):
setattr(scaled, attr, getattr(output, attr))
return scaled
NODE_CLASS_MAPPINGS = {
"SmartPainterLongVideo": SmartPainterLongVideo,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"SmartPainterLongVideo": "Smart Painter Long Video",
}