From f83b43efdba9517555d03a2240bdb4513dd18822 Mon Sep 17 00:00:00 2001 From: wuwukasi Date: Mon, 23 Feb 2026 23:02:57 +0800 Subject: [PATCH 01/18] feat: Support transition_video for seamless generation --- nodes.py | 83 ++++++++++++++++++++++++++++++++- nodes_sampler.py | 119 +++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 187 insertions(+), 15 deletions(-) diff --git a/nodes.py b/nodes.py index 00ad6fc5..2aada103 100644 --- a/nodes.py +++ b/nodes.py @@ -1208,6 +1208,7 @@ def INPUT_TYPES(s): "bg_images": ("IMAGE", {"tooltip": "background images"}), "mask": ("MASK", {"tooltip": "mask"}), "start_ref_image": ("IMAGE", {"tooltip": "start ref image"}), + "transition_video": ("IMAGE", {"default": None, "tooltip": "Transition video frames (32 images, encoded to 8 latent frames). Acts as hard conditioning guide for seamless connection."}), "tiled_vae": ("BOOLEAN", {"default": False, "tooltip": "Use tiled VAE encoding for reduced memory use"}), } } @@ -1218,7 +1219,8 @@ def INPUT_TYPES(s): CATEGORY = "WanVideoWrapper" def process(self, vae, width, height, num_frames, force_offload, frame_window_size, colormatch, pose_strength, face_strength, - ref_images=None, pose_images=None, face_images=None, clip_embeds=None, tiled_vae=False, bg_images=None, mask=None, start_ref_image=None): + ref_images=None, pose_images=None, face_images=None, clip_embeds=None, tiled_vae=False, bg_images=None, mask=None, start_ref_image=None, + transition_video=None): W = (width // 16) * 16 H = (height // 16) * 16 @@ -1229,6 +1231,26 @@ def process(self, vae, width, height, num_frames, force_offload, frame_window_si num_refs = ref_images.shape[0] if ref_images is not None else 0 num_frames = ((num_frames - 1) // 4) * 4 + 1 + if transition_video is not None: + + # --- [Core Mod] Reserve space for insertion logic and shift subsequent actions --- + # 1. Expand canvas: Add space for 32 pixel frames (corresponding to 8 Latent frames). + num_frames += 32 + + # 2. Shift signals: Pad all user-provided control signals at the beginning by repeating frame 0 for 32 frames. + # This accurately shifts the original 1st frame's action to the 33rd frame (Latent's 9th frame). + if pose_images is not None: + pose_images = torch.cat([pose_images[0:1].repeat(32, 1, 1, 1), pose_images], dim=0) + if face_images is not None: + face_images = torch.cat([face_images[0:1].repeat(32, 1, 1, 1), face_images], dim=0) + if bg_images is not None: + bg_images = torch.cat([bg_images[0:1].repeat(32, 1, 1, 1), bg_images], dim=0) + if mask is not None: + mask = torch.cat([mask[0:1].repeat(32, 1, 1), mask], dim=0) + # ---------------------------------------------------- + + if start_ref_image is not None: + log.warning("Both transition_video and start_ref_image provided. Using transition_video only (loop disabled).") looping = num_frames > frame_window_size or start_ref_image is not None if num_frames < frame_window_size: @@ -1334,6 +1356,63 @@ def process(self, vae, width, height, num_frames, force_offload, frame_window_si resized_start_ref_image = start_ref_image.permute(3, 0, 1, 2) # C, T, H, W resized_start_ref_image = resized_start_ref_image[:3] * 2 - 1 + # ============ Transition video processing ============ + transition_latent = None + transition_mask_values = None + + if transition_video is not None: + # transition_video input: 32 images [B, H, W, C] + # Expecting B=32, which encodes to 8 latent frames + b, h, w, c = transition_video.shape + log.info(f"Transition video input: {b} frames, {h}x{w}") + + # Verify frame count to ensure it is exactly 32 frames + expected_input_frames = 32 + if b != expected_input_frames: + log.warning(f"Transition video has {b} frames, expected {expected_input_frames}. Resizing time dimension.") + if b > expected_input_frames: + # Downsample to 32 frames + indices = torch.linspace(0, b-1, expected_input_frames).long() + transition_video = transition_video[indices] + else: + # Repeat frames to reach 32 frames + repeat_factor = math.ceil(expected_input_frames / b) + transition_video = transition_video.repeat(repeat_factor, 1, 1, 1)[:expected_input_frames] + + b = transition_video.shape[0] # It's 32 now + + # Adjust spatial dimensions to target WxH + if h != H or w != W: + transition_video = transition_video.reshape(-1, h, w, c) + transition_video = common_upscale(transition_video.movedim(-1, 1), W, H, "lanczos", "disabled").movedim(0, 1) + transition_video = transition_video.reshape(b, c, H, W).permute(0, 2, 3, 1) # [B, H, W, C] + + # Normalize to [-1, 1] + transition_video = transition_video.permute(3, 0, 1, 2) # [C, T, H, W] + transition_video = transition_video[:3] * 2 - 1 # Keep only RGB channels + + + # VAE Encoding (32 pixel frames -> 8 latent frames) + vae.to(device) + transition_latent = vae.encode([transition_video.to(device, vae.dtype)], device, tiled=tiled_vae)[0] + log.info(f"Transition latent encoded: {transition_latent.shape[1] if len(transition_latent.shape) > 1 else transition_latent.shape[0]} frames, shape {transition_latent.shape}") + transition_len = transition_latent.shape[1] # It should be 8 + log.info(f"Transition latent encoded: {transition_len} frames, shape {transition_latent.shape}") + + # ============ Generate Mask values ============ + # Force mask to all 1s, making it act purely as a hard conditioning guide. + # The model will strictly follow these frames without altering them. + transition_mask_values = torch.ones(transition_len) + + log.info("Transition mask: forced to all 1s for hard conditioning.") + log.info(f"Mask values: {transition_mask_values.tolist()}") + # ========================================== + + if force_offload: + transition_latent = transition_latent.to(offload_device) + transition_mask_values = transition_mask_values.to(offload_device) + # ================================================ + seq_len = math.ceil((target_shape[2] * target_shape[3]) / 4 * target_shape[1]) if force_offload: @@ -1353,6 +1432,8 @@ def process(self, vae, width, height, num_frames, force_offload, frame_window_si "ref_latent": ref_latent, "ref_image": resized_ref_images if ref_images is not None else None, "start_ref_image": resized_start_ref_image if start_ref_image is not None else None, + "transition_latent": transition_latent, + "transition_mask_values": transition_mask_values, "face_pixels": resized_face_images if face_images is not None else None, "num_frames": num_frames, "target_shape": target_shape, diff --git a/nodes_sampler.py b/nodes_sampler.py index dfe17ade..606fdb13 100644 --- a/nodes_sampler.py +++ b/nodes_sampler.py @@ -476,6 +476,18 @@ def process(self, model, image_embeds, shift, steps, cfg, seed, scheduler, rifle # region WanAnim inputs frame_window_size = image_embeds.get("frame_window_size", 77) wananimate_loop = image_embeds.get("looping", False) + # ============ Global transition_video read ============ + transition_latent = image_embeds.get("transition_latent", None) + transition_mask_values = image_embeds.get("transition_mask_values", None) + transition_len = transition_latent.shape[1] if transition_latent is not None else 0 + has_transition = transition_latent is not None + + + if has_transition: + log.info(f"Transition video: {transition_len} latent frames") + if transition_mask_values is not None: + log.info(f"Linear decay mask values: {transition_mask_values.tolist()}") + # ================================================================ if wananimate_loop and context_options is not None: raise Exception("context_options are not compatible or necessary with WanAnim looping, since it creates the video in a loop.") wananim_pose_latents = image_embeds.get("pose_latents", None) @@ -1851,6 +1863,10 @@ def predict_with_cfg(z, cfg_scale, positive_embeds, negative_embeds, timestep, i enhance_enabled = True #region context windowing if context_options is not None: + # ============ Get latent spatial dimensions ============ + lat_h = latent.shape[-2] + lat_w = latent.shape[-1] + # ============================================ counter = torch.zeros_like(latent_model_input, device=device) noise_pred = torch.zeros_like(latent_model_input, device=device) context_queue = list(context(idx, steps, latent_video_length, context_frames, context_stride, context_overlap)) @@ -1885,11 +1901,37 @@ def predict_with_cfg(z, cfg_scale, positive_embeds, negative_embeds, timestep, i partial_img_emb = partial_control_latents = None if image_cond is not None: partial_img_emb = image_cond[:, c].to(device) + # ============ Build msk channels ============ + window_frames = len(c) + window_msk = partial_img_emb[:4].clone() + # ======================================== + + # ============ Transition replacement (1st window only) ============ + if has_transition and c[0] == 0: + # image_cond contains [ref_latent (4 channels) + target_latent (16 channels)] + # Need to skip the first 4 ref_latent channels, replacing only the target_latent part + # image_cond structure: [image_cond_mask (4) + image_embeds (32)] + # transition_latent (16 channels) replaces the first 8 frames of image_embeds (16 channels) + partial_img_emb[4:20, 1:1+transition_len] = transition_latent.to(device, dtype=transition_latent.dtype) + log.info(f"Replaced first {transition_len} latent frames with transition_video (context_options mode)") + # ============================================================ + + + # ============ Set transition mask ============ + if has_transition and c[0] == 0: + window_trans_start = 1 + window_trans_end = 1 + transition_len + + if window_trans_start < window_trans_end: + mask_slice = transition_mask_values[0:transition_len] + window_msk[:, window_trans_start:window_trans_end] = mask_slice.view(1, -1, 1, 1) + # ================================================================ if c[0] != 0 and context_reference_latent is not None: if context_reference_latent.shape[0] == 1: #only single extra init latent new_init_image = context_reference_latent[0, :, 0].to(device) # Concatenate the first 4 channels of partial_img_emb with new_init_image to match the required shape partial_img_emb[:, 0] = torch.cat([image_cond[:4, 0].to(device), new_init_image], dim=0) + window_msk[:, 0] = image_cond[:4, 0].to(device) elif context_reference_latent.shape[0] > 1: num_extra_inits = context_reference_latent.shape[0] section_size = (latent_video_length / num_extra_inits) @@ -1898,9 +1940,11 @@ def predict_with_cfg(z, cfg_scale, positive_embeds, negative_embeds, timestep, i log.info(f"extra init image index: {extra_init_index}") new_init_image = context_reference_latent[extra_init_index, :, 0].to(device) partial_img_emb[:, 0] = torch.cat([image_cond[:4, 0].to(device), new_init_image], dim=0) + window_msk[:, 0] = image_cond[:4, 0].to(device) else: new_init_image = image_cond[:, 0].to(device) partial_img_emb[:, 0] = new_init_image + window_msk[:, 0] = image_cond[:4, 0].to(device) if control_latents is not None: partial_control_latents = control_latents[:, c] @@ -2018,11 +2062,17 @@ def predict_with_cfg(z, cfg_scale, positive_embeds, negative_embeds, timestep, i orig_model_input_frames = partial_latent_model_input.shape[1] + # ============ Replace msk placeholder channels ============ + # image_cond structure: [image_cond_mask (4) + image_embeds (32)] = 36 channels + # Replace the first 4 channels of image_cond_mask with window_msk + image_cond_in = partial_img_emb.clone() + image_cond_in[:4] = window_msk # Replace the first 4 channels + # ======================================== noise_pred_context, _, new_teacache = predict_with_cfg( partial_latent_model_input, cfg[idx], positive, text_embeds["negative_prompt_embeds"], - partial_timestep, idx, partial_img_emb, clip_fea, partial_control_latents, partial_vace_context, partial_unianim_data,partial_audio_proj, + partial_timestep, idx, image_cond_in, clip_fea, partial_control_latents, partial_vace_context, partial_unianim_data,partial_audio_proj, partial_control_camera_latents, partial_add_cond, current_teacache, context_window=c, fantasy_portrait_input=partial_fantasy_portrait_input, mtv_motion_tokens=partial_mtv_motion_tokens, s2v_audio_input=partial_s2v_audio_input, s2v_motion_frames=[1, 0], s2v_pose=partial_s2v_pose, humo_image_cond=humo_image_cond, humo_image_cond_neg=humo_image_cond_neg, humo_audio=humo_audio, humo_audio_neg=humo_audio_neg, @@ -2259,7 +2309,7 @@ def predict_with_cfg(z, cfg_scale, positive_embeds, negative_embeds, timestep, i noise = torch.randn(16, latent_window_size + 1, lat_h, lat_w, dtype=torch.float32, device=torch.device("cpu"), generator=seed_g).to(device) seq_len = math.ceil((noise.shape[2] * noise.shape[3]) / 4 * noise.shape[1]) - if current_ref_images is not None or bg_images is not None or ref_latent is not None: + if current_ref_images is not None or bg_images is not None or ref_latent is not None or has_transition: if offload: offload_transformer(transformer, remove_lora=False) offloaded = True @@ -2271,21 +2321,62 @@ def predict_with_cfg(z, cfg_scale, positive_embeds, negative_embeds, timestep, i if bg_images is not None: bg_image_slice = bg_images_in[:, start:end].to(device) else: - bg_image_slice = torch.zeros(3, frame_window_size-refert_num, lat_h * 8, lat_w * 8, device=device, dtype=vae.dtype) - if mask_reft_len == 0: + bg_image_slice = torch.zeros(3, frame_window_size-refert_num, lat_h * 8, lat_w * 8, device=device, dtype=vae.dtype) + if mask_reft_len == 0 and not has_transition: temporal_ref_latents = vae.encode([bg_image_slice], device,tiled=tiled_vae)[0] else: - concatenated = torch.cat([current_ref_images.to(device, dtype=vae.dtype), bg_image_slice[:, mask_reft_len:]], dim=1) - temporal_ref_latents = vae.encode([concatenated.to(device, vae.dtype)], device,tiled=tiled_vae, pbar=False)[0] - msk[:, :mask_reft_len] = 1 - - if msk.shape[1] != temporal_ref_latents.shape[1]: - if temporal_ref_latents.shape[1] < msk.shape[1]: - pad_len = msk.shape[1] - temporal_ref_latents.shape[1] - pad_tensor = temporal_ref_latents[:, -1:].repeat(1, pad_len, 1, 1) - temporal_ref_latents = torch.cat([temporal_ref_latents, pad_tensor], dim=1) + # Build concatenated image + concat_parts = [] + + # 1. Add start_ref_image (if any) + if current_ref_images is not None: + concat_parts.append(current_ref_images.to(device, dtype=vae.dtype)) + + # 2. Add the remaining part of bg_image_slice + bg_start_idx = mask_reft_len if current_ref_images is not None else 0 + if bg_image_slice.shape[1] > bg_start_idx: + concat_parts.append(bg_image_slice[:, bg_start_idx:]) + + # 3. VAE encode the image part + if len(concat_parts) > 0: + image_concat = torch.cat(concat_parts, dim=1) + temporal_ref_latents = vae.encode([image_concat.to(device, vae.dtype)], device, tiled=tiled_vae, pbar=False)[0] else: - temporal_ref_latents = temporal_ref_latents[:, :msk.shape[1]] + temporal_ref_latents = None + + # 4. Initialize mask + msk = torch.zeros(4, latent_window_size, lat_h, lat_w, device=device, dtype=dtype) + + # 4a. Hard mask for start_ref_image + if current_ref_images is not None: + msk[:, :mask_reft_len] = 1 + + # 4b. Hard mask for transition_latent (replaces first 8 target latent frames ON THE FIRST CHUNK ONLY) + if has_transition and start == 0: + if temporal_ref_latents is not None: + # ============ Mod: Replace first transition_len frames of target latent ============ + # Keep target latent after transition_len + remaining_target = temporal_ref_latents[:, transition_len:] + # transition_latent replaces the first transition_len target latent frames + temporal_ref_latents = torch.cat([transition_latent.to(device, dtype=transition_latent.dtype), remaining_target], dim=1) + log.info(f"Replaced first {transition_len} target latent frames with transition_video in Chunk 0") + # ======================================================================== + else: + # Only transition_latent, no image part + temporal_ref_latents = transition_latent.to(device, dtype=dtype) + + # Set hard mask. + transition_start = 0 + msk[:, transition_start:transition_start+transition_len] = transition_mask_values.to(device).view(1, -1, 1, 1) + + # 5. Handle shape mismatch + if temporal_ref_latents is not None and msk.shape[1] != temporal_ref_latents.shape[1]: + if temporal_ref_latents.shape[1] < msk.shape[1]: + pad_len = msk.shape[1] - temporal_ref_latents.shape[1] + pad_tensor = temporal_ref_latents[:, -1:].repeat(1, pad_len, 1, 1) + temporal_ref_latents = torch.cat([temporal_ref_latents, pad_tensor], dim=1) + else: + temporal_ref_latents = temporal_ref_latents[:, :msk.shape[1]] if ref_latent is not None: temporal_ref_latents = torch.cat([msk, temporal_ref_latents], dim=0) # 4+C T H W From 001a95bf07a6ad2427b82a36048f4a47bd83825f Mon Sep 17 00:00:00 2001 From: wuwukasi Date: Mon, 23 Feb 2026 23:12:52 +0800 Subject: [PATCH 02/18] Update nodes_sampler.py --- nodes_sampler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nodes_sampler.py b/nodes_sampler.py index 606fdb13..2918f516 100644 --- a/nodes_sampler.py +++ b/nodes_sampler.py @@ -2321,7 +2321,7 @@ def predict_with_cfg(z, cfg_scale, positive_embeds, negative_embeds, timestep, i if bg_images is not None: bg_image_slice = bg_images_in[:, start:end].to(device) else: - bg_image_slice = torch.zeros(3, frame_window_size-refert_num, lat_h * 8, lat_w * 8, device=device, dtype=vae.dtype) + bg_image_slice = torch.zeros(3, frame_window_size-refert_num, lat_h * 8, lat_w * 8, device=device, dtype=vae.dtype) if mask_reft_len == 0 and not has_transition: temporal_ref_latents = vae.encode([bg_image_slice], device,tiled=tiled_vae)[0] else: From 0e36ffc7436ad5a763c4074bd3d066ea75d7a039 Mon Sep 17 00:00:00 2001 From: wuwukasi Date: Tue, 24 Feb 2026 15:04:00 +0800 Subject: [PATCH 03/18] style: remove extra spaces --- nodes_sampler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nodes_sampler.py b/nodes_sampler.py index 2918f516..59146c80 100644 --- a/nodes_sampler.py +++ b/nodes_sampler.py @@ -2321,7 +2321,7 @@ def predict_with_cfg(z, cfg_scale, positive_embeds, negative_embeds, timestep, i if bg_images is not None: bg_image_slice = bg_images_in[:, start:end].to(device) else: - bg_image_slice = torch.zeros(3, frame_window_size-refert_num, lat_h * 8, lat_w * 8, device=device, dtype=vae.dtype) + bg_image_slice = torch.zeros(3, frame_window_size-refert_num, lat_h * 8, lat_w * 8, device=device, dtype=vae.dtype) if mask_reft_len == 0 and not has_transition: temporal_ref_latents = vae.encode([bg_image_slice], device,tiled=tiled_vae)[0] else: From ea68d754b52b75d4f07ed6e84413c2bbf01c359a Mon Sep 17 00:00:00 2001 From: wuwukasi Date: Fri, 6 Mar 2026 18:07:12 +0800 Subject: [PATCH 04/18] fix: resolve false loop trigger when using transition_video with context_options This commit addresses an issue where the `transition_video` padding conflicted with WanAnimate's built-in loop logic: - Issue: The 32-frame padding added for `transition_video` artificially increased `num_frames`, causing the condition `num_frames > frame_window_size` to evaluate to True. This falsely triggered the built-in loop mechanism, which subsequently crashed when `context_options` was also enabled. - Fix: Introduced an `effective_frames` calculation that subtracts the 32 padding frames before evaluating the loop condition. This ensures that the built-in loop is only triggered based on the actual target video length. --- nodes.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nodes.py b/nodes.py index 2aada103..5825550f 100644 --- a/nodes.py +++ b/nodes.py @@ -1251,7 +1251,8 @@ def process(self, vae, width, height, num_frames, force_offload, frame_window_si if start_ref_image is not None: log.warning("Both transition_video and start_ref_image provided. Using transition_video only (loop disabled).") - looping = num_frames > frame_window_size or start_ref_image is not None + effective_frames = num_frames - 32 if transition_video is not None else num_frames + looping = effective_frames > frame_window_size or start_ref_image is not None if num_frames < frame_window_size: frame_window_size = num_frames From 8304d86ebe7b35e04ade1f473ce25f10d27e60ec Mon Sep 17 00:00:00 2001 From: wuwukasi <152004308+wuwukaka@users.noreply.github.com> Date: Sun, 5 Apr 2026 22:34:24 +0800 Subject: [PATCH 05/18] fix(nodes_sampler): fix tensor size mismatch in WanAnimate loop mode Resolves a RuntimeError during standard WanAnimate loop generation when transition_video is disabled. The error occurred because the VAE time dimension alignment logic was incorrectly scoped inside the `else` block during the transition_video feature integration. --- nodes_sampler.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/nodes_sampler.py b/nodes_sampler.py index 59146c80..29466e7f 100644 --- a/nodes_sampler.py +++ b/nodes_sampler.py @@ -2369,15 +2369,17 @@ def predict_with_cfg(z, cfg_scale, positive_embeds, negative_embeds, timestep, i transition_start = 0 msk[:, transition_start:transition_start+transition_len] = transition_mask_values.to(device).view(1, -1, 1, 1) - # 5. Handle shape mismatch - if temporal_ref_latents is not None and msk.shape[1] != temporal_ref_latents.shape[1]: - if temporal_ref_latents.shape[1] < msk.shape[1]: - pad_len = msk.shape[1] - temporal_ref_latents.shape[1] - pad_tensor = temporal_ref_latents[:, -1:].repeat(1, pad_len, 1, 1) - temporal_ref_latents = torch.cat([temporal_ref_latents, pad_tensor], dim=1) - else: - temporal_ref_latents = temporal_ref_latents[:, :msk.shape[1]] - + # [FIX] Unified shape alignment for temporal_ref_latents and msk + # Handles VAE time downsampling offset: (T-1)//4 + 1 vs latent_window_size + # Moved outside branches to ensure execution for both transition and non-transition modes + if temporal_ref_latents is not None and msk.shape[1] != temporal_ref_latents.shape[1]: + if temporal_ref_latents.shape[1] < msk.shape[1]: + pad_len = msk.shape[1] - temporal_ref_latents.shape[1] + pad_tensor = temporal_ref_latents[:, -1:].repeat(1, pad_len, 1, 1) + temporal_ref_latents = torch.cat([temporal_ref_latents, pad_tensor], dim=1) + else: + temporal_ref_latents = temporal_ref_latents[:, :msk.shape[1]] + if ref_latent is not None: temporal_ref_latents = torch.cat([msk, temporal_ref_latents], dim=0) # 4+C T H W image_cond_in = torch.cat([ref_latent.to(device), temporal_ref_latents], dim=1) # 4+C T+trefs H W From 2a53fee288c2d22045f7e5ec10bce1520e52b537 Mon Sep 17 00:00:00 2001 From: wuwukasi Date: Tue, 7 Apr 2026 12:34:38 +0800 Subject: [PATCH 06/18] fix:Unify transition video encode path for mismatched resolutions Use the same BHWC -> CTHW -> RGB -> normalize -> VAE encode flow for transition_video regardless of input size. For mismatched spatial dimensions, resize in BCHW and convert back to BHWC, removing reshape-based tensor reinterpretation that could corrupt channel/frame ordering. --- nodes.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/nodes.py b/nodes.py index 5825550f..b4d85247 100644 --- a/nodes.py +++ b/nodes.py @@ -1380,13 +1380,15 @@ def process(self, vae, width, height, num_frames, force_offload, frame_window_si repeat_factor = math.ceil(expected_input_frames / b) transition_video = transition_video.repeat(repeat_factor, 1, 1, 1)[:expected_input_frames] - b = transition_video.shape[0] # It's 32 now + b, h, w, c = transition_video.shape # It should be 32 now - # Adjust spatial dimensions to target WxH + # Adjust spatial dimensions to target WxH. + # Keep the same semantic flow as the matched-size path: + # BHWC -> (optional resize in BCHW) -> BHWC -> CTHW -> normalize -> encode if h != H or w != W: - transition_video = transition_video.reshape(-1, h, w, c) - transition_video = common_upscale(transition_video.movedim(-1, 1), W, H, "lanczos", "disabled").movedim(0, 1) - transition_video = transition_video.reshape(b, c, H, W).permute(0, 2, 3, 1) # [B, H, W, C] + transition_video = common_upscale( + transition_video.movedim(-1, 1), W, H, "lanczos", "disabled" + ).movedim(1, -1) # Normalize to [-1, 1] transition_video = transition_video.permute(3, 0, 1, 2) # [C, T, H, W] From a328440467e656bdb398860171c476324dfc9e73 Mon Sep 17 00:00:00 2001 From: wuwukasi Date: Tue, 7 Apr 2026 12:39:36 +0800 Subject: [PATCH 07/18] Update nodes_sampler.py --- nodes_sampler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nodes_sampler.py b/nodes_sampler.py index 29466e7f..24124352 100644 --- a/nodes_sampler.py +++ b/nodes_sampler.py @@ -2379,7 +2379,7 @@ def predict_with_cfg(z, cfg_scale, positive_embeds, negative_embeds, timestep, i temporal_ref_latents = torch.cat([temporal_ref_latents, pad_tensor], dim=1) else: temporal_ref_latents = temporal_ref_latents[:, :msk.shape[1]] - + if ref_latent is not None: temporal_ref_latents = torch.cat([msk, temporal_ref_latents], dim=0) # 4+C T H W image_cond_in = torch.cat([ref_latent.to(device), temporal_ref_latents], dim=1) # 4+C T+trefs H W From 28ee09942313b2b92a93cf67d940db24d4baba74 Mon Sep 17 00:00:00 2001 From: wuwukasi Date: Sun, 12 Apr 2026 22:05:28 +0800 Subject: [PATCH 08/18] fix(sampler): handle missing image_cond in context-window path In the context-window sampling branch, partial_img_emb can be None when no image condition is provided (for example, some SCAIL runs). The previous code unconditionally called partial_img_emb.clone(), causing: AttributeError: 'NoneType' object has no attribute 'clone' This change makes image conditioning optional in that path: - build image_cond_in only when partial_img_emb is available - otherwise pass None through to predict_with_cfg This restores safe behavior for context-window sampling without image conditioning and prevents the runtime crash. --- nodes_sampler.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/nodes_sampler.py b/nodes_sampler.py index 24124352..2807c54f 100644 --- a/nodes_sampler.py +++ b/nodes_sampler.py @@ -2063,10 +2063,15 @@ def predict_with_cfg(z, cfg_scale, positive_embeds, negative_embeds, timestep, i orig_model_input_frames = partial_latent_model_input.shape[1] # ============ Replace msk placeholder channels ============ - # image_cond structure: [image_cond_mask (4) + image_embeds (32)] = 36 channels - # Replace the first 4 channels of image_cond_mask with window_msk - image_cond_in = partial_img_emb.clone() - image_cond_in[:4] = window_msk # Replace the first 4 channels + # image_cond is optional in this path. Only build image_cond_in + # when partial_img_emb is available. + if partial_img_emb is not None: + # image_cond structure: [image_cond_mask (4) + image_embeds (32)] = 36 channels + # Replace the first 4 channels of image_cond_mask with window_msk + image_cond_in = partial_img_emb.clone() + image_cond_in[:4] = window_msk # Replace the first 4 channels + else: + image_cond_in = None # ======================================== noise_pred_context, _, new_teacache = predict_with_cfg( partial_latent_model_input, From e041a43cc4c2398279566cc12c3de22ff114bffc Mon Sep 17 00:00:00 2001 From: wuwukasi Date: Tue, 19 May 2026 00:51:52 +0800 Subject: [PATCH 09/18] feat: add prefix frames with canvas-expansion approach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Embed prefix images into bg canvas at encode time (like transition_video), replacing per-window injection during sampling - Canvas expands by 37 frames (17 prefix + 20 reserve for optional transition) - Dynamic 1-5 image support: img0(×1), img1(×4), ..., imgN-1(×4) - Prefix mask=1 for actual frames, unused reserve stays mask=0 - Compatible with transition_video: when both present, transition also embedded in canvas (last 20 frames of 37 expansion), sampler injection skipped - Non-first context windows prepend indices 0-5 (ref+prefix) to model input for consistency, predictions sliced off after model call - VAE decode trims first 37 pixel frames when prefix is active - Fix latent_window_size for non-looping to cover full expanded bg range --- nodes.py | 94 +++++++++++++++++++++++++++++++++++++++++++++--- nodes_sampler.py | 93 ++++++++++++++++++++++++++++++++--------------- 2 files changed, 154 insertions(+), 33 deletions(-) diff --git a/nodes.py b/nodes.py index b4d85247..084039a3 100644 --- a/nodes.py +++ b/nodes.py @@ -1209,6 +1209,7 @@ def INPUT_TYPES(s): "mask": ("MASK", {"tooltip": "mask"}), "start_ref_image": ("IMAGE", {"tooltip": "start ref image"}), "transition_video": ("IMAGE", {"default": None, "tooltip": "Transition video frames (32 images, encoded to 8 latent frames). Acts as hard conditioning guide for seamless connection."}), + "prefix_frames": ("IMAGE", {"default": None, "tooltip": "3 reference images. Expands canvas by 17 pixel frames, encoded together with bg frames. Image 0 ×5, image 1 ×4, image 2 ×4, image 0 ×4. Shifts pose/face by 17 frames."}), "tiled_vae": ("BOOLEAN", {"default": False, "tooltip": "Use tiled VAE encoding for reduced memory use"}), } } @@ -1220,7 +1221,7 @@ def INPUT_TYPES(s): def process(self, vae, width, height, num_frames, force_offload, frame_window_size, colormatch, pose_strength, face_strength, ref_images=None, pose_images=None, face_images=None, clip_embeds=None, tiled_vae=False, bg_images=None, mask=None, start_ref_image=None, - transition_video=None): + transition_video=None, prefix_frames=None): W = (width // 16) * 16 H = (height // 16) * 16 @@ -1231,7 +1232,7 @@ def process(self, vae, width, height, num_frames, force_offload, frame_window_si num_refs = ref_images.shape[0] if ref_images is not None else 0 num_frames = ((num_frames - 1) // 4) * 4 + 1 - if transition_video is not None: + if transition_video is not None and prefix_frames is None: # --- [Core Mod] Reserve space for insertion logic and shift subsequent actions --- # 1. Expand canvas: Add space for 32 pixel frames (corresponding to 8 Latent frames). @@ -1251,7 +1252,32 @@ def process(self, vae, width, height, num_frames, force_offload, frame_window_si if start_ref_image is not None: log.warning("Both transition_video and start_ref_image provided. Using transition_video only (loop disabled).") - effective_frames = num_frames - 32 if transition_video is not None else num_frames + # ============ Prefix frames: expand canvas and shift control signals ============ + if prefix_frames is not None: + # Expand canvas: always 37 = 17 prefix + 20 reserve (for optional transition) + extra = 37 + num_frames += extra + # Trim 1-3 frames from end to keep num_frames % 4 == 1 (required by repeat_interleave + view) + trim = (num_frames - 1) % 4 + num_frames -= trim + + # Shift control signals by extra pixel frames (pad beginning with first frame) + if pose_images is not None: + pose_images = torch.cat([pose_images[0:1].repeat(extra, 1, 1, 1), pose_images], dim=0) + if face_images is not None: + face_images = torch.cat([face_images[0:1].repeat(extra, 1, 1, 1), face_images], dim=0) + if bg_images is not None: + bg_images = torch.cat([bg_images[0:1].repeat(extra, 1, 1, 1), bg_images], dim=0) + if mask is not None: + mask = torch.cat([mask[0:1].repeat(extra, 1, 1), mask], dim=0) + # ----------------------------------------------------------------- + + if prefix_frames is not None: + effective_frames = num_frames - 37 + elif transition_video is not None: + effective_frames = num_frames - 32 + else: + effective_frames = num_frames looping = effective_frames > frame_window_size or start_ref_image is not None if num_frames < frame_window_size: @@ -1262,6 +1288,9 @@ def process(self, vae, width, height, num_frames, force_offload, frame_window_si if not looping: num_frames = num_frames + num_refs * 4 + # latent_window_size must cover the full bg latent range (including prefix/transition expansion), + # otherwise context windows that reach past the original frame count will clamp pose indices + latent_window_size = target_shape[1] - num_refs else: latent_window_size = latent_window_size + 1 @@ -1298,9 +1327,52 @@ def process(self, vae, width, height, num_frames, force_offload, frame_window_si resized_bg_images = bg_images.permute(3, 0, 1, 2) # C, T, H, W resized_bg_images = (resized_bg_images[:3] * 2 - 1) + actual_prefix_px = 0 if not looping: if bg_images is None: resized_bg_images = torch.zeros(3, num_frames - num_refs, H, W, device=device, dtype=vae.dtype) + + # ============ Prefix: replace first N pixel frames of canvas (dynamic 1-5 images) ============ + if prefix_frames is not None: + pf = prefix_frames + b_pf, h_pf, w_pf, c_pf = pf.shape + log.info(f"Prefix frames input: {b_pf} frames, {h_pf}x{w_pf}") + + # Limit to max 5 images + if b_pf > 5: + log.warning(f"Prefix has {b_pf} images, max 5. Truncating.") + pf = pf[:5] + b_pf = 5 + + # Build frame sequence: img0(×1), img1(×4), ..., imgN-1(×4) + pf_frames = pf[0:1] # img0, 1 copy + for i in range(1, b_pf): + pf_frames = torch.cat([pf_frames, pf[i:i+1].repeat(4, 1, 1, 1)], dim=0) + actual_prefix_px = pf_frames.shape[0] + log.info(f"Prefix: {b_pf} images -> {actual_prefix_px} pixel frames") + + if h_pf != H or w_pf != W: + pf_frames = common_upscale(pf_frames.movedim(-1, 1), W, H, "lanczos", "disabled").movedim(1, -1) + pf_frames = pf_frames.permute(3, 0, 1, 2)[:3] * 2 - 1 # [C, actual_prefix_px, H, W] + resized_bg_images[:, :actual_prefix_px] = pf_frames.to(device, dtype=resized_bg_images.dtype) + del pf, pf_frames + log.info(f"Prefix: replaced first {actual_prefix_px} pixel frames of black canvas") + + # If transition_video also present, embed last 20 frames into canvas positions 17-37 + if transition_video is not None: + tv = transition_video # [B, H, W, C] + b_tv = tv.shape[0] + if b_tv >= 20: + tv = tv[-20:] + else: + tv = torch.cat([tv[0:1].repeat(20 - b_tv, 1, 1, 1), tv], dim=0) + if tv.shape[1] != H or tv.shape[2] != W: + tv = common_upscale(tv.movedim(-1, 1), W, H, "lanczos", "disabled").movedim(1, -1) + tv = tv.permute(3, 0, 1, 2)[:3] * 2 - 1 # [C, 20, H, W] + resized_bg_images[:, 17:37] = tv.to(device, dtype=resized_bg_images.dtype) + log.info("Prefix+Transition: embedded last 20 transition frames into canvas positions 17-37") + # ========================================================================== + bg_latents = vae.encode([resized_bg_images.to(device, vae.dtype)], device,tiled=tiled_vae)[0].to(offload_device) del resized_bg_images elif bg_images is not None: @@ -1326,7 +1398,14 @@ def process(self, vae, width, height, num_frames, force_offload, frame_window_si bg_mask = torch.cat([bg_mask, bg_mask[-1:].repeat(num_frames - bg_mask.shape[0], 1, 1)], dim=0) bg_mask = common_upscale(bg_mask.unsqueeze(1), lat_w, lat_h, "nearest", "disabled").squeeze(1) bg_mask = bg_mask.unsqueeze(-1).permute(3, 0, 1, 2).to(offload_device, vae.dtype) # C, T, H, W - + + # ============ Prefix: set mask=1 for actual prefix frames and optionally transition ============ + if prefix_frames is not None: + bg_mask[:, :actual_prefix_px] = 1.0 # only actual prefix pixel frames + if transition_video is not None: + bg_mask[:, 17:37] = 1.0 + # ====================================================================================== + if bg_images is None and looping: bg_mask[:, :num_refs] = 1 bg_mask_mask_repeated = torch.repeat_interleave(bg_mask[:, 0:1], repeats=4, dim=1) # T, C, H, W @@ -1437,6 +1516,7 @@ def process(self, vae, width, height, num_frames, force_offload, frame_window_si "start_ref_image": resized_start_ref_image if start_ref_image is not None else None, "transition_latent": transition_latent, "transition_mask_values": transition_mask_values, + "has_prefix": prefix_frames is not None, "face_pixels": resized_face_images if face_images is not None else None, "num_frames": num_frames, "target_shape": target_shape, @@ -2208,6 +2288,7 @@ def decode(self, vae, samples, enable_vae_tiling, tile_x, tile_y, tile_stride_x, latents = samples["samples"].clone() end_image = samples.get("end_image", None) has_ref = samples.get("has_ref", False) + has_prefix = samples.get("has_prefix", False) drop_last = samples.get("drop_last", False) is_looped = samples.get("looped", False) @@ -2249,9 +2330,12 @@ def decode(self, vae, samples, enable_vae_tiling, tile_x, tile_y, tile_stride_x, temp_images = (temp_images - temp_images.min()) / (temp_images.max() - temp_images.min()) images = torch.cat([temp_images[:, 9:].to(images), images[:, 5:]], dim=1) - if end_image is not None: + if end_image is not None: images = images[:, 0:-1] + if has_prefix and not is_looped: + images = images[:, 37:] + vae.to(offload_device) mm.soft_empty_cache() diff --git a/nodes_sampler.py b/nodes_sampler.py index dd90a489..4b8f6ffd 100644 --- a/nodes_sampler.py +++ b/nodes_sampler.py @@ -488,6 +488,9 @@ def process(self, model, image_embeds, shift, steps, cfg, seed, scheduler, rifle if transition_mask_values is not None: log.info(f"Linear decay mask values: {transition_mask_values.tolist()}") # ================================================================ + has_prefix = image_embeds.get("has_prefix", False) + if has_prefix: + log.info("Prefix frames: detected, will prepend indices 0-5 to each window (except first)") if wananimate_loop and context_options is not None: raise Exception("context_options are not compatible or necessary with WanAnim looping, since it creates the video in a loop.") wananim_pose_latents = image_embeds.get("pose_latents", None) @@ -640,6 +643,7 @@ def process(self, model, image_embeds, shift, steps, cfg, seed, scheduler, rifle seq_len = (context_frames * 2 + 1 + mocha_num_refs) * (noise.shape[2] * noise.shape[3] // 4) else: seq_len = math.ceil((noise.shape[2] * noise.shape[3]) / 4 * context_frames) + base_patches_per_frame = math.ceil((noise.shape[2] * noise.shape[3]) / 4) log.info(f"context window seq len: {seq_len}") if context_options["freenoise"]: @@ -1905,46 +1909,51 @@ def predict_with_cfg(z, cfg_scale, positive_embeds, negative_embeds, timestep, i window_frames = len(c) window_msk = partial_img_emb[:4].clone() # ======================================== - + # ============ Transition replacement (1st window only) ============ - if has_transition and c[0] == 0: - # image_cond contains [ref_latent (4 channels) + target_latent (16 channels)] - # Need to skip the first 4 ref_latent channels, replacing only the target_latent part - # image_cond structure: [image_cond_mask (4) + image_embeds (32)] - # transition_latent (16 channels) replaces the first 8 frames of image_embeds (16 channels) + if has_transition and c[0] == 0 and not has_prefix: partial_img_emb[4:20, 1:1+transition_len] = transition_latent.to(device, dtype=transition_latent.dtype) log.info(f"Replaced first {transition_len} latent frames with transition_video (context_options mode)") # ============================================================ - # ============ Set transition mask ============ - if has_transition and c[0] == 0: + if has_transition and c[0] == 0 and not has_prefix: window_trans_start = 1 window_trans_end = 1 + transition_len - if window_trans_start < window_trans_end: mask_slice = transition_mask_values[0:transition_len] window_msk[:, window_trans_start:window_trans_end] = mask_slice.view(1, -1, 1, 1) # ================================================================ - if c[0] != 0 and context_reference_latent is not None: - if context_reference_latent.shape[0] == 1: #only single extra init latent - new_init_image = context_reference_latent[0, :, 0].to(device) - # Concatenate the first 4 channels of partial_img_emb with new_init_image to match the required shape - partial_img_emb[:, 0] = torch.cat([image_cond[:4, 0].to(device), new_init_image], dim=0) - window_msk[:, 0] = image_cond[:4, 0].to(device) - elif context_reference_latent.shape[0] > 1: - num_extra_inits = context_reference_latent.shape[0] - section_size = (latent_video_length / num_extra_inits) - extra_init_index = min(int(max(c) / section_size), num_extra_inits - 1) - if context_options["verbose"]: - log.info(f"extra init image index: {extra_init_index}") - new_init_image = context_reference_latent[extra_init_index, :, 0].to(device) - partial_img_emb[:, 0] = torch.cat([image_cond[:4, 0].to(device), new_init_image], dim=0) + + # ============ Prefix: prepend indices 0-5 to non-first windows ============ + if has_prefix and c[0] != 0: + prefix_ctx = image_cond[:, :6].to(device, dtype) + partial_img_emb = torch.cat([prefix_ctx, partial_img_emb], dim=1) + prefix_msk = image_cond[:4, :6].to(device, dtype) + window_msk = torch.cat([prefix_msk, window_msk], dim=1) + # ====================================================================== + + # ============ ref_latent replacement (skip when prefix active) ============ + if not has_prefix: + if c[0] != 0 and context_reference_latent is not None: + if context_reference_latent.shape[0] == 1: + new_init_image = context_reference_latent[0, :, 0].to(device) + partial_img_emb[:, 0] = torch.cat([image_cond[:4, 0].to(device), new_init_image], dim=0) + window_msk[:, 0] = image_cond[:4, 0].to(device) + elif context_reference_latent.shape[0] > 1: + num_extra_inits = context_reference_latent.shape[0] + section_size = (latent_video_length / num_extra_inits) + extra_init_index = min(int(max(c) / section_size), num_extra_inits - 1) + if context_options["verbose"]: + log.info(f"extra init image index: {extra_init_index}") + new_init_image = context_reference_latent[extra_init_index, :, 0].to(device) + partial_img_emb[:, 0] = torch.cat([image_cond[:4, 0].to(device), new_init_image], dim=0) + window_msk[:, 0] = image_cond[:4, 0].to(device) + else: + new_init_image = image_cond[:, 0].to(device) + partial_img_emb[:, 0] = new_init_image window_msk[:, 0] = image_cond[:4, 0].to(device) - else: - new_init_image = image_cond[:, 0].to(device) - partial_img_emb[:, 0] = new_init_image - window_msk[:, 0] = image_cond[:4, 0].to(device) + # ========================================================================== if control_latents is not None: partial_control_latents = control_latents[:, c] @@ -1995,6 +2004,12 @@ def predict_with_cfg(z, cfg_scale, positive_embeds, negative_embeds, timestep, i if latents_to_insert is not None and c[0] != 0: partial_latent_model_input[:, :1] = latents_to_insert + # ============ Prefix: prepend noise for indices 0-5 ============ + if has_prefix and c[0] != 0: + prefix_noise = latent_model_input[:, :6].to(device) + partial_latent_model_input = torch.cat([prefix_noise, partial_latent_model_input], dim=1) + # ============================================================= + partial_unianim_data = None if unianim_data is not None: partial_dwpose = dwpose_data[:, :, c] @@ -2043,7 +2058,18 @@ def predict_with_cfg(z, cfg_scale, positive_embeds, negative_embeds, timestep, i end = c[-1] center_indices = torch.arange(start, end, 1) center_indices = torch.clamp(center_indices, min=0, max=wananim_pose_latents.shape[2] - 1) - partial_wananim_pose_latents = wananim_pose_latents[:, :, center_indices][:, :, :context_frames-1].to(device, dtype) + pose_limit = context_frames - 1 + partial_wananim_pose_latents = wananim_pose_latents[:, :, center_indices][:, :, :pose_limit].to(device, dtype) + + # ============ Prefix: prepend face/pose for indices 0-5 ============ + if has_prefix and c[0] != 0: + if partial_wananim_face_pixels is not None: + prefix_face = wananim_face_pixels[:, :, :24].to(device, dtype) + partial_wananim_face_pixels = torch.cat([prefix_face, partial_wananim_face_pixels], dim=2) + if partial_wananim_pose_latents is not None: + prefix_pose = wananim_pose_latents[:, :, :6].to(device, dtype) + partial_wananim_pose_latents = torch.cat([prefix_pose, partial_wananim_pose_latents], dim=2) + # ================================================================ partial_flashvsr_LQ_latent = None if LQ_images is not None: @@ -2073,6 +2099,9 @@ def predict_with_cfg(z, cfg_scale, positive_embeds, negative_embeds, timestep, i else: image_cond_in = None # ======================================== + original_seq_len = seq_len + if has_prefix and c[0] != 0: + seq_len = original_seq_len + 6 * base_patches_per_frame noise_pred_context, _, new_teacache = predict_with_cfg( partial_latent_model_input, cfg[idx], positive, @@ -2084,6 +2113,13 @@ def predict_with_cfg(z, cfg_scale, positive_embeds, negative_embeds, timestep, i wananim_face_pixels=partial_wananim_face_pixels, wananim_pose_latents=partial_wananim_pose_latents, multitalk_audio_embeds=multitalk_audio_embeds, uni3c_data=uni3c_data, flashvsr_LQ_latent=partial_flashvsr_LQ_latent) + seq_len = original_seq_len # restore seq_len + + # ============ Prefix: slice off prepended predictions ============ + if has_prefix and c[0] != 0: + noise_pred_context = noise_pred_context[:, 6:] + # ============================================================== + if cache_args is not None: self.window_tracker.cache_states[window_id] = new_teacache @@ -2728,6 +2764,7 @@ def predict_with_cfg(z, cfg_scale, positive_embeds, negative_embeds, timestep, i "looped": is_looped, "end_image": end_image if not fun_or_fl2v_model else None, "has_ref": has_ref, + "has_prefix": has_prefix, "drop_last": drop_last, "generator_state": seed_g.get_state(), "original_image": original_image.cpu() if original_image is not None else None, From 75ace7e121a8116fab9c65dc48314d89bbd57e33 Mon Sep 17 00:00:00 2001 From: wuwukasi Date: Tue, 19 May 2026 22:28:36 +0800 Subject: [PATCH 10/18] fix: sample pose/face expansion frames with reverse stride-2 instead of repeating frame 0 Replace pose_images[0:1].repeat(37) and face_images[0:1].repeat(37) with sparse temporal sampling (every 2nd frame from index 0, reversed) so the 37-frame expansion region carries a varied motion preview rather than a static copy of the first frame. --- nodes.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/nodes.py b/nodes.py index 084039a3..bedbabf5 100644 --- a/nodes.py +++ b/nodes.py @@ -1261,11 +1261,16 @@ def process(self, vae, width, height, num_frames, force_offload, frame_window_si trim = (num_frames - 1) % 4 num_frames -= trim - # Shift control signals by extra pixel frames (pad beginning with first frame) + # Pad beginning with sampled+reversed frames for pose/face (sparse temporal context), + # repeat frame 0 for bg/mask if pose_images is not None: - pose_images = torch.cat([pose_images[0:1].repeat(extra, 1, 1, 1), pose_images], dim=0) + sampled = pose_images[0:extra*2:2] # indices 0,2,4,... (extra frames) + sampled = torch.flip(sampled, [0]) # reverse order + pose_images = torch.cat([sampled, pose_images], dim=0) if face_images is not None: - face_images = torch.cat([face_images[0:1].repeat(extra, 1, 1, 1), face_images], dim=0) + sampled = face_images[0:extra*2:2] + sampled = torch.flip(sampled, [0]) + face_images = torch.cat([sampled, face_images], dim=0) if bg_images is not None: bg_images = torch.cat([bg_images[0:1].repeat(extra, 1, 1, 1), bg_images], dim=0) if mask is not None: From 7dcbcb2488d1fe02d1aa4943017465f5f4ff84ba Mon Sep 17 00:00:00 2001 From: wuwukasi Date: Tue, 19 May 2026 23:00:01 +0800 Subject: [PATCH 11/18] fix: pad uni3c render_latent with 10 leading frames when prefix is active --- nodes_sampler.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/nodes_sampler.py b/nodes_sampler.py index 4b8f6ffd..a488163d 100644 --- a/nodes_sampler.py +++ b/nodes_sampler.py @@ -854,7 +854,13 @@ def process(self, model, image_embeds, shift, steps, cfg, seed, scheduler, rifle render_latent = uni3c_embeds["render_latent"].to(device) uni3c_data = uni3c_embeds.copy() if render_latent.shape != noise.shape: - render_latent = torch.nn.functional.interpolate(render_latent, size=(noise.shape[1], noise.shape[2], noise.shape[3]), mode='trilinear', align_corners=False) + # If temporal is shorter (prefix/transition expansion), pad with first frame at beginning + if has_prefix: + pad_len = 10 # 37 pixel frames → 10 latent frames + first_frame = render_latent[:, :, :1].repeat(1, 1, pad_len, 1, 1) + render_latent = torch.cat([first_frame, render_latent], dim=2) + if render_latent.shape != noise.shape: + render_latent = torch.nn.functional.interpolate(render_latent, size=(noise.shape[1], noise.shape[2], noise.shape[3]), mode='trilinear', align_corners=False) uni3c_data["render_latent"] = render_latent # Enhance-a-video (feta) From 8456308f448f52591c949f6e2000c6b6478ca70e Mon Sep 17 00:00:00 2001 From: wuwukasi Date: Wed, 20 May 2026 23:46:37 +0800 Subject: [PATCH 12/18] fix: unify mask and bg padding to use sampled+reversed frames Replace repeat-first-frame padding with sparse stride-2 reversed sampling for mask and bg_images, matching the pose/face expansion logic. All four control signals now share consistent temporal context in the 37-frame expansion region. --- nodes.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/nodes.py b/nodes.py index bedbabf5..27daa769 100644 --- a/nodes.py +++ b/nodes.py @@ -1272,9 +1272,13 @@ def process(self, vae, width, height, num_frames, force_offload, frame_window_si sampled = torch.flip(sampled, [0]) face_images = torch.cat([sampled, face_images], dim=0) if bg_images is not None: - bg_images = torch.cat([bg_images[0:1].repeat(extra, 1, 1, 1), bg_images], dim=0) + sampled = bg_images[0:extra*2:2] + sampled = torch.flip(sampled, [0]) + bg_images = torch.cat([sampled, bg_images], dim=0) if mask is not None: - mask = torch.cat([mask[0:1].repeat(extra, 1, 1), mask], dim=0) + sampled = mask[0:extra*2:2] + sampled = torch.flip(sampled, [0]) + mask = torch.cat([sampled, mask], dim=0) # ----------------------------------------------------------------- if prefix_frames is not None: From c3bb8e981e6ccfb5936e8d86ec7b41892a8d6b5e Mon Sep 17 00:00:00 2001 From: wuwukasi Date: Fri, 22 May 2026 20:47:04 +0800 Subject: [PATCH 13/18] feat: unified canvas expansion for prefix and transition video MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add dynamic prefix frames (1-5 images, img0×1 + imgN×4 pattern) - Expand canvas by 21/37 frames at encode time (transition/prefix) - Embed prefix and transition into canvas before VAE encode, removing per-window injection logic from the sampling loop - Unify pose/face/mask/bg padding to stride-2 reversed sampling - Support looping mode: prefix_ctx (ref+prefix latent) prepended to every chunk like ref_latent; pose/face copy first frame of current chunk to fill prefix region - Dynamic decode trim via canvas_expansion_px (21/37/0) - Fix latent_window_size to cover full expanded bg range, preventing pose index clamping in non-first context windows - Pad uni3c render_latent with 10 leading first-frame copies when prefix is active - Remove independent VAE encode for transition (now embedded in canvas) --- nodes.py | 137 +++++++++++++++++++++++++++++++++-------------- nodes_sampler.py | 26 +++++++-- 2 files changed, 118 insertions(+), 45 deletions(-) diff --git a/nodes.py b/nodes.py index 27daa769..4178532c 100644 --- a/nodes.py +++ b/nodes.py @@ -1233,21 +1233,28 @@ def process(self, vae, width, height, num_frames, force_offload, frame_window_si num_frames = ((num_frames - 1) // 4) * 4 + 1 if transition_video is not None and prefix_frames is None: - + # --- [Core Mod] Reserve space for insertion logic and shift subsequent actions --- - # 1. Expand canvas: Add space for 32 pixel frames (corresponding to 8 Latent frames). - num_frames += 32 - - # 2. Shift signals: Pad all user-provided control signals at the beginning by repeating frame 0 for 32 frames. - # This accurately shifts the original 1st frame's action to the 33rd frame (Latent's 9th frame). + # 1. Expand canvas: Add space for 21 pixel frames (corresponding to 6 Latent frames). + num_frames += 21 + + # 2. Shift control signals by 21 pixel frames with sampled+reversed padding if pose_images is not None: - pose_images = torch.cat([pose_images[0:1].repeat(32, 1, 1, 1), pose_images], dim=0) + sampled = pose_images[0:42:2] # 21 frames from indices 0,2,...,40 + sampled = torch.flip(sampled, [0]) # reverse order + pose_images = torch.cat([sampled, pose_images], dim=0) if face_images is not None: - face_images = torch.cat([face_images[0:1].repeat(32, 1, 1, 1), face_images], dim=0) + sampled = face_images[0:42:2] + sampled = torch.flip(sampled, [0]) + face_images = torch.cat([sampled, face_images], dim=0) if bg_images is not None: - bg_images = torch.cat([bg_images[0:1].repeat(32, 1, 1, 1), bg_images], dim=0) + sampled = bg_images[0:42:2] + sampled = torch.flip(sampled, [0]) + bg_images = torch.cat([sampled, bg_images], dim=0) if mask is not None: - mask = torch.cat([mask[0:1].repeat(32, 1, 1), mask], dim=0) + sampled = mask[0:42:2] + sampled = torch.flip(sampled, [0]) + mask = torch.cat([sampled, mask], dim=0) # ---------------------------------------------------- if start_ref_image is not None: @@ -1284,7 +1291,7 @@ def process(self, vae, width, height, num_frames, force_offload, frame_window_si if prefix_frames is not None: effective_frames = num_frames - 37 elif transition_video is not None: - effective_frames = num_frames - 32 + effective_frames = num_frames - 21 else: effective_frames = num_frames looping = effective_frames > frame_window_size or start_ref_image is not None @@ -1337,36 +1344,33 @@ def process(self, vae, width, height, num_frames, force_offload, frame_window_si resized_bg_images = (resized_bg_images[:3] * 2 - 1) actual_prefix_px = 0 + prefix_pixel_data = None # holds [C, actual_prefix_px, H, W] normalized pixel data for reuse + if prefix_frames is not None: + pf = prefix_frames + b_pf, h_pf, w_pf, c_pf = pf.shape + log.info(f"Prefix frames input: {b_pf} frames, {h_pf}x{w_pf}") + if b_pf > 5: + log.warning(f"Prefix has {b_pf} images, max 5. Truncating.") + pf = pf[:5] + b_pf = 5 + pf_frames = pf[0:1] + for i in range(1, b_pf): + pf_frames = torch.cat([pf_frames, pf[i:i+1].repeat(4, 1, 1, 1)], dim=0) + actual_prefix_px = pf_frames.shape[0] + log.info(f"Prefix: {b_pf} images -> {actual_prefix_px} pixel frames") + if h_pf != H or w_pf != W: + pf_frames = common_upscale(pf_frames.movedim(-1, 1), W, H, "lanczos", "disabled").movedim(1, -1) + prefix_pixel_data = pf_frames.permute(3, 0, 1, 2)[:3] * 2 - 1 # [C, actual_prefix_px, H, W] + del pf, pf_frames + if not looping: if bg_images is None: resized_bg_images = torch.zeros(3, num_frames - num_refs, H, W, device=device, dtype=vae.dtype) - # ============ Prefix: replace first N pixel frames of canvas (dynamic 1-5 images) ============ - if prefix_frames is not None: - pf = prefix_frames - b_pf, h_pf, w_pf, c_pf = pf.shape - log.info(f"Prefix frames input: {b_pf} frames, {h_pf}x{w_pf}") - - # Limit to max 5 images - if b_pf > 5: - log.warning(f"Prefix has {b_pf} images, max 5. Truncating.") - pf = pf[:5] - b_pf = 5 - - # Build frame sequence: img0(×1), img1(×4), ..., imgN-1(×4) - pf_frames = pf[0:1] # img0, 1 copy - for i in range(1, b_pf): - pf_frames = torch.cat([pf_frames, pf[i:i+1].repeat(4, 1, 1, 1)], dim=0) - actual_prefix_px = pf_frames.shape[0] - log.info(f"Prefix: {b_pf} images -> {actual_prefix_px} pixel frames") - - if h_pf != H or w_pf != W: - pf_frames = common_upscale(pf_frames.movedim(-1, 1), W, H, "lanczos", "disabled").movedim(1, -1) - pf_frames = pf_frames.permute(3, 0, 1, 2)[:3] * 2 - 1 # [C, actual_prefix_px, H, W] - resized_bg_images[:, :actual_prefix_px] = pf_frames.to(device, dtype=resized_bg_images.dtype) - del pf, pf_frames + # ============ Prefix: replace first N pixel frames of canvas ============ + if prefix_pixel_data is not None: + resized_bg_images[:, :actual_prefix_px] = prefix_pixel_data.to(device, dtype=resized_bg_images.dtype) log.info(f"Prefix: replaced first {actual_prefix_px} pixel frames of black canvas") - # If transition_video also present, embed last 20 frames into canvas positions 17-37 if transition_video is not None: tv = transition_video # [B, H, W, C] @@ -1382,10 +1386,39 @@ def process(self, vae, width, height, num_frames, force_offload, frame_window_si log.info("Prefix+Transition: embedded last 20 transition frames into canvas positions 17-37") # ========================================================================== + # ============ Transition (no prefix): embed into canvas first 21 frames ============ + if transition_video is not None and prefix_frames is None: + tv = transition_video # [B, H, W, C] + b_tv = tv.shape[0] + if b_tv >= 21: + tv = tv[-21:] + else: + tv = torch.cat([tv[0:1].repeat(21 - b_tv, 1, 1, 1), tv], dim=0) + if tv.shape[1] != H or tv.shape[2] != W: + tv = common_upscale(tv.movedim(-1, 1), W, H, "lanczos", "disabled").movedim(1, -1) + tv = tv.permute(3, 0, 1, 2)[:3] * 2 - 1 # [C, 21, H, W] + resized_bg_images[:, :21] = tv.to(device, dtype=resized_bg_images.dtype) + log.info("Transition: embedded first 21 pixel frames of black canvas") + # ========================================================================== + bg_latents = vae.encode([resized_bg_images.to(device, vae.dtype)], device,tiled=tiled_vae)[0].to(offload_device) del resized_bg_images elif bg_images is not None: resized_bg_images = resized_bg_images.to(offload_device, dtype=vae.dtype) + elif transition_video is not None or prefix_frames is not None: + # Looping mode: create canvas (transition and/or prefix handled separately via prefix_ctx) + resized_bg_images = torch.zeros(3, num_frames - num_refs, H, W, device=offload_device, dtype=vae.dtype) + if transition_video is not None: + tv = transition_video # [B, H, W, C] + b_tv = tv.shape[0] + if b_tv >= 21: + tv = tv[-21:] + else: + tv = torch.cat([tv[0:1].repeat(21 - b_tv, 1, 1, 1), tv], dim=0) + tv = tv.permute(3, 0, 1, 2)[:3] * 2 - 1 # [C, 21, H, W] + resized_bg_images[:, :21] = tv.to(offload_device, dtype=resized_bg_images.dtype) + log.info("Transition (loop): embedded first 21 pixel frames of canvas") + # Prefix NOT embedded in canvas for looping — handled via prefix_ctx prepend later if ref_images is not None: if ref_images.shape[1] != H or ref_images.shape[2] != W: @@ -1399,6 +1432,21 @@ def process(self, vae, width, height, num_frames, force_offload, frame_window_si msk[:, :num_refs] = 1 ref_latent_masked = torch.cat([msk, ref_latent], dim=0).to(offload_device) # 4+C 1 H W + # ============ Prefix: VAE encode for looping (prepended to each chunk like ref) ============ + prefix_ctx = None + prefix_T = 0 + if prefix_frames is not None and looping: + vae.to(device) + prefix_latent = vae.encode([prefix_pixel_data.to(device, vae.dtype).unsqueeze(0)], device, tiled=tiled_vae)[0] + prefix_T = prefix_latent.shape[1] + prefix_msk = torch.ones(4, prefix_T, lat_h, lat_w, device=offload_device, dtype=vae.dtype) + prefix_latent_masked = torch.cat([prefix_msk, prefix_latent.to(offload_device)], dim=0) # [20, prefix_T, ...] + prefix_ctx = torch.cat([ref_latent_masked, prefix_latent_masked], dim=1) # [20, 1+prefix_T, ...] + log.info(f"Prefix (loop): encoded {actual_prefix_px}px -> {prefix_T} latent, prefix_ctx: {prefix_ctx.shape}") + if force_offload: + vae.to(offload_device) + # =========================================================================================== + if mask is None: bg_mask = torch.zeros(1, num_frames, lat_h, lat_w, device=offload_device, dtype=vae.dtype) else: @@ -1413,6 +1461,9 @@ def process(self, vae, width, height, num_frames, force_offload, frame_window_si bg_mask[:, :actual_prefix_px] = 1.0 # only actual prefix pixel frames if transition_video is not None: bg_mask[:, 17:37] = 1.0 + # ======= Transition (no prefix): set mask=1 for first 21 pixel frames ======= + elif transition_video is not None: + bg_mask[:, :21] = 1.0 # ====================================================================================== if bg_images is None and looping: @@ -1449,7 +1500,7 @@ def process(self, vae, width, height, num_frames, force_offload, frame_window_si transition_latent = None transition_mask_values = None - if transition_video is not None: + if False: # Transition now embedded in canvas (non-looping) or bg_images (looping), no independent VAE encode needed # transition_video input: 32 images [B, H, W, C] # Expecting B=32, which encodes to 8 latent frames b, h, w, c = transition_video.shape @@ -1517,8 +1568,8 @@ def process(self, vae, width, height, num_frames, force_offload, frame_window_si "max_seq_len": seq_len, "pose_latents": pose_latents, "pose_images": resized_pose_images if pose_images is not None and looping else None, - "bg_images": resized_bg_images if bg_images is not None and looping else None, - "ref_masks": bg_mask if mask is not None and looping else None, + "bg_images": resized_bg_images if (bg_images is not None or transition_video is not None or prefix_frames is not None) and looping else None, + "ref_masks": bg_mask if (mask is not None or prefix_frames is not None) and looping else None, "is_masked": mask is not None, "ref_latent": ref_latent, "ref_image": resized_ref_images if ref_images is not None else None, @@ -1526,6 +1577,9 @@ def process(self, vae, width, height, num_frames, force_offload, frame_window_si "transition_latent": transition_latent, "transition_mask_values": transition_mask_values, "has_prefix": prefix_frames is not None, + "canvas_expansion_px": 37 if prefix_frames is not None else (21 if transition_video is not None else 0), + "prefix_ctx": prefix_ctx, + "prefix_T": prefix_T, "face_pixels": resized_face_images if face_images is not None else None, "num_frames": num_frames, "target_shape": target_shape, @@ -2298,6 +2352,7 @@ def decode(self, vae, samples, enable_vae_tiling, tile_x, tile_y, tile_stride_x, end_image = samples.get("end_image", None) has_ref = samples.get("has_ref", False) has_prefix = samples.get("has_prefix", False) + canvas_expansion_px = samples.get("canvas_expansion_px", 0) drop_last = samples.get("drop_last", False) is_looped = samples.get("looped", False) @@ -2342,8 +2397,8 @@ def decode(self, vae, samples, enable_vae_tiling, tile_x, tile_y, tile_stride_x, if end_image is not None: images = images[:, 0:-1] - if has_prefix and not is_looped: - images = images[:, 37:] + if canvas_expansion_px and not is_looped: + images = images[:, canvas_expansion_px:] vae.to(offload_device) diff --git a/nodes_sampler.py b/nodes_sampler.py index a488163d..ceae528c 100644 --- a/nodes_sampler.py +++ b/nodes_sampler.py @@ -489,6 +489,7 @@ def process(self, model, image_embeds, shift, steps, cfg, seed, scheduler, rifle log.info(f"Linear decay mask values: {transition_mask_values.tolist()}") # ================================================================ has_prefix = image_embeds.get("has_prefix", False) + canvas_expansion_px = image_embeds.get("canvas_expansion_px", 0) if has_prefix: log.info("Prefix frames: detected, will prepend indices 0-5 to each window (except first)") if wananimate_loop and context_options is not None: @@ -2302,6 +2303,8 @@ def predict_with_cfg(z, cfg_scale, positive_embeds, negative_embeds, timestep, i ref_images = image_embeds.get("ref_image", None) bg_images = image_embeds.get("bg_images", None) pose_images = image_embeds.get("pose_images", None) + prefix_ctx = image_embeds.get("prefix_ctx", None) + prefix_T = image_embeds.get("prefix_T", 0) current_ref_images = image_embeds.get("start_ref_image", None) if current_ref_images is not None: @@ -2353,10 +2356,10 @@ def predict_with_cfg(z, cfg_scale, positive_embeds, negative_embeds, timestep, i self.cache_state = [None, None] - noise = torch.randn(16, latent_window_size + 1, lat_h, lat_w, dtype=torch.float32, device=torch.device("cpu"), generator=seed_g).to(device) + noise = torch.randn(16, latent_window_size + 1 + prefix_T, lat_h, lat_w, dtype=torch.float32, device=torch.device("cpu"), generator=seed_g).to(device) seq_len = math.ceil((noise.shape[2] * noise.shape[3]) / 4 * noise.shape[1]) - if current_ref_images is not None or bg_images is not None or ref_latent is not None or has_transition: + if current_ref_images is not None or bg_images is not None or ref_latent is not None or has_transition or prefix_ctx is not None: if offload: offload_transformer(transformer, remove_lora=False) offloaded = True @@ -2427,9 +2430,10 @@ def predict_with_cfg(z, cfg_scale, positive_embeds, negative_embeds, timestep, i else: temporal_ref_latents = temporal_ref_latents[:, :msk.shape[1]] - if ref_latent is not None: + if ref_latent is not None or prefix_ctx is not None: + ref_part = prefix_ctx if prefix_ctx is not None else ref_latent temporal_ref_latents = torch.cat([msk, temporal_ref_latents], dim=0) # 4+C T H W - image_cond_in = torch.cat([ref_latent.to(device), temporal_ref_latents], dim=1) # 4+C T+trefs H W + image_cond_in = torch.cat([ref_part.to(device), temporal_ref_latents], dim=1) # 4+C T+trefs H W del temporal_ref_latents, msk, bg_image_slice else: image_cond_in = torch.cat([torch.tile(torch.zeros_like(noise[:1]), [4, 1, 1, 1]), torch.zeros_like(noise)], dim=0).to(device) @@ -2449,6 +2453,16 @@ def predict_with_cfg(z, cfg_scale, positive_embeds, negative_embeds, timestep, i elif wananim_face_pixels is not None: face_images_in = face_images[:, :, start:end].to(device, torch.float32) if face_images is not None else None + # ============ Prefix prepend for looping: copy first frame of current chunk ============ + if prefix_T > 0: + if pose_input_slice is not None: + first_pose = pose_input_slice[:, :, :1].repeat(1, 1, prefix_T, 1, 1) + pose_input_slice = torch.cat([first_pose, pose_input_slice], dim=2) + if face_images_in is not None: + first_face = face_images_in[:, :, :1].repeat(1, 1, prefix_T * 4, 1, 1) + face_images_in = torch.cat([first_face, face_images_in], dim=2) + # ======================================================================================== + if samples is not None: input_samples = samples["samples"] if input_samples is not None: @@ -2541,6 +2555,9 @@ def predict_with_cfg(z, cfg_scale, positive_embeds, negative_embeds, timestep, i timestep, i, cache_state=self.cache_state, image_cond=image_cond_in, clip_fea=clip_fea, wananim_face_pixels=face_images_in, wananim_pose_latents=pose_input_slice, uni3c_data=uni3c_data_input, ) + if prefix_T > 0: + noise_pred = noise_pred[:, prefix_T:] + latent = latent[:, prefix_T:] if callback is not None: callback_latent = (latent_model_input.to(device) - noise_pred.to(device) * t.to(device) / 1000).detach().permute(1,0,2,3) callback(step_iteration_count, callback_latent, None, estimated_iterations*(len(timesteps))) @@ -2771,6 +2788,7 @@ def predict_with_cfg(z, cfg_scale, positive_embeds, negative_embeds, timestep, i "end_image": end_image if not fun_or_fl2v_model else None, "has_ref": has_ref, "has_prefix": has_prefix, + "canvas_expansion_px": canvas_expansion_px, "drop_last": drop_last, "generator_state": seed_g.get_state(), "original_image": original_image.cpu() if original_image is not None else None, From f073b64b937654b2e505ed7bf5b2252275774cbd Mon Sep 17 00:00:00 2001 From: wuwukasi Date: Sat, 23 May 2026 11:21:55 +0800 Subject: [PATCH 14/18] fix: align num_frames to %4==1 after transition_video expansion --- nodes.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/nodes.py b/nodes.py index 4178532c..83c56e33 100644 --- a/nodes.py +++ b/nodes.py @@ -1237,6 +1237,8 @@ def process(self, vae, width, height, num_frames, force_offload, frame_window_si # --- [Core Mod] Reserve space for insertion logic and shift subsequent actions --- # 1. Expand canvas: Add space for 21 pixel frames (corresponding to 6 Latent frames). num_frames += 21 + trim = (num_frames - 1) % 4 + num_frames -= trim # 2. Shift control signals by 21 pixel frames with sampled+reversed padding if pose_images is not None: From 787be3cbb896b05dc54b42afaa8edbcfba114eae Mon Sep 17 00:00:00 2001 From: wuwukasi Date: Sat, 23 May 2026 23:05:27 +0800 Subject: [PATCH 15/18] Update nodes.py --- nodes.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nodes.py b/nodes.py index 83c56e33..942a743f 100644 --- a/nodes.py +++ b/nodes.py @@ -1210,6 +1210,7 @@ def INPUT_TYPES(s): "start_ref_image": ("IMAGE", {"tooltip": "start ref image"}), "transition_video": ("IMAGE", {"default": None, "tooltip": "Transition video frames (32 images, encoded to 8 latent frames). Acts as hard conditioning guide for seamless connection."}), "prefix_frames": ("IMAGE", {"default": None, "tooltip": "3 reference images. Expands canvas by 17 pixel frames, encoded together with bg frames. Image 0 ×5, image 1 ×4, image 2 ×4, image 0 ×4. Shifts pose/face by 17 frames."}), + "Prefix & Transition_video by wuwukasi (Bilibili)": ("BOOLEAN", {"default": True, "tooltip": "Informational only. Prefix and Transition_video improvements by wuwukasi (Bilibili)."}), "tiled_vae": ("BOOLEAN", {"default": False, "tooltip": "Use tiled VAE encoding for reduced memory use"}), } } @@ -1221,7 +1222,7 @@ def INPUT_TYPES(s): def process(self, vae, width, height, num_frames, force_offload, frame_window_size, colormatch, pose_strength, face_strength, ref_images=None, pose_images=None, face_images=None, clip_embeds=None, tiled_vae=False, bg_images=None, mask=None, start_ref_image=None, - transition_video=None, prefix_frames=None): + transition_video=None, prefix_frames=None, **kwargs): W = (width // 16) * 16 H = (height // 16) * 16 From cfe33175832530d8b39cb3eb2cc3cfbb8d106b4a Mon Sep 17 00:00:00 2001 From: wuwukasi Date: Sun, 24 May 2026 00:09:05 +0800 Subject: [PATCH 16/18] Update nodes.py --- nodes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nodes.py b/nodes.py index 942a743f..1994b284 100644 --- a/nodes.py +++ b/nodes.py @@ -1210,8 +1210,8 @@ def INPUT_TYPES(s): "start_ref_image": ("IMAGE", {"tooltip": "start ref image"}), "transition_video": ("IMAGE", {"default": None, "tooltip": "Transition video frames (32 images, encoded to 8 latent frames). Acts as hard conditioning guide for seamless connection."}), "prefix_frames": ("IMAGE", {"default": None, "tooltip": "3 reference images. Expands canvas by 17 pixel frames, encoded together with bg frames. Image 0 ×5, image 1 ×4, image 2 ×4, image 0 ×4. Shifts pose/face by 17 frames."}), - "Prefix & Transition_video by wuwukasi (Bilibili)": ("BOOLEAN", {"default": True, "tooltip": "Informational only. Prefix and Transition_video improvements by wuwukasi (Bilibili)."}), "tiled_vae": ("BOOLEAN", {"default": False, "tooltip": "Use tiled VAE encoding for reduced memory use"}), + "Prefix & Transition_video by wuwukasi (Bilibili)": ("BOOLEAN", {"default": True, "tooltip": "Informational only. Prefix and Transition_video improvements by wuwukasi (Bilibili)."}), } } From 97752f58fa2cd5a7feff7a2bb19c510866e1336a Mon Sep 17 00:00:00 2001 From: wuwukasi Date: Sun, 24 May 2026 00:10:04 +0800 Subject: [PATCH 17/18] Update nodes.py --- nodes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nodes.py b/nodes.py index 1994b284..d7364a83 100644 --- a/nodes.py +++ b/nodes.py @@ -1211,7 +1211,7 @@ def INPUT_TYPES(s): "transition_video": ("IMAGE", {"default": None, "tooltip": "Transition video frames (32 images, encoded to 8 latent frames). Acts as hard conditioning guide for seamless connection."}), "prefix_frames": ("IMAGE", {"default": None, "tooltip": "3 reference images. Expands canvas by 17 pixel frames, encoded together with bg frames. Image 0 ×5, image 1 ×4, image 2 ×4, image 0 ×4. Shifts pose/face by 17 frames."}), "tiled_vae": ("BOOLEAN", {"default": False, "tooltip": "Use tiled VAE encoding for reduced memory use"}), - "Prefix & Transition_video by wuwukasi (Bilibili)": ("BOOLEAN", {"default": True, "tooltip": "Informational only. Prefix and Transition_video improvements by wuwukasi (Bilibili)."}), + "Prefix & Transition_video by wuwukasi (BiliBili)": ("BOOLEAN", {"default": True, "tooltip": "Informational only. Prefix and Transition_video improvements by wuwukasi (Bilibili)."}), } } From 816bdeed279aa7f7a844718b93eb75f3c65266c4 Mon Sep 17 00:00:00 2001 From: wuwukasi Date: Sat, 30 May 2026 15:58:49 +0800 Subject: [PATCH 18/18] =?UTF-8?q?fix:=20three=20prefix/transition=20fixes?= =?UTF-8?q?=20=E2=80=94=20context=20window=20index=20clamp,=20looping=20VA?= =?UTF-8?q?E=20unsqueeze,=20transition=2012-frame=20adjustment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nodes.py | 23 ++++++++++++----------- nodes_sampler.py | 2 ++ 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/nodes.py b/nodes.py index d7364a83..04c46e1e 100644 --- a/nodes.py +++ b/nodes.py @@ -1,3 +1,4 @@ +# Licensed under the Apache License, Version 2.0 import os, gc, math import torch import torch.nn.functional as F @@ -1211,7 +1212,7 @@ def INPUT_TYPES(s): "transition_video": ("IMAGE", {"default": None, "tooltip": "Transition video frames (32 images, encoded to 8 latent frames). Acts as hard conditioning guide for seamless connection."}), "prefix_frames": ("IMAGE", {"default": None, "tooltip": "3 reference images. Expands canvas by 17 pixel frames, encoded together with bg frames. Image 0 ×5, image 1 ×4, image 2 ×4, image 0 ×4. Shifts pose/face by 17 frames."}), "tiled_vae": ("BOOLEAN", {"default": False, "tooltip": "Use tiled VAE encoding for reduced memory use"}), - "Prefix & Transition_video by wuwukasi (BiliBili)": ("BOOLEAN", {"default": True, "tooltip": "Informational only. Prefix and Transition_video improvements by wuwukasi (Bilibili)."}), + "Prefix & Transition Video by wuwukasi(bilibili)": ("BOOLEAN", {"default": True, "label_on": "ON", "label_off": "ON"}), } } @@ -1264,7 +1265,7 @@ def process(self, vae, width, height, num_frames, force_offload, frame_window_si log.warning("Both transition_video and start_ref_image provided. Using transition_video only (loop disabled).") # ============ Prefix frames: expand canvas and shift control signals ============ if prefix_frames is not None: - # Expand canvas: always 37 = 17 prefix + 20 reserve (for optional transition) + # Expand canvas: always 37 = 17 prefix + 12 transition + 8 reserve extra = 37 num_frames += extra # Trim 1-3 frames from end to keep num_frames % 4 == 1 (required by repeat_interleave + view) @@ -1374,19 +1375,19 @@ def process(self, vae, width, height, num_frames, force_offload, frame_window_si if prefix_pixel_data is not None: resized_bg_images[:, :actual_prefix_px] = prefix_pixel_data.to(device, dtype=resized_bg_images.dtype) log.info(f"Prefix: replaced first {actual_prefix_px} pixel frames of black canvas") - # If transition_video also present, embed last 20 frames into canvas positions 17-37 + # If transition_video also present, embed last 12 frames into canvas positions 25-37 if transition_video is not None: tv = transition_video # [B, H, W, C] b_tv = tv.shape[0] - if b_tv >= 20: - tv = tv[-20:] + if b_tv >= 12: + tv = tv[-12:] else: - tv = torch.cat([tv[0:1].repeat(20 - b_tv, 1, 1, 1), tv], dim=0) + tv = torch.cat([tv[0:1].repeat(12 - b_tv, 1, 1, 1), tv], dim=0) if tv.shape[1] != H or tv.shape[2] != W: tv = common_upscale(tv.movedim(-1, 1), W, H, "lanczos", "disabled").movedim(1, -1) - tv = tv.permute(3, 0, 1, 2)[:3] * 2 - 1 # [C, 20, H, W] - resized_bg_images[:, 17:37] = tv.to(device, dtype=resized_bg_images.dtype) - log.info("Prefix+Transition: embedded last 20 transition frames into canvas positions 17-37") + tv = tv.permute(3, 0, 1, 2)[:3] * 2 - 1 # [C, 12, H, W] + resized_bg_images[:, 25:37] = tv.to(device, dtype=resized_bg_images.dtype) + log.info("Prefix+Transition: embedded last 12 transition frames into canvas positions 25-37") # ========================================================================== # ============ Transition (no prefix): embed into canvas first 21 frames ============ @@ -1440,7 +1441,7 @@ def process(self, vae, width, height, num_frames, force_offload, frame_window_si prefix_T = 0 if prefix_frames is not None and looping: vae.to(device) - prefix_latent = vae.encode([prefix_pixel_data.to(device, vae.dtype).unsqueeze(0)], device, tiled=tiled_vae)[0] + prefix_latent = vae.encode([prefix_pixel_data.to(device, vae.dtype)], device, tiled=tiled_vae)[0] prefix_T = prefix_latent.shape[1] prefix_msk = torch.ones(4, prefix_T, lat_h, lat_w, device=offload_device, dtype=vae.dtype) prefix_latent_masked = torch.cat([prefix_msk, prefix_latent.to(offload_device)], dim=0) # [20, prefix_T, ...] @@ -1463,7 +1464,7 @@ def process(self, vae, width, height, num_frames, force_offload, frame_window_si if prefix_frames is not None: bg_mask[:, :actual_prefix_px] = 1.0 # only actual prefix pixel frames if transition_video is not None: - bg_mask[:, 17:37] = 1.0 + bg_mask[:, 25:37] = 1.0 # ======= Transition (no prefix): set mask=1 for first 21 pixel frames ======= elif transition_video is not None: bg_mask[:, :21] = 1.0 diff --git a/nodes_sampler.py b/nodes_sampler.py index ceae528c..b18270b5 100644 --- a/nodes_sampler.py +++ b/nodes_sampler.py @@ -1880,6 +1880,8 @@ def predict_with_cfg(z, cfg_scale, positive_embeds, negative_embeds, timestep, i # ============================================ counter = torch.zeros_like(latent_model_input, device=device) noise_pred = torch.zeros_like(latent_model_input, device=device) + if image_cond is not None and image_cond.ndim > 1: + latent_video_length = min(latent_video_length, image_cond.shape[1]) context_queue = list(context(idx, steps, latent_video_length, context_frames, context_stride, context_overlap)) fraction_per_context = 1.0 / len(context_queue) context_pbar = ProgressBar(steps)