Files
ckinpdx 71aea06fe4 Adjust image1 placement and update related comments
Updated handling of image1 to be placed on the timeline with adjustable strength. Modified tooltips and comments to reflect this change.
2025-12-08 17:34:02 -08:00

759 lines
27 KiB
Python

import torch
class WanKeyframeBuilder:
"""
Keyframe Timeline Builder for Wan Video Workflows
Places keyframe images at specific positions along a timeline with controllable
influence strength. Designed to work with WanVideoImageToVideoEncode node.
- First and last keyframes get independent strengths
- Middle keyframes get adjustable influence (default 0.8) for smoother motion
- Non-keyframe positions are gray (0.5) for the model to generate freely
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image1": ("IMAGE",),
"num_frames": (
"INT",
{
"default": 81,
"min": 1,
"max": 4096,
"step": 4,
"tooltip": "Total frames in the output timeline. Should be 4n+1 (e.g., 81, 121, 161) for Wan models.",
},
),
"spacing_mode": (
["even", "manual"],
{
"tooltip": "Even: auto-distribute keyframes. Manual: use frame position inputs.",
},
),
"first_strength": (
"FLOAT",
{
"default": 1.0,
"min": 0.0,
"max": 1.0,
"step": 0.05,
"tooltip": "Mask strength for the FIRST keyframe. 1.0 = fully locked.",
},
),
"last_strength": (
"FLOAT",
{
"default": 1.0,
"min": 0.0,
"max": 1.0,
"step": 0.05,
"tooltip": "Mask strength for the LAST keyframe. 1.0 = fully locked.",
},
),
"middle_strength": (
"FLOAT",
{
"default": 0.8,
"min": 0.0,
"max": 1.0,
"step": 0.05,
"tooltip": "Mask strength for middle keyframes. 0.8 gives the model some freedom to interpret the pose.",
},
),
"frame_1": (
"INT",
{
"default": 0,
"min": 0,
"max": 4095,
"step": 1,
"tooltip": "Position for keyframe 1 (used in manual mode)",
},
),
"frame_2": (
"INT",
{
"default": 20,
"min": 0,
"max": 4095,
"step": 1,
"tooltip": "Position for keyframe 2 (used in manual mode)",
},
),
"frame_3": (
"INT",
{
"default": 40,
"min": 0,
"max": 4095,
"step": 1,
"tooltip": "Position for keyframe 3 (used in manual mode)",
},
),
"frame_4": (
"INT",
{
"default": 60,
"min": 0,
"max": 4095,
"step": 1,
"tooltip": "Position for keyframe 4 (used in manual mode)",
},
),
"frame_5": (
"INT",
{
"default": 80,
"min": 0,
"max": 4095,
"step": 1,
"tooltip": "Position for keyframe 5 (used in manual mode)",
},
),
"frame_6": (
"INT",
{
"default": 100,
"min": 0,
"max": 4095,
"step": 1,
"tooltip": "Position for keyframe 6 (used in manual mode)",
},
),
"frame_7": (
"INT",
{
"default": 120,
"min": 0,
"max": 4095,
"step": 1,
"tooltip": "Position for keyframe 7 (used in manual mode)",
},
),
"frame_8": (
"INT",
{
"default": 140,
"min": 0,
"max": 4095,
"step": 1,
"tooltip": "Position for keyframe 8 (used in manual mode)",
},
),
},
"optional": {
"image2": ("IMAGE",),
"image3": ("IMAGE",),
"image4": ("IMAGE",),
"image5": ("IMAGE",),
"image6": ("IMAGE",),
"image7": ("IMAGE",),
"image8": ("IMAGE",),
},
}
RETURN_TYPES = ("IMAGE", "MASK", "IMAGE")
RETURN_NAMES = ("images", "masks", "keyframes")
FUNCTION = "build_sequence"
CATEGORY = "WanKeyframeBuilder"
def _prepare_single_frame(self, img):
"""Ensure image shape = [1, H, W, C]."""
if img.ndim == 3:
img = img.unsqueeze(0)
if img.shape[0] > 1:
img = img[:1]
return img
def build_sequence(
self,
image1,
num_frames,
spacing_mode,
first_strength,
last_strength,
middle_strength,
frame_1,
frame_2,
frame_3,
frame_4,
frame_5,
frame_6,
frame_7,
frame_8,
image2=None,
image3=None,
image4=None,
image5=None,
image6=None,
image7=None,
image8=None,
):
# Collect all possible images
all_imgs = [
image1,
image2,
image3,
image4,
image5,
image6,
image7,
image8,
]
# Keep only connected (non-None) images
available_imgs = [img for img in all_imgs if img is not None]
# Number of keyframes = number of connected images
N = len(available_imgs)
# Normalize all used images to [1, H, W, C]
used_imgs = [self._prepare_single_frame(img) for img in available_imgs]
# Validate resolution - ComfyUI IMAGE is [B, H, W, C]
h, w = used_imgs[0].shape[1], used_imgs[0].shape[2]
C = used_imgs[0].shape[3]
for idx, im in enumerate(used_imgs[1:], start=2):
if im.shape[1] != h or im.shape[2] != w:
raise ValueError(
f"All keyframe images must match resolution. First is {w}x{h}, "
f"but image {idx} is {im.shape[2]}x{im.shape[1]}."
)
T = max(int(num_frames), 1)
device = used_imgs[0].device
dtype = used_imgs[0].dtype
# Fixed mid-gray background (0.5)
gray = torch.full((1, h, w, C), 0.5, device=device, dtype=dtype)
# Allocate outputs
images_out = gray.repeat(T, 1, 1, 1) # [T, H, W, C]
masks_out = torch.zeros((T, h, w), device=device, dtype=dtype) # [T, H, W]
# Manual frame indices (we only use first N)
manual_frames = [
frame_1,
frame_2,
frame_3,
frame_4,
frame_5,
frame_6,
frame_7,
frame_8,
][:N]
# Determine keyframe anchor indices
if spacing_mode == "manual":
key_idx = [max(0, min(T - 1, int(f))) for f in manual_frames]
key_idx = sorted(key_idx)
else:
# Even spacing across full timeline
if N == 1:
key_idx = [0] # Single image goes at start
else:
key_idx = [round(i * (T - 1) / (N - 1)) for i in range(N)]
# Apply keyframes
for anchor, img_tensor in zip(key_idx, used_imgs):
img = img_tensor[0] # [H, W, C]
# Place keyframe image
images_out[anchor] = img
# Set mask strength based on position
if anchor == key_idx[0] and anchor == key_idx[-1]:
# Only one keyframe
masks_out[anchor] = first_strength
elif anchor == key_idx[0]:
masks_out[anchor] = first_strength
elif anchor == key_idx[-1]:
masks_out[anchor] = last_strength
else:
masks_out[anchor] = middle_strength
# Build keyframes-only batch [N, H, W, C]
keyframes_batch = torch.cat(used_imgs, dim=0)
return images_out, masks_out, keyframes_batch
class WanKeyframeBuilderContinuation:
"""
Keyframe Timeline Builder with Continuation Support + SVI Outputs
Designed for multi-pass video generation where HuMo handles quality via
reference images. Continuation frames provide motion context without a
first keyframe on the timeline.
- Continuation frames (from previous generation) fill positions 0 to N-1
- image1 is placed on the timeline (with adjustable strength)
- Keyframes (image2+) are distributed across remaining timeline
- Strength can be uniform or decay from start to end
SVI-Specific Outputs:
- svi_reference_only: Timeline filled entirely with image1 (for SVI-Shot reference padding),
overridden at continuation/keyframe positions.
- svi_keyframe_segments: Timeline where each segment is filled with its corresponding keyframe
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image1": (
"IMAGE",
{
"tooltip": "First reference image. Placed on the timeline and used for SVI/reference.",
},
),
"num_frames": (
"INT",
{
"default": 81,
"min": 1,
"max": 4096,
"step": 4,
"tooltip": "Total frames in the output timeline. Should be 4n+1 (e.g., 81, 121, 161) for Wan models.",
},
),
"spacing_mode": (
["even", "manual"],
{
"tooltip": "Even: auto-distribute keyframes. Manual: use frame position inputs.",
},
),
"last_strength": (
"FLOAT",
{
"default": 1.0,
"min": 0.0,
"max": 1.0,
"step": 0.05,
"tooltip": "Mask strength for the LAST keyframe. 1.0 = fully locked.",
},
),
"middle_strength": (
"FLOAT",
{
"default": 0.8,
"min": 0.0,
"max": 1.0,
"step": 0.05,
"tooltip": "Mask strength for middle keyframes.",
},
),
"continuation_frames": (
"INT",
{
"default": 3,
"min": 1,
"max": 64,
"step": 1,
"tooltip": "Number of frames to use from continuation input (taken from END of previous video).",
},
),
"continuation_strength_mode": (
["uniform", "decay"],
{
"tooltip": "Uniform: all continuation frames use same strength. Decay: exponential decay from start to end.",
},
),
"continuation_strength": (
"FLOAT",
{
"default": 1.0,
"min": 0.0,
"max": 1.0,
"step": 0.05,
"tooltip": "Strength for continuation frames (uniform mode).",
},
),
"continuation_strength_start": (
"FLOAT",
{
"default": 0.8,
"min": 0.0,
"max": 1.0,
"step": 0.05,
"tooltip": "Starting strength for first continuation frame (decay mode).",
},
),
"continuation_strength_end": (
"FLOAT",
{
"default": 0.2,
"min": 0.0,
"max": 1.0,
"step": 0.05,
"tooltip": "Ending strength for last continuation frame (decay mode).",
},
),
"continuation_decay_rate": (
"FLOAT",
{
"default": 3.0,
"min": 0.1,
"max": 10.0,
"step": 0.1,
"tooltip": "Exponential decay rate. Higher = sharper drop. 3.0 = moderate, 5.0+ = aggressive.",
},
),
"place_first_keyframe": (
"BOOLEAN",
{
"default": True,
"tooltip": "Place image1 on the timeline (after continuation if present, otherwise at frame 0).",
},
),
"first_keyframe_strength": (
"FLOAT",
{
"default": 1.0,
"min": 0.0,
"max": 1.0,
"step": 0.05,
"tooltip": "Mask strength for image1 wherever it is placed.",
},
),
"frame_2": (
"INT",
{
"default": 40,
"min": 0,
"max": 4095,
"step": 1,
"tooltip": "Position for keyframe 2 (used in manual mode)",
},
),
"frame_3": (
"INT",
{
"default": 80,
"min": 0,
"max": 4095,
"step": 1,
"tooltip": "Position for keyframe 3 (used in manual mode)",
},
),
"frame_4": (
"INT",
{
"default": 60,
"min": 0,
"max": 4095,
"step": 1,
"tooltip": "Position for keyframe 4 (used in manual mode)",
},
),
"frame_5": (
"INT",
{
"default": 80,
"min": 0,
"max": 4095,
"step": 1,
"tooltip": "Position for keyframe 5 (used in manual mode)",
},
),
"frame_6": (
"INT",
{
"default": 100,
"min": 0,
"max": 4095,
"step": 1,
"tooltip": "Position for keyframe 6 (used in manual mode)",
},
),
"frame_7": (
"INT",
{
"default": 120,
"min": 0,
"max": 4095,
"step": 1,
"tooltip": "Position for keyframe 7 (used in manual mode)",
},
),
"frame_8": (
"INT",
{
"default": 140,
"min": 0,
"max": 4095,
"step": 1,
"tooltip": "Position for keyframe 8 (used in manual mode)",
},
),
},
"optional": {
"image2": ("IMAGE",),
"image3": ("IMAGE",),
"image4": ("IMAGE",),
"image5": ("IMAGE",),
"image6": ("IMAGE",),
"image7": ("IMAGE",),
"image8": ("IMAGE",),
"continuation_image": (
"IMAGE",
{
"tooltip": "Frames from previous generation. Last N frames will be used for motion context.",
},
),
},
}
RETURN_TYPES = ("IMAGE", "MASK", "IMAGE", "IMAGE", "IMAGE")
RETURN_NAMES = ("images", "masks", "keyframes", "svi_reference_only", "svi_keyframe_segments")
FUNCTION = "build_sequence"
CATEGORY = "WanKeyframeBuilder"
def _prepare_single_frame(self, img):
"""Ensure image shape = [1, H, W, C]."""
if img.ndim == 3:
img = img.unsqueeze(0)
if img.shape[0] > 1:
img = img[:1]
return img
def build_sequence(
self,
image1,
num_frames,
spacing_mode,
last_strength,
middle_strength,
continuation_frames,
continuation_strength_mode,
continuation_strength,
continuation_strength_start,
continuation_strength_end,
continuation_decay_rate,
place_first_keyframe,
first_keyframe_strength,
frame_2,
frame_3,
frame_4,
frame_5,
frame_6,
frame_7,
frame_8,
image2=None,
image3=None,
image4=None,
image5=None,
image6=None,
image7=None,
image8=None,
continuation_image=None,
):
# image1 is required and is placed on the timeline (with adjustable strength)
# Collect timeline keyframes (image2+)
timeline_imgs = [
image2,
image3,
image4,
image5,
image6,
image7,
image8,
]
# All images for keyframes output (image1 + any connected image2-8)
all_keyframe_imgs = [image1] + [img for img in timeline_imgs if img is not None]
# Timeline images only (image2+ that are connected)
available_timeline_imgs = [img for img in timeline_imgs if img is not None]
# Number of keyframes on timeline (excluding image1, which is special)
N = len(available_timeline_imgs)
# Normalize image1 for reference
img1_prepared = self._prepare_single_frame(image1)
h, w = img1_prepared.shape[1], img1_prepared.shape[2]
C = img1_prepared.shape[3]
# Normalize timeline images
used_timeline_imgs = [self._prepare_single_frame(img) for img in available_timeline_imgs]
# Validate resolution against image1
for idx, im in enumerate(used_timeline_imgs, start=2):
if im.shape[1] != h or im.shape[2] != w:
raise ValueError(
f"All keyframe images must match resolution. image1 is {w}x{h}, "
f"but image{idx} is {im.shape[2]}x{im.shape[1]}."
)
T = max(int(num_frames), 1)
device = img1_prepared.device
dtype = img1_prepared.dtype
# Fixed mid-gray background (0.5) for standard output
gray = torch.full((1, h, w, C), 0.5, device=device, dtype=dtype)
# Allocate standard outputs
images_out = gray.repeat(T, 1, 1, 1) # [T, H, W, C]
masks_out = torch.zeros((T, h, w), device=device, dtype=dtype) # [T, H, W]
# === SVI OUTPUT 1: Reference Only ===
# Timeline filled entirely with image1 (for SVI-Shot reference padding)
svi_reference_only = img1_prepared[0].unsqueeze(0).repeat(T, 1, 1, 1)
# Process continuation frames
cont_frames_list = []
if continuation_image is not None:
cont_img = continuation_image
if cont_img.ndim == 3:
cont_img = cont_img.unsqueeze(0)
# Take last N frames from previous video (in chronological order)
num_cont = min(continuation_frames, cont_img.shape[0])
cont_img = cont_img[-num_cont:] # Take last N frames
# DO NOT reverse - keep chronological order
# First frame in cont_img is oldest, last frame is newest
# Resize each frame if needed
for i in range(cont_img.shape[0]):
frame = cont_img[i : i + 1] # Keep as [1, H, W, C]
if frame.shape[1] != h or frame.shape[2] != w:
frame = frame.permute(0, 3, 1, 2) # [B, H, W, C] -> [B, C, H, W]
frame = torch.nn.functional.interpolate(
frame,
size=(h, w),
mode="bilinear",
align_corners=False,
)
frame = frame.permute(0, 2, 3, 1) # [B, C, H, W] -> [B, H, W, C]
cont_frames_list.append(frame[0]) # Store as [H, W, C]
# Apply continuation frames at positions 0 to N-1
num_cont_applied = len(cont_frames_list)
for i, cont_frame in enumerate(cont_frames_list):
if i < T: # Safety check
images_out[i] = cont_frame
svi_reference_only[i] = cont_frame # Also apply to SVI reference output
# Calculate strength based on mode
if continuation_strength_mode == "uniform":
strength = continuation_strength
else: # decay - from start strength to end strength
if num_cont_applied > 1:
t = i / (num_cont_applied - 1) # 0 to 1 (0=first/oldest, 1=last/newest)
import math
# Exponential decay from start to end
decay = math.exp(-continuation_decay_rate * t)
strength = continuation_strength_end + (continuation_strength_start - continuation_strength_end) * decay
else:
strength = continuation_strength_start
masks_out[i] = strength
# === Place image1 on the timeline with adjustable strength ===
# - If continuation frames exist: directly after them
# - If none: at frame 0
if place_first_keyframe:
if num_cont_applied > 0:
first_keyframe_position = num_cont_applied
else:
first_keyframe_position = 0
if first_keyframe_position < T:
img1_frame = img1_prepared[0] # [H, W, C]
images_out[first_keyframe_position] = img1_frame
svi_reference_only[first_keyframe_position] = img1_frame
masks_out[first_keyframe_position] = first_keyframe_strength
# Manual frame indices for timeline keyframes (image2+)
manual_frames = [
frame_2,
frame_3,
frame_4,
frame_5,
frame_6,
frame_7,
frame_8,
][:N]
# Determine keyframe anchor indices for remaining keyframes (image2+)
key_idx = []
if N > 0:
if spacing_mode == "manual":
key_idx = [max(0, min(T - 1, int(f))) for f in manual_frames]
key_idx = sorted(key_idx)
else:
# Even spacing as if image1 was at a phantom position 0
total_for_spacing = N + 1
all_positions = [round(i * (T - 1) / (total_for_spacing - 1)) for i in range(total_for_spacing)]
key_idx = all_positions[1:] # Skip position 0 (reserved conceptually for image1)
# Apply timeline keyframes (image2+)
for idx in range(len(key_idx)):
anchor = key_idx[idx]
img_tensor = used_timeline_imgs[idx]
img = img_tensor[0] # [H, W, C]
images_out[anchor] = img
# Set mask strength
if anchor == key_idx[-1]:
masks_out[anchor] = last_strength
else:
masks_out[anchor] = middle_strength
# === SVI OUTPUT 2: Keyframe Segments ===
# Each keyframe fills its segment equally
svi_keyframe_segments = img1_prepared[0].unsqueeze(0).repeat(T, 1, 1, 1)
# Apply continuation frames first (same as standard output)
for i, cont_frame in enumerate(cont_frames_list):
if i < T:
svi_keyframe_segments[i] = cont_frame
# Divide remaining timeline into equal segments for each keyframe (image2+)
if N > 0:
cont_end = num_cont_applied # Where continuation frames end
remaining_frames = T - cont_end
if remaining_frames > 0:
# Calculate segment boundaries
segment_size = remaining_frames / N
for idx, img_tensor in enumerate(used_timeline_imgs):
img = img_tensor[0] # [H, W, C]
# Calculate this keyframe's segment range
start_pos = cont_end + int(idx * segment_size)
end_pos = cont_end + int((idx + 1) * segment_size)
# Last segment extends to end
if idx == N - 1:
end_pos = T
# Fill this segment with the keyframe
for pos in range(start_pos, end_pos):
if 0 <= pos < T:
svi_keyframe_segments[pos] = img
# Build keyframes-only batch [N+1, H, W, C] (image1 + timeline images)
keyframes_list = [img1_prepared] + used_timeline_imgs
keyframes_batch = torch.cat(keyframes_list, dim=0)
return images_out, masks_out, keyframes_batch, svi_reference_only, svi_keyframe_segments
NODE_CLASS_MAPPINGS = {
"WanKeyframeBuilder": WanKeyframeBuilder,
"WanKeyframeBuilderContinuation": WanKeyframeBuilderContinuation,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"WanKeyframeBuilder": "Wan Keyframe Builder",
"WanKeyframeBuilderContinuation": "Wan Keyframe Builder (Continuation)",
}