import torch, warnings, glob, os, types import numpy as np from PIL import Image from einops import repeat, reduce from typing import Optional, Union from dataclasses import dataclass from modelscope import snapshot_download from einops import rearrange import numpy as np from PIL import Image from tqdm import tqdm from typing import Optional from typing_extensions import Literal from ..models import ModelManager, load_state_dict from ..models.wan_video_dit import WanModel, RMSNorm, sinusoidal_embedding_1d from ..models.wan_video_text_encoder import WanTextEncoder, T5RelativeEmbedding, T5LayerNorm from ..models.wan_video_vae import WanVideoVAE, RMS_norm, CausalConv3d, Upsample from ..models.wan_video_image_encoder import WanImageEncoder from ..models.wan_video_vace import VaceWanModel from ..models.wan_video_motion_controller import WanMotionControllerModel from ..schedulers.flow_match import FlowMatchScheduler from ..prompters import WanPrompter from ..vram_management import enable_vram_management, AutoWrappedModule, AutoWrappedLinear, WanAutoCastLayerNorm from ..lora import GeneralLoRALoader from ..models.memory.framepack_length import ( framepack_align_context_actions_to_latents, framepack_length_compress_context_latents, ) from ..models.memory.framepack_weight import apply_framepack_token_weights from ..models.memory.spatial_grid_memory import ( apply_spatial_cross_attn_readout, inject_spatial_memory, ) def pad_actions_for_condition(actions, condition_length, condition_actions=None): """Pad actions list to account for appended condition latents (VWM-style). When condition latents are concatenated along the time axis, the actions sequence must be extended by ``condition_length`` entries so that the frame count matches the total latent length (target + condition). """ if actions is None or condition_length <= 0: return actions if isinstance(actions, torch.Tensor): actions = actions.detach().cpu().tolist() elif isinstance(actions, np.ndarray): actions = actions.tolist() else: actions = list(actions) if actions and isinstance(actions[0], (list, tuple)) and actions[0] and isinstance(actions[0][0], (list, tuple)): actions = actions[0] if len(actions) == 0: return actions appended_actions = [] if condition_actions is not None: if isinstance(condition_actions, torch.Tensor): condition_actions = condition_actions.detach().cpu().tolist() elif isinstance(condition_actions, np.ndarray): condition_actions = condition_actions.tolist() else: condition_actions = list(condition_actions) if ( condition_actions and isinstance(condition_actions[0], (list, tuple)) and condition_actions[0] and isinstance(condition_actions[0][0], (list, tuple)) ): condition_actions = condition_actions[0] appended_actions = [list(action) for action in condition_actions[:condition_length]] zero_action = [0.0] * len(actions[0]) while len(appended_actions) < condition_length: appended_actions.append(zero_action[:]) return actions + appended_actions class BasePipeline(torch.nn.Module): def __init__( self, device="cuda", torch_dtype=torch.float16, height_division_factor=64, width_division_factor=64, time_division_factor=None, time_division_remainder=None, ): super().__init__() # The device and torch_dtype is used for the storage of intermediate variables, not models. self.device = device self.torch_dtype = torch_dtype # The following parameters are used for shape check. self.height_division_factor = height_division_factor self.width_division_factor = width_division_factor self.time_division_factor = time_division_factor self.time_division_remainder = time_division_remainder self.vram_management_enabled = False def to(self, *args, **kwargs): device, dtype, non_blocking, convert_to_format = torch._C._nn._parse_to(*args, **kwargs) if device is not None: self.device = device if dtype is not None: self.torch_dtype = dtype super().to(*args, **kwargs) return self def check_resize_height_width(self, height, width, num_frames=None): # Shape check if height % self.height_division_factor != 0: height = (height + self.height_division_factor - 1) // self.height_division_factor * self.height_division_factor print(f"height % {self.height_division_factor} != 0. We round it up to {height}.") if width % self.width_division_factor != 0: width = (width + self.width_division_factor - 1) // self.width_division_factor * self.width_division_factor print(f"width % {self.width_division_factor} != 0. We round it up to {width}.") if num_frames is None: return height, width else: if num_frames % self.time_division_factor != self.time_division_remainder: num_frames = (num_frames + self.time_division_factor - 1) // self.time_division_factor * self.time_division_factor + self.time_division_remainder print(f"num_frames % {self.time_division_factor} != {self.time_division_remainder}. We round it up to {num_frames}.") return height, width, num_frames def preprocess_image(self, image, torch_dtype=None, device=None, pattern="B C H W", min_value=-1, max_value=1): # Transform a PIL.Image to torch.Tensor image = torch.Tensor(np.array(image, dtype=np.float32)) image = image.to(dtype=torch_dtype or self.torch_dtype, device=device or self.device) image = image * ((max_value - min_value) / 255) + min_value image = repeat(image, f"H W C -> {pattern}", **({"B": 1} if "B" in pattern else {})) return image def preprocess_video(self, video, torch_dtype=None, device=None, pattern="B C T H W", min_value=-1, max_value=1): # Transform a list of PIL.Image to torch.Tensor video = [self.preprocess_image(image, torch_dtype=torch_dtype, device=device, min_value=min_value, max_value=max_value) for image in video] video = torch.stack(video, dim=pattern.index("T") // 2) return video def vae_output_to_image(self, vae_output, pattern="B C H W", min_value=-1, max_value=1): # Transform a torch.Tensor to PIL.Image if pattern != "H W C": vae_output = reduce(vae_output, f"{pattern} -> H W C", reduction="mean") image = ((vae_output - min_value) * (255 / (max_value - min_value))).clip(0, 255) image = image.to(device="cpu", dtype=torch.uint8) image = Image.fromarray(image.numpy()) return image def vae_output_to_video(self, vae_output, pattern="B C T H W", min_value=-1, max_value=1): # Transform a torch.Tensor to list of PIL.Image if pattern != "T H W C": vae_output = reduce(vae_output, f"{pattern} -> T H W C", reduction="mean") video = [self.vae_output_to_image(image, pattern="H W C", min_value=min_value, max_value=max_value) for image in vae_output] return video def load_models_to_device(self, model_names=[]): if self.vram_management_enabled: # offload models for name, model in self.named_children(): if name not in model_names: if hasattr(model, "vram_management_enabled") and model.vram_management_enabled: for module in model.modules(): if hasattr(module, "offload"): module.offload() else: model.cpu() torch.cuda.empty_cache() # onload models for name, model in self.named_children(): if name in model_names: if hasattr(model, "vram_management_enabled") and model.vram_management_enabled: for module in model.modules(): if hasattr(module, "onload"): module.onload() else: model.to(self.device) def generate_noise(self, shape, seed=None, rand_device="cpu", rand_torch_dtype=torch.float32, device=None, torch_dtype=None): # Initialize Gaussian noise generator = None if seed is None else torch.Generator(rand_device).manual_seed(seed) noise = torch.randn(shape, generator=generator, device=rand_device, dtype=rand_torch_dtype) noise = noise.to(dtype=torch_dtype or self.torch_dtype, device=device or self.device) return noise def enable_cpu_offload(self): warnings.warn("`enable_cpu_offload` will be deprecated. Please use `enable_vram_management`.") self.vram_management_enabled = True def get_vram(self): return torch.cuda.mem_get_info(self.device if (isinstance(self.device, torch.device) and self.device.index is not None) else 0)[1] / (1024 ** 3) def freeze_except(self, model_names): for name, model in self.named_children(): if name in model_names: model.train() model.requires_grad_(True) else: model.eval() model.requires_grad_(False) @dataclass class ModelConfig: path: Union[str, list[str]] = None model_id: str = None origin_file_pattern: Union[str, list[str]] = None download_resource: str = "ModelScope" offload_device: Optional[Union[str, torch.device]] = None offload_dtype: Optional[torch.dtype] = None skip_download: bool = False def download_if_necessary(self, local_model_path="./models", skip_download=False, use_usp=False): if self.path is None: # Check model_id and origin_file_pattern if self.model_id is None: raise ValueError(f"""No valid model files. Please use `ModelConfig(path="xxx")` or `ModelConfig(model_id="xxx/yyy", origin_file_pattern="zzz")`.""") # Skip if not in rank 0 if use_usp: import torch.distributed as dist skip_download = dist.get_rank() != 0 # Check whether the origin path is a folder if self.origin_file_pattern is None or self.origin_file_pattern == "": self.origin_file_pattern = "" allow_file_pattern = None is_folder = True elif isinstance(self.origin_file_pattern, str) and self.origin_file_pattern.endswith("/"): allow_file_pattern = self.origin_file_pattern + "*" is_folder = True else: allow_file_pattern = self.origin_file_pattern is_folder = False # Download skip_download = skip_download or self.skip_download if not skip_download: downloaded_files = glob.glob(self.origin_file_pattern, root_dir=os.path.join(local_model_path, self.model_id)) snapshot_download( self.model_id, local_dir=os.path.join(local_model_path, self.model_id), allow_file_pattern=allow_file_pattern, ignore_file_pattern=downloaded_files, local_files_only=False ) # Let rank 1, 2, ... wait for rank 0 if use_usp: import torch.distributed as dist dist.barrier(device_ids=[dist.get_rank()]) # Return downloaded files if is_folder: self.path = os.path.join(local_model_path, self.model_id, self.origin_file_pattern) else: self.path = glob.glob(os.path.join(local_model_path, self.model_id, self.origin_file_pattern)) if isinstance(self.path, list) and len(self.path) == 1: self.path = self.path[0] class WanVideoPipeline(BasePipeline): def __init__(self, device="cuda", torch_dtype=torch.bfloat16, tokenizer_path=None): super().__init__( device=device, torch_dtype=torch_dtype, height_division_factor=16, width_division_factor=16, time_division_factor=4, time_division_remainder=1 ) self.scheduler = FlowMatchScheduler(shift=5, sigma_min=0.0, extra_one_step=True) self.prompter = WanPrompter(tokenizer_path=tokenizer_path) self.text_encoder: WanTextEncoder = None self.image_encoder: WanImageEncoder = None self.dit: WanModel = None self.vae: WanVideoVAE = None self.motion_controller: WanMotionControllerModel = None self.vace: VaceWanModel = None self.in_iteration_models = ("dit", "motion_controller", "vace") self.unit_runner = PipelineUnitRunner() self.units = [ WanVideoUnit_ShapeChecker(), WanVideoUnit_NoiseInitializer(), WanVideoUnit_InputVideoEmbedder(), WanVideoUnit_PromptEmbedder(), WanVideoUnit_ImageEmbedder(), WanVideoUnit_FunControl(), WanVideoUnit_FunReference(), WanVideoUnit_FunCameraControl(), WanVideoUnit_SpeedControl(), WanVideoUnit_VACE(), WanVideoUnit_UnifiedSequenceParallel(), WanVideoUnit_TeaCache(), WanVideoUnit_CfgMerger(), ] self.model_fn = model_fn_wan_video def load_lora(self, module, path, alpha=1): loader = GeneralLoRALoader(torch_dtype=self.torch_dtype, device=self.device) lora = load_state_dict(path, torch_dtype=self.torch_dtype, device=self.device) loader.load(module, lora, alpha=alpha) def training_loss(self, **inputs): # Use len(self.scheduler.timesteps) instead of num_train_timesteps to avoid index out of bounds # The timesteps array may have fewer elements than num_train_timesteps num_timesteps = len(self.scheduler.timesteps) if hasattr(self.scheduler, 'timesteps') and self.scheduler.timesteps is not None else self.scheduler.num_train_timesteps timestep_id = torch.randint(0, num_timesteps, (1,)) timestep = self.scheduler.timesteps[timestep_id].to(dtype=self.torch_dtype, device=self.device) # Context Memory mode: handle context latents separately context_latents = inputs.get("context_latents", None) num_context_frames = inputs.get("num_context_frames", 0) training_mode = inputs.get("training_mode", "context") # "predict", "context", or "condition" # Experiment 1_2: allow context to be concatenated at the END (suffix) instead of the beginning (prefix) import os context_position = inputs.get("context_position", os.environ.get("CONTEXT_POSITION", "prefix")) if context_position not in ("prefix", "suffix"): context_position = "prefix" # Optional: Frame-level FramePack compression on context latents (K -> K') # Use pipeline attributes as the primary source (so inference/multichunk can align), # but also allow per-call override via inputs. use_framepack_len = bool( inputs.get("use_framepack_length_compress", False) or getattr(self, "use_framepack_length_compress", False) ) framepack_ratio = int( inputs.get("framepack_ratio", getattr(self, "framepack_ratio", 1)) or 1 ) framepack_strategy = str( inputs.get("framepack_length_strategy", getattr(self, "framepack_length_strategy", "distance_merge")) or "distance_merge" ).lower() framepack_recent_keep_ratio = float( inputs.get("framepack_recent_keep_ratio", getattr(self, "framepack_recent_keep_ratio", 0.5)) or 0.5 ) framepack_multiscale_w2 = float( inputs.get("framepack_multiscale_w2", getattr(self, "framepack_multiscale_w2", 0.25)) or 0.25 ) framepack_multiscale_w4 = float( inputs.get("framepack_multiscale_w4", getattr(self, "framepack_multiscale_w4", 0.15)) or 0.15 ) if context_latents is not None and num_context_frames > 0 and use_framepack_len and framepack_ratio > 1: # context_latents: (B, C, K, H, W) -> (B, C, K', H, W); non-overlapping r-frames mean pool # Pad: replicate last latent until K divisible by r; context_actions get same pad + same mean (train = infer) r = int(framepack_ratio) Kc0 = int(context_latents.shape[2]) pad0 = (r - (Kc0 % r)) % r K_after_pad = Kc0 + pad0 if inputs.get("context_actions") is not None: inputs["context_actions"] = framepack_align_context_actions_to_latents( inputs["context_actions"], K_orig_latent=Kc0, K_after_pad=K_after_pad, framepack_ratio=r, device=context_latents.device, dtype=context_latents.dtype, strategy=framepack_strategy, recent_keep_ratio=framepack_recent_keep_ratio, ) context_latents, new_k, _, _ = framepack_length_compress_context_latents( context_latents, r, strategy=framepack_strategy, recent_keep_ratio=framepack_recent_keep_ratio, multiscale_w2=framepack_multiscale_w2, multiscale_w4=framepack_multiscale_w4, ) num_context_frames = int(new_k) inputs["context_latents"] = context_latents inputs["num_context_frames"] = num_context_frames # Context Noise Augmentation: add noise to context latents during training # Experiment 7: Align training-inference noise distribution # Use fixed small noise (sigma=0.1) to match inference strategy (Zero-shot ICL standard trick) context_noise_prob = inputs.get("context_noise_prob", 0.0) # Probability of adding noise (legacy, for backward compatibility) context_noise_std = inputs.get("context_noise_std", 0.02) # Noise standard deviation (legacy) context_fixed_noise_std = inputs.get("context_fixed_noise_std", None) # Fixed noise std (Experiment 7: sigma=0.1) if context_latents is not None and num_context_frames > 0: if training_mode == "condition": # Condition mode (Experiment 17): Full video prediction with condition frames # Formula: (N-1)/4+1 where N is number of frames # - Condition: First 5 frames → 2 Latent Tokens ((5-1)/4+1 = 2) # - Target: Full 81 frames → 21 Latent Tokens ((81-1)/4+1 = 21) # - Concatenation: [Condition (2 tokens), Target (21 tokens)] = 23 tokens total # - Loss: Compute on Target (21 tokens), Condition (2 tokens) is used as condition only # input_latents shape: (B, C, 21, H, W) - 81 frames encoded as 21 latent tokens input_latents = inputs["input_latents"] noise = inputs["noise"] # Condition latents: 5 frames encoded as 2 latent tokens (clean, from context_latents parameter) # Shape: (B, C, 2, H, W) where 2 is the number of latent tokens condition_frames = context_latents # Already clean, no noise added # Get actual dimensions (in latent tokens, not frames) condition_latent_tokens = condition_frames.shape[2] if len(condition_frames.shape) > 2 else num_context_frames target_latent_tokens = input_latents.shape[2] if len(input_latents.shape) > 2 else 21 # Validate dimensions match expected values (in latent tokens) # Formula: (N-1)/4+1 where N is number of frames # Expected: 5 frames → 2 latent tokens ((5-1)/4+1 = 2) expected_condition_tokens = 2 if condition_latent_tokens != expected_condition_tokens: import warnings warnings.warn( f"Condition latent tokens mismatch: expected {expected_condition_tokens} " f"(from 5 frames), got {condition_latent_tokens}. Using actual value." ) num_context_frames = condition_latent_tokens else: num_context_frames = expected_condition_tokens # Target latents: 81 frames encoded as 21 latent tokens (will be noised) # Shape: (B, C, 21, H, W) # Formula: (81-1)/4+1 = 21 latent tokens target_latents = input_latents # 81 frames → 21 latent tokens target_noise = noise # Noise for 21 latent tokens # Ensure noise matches target_latents shape (in latent tokens) # Formula: (N-1)/4+1 where N is number of frames expected_target_tokens = 21 # (81-1)/4+1 = 21 tokens if target_noise.shape[2] != target_latent_tokens: import warnings warnings.warn( f"Noise latent tokens mismatch: expected {target_latent_tokens} " f"(from 81 frames), got {target_noise.shape[2]}. Truncating or padding noise." ) if target_noise.shape[2] > target_latent_tokens: target_noise = target_noise[:, :, :target_latent_tokens, :, :] else: # Pad noise if needed (shouldn't happen normally) padding = target_latent_tokens - target_noise.shape[2] target_noise = torch.cat([ target_noise, torch.zeros_like(target_noise[:, :, :padding, :, :]) ], dim=2) # Add noise to target latents (81 frames) noisy_target_latents = self.scheduler.add_noise(target_latents, target_noise, timestep) training_target = self.scheduler.training_target(target_latents, target_noise, timestep) # Experiment 7: Fixed noise strategy (align with inference) # If context_fixed_noise_std is set, always add fixed noise (matching inference) # Otherwise, use legacy probabilistic noise augmentation if context_fixed_noise_std is not None and context_fixed_noise_std > 0.0: # Always add fixed small noise to condition (matching inference strategy) # This ensures training-inference distribution alignment condition_noise = torch.randn_like(condition_frames) * context_fixed_noise_std condition_frames = condition_frames + condition_noise elif context_noise_prob > 0.0 and torch.rand(1).item() < context_noise_prob: # Legacy: Randomly add noise to condition latents (probabilistic augmentation) condition_noise = torch.randn_like(condition_frames) * context_noise_std condition_frames = condition_frames + condition_noise # Concatenate clean condition latents with noisy target latents # condition_frames: (B, C, 2, H, W), noisy_target_latents: (B, C, 21, H, W) # Result: (B, C, 2+21, H, W) = (B, C, 23, H, W) # Ensure dimensions match before concatenation (except in dimension 2 - latent tokens) if condition_frames.shape[:2] != noisy_target_latents.shape[:2] or \ condition_frames.shape[3:] != noisy_target_latents.shape[3:]: raise RuntimeError( f"Dimension mismatch when concatenating condition and target latents:\n" f" condition_frames shape: {condition_frames.shape}\n" f" noisy_target_latents shape: {noisy_target_latents.shape}\n" f" Expected same shape except in dimension 2 (latent tokens).\n" f" Condition tokens: {condition_latent_tokens}, Target tokens: {target_latent_tokens}" ) # Experiment 1_2: if suffix => [Target, Context], else [Context, Target] if context_position == "suffix": latents = torch.cat([noisy_target_latents, condition_frames], dim=2) else: latents = torch.cat([condition_frames, noisy_target_latents], dim=2) # Store concatenated latents and condition info for model forward inputs["latents"] = latents inputs["num_context_frames"] = num_context_frames if "actions" in inputs and inputs["actions"] is not None: inputs["actions"] = pad_actions_for_condition( inputs["actions"], num_context_frames, condition_actions=inputs.get("context_actions"), ) noise_pred = self.model_fn(**inputs, timestep=timestep) if context_position == "suffix": target_noise_pred = noise_pred[:, :, :target_latent_tokens, :, :] else: target_noise_pred = noise_pred[:, :, num_context_frames:, :, :] loss = torch.nn.functional.mse_loss(target_noise_pred.float(), training_target.float()) loss = loss * self.scheduler.training_weight(timestep) else: # training_mode == "context" (default, Experiment 16) # Context as Memory mode (Experiment 16): Inpainting mode # - Context: First K frames (4 frames, 1 Latent Token) - clean, no noise # - Target: Last 77 frames (20 Latent Tokens, num_frames % 4 == 1) - will be noised # - Concatenation: [Context (K), Target (77)] = 81 frames total (21 Latent Tokens) # - Loss: Only compute on Target (77 frames, 20 Latent Tokens), Context is masked out # input_latents shape: (B, C, 77, H, W) - target frames only (last 77 frames) input_latents = inputs["input_latents"] noise = inputs["noise"] # Context latents: First K frames (clean, from context_latents parameter) # Shape: (B, C, K, H, W) where K=4 frames context_frames = context_latents # Already clean, no noise added # Target latents: Last 77 frames (will be noised) # Shape: (B, C, 77, H, W) target_latents = input_latents # Last 77 frames target_noise = noise # Noise for 77 frames (matches num_frames % 4 == 1 requirement) # Add noise to target latents (77 frames) noisy_target_latents = self.scheduler.add_noise(target_latents, target_noise, timestep) training_target = self.scheduler.training_target(target_latents, target_noise, timestep) # Experiment 7: Fixed noise strategy (align with inference) # If context_fixed_noise_std is set, always add fixed noise (matching inference) # Otherwise, use legacy probabilistic noise augmentation if context_fixed_noise_std is not None and context_fixed_noise_std > 0.0: # Always add fixed small noise to context (matching inference strategy) # This ensures training-inference distribution alignment context_noise = torch.randn_like(context_frames) * context_fixed_noise_std context_frames = context_frames + context_noise elif context_noise_prob > 0.0 and torch.rand(1).item() < context_noise_prob: # Legacy: Randomly add noise to context latents (probabilistic augmentation) context_noise = torch.randn_like(context_frames) * context_noise_std context_frames = context_frames + context_noise # Concatenate clean context latents with noisy target latents # context_frames: (B, C, K, H, W), noisy_target_latents: (B, C, 77, H, W) # Result: (B, C, K+77, H, W) = (B, C, 81, H, W) # Debug: Check dimensions before concatenation context_frames_dim = context_frames.shape[2] if len(context_frames.shape) > 2 else 0 target_latents_dim = noisy_target_latents.shape[2] if len(noisy_target_latents.shape) > 2 else 0 # Ensure dimensions match (except in dimension 2) if context_frames.shape[:2] != noisy_target_latents.shape[:2] or \ context_frames.shape[3:] != noisy_target_latents.shape[3:]: raise RuntimeError( f"Dimension mismatch when concatenating context and target latents:\n" f" context_frames shape: {context_frames.shape}\n" f" noisy_target_latents shape: {noisy_target_latents.shape}\n" f" Expected same shape except in dimension 2 (frames).\n" f" Context frames: {context_frames_dim}, Target frames: {target_latents_dim}" ) # Experiment 1_2: if suffix => [Target, Context], else [Context, Target] if context_position == "suffix": latents = torch.cat([noisy_target_latents, context_frames], dim=2) else: latents = torch.cat([context_frames, noisy_target_latents], dim=2) # Store concatenated latents and context info for model forward inputs["latents"] = latents inputs["num_context_frames"] = num_context_frames if "actions" in inputs and inputs["actions"] is not None: inputs["actions"] = pad_actions_for_condition( inputs["actions"], num_context_frames, condition_actions=inputs.get("context_actions"), ) noise_pred = self.model_fn(**inputs, timestep=timestep) if context_position == "suffix": target_noise_pred = noise_pred[:, :, :target_latents.shape[2], :, :] else: target_noise_pred = noise_pred[:, :, num_context_frames:, :, :] loss = torch.nn.functional.mse_loss(target_noise_pred.float(), training_target.float()) loss = loss * self.scheduler.training_weight(timestep) else: # Standard mode: add noise to all latents inputs["latents"] = self.scheduler.add_noise(inputs["input_latents"], inputs["noise"], timestep) training_target = self.scheduler.training_target(inputs["input_latents"], inputs["noise"], timestep) noise_pred = self.model_fn(**inputs, timestep=timestep) loss = torch.nn.functional.mse_loss(noise_pred.float(), training_target.float()) loss = loss * self.scheduler.training_weight(timestep) return loss def enable_vram_management(self, num_persistent_param_in_dit=None, vram_limit=None, vram_buffer=0.5): self.vram_management_enabled = True if num_persistent_param_in_dit is not None: vram_limit = None else: if vram_limit is None: vram_limit = self.get_vram() vram_limit = vram_limit - vram_buffer if self.text_encoder is not None: dtype = next(iter(self.text_encoder.parameters())).dtype enable_vram_management( self.text_encoder, module_map = { torch.nn.Linear: AutoWrappedLinear, torch.nn.Embedding: AutoWrappedModule, T5RelativeEmbedding: AutoWrappedModule, T5LayerNorm: AutoWrappedModule, }, module_config = dict( offload_dtype=dtype, offload_device="cpu", onload_dtype=dtype, onload_device="cpu", computation_dtype=self.torch_dtype, computation_device=self.device, ), vram_limit=vram_limit, ) if self.dit is not None: dtype = next(iter(self.dit.parameters())).dtype device = "cpu" if vram_limit is not None else self.device enable_vram_management( self.dit, module_map = { torch.nn.Linear: AutoWrappedLinear, torch.nn.Conv3d: AutoWrappedModule, torch.nn.LayerNorm: WanAutoCastLayerNorm, RMSNorm: AutoWrappedModule, torch.nn.Conv2d: AutoWrappedModule, }, module_config = dict( offload_dtype=dtype, offload_device="cpu", onload_dtype=dtype, onload_device=device, computation_dtype=self.torch_dtype, computation_device=self.device, ), max_num_param=num_persistent_param_in_dit, overflow_module_config = dict( offload_dtype=dtype, offload_device="cpu", onload_dtype=dtype, onload_device="cpu", computation_dtype=self.torch_dtype, computation_device=self.device, ), vram_limit=vram_limit, ) if self.vae is not None: dtype = next(iter(self.vae.parameters())).dtype enable_vram_management( self.vae, module_map = { torch.nn.Linear: AutoWrappedLinear, torch.nn.Conv2d: AutoWrappedModule, RMS_norm: AutoWrappedModule, CausalConv3d: AutoWrappedModule, Upsample: AutoWrappedModule, torch.nn.SiLU: AutoWrappedModule, torch.nn.Dropout: AutoWrappedModule, }, module_config = dict( offload_dtype=dtype, offload_device="cpu", onload_dtype=dtype, onload_device=self.device, computation_dtype=self.torch_dtype, computation_device=self.device, ), ) if self.image_encoder is not None: dtype = next(iter(self.image_encoder.parameters())).dtype enable_vram_management( self.image_encoder, module_map = { torch.nn.Linear: AutoWrappedLinear, torch.nn.Conv2d: AutoWrappedModule, torch.nn.LayerNorm: AutoWrappedModule, }, module_config = dict( offload_dtype=dtype, offload_device="cpu", onload_dtype=dtype, onload_device="cpu", computation_dtype=dtype, computation_device=self.device, ), ) if self.motion_controller is not None: dtype = next(iter(self.motion_controller.parameters())).dtype enable_vram_management( self.motion_controller, module_map = { torch.nn.Linear: AutoWrappedLinear, }, module_config = dict( offload_dtype=dtype, offload_device="cpu", onload_dtype=dtype, onload_device="cpu", computation_dtype=dtype, computation_device=self.device, ), ) if self.vace is not None: device = "cpu" if vram_limit is not None else self.device enable_vram_management( self.vace, module_map = { torch.nn.Linear: AutoWrappedLinear, torch.nn.Conv3d: AutoWrappedModule, torch.nn.LayerNorm: AutoWrappedModule, RMSNorm: AutoWrappedModule, }, module_config = dict( offload_dtype=dtype, offload_device="cpu", onload_dtype=dtype, onload_device=device, computation_dtype=self.torch_dtype, computation_device=self.device, ), vram_limit=vram_limit, ) def initialize_usp(self): import torch.distributed as dist from xfuser.core.distributed import initialize_model_parallel, init_distributed_environment dist.init_process_group(backend="nccl", init_method="env://") init_distributed_environment(rank=dist.get_rank(), world_size=dist.get_world_size()) initialize_model_parallel( sequence_parallel_degree=dist.get_world_size(), ring_degree=1, ulysses_degree=dist.get_world_size(), ) torch.cuda.set_device(dist.get_rank()) def enable_usp(self): from xfuser.core.distributed import get_sequence_parallel_world_size from ..distributed.xdit_context_parallel import usp_attn_forward, usp_dit_forward for block in self.dit.blocks: block.self_attn.forward = types.MethodType(usp_attn_forward, block.self_attn) self.dit.forward = types.MethodType(usp_dit_forward, self.dit) self.sp_size = get_sequence_parallel_world_size() self.use_unified_sequence_parallel = True @staticmethod def from_pretrained( torch_dtype: torch.dtype = torch.bfloat16, device: Union[str, torch.device] = "cuda", model_configs: list[ModelConfig] = [], tokenizer_config: ModelConfig = ModelConfig(model_id="Wan-AI/Wan2.1-T2V-1.3B", origin_file_pattern="google/*"), local_model_path: str = "./models", skip_download: bool = False, redirect_common_files: bool = True, use_usp=False, ): # Redirect model path if redirect_common_files: redirect_dict = { "models_t5_umt5-xxl-enc-bf16.pth": "Wan-AI/Wan2.1-T2V-1.3B", "Wan2.1_VAE.pth": "Wan-AI/Wan2.1-T2V-1.3B", "models_clip_open-clip-xlm-roberta-large-vit-huge-14.pth": "Wan-AI/Wan2.1-I2V-14B-480P", } for model_config in model_configs: if model_config.origin_file_pattern is None or model_config.model_id is None: continue if model_config.origin_file_pattern in redirect_dict and model_config.model_id != redirect_dict[model_config.origin_file_pattern]: print(f"To avoid repeatedly downloading model files, ({model_config.model_id}, {model_config.origin_file_pattern}) is redirected to ({redirect_dict[model_config.origin_file_pattern]}, {model_config.origin_file_pattern}). You can use `redirect_common_files=False` to disable file redirection.") model_config.model_id = redirect_dict[model_config.origin_file_pattern] # Initialize pipeline pipe = WanVideoPipeline(device=device, torch_dtype=torch_dtype) if use_usp: pipe.initialize_usp() # Download and load models model_manager = ModelManager() for model_config in model_configs: model_config.download_if_necessary(local_model_path, skip_download=skip_download, use_usp=use_usp) model_manager.load_model( model_config.path, device=model_config.offload_device or device, torch_dtype=model_config.offload_dtype or torch_dtype ) # Load models pipe.text_encoder = model_manager.fetch_model("wan_video_text_encoder") pipe.dit = model_manager.fetch_model("wan_video_dit") pipe.vae = model_manager.fetch_model("wan_video_vae") pipe.image_encoder = model_manager.fetch_model("wan_video_image_encoder") pipe.motion_controller = model_manager.fetch_model("wan_video_motion_controller") pipe.vace = model_manager.fetch_model("wan_video_vace") # Initialize tokenizer if tokenizer_config is not None: tokenizer_config.download_if_necessary(local_model_path, skip_download=skip_download) pipe.prompter.fetch_models(pipe.text_encoder) pipe.prompter.fetch_tokenizer(tokenizer_config.path) else: pipe.prompter.fetch_models(pipe.text_encoder) # Unified Sequence Parallel if use_usp: pipe.enable_usp() return pipe @torch.no_grad() def __call__( self, # Prompt prompt: str, negative_prompt: Optional[str] = "", # Image-to-video input_image: Optional[Image.Image] = None, # First-last-frame-to-video end_image: Optional[Image.Image] = None, # Video-to-video input_video: Optional[list[Image.Image]] = None, denoising_strength: Optional[float] = 1.0, # ControlNet control_video: Optional[list[Image.Image]] = None, reference_image: Optional[Image.Image] = None, # Camera control camera_control_direction: Optional[Literal["Left", "Right", "Up", "Down", "LeftUp", "LeftDown", "RightUp", "RightDown"]] = None, camera_control_speed: Optional[float] = 1/54, camera_control_origin: Optional[tuple] = (0, 0.532139961, 0.946026558, 0.5, 0.5, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0), # Explicit camera trajectory control (per-frame camera params) # Each entry should be a list/tuple like CameraCtrl format (len>=19): # [frame_id, fx, fy, cx, cy, *, *, w2c(12 vals)] camera_control_poses: Optional[list] = None, # VACE vace_video: Optional[list[Image.Image]] = None, vace_video_mask: Optional[Image.Image] = None, vace_reference_image: Optional[Image.Image] = None, vace_scale: Optional[float] = 1.0, # Randomness seed: Optional[int] = None, rand_device: Optional[str] = "cpu", # Shape height: Optional[int] = 480, width: Optional[int] = 832, num_frames=81, # Classifier-free guidance cfg_scale: Optional[float] = 5.0, cfg_merge: Optional[bool] = False, # Scheduler num_inference_steps: Optional[int] = 50, sigma_shift: Optional[float] = 5.0, # Speed control motion_bucket_id: Optional[int] = None, # VAE tiling tiled: Optional[bool] = True, tile_size: Optional[tuple[int, int]] = (30, 52), tile_stride: Optional[tuple[int, int]] = (15, 26), # Sliding window sliding_window_size: Optional[int] = None, sliding_window_stride: Optional[int] = None, # Teacache tea_cache_l1_thresh: Optional[float] = None, tea_cache_model_id: Optional[str] = "", # progress_bar progress_bar_cmd=tqdm, action_path=None, # Context Memory parameters context_latents: Optional[torch.Tensor] = None, num_context_frames: int = 0, enable_context_memory: bool = False, cfg_target_only: bool = False, # Only apply CFG to target frames (not context) inference_noise_level: Optional[float] = None, # Experiment 19: Add small noise to context latents during inference for distribution alignment context_actions: Optional[torch.Tensor] = None, # RT poses for context frames [N_ctx, 12] context_position: Optional[str] = None, # "suffix" = context at end (exp1_4_2), "prefix" = context at start; default from env CONTEXT_POSITION cam_pose_actions=None, # VWM-style: list of 21 relative RT vectors (12 floats each), already latent-frame-aligned ): # Scheduler self.scheduler.set_timesteps(num_inference_steps, denoising_strength=denoising_strength, shift=sigma_shift) # Inputs inputs_posi = { "prompt": prompt, "tea_cache_l1_thresh": tea_cache_l1_thresh, "tea_cache_model_id": tea_cache_model_id, "num_inference_steps": num_inference_steps, } inputs_nega = { "negative_prompt": negative_prompt, "tea_cache_l1_thresh": tea_cache_l1_thresh, "tea_cache_model_id": tea_cache_model_id, "num_inference_steps": num_inference_steps, } inputs_shared = { "input_image": input_image, "end_image": end_image, "input_video": input_video, "denoising_strength": denoising_strength, "control_video": control_video, "reference_image": reference_image, "camera_control_direction": camera_control_direction, "camera_control_speed": camera_control_speed, "camera_control_origin": camera_control_origin, "camera_control_poses": camera_control_poses, "vace_video": vace_video, "vace_video_mask": vace_video_mask, "vace_reference_image": vace_reference_image, "vace_scale": vace_scale, "seed": seed, "rand_device": rand_device, "height": height, "width": width, "num_frames": num_frames, "cfg_scale": cfg_scale, "cfg_merge": cfg_merge, "sigma_shift": sigma_shift, "motion_bucket_id": motion_bucket_id, "tiled": tiled, "tile_size": tile_size, "tile_stride": tile_stride, "sliding_window_size": sliding_window_size, "sliding_window_stride": sliding_window_stride, } if action_path is not None: import json with open(action_path, "r") as f: data = json.load(f) action_seq = data.get("actions", data) if isinstance(action_seq, dict): sorted_items = sorted(action_seq.items(), key=lambda kv: int(kv[0]) if str(kv[0]).isdigit() else float('inf')) else: sorted_items = list(enumerate(action_seq)) first_val = sorted_items[0][1] if sorted_items else None use_rt_format = isinstance(first_val, (list, tuple)) and len(first_val) == 12 actions = [] for _, val in sorted_items: if use_rt_format: actions.append(list(val) if isinstance(val, (list, tuple)) else val) else: action_dict = val action = [0] * (2 + 2 + 3 + 1 + 2) if action_dict.get('ws') == 1: action[0] = 1 elif action_dict.get('ws') == 2: action[1] = 1 if action_dict.get('ad') == 1: action[2] = 1 elif action_dict.get('ad') == 2: action[3] = 1 if action_dict.get('scs') == 1 and action_dict.get('jump_invalid', 0) == 0: action[4] = 1 elif action_dict.get('scs') == 2: action[5] = 1 elif action_dict.get('scs') == 3: action[6] = 1 if action_dict.get("collision") == 1: action[7] = 1 action[0] = action[1] = action[2] = action[3] = 0 action[8] = action_dict.get('pre_pitch', 0) action[9] = action_dict.get('pre_yaw', 0) actions.append(action) if use_rt_format: # 12-dim RT actions: align with training (CamVideoDataset uses # pose_indices = list(range(0, num_frames, 4)) to pick one RT per # latent frame, since the Wan VAE compresses temporally by 4×). # The sampling JSON written by _build_gt_action_json contains one RT # per pixel frame (e.g. 81 entries for num_frames=81), so we must # subsample to (num_frames - 1) // 4 + 1 latent-frame entries here. # Otherwise DiTBlock_w_Action will try to reshape spatial tokens # by a frame count that does not divide them, and crash with # "shape '[1, 81, -1, 1536]' is invalid for input of size ...". _stride = 4 _n_pixel = int(num_frames) if num_frames else len(actions) _n_target_latent = (_n_pixel - 1) // _stride + 1 if _n_pixel > 0 else len(actions) if len(actions) >= _n_pixel and _n_target_latent > 0: actions = [actions[i * _stride] for i in range(_n_target_latent)] elif len(actions) > _n_target_latent: # JSON is shorter than _n_pixel but still longer than _n_target_latent. # Re-stride uniformly across what we have to land on _n_target_latent samples. step = max(1, len(actions) // _n_target_latent) actions = [actions[min(i * step, len(actions) - 1)] for i in range(_n_target_latent)] elif 0 < len(actions) < _n_target_latent: # Pad by repeating the last RT to keep the expected latent length. actions = actions + [list(actions[-1])] * (_n_target_latent - len(actions)) else: # Legacy non-RT (binary action) format: keep original truncation. actions = actions[:80] inputs_shared["actions"] = actions elif cam_pose_actions is not None: inputs_shared["actions"] = cam_pose_actions for unit in self.units: inputs_shared, inputs_posi, inputs_nega = self.unit_runner(unit, self, inputs_shared, inputs_posi, inputs_nega) # Denoise self.load_models_to_device(self.in_iteration_models) models = {name: getattr(self, name) for name in self.in_iteration_models} # Context Memory mode: prepare context latents # Concatenation (default): [target(noise), context(clean)] 与训练一致,衔接正确;Repainting 仅保留兼容 use_concatenation_inference = os.environ.get("USE_CONCATENATION_INFERENCE", "true").lower() == "true" # Experiment 1_2: optionally place context at END (suffix) instead of BEGIN (prefix); prefer explicit arg over env _context_position = (context_position or os.environ.get("CONTEXT_POSITION", "prefix")).lower() if _context_position not in ("prefix", "suffix"): _context_position = "prefix" context_position = _context_position if enable_context_memory and context_latents is not None and num_context_frames > 0: # NOTE: num_context_frames is treated as *latent token count* in this codebase. # Callers must provide context_latents already encoded by VAE (token-level). context_latents_clean = context_latents.to(dtype=self.torch_dtype, device=self.device) # (B, C, K, H, W) # Experiment 19: Add small noise to context latents during inference for distribution alignment # This aligns inference distribution with training (where context has noise) # Prevents OOD issues and breaks "perfect match" to increase dynamicity if inference_noise_level is None: # Default: No noise (0.0) unless explicitly set # For Experiment 19, training script will automatically set inference_noise_level from context_fixed_noise_std inference_noise_level = 0.0 if inference_noise_level > 0.0: # Add small Gaussian noise to context latents (only to context, not to main latents) # This noise is added before concatenation, so it's part of the condition input context_noise = torch.randn_like(context_latents_clean) * inference_noise_level context_latents_clean = context_latents_clean + context_noise # Context Memory: runtime memory baselines (must be wired for inference/multichunk) inputs_shared["use_framepack_memory"] = getattr(self, "use_framepack_memory", False) inputs_shared["context_temporal_decay"] = float(getattr(self, "context_temporal_decay", 1.0) or 1.0) inputs_shared["context_attention_weight"] = float(getattr(self, "context_attention_weight", 1.0) or 1.0) inputs_shared["use_spatial_memory"] = getattr(self, "use_spatial_memory", False) inputs_shared["spatial_memory_tokens"] = int(getattr(self, "spatial_memory_tokens", 64) or 64) inputs_shared["use_spatial_memory_legacy"] = bool(getattr(self, "use_spatial_memory_legacy", False)) inputs_shared["spatial_memory_module"] = getattr(self, "spatial_memory_module", None) inputs_shared["spatial_memory_inject_mode"] = getattr(self, "spatial_memory_inject_mode", "concat_text") inputs_shared["spatial_memory_readout_module"] = getattr(self, "spatial_memory_readout_module", None) # Frame-level FramePack length compression for inference: same as training_loss (shared helpers) use_framepack_len = bool(getattr(self, "use_framepack_length_compress", False)) framepack_ratio = int(getattr(self, "framepack_ratio", 1) or 1) framepack_strategy = str(getattr(self, "framepack_length_strategy", "distance_merge") or "distance_merge").lower() framepack_recent_keep_ratio = float(getattr(self, "framepack_recent_keep_ratio", 0.5) or 0.5) framepack_multiscale_w2 = float(getattr(self, "framepack_multiscale_w2", 0.25) or 0.25) framepack_multiscale_w4 = float(getattr(self, "framepack_multiscale_w4", 0.15) or 0.15) if use_framepack_len and framepack_ratio > 1 and context_latents_clean is not None and num_context_frames > 0: r = int(framepack_ratio) Kc0 = int(context_latents_clean.shape[2]) pad0 = (r - (Kc0 % r)) % r K_after_pad = Kc0 + pad0 if context_actions is not None: context_actions = framepack_align_context_actions_to_latents( context_actions, K_orig_latent=Kc0, K_after_pad=K_after_pad, framepack_ratio=r, device=context_latents_clean.device, dtype=context_latents_clean.dtype, strategy=framepack_strategy, recent_keep_ratio=framepack_recent_keep_ratio, ) context_latents_clean, new_k, _, _ = framepack_length_compress_context_latents( context_latents_clean, r, strategy=framepack_strategy, recent_keep_ratio=framepack_recent_keep_ratio, multiscale_w2=framepack_multiscale_w2, multiscale_w4=framepack_multiscale_w4, ) num_context_frames = int(new_k) if use_concatenation_inference: # Concatenation Strategy (Experiment 12/16): Direct input concatenation # After CM training, model can handle Clean Context + Noisy Target directly # Experiment 16: Context (4 frames) + Target (77 frames) = 81 frames total # Concatenation: [Context (4), Target (77)] = 81 frames total target_latents_init = inputs_shared["latents"] # (B, C, 77, H, W) - target frames noise # Direct concatenation: [Clean Context (4), Random Noise (77)] OR [Random Noise (77), Clean Context (4)] # Model has been trained to handle this distribution (Experiment 16: 81 frames) # Experiment 1_2 inference: if suffix => [Target(noise), Context(clean)] (context at end) if context_position == "suffix": latents_concatenated = torch.cat([target_latents_init, context_latents_clean], dim=2) else: latents_concatenated = torch.cat([context_latents_clean, target_latents_init], dim=2) inputs_shared["latents"] = latents_concatenated inputs_shared["num_context_frames"] = num_context_frames inputs_shared["context_position"] = context_position if "actions" in inputs_shared and inputs_shared["actions"] is not None: inputs_shared["actions"] = pad_actions_for_condition( inputs_shared["actions"], num_context_frames, condition_actions=context_actions, ) if getattr(self, "use_moc", False) and getattr(self, "moc_module", None) is not None: inputs_shared["moc_module"] = self.moc_module inputs_shared["use_moc"] = True else: # Repainting Strategy (Experiment 11): Initialize all latents as noise, then repaint context in loop # Store clean context latents for repainting # Initialize all latents as random noise (including context part) # Experiment 1_2: layout must match training — suffix => [target, context], prefix => [context, target] target_latents_init = inputs_shared["latents"] # (B, C, T, H, W) batch_size, channels = target_latents_init.shape[0], target_latents_init.shape[1] height, width = target_latents_init.shape[3], target_latents_init.shape[4] context_noise_init = torch.randn( batch_size, channels, num_context_frames, height, width, dtype=self.torch_dtype, device=self.device ) if context_position == "suffix": # [target, context] so that last K frames are context (chunk1 last frame etc.), first T are generated latents_full = torch.cat([target_latents_init, context_noise_init], dim=2) # (B, C, T+K, H, W) else: latents_full = torch.cat([context_noise_init, target_latents_init], dim=2) # (B, C, K+T, H, W) inputs_shared["latents"] = latents_full else: context_latents_clean = None use_concatenation_inference = False if enable_context_memory and num_context_frames > 0 and getattr(self, "use_moc", False) and getattr(self, "moc_module", None) is not None: inputs_shared["moc_module"] = self.moc_module inputs_shared["use_moc"] = True if enable_context_memory and num_context_frames > 0 and getattr(self, "use_unified_implicit", False) and getattr(self, "unified_implicit_encoder", None) is not None and getattr(self, "context_learning_injector", None) is not None: inputs_shared["use_unified_implicit"] = True inputs_shared["unified_implicit_encoder"] = self.unified_implicit_encoder inputs_shared["context_learning_injector"] = self.context_learning_injector for progress_id, timestep in enumerate(progress_bar_cmd(self.scheduler.timesteps)): timestep = timestep.unsqueeze(0).to(dtype=self.torch_dtype, device=self.device) # Context Memory: Choose inference method if enable_context_memory and context_latents_clean is not None: if use_concatenation_inference: # Concatenation Strategy (Experiment 12): No special handling needed # Context was already concatenated at input stage, just pass through inputs_shared["num_context_frames"] = num_context_frames else: # Repainting Strategy (Experiment 11): Replace context part in each step # --- DEBUG START: Latent Statistics Check (Experiment 11) --- # Log latent statistics to file for analysis (no console print to avoid log file clutter) import json import time debug_log_path = os.environ.get("CONTEXT_DEBUG_LOG", "logs/context_latent_stats.jsonl") # Get current latents (all noisy at this point, before repainting) latents_current = inputs_shared["latents"] # (B, C, K+T, H, W) if context_position == "suffix": target_latents_current = latents_current[:, :, :-num_context_frames, :, :] # (B, C, T, H, W) else: target_latents_current = latents_current[:, :, num_context_frames:, :, :] # (B, C, T, H, W) # Calculate statistics before repainting target_mean = target_latents_current.mean().item() target_std = target_latents_current.std().item() target_min = target_latents_current.min().item() target_max = target_latents_current.max().item() context_mean = context_latents_clean.mean().item() context_std = context_latents_clean.std().item() context_min = context_latents_clean.min().item() context_max = context_latents_clean.max().item() ratio = context_std / target_std if target_std > 0 else float('inf') context_mean_abs = abs(context_mean) # Log to file debug_entry = { "timestamp": time.time(), "timestep_id": progress_id, "total_timesteps": len(self.scheduler.timesteps), "target_latents": { "mean": target_mean, "std": target_std, "min": target_min, "max": target_max }, "context_latents_clean": { "mean": context_mean, "std": context_std, "min": context_min, "max": context_max }, "ratio": { "std_ratio": ratio, "mean_abs": context_mean_abs }, "warnings": [] } # Add warnings if ratio < 0.2 or ratio > 5.0: debug_entry["warnings"].append(f"Large std ratio: {ratio:.4f} (Context/Target)") if context_mean_abs > 2.0: debug_entry["warnings"].append(f"Context mean far from 0: {context_mean:.4f}") if context_std < 0.2: debug_entry["warnings"].append(f"Very small context std: {context_std:.4f}") if context_std > 5.0: debug_entry["warnings"].append(f"Very large context std: {context_std:.4f}") # Write to log file only (no console print) try: os.makedirs(os.path.dirname(debug_log_path), exist_ok=True) with open(debug_log_path, "a") as f: f.write(json.dumps(debug_entry) + "\n") except Exception as e: pass # Silently fail if logging fails # --- DEBUG END --- # Repainting: Add noise to clean context to match current timestep # Generate noise for context (can be deterministic if using same seed) noise_for_context = torch.randn_like(context_latents_clean) # Add noise to clean context using scheduler (forward diffusion) # This creates noisy context at the current timestep level context_latents_noisy = self.scheduler.add_noise( context_latents_clean, noise_for_context, timestep.unsqueeze(0) ) # Hard Replacement: Replace context part in latents # latents_current: (B, C, K+T, H, W) # Prefix: context = first K frames; Suffix: context = last K (must match concat layout) latents_repainted = latents_current.clone() if context_position == "suffix": latents_repainted[:, :, -num_context_frames:, :, :] = context_latents_noisy else: latents_repainted[:, :, :num_context_frames, :, :] = context_latents_noisy inputs_shared["latents"] = latents_repainted inputs_shared["num_context_frames"] = num_context_frames inputs_shared["context_position"] = context_position else: inputs_shared.pop("num_context_frames", None) # Remove if exists inputs_shared.pop("context_position", None) # Inference try: noise_pred_posi = self.model_fn(**models, **inputs_shared, **inputs_posi, timestep=timestep) except Exception as e: raise if cfg_scale != 1.0: if cfg_merge: noise_pred_posi, noise_pred_nega = noise_pred_posi.chunk(2, dim=0) else: noise_pred_nega = self.model_fn(**models, **inputs_shared, **inputs_nega, timestep=timestep) # CFG only for target frames (Experiment 7) if cfg_target_only and enable_context_memory and context_latents_clean is not None and num_context_frames > 0: # Split context and target parts (handle suffix mode) if context_position == "suffix": # Experiment 1_2: target is FRONT, context is BACK target_posi = noise_pred_posi[:, :, :-num_context_frames, :, :] context_posi = noise_pred_posi[:, :, -num_context_frames:, :, :] target_nega = noise_pred_nega[:, :, :-num_context_frames, :, :] context_nega = noise_pred_nega[:, :, -num_context_frames:, :, :] else: # Prefix mode: context is FRONT, target is BACK context_posi = noise_pred_posi[:, :, :num_context_frames, :, :] target_posi = noise_pred_posi[:, :, num_context_frames:, :, :] context_nega = noise_pred_nega[:, :, :num_context_frames, :, :] target_nega = noise_pred_nega[:, :, num_context_frames:, :, :] # Apply CFG only to target part target_cfg = target_nega + cfg_scale * (target_posi - target_nega) # Keep context part unchanged (use positive prediction, or average) # Using positive prediction for context to maintain consistency context_cfg = context_posi # Concatenate back (handle suffix mode) if context_position == "suffix": noise_pred = torch.cat([target_cfg, context_cfg], dim=2) else: noise_pred = torch.cat([context_cfg, target_cfg], dim=2) else: # Standard CFG: apply to all frames noise_pred = noise_pred_nega + cfg_scale * (noise_pred_posi - noise_pred_nega) else: noise_pred = noise_pred_posi # Scheduler Step if enable_context_memory and context_latents_clean is not None: if use_concatenation_inference: # Concatenation Strategy: Update all latents, but lock Context to prevent corruption # CRITICAL FIX: Model was trained with Loss only on Target frames, so noise_pred # for Context frames is undefined/random. We must lock Context after each step # to prevent it from being corrupted by garbage predictions. inputs_shared["latents"] = self.scheduler.step(noise_pred, self.scheduler.timesteps[progress_id], inputs_shared["latents"]) # Lock Context: Reset Context part to original Clean Context after scheduler step # This prevents Context from being corrupted by model's undefined predictions on Context frames # Experiment 1_2: if suffix => lock BACK, else lock FRONT if context_position == "suffix": inputs_shared["latents"][:, :, -num_context_frames:, :, :] = context_latents_clean else: inputs_shared["latents"][:, :, :num_context_frames, :, :] = context_latents_clean else: # Repainting Strategy: Update all latents, but context will be repainted in next step inputs_shared["latents"] = self.scheduler.step(noise_pred, self.scheduler.timesteps[progress_id], inputs_shared["latents"]) else: # Standard mode: update all latents inputs_shared["latents"] = self.scheduler.step(noise_pred, self.scheduler.timesteps[progress_id], inputs_shared["latents"]) # Final latents: Extract target part if context memory was used if enable_context_memory and context_latents_clean is not None: # After denoising, latents contain both context and target # Extract only target part for final output (context was only for conditioning) # Experiment 16: Extract full 81 frames (including reconstruction of first 4 frames) # Experiment 1_2: if suffix => extract FRONT, else extract BACK if context_position == "suffix": inputs_shared["latents"] = inputs_shared["latents"][:, :, :-num_context_frames, :, :] # (B, C, 81, H, W) else: inputs_shared["latents"] = inputs_shared["latents"][:, :, num_context_frames:, :, :] # (B, C, 81, H, W) # VACE (TODO: remove it) if vace_reference_image is not None: inputs_shared["latents"] = inputs_shared["latents"][:, :, 1:] # Decode self.load_models_to_device(['vae']) video = self.vae.decode(inputs_shared["latents"], device=self.device, tiled=tiled, tile_size=tile_size, tile_stride=tile_stride) video = self.vae_output_to_video(video) self.load_models_to_device([]) return video class PipelineUnit: def __init__( self, seperate_cfg: bool = False, take_over: bool = False, input_params: tuple[str] = None, input_params_posi: dict[str, str] = None, input_params_nega: dict[str, str] = None, onload_model_names: tuple[str] = None ): self.seperate_cfg = seperate_cfg self.take_over = take_over self.input_params = input_params self.input_params_posi = input_params_posi self.input_params_nega = input_params_nega self.onload_model_names = onload_model_names def process(self, pipe: WanVideoPipeline, inputs: dict, positive=True, **kwargs) -> dict: raise NotImplementedError("`process` is not implemented.") class PipelineUnitRunner: def __init__(self): pass def __call__(self, unit: PipelineUnit, pipe: WanVideoPipeline, inputs_shared: dict, inputs_posi: dict, inputs_nega: dict) -> tuple[dict, dict]: if unit.take_over: # Let the pipeline unit take over this function. inputs_shared, inputs_posi, inputs_nega = unit.process(pipe, inputs_shared=inputs_shared, inputs_posi=inputs_posi, inputs_nega=inputs_nega) elif unit.seperate_cfg: # Positive side processor_inputs = {name: inputs_posi.get(name_) for name, name_ in unit.input_params_posi.items()} if unit.input_params is not None: for name in unit.input_params: processor_inputs[name] = inputs_shared.get(name) processor_outputs = unit.process(pipe, **processor_inputs) inputs_posi.update(processor_outputs) # Negative side if inputs_shared["cfg_scale"] != 1: processor_inputs = {name: inputs_nega.get(name_) for name, name_ in unit.input_params_nega.items()} if unit.input_params is not None: for name in unit.input_params: processor_inputs[name] = inputs_shared.get(name) processor_outputs = unit.process(pipe, **processor_inputs) inputs_nega.update(processor_outputs) else: inputs_nega.update(processor_outputs) else: processor_inputs = {name: inputs_shared.get(name) for name in unit.input_params} processor_outputs = unit.process(pipe, **processor_inputs) inputs_shared.update(processor_outputs) return inputs_shared, inputs_posi, inputs_nega class WanVideoUnit_ShapeChecker(PipelineUnit): def __init__(self): super().__init__(input_params=("height", "width", "num_frames")) def process(self, pipe: WanVideoPipeline, height, width, num_frames): height, width, num_frames = pipe.check_resize_height_width(height, width, num_frames) return {"height": height, "width": width, "num_frames": num_frames} class WanVideoUnit_NoiseInitializer(PipelineUnit): def __init__(self): super().__init__(input_params=("height", "width", "num_frames", "seed", "rand_device", "vace_reference_image", "batch_size")) def process(self, pipe: WanVideoPipeline, height, width, num_frames, seed, rand_device, vace_reference_image, batch_size=None): length = (num_frames - 1) // 4 + 1 if vace_reference_image is not None: length += 1 batch_size = int(batch_size or 1) noise = pipe.generate_noise((batch_size, 16, length, height//8, width//8), seed=seed, rand_device=rand_device) if vace_reference_image is not None: noise = torch.concat((noise[:, :, -1:], noise[:, :, :-1]), dim=2) return {"noise": noise} class WanVideoUnit_InputVideoEmbedder(PipelineUnit): def __init__(self): super().__init__( input_params=("input_video", "noise", "tiled", "tile_size", "tile_stride", "vace_reference_image"), onload_model_names=("vae",) ) def process(self, pipe: WanVideoPipeline, input_video, noise, tiled, tile_size, tile_stride, vace_reference_image): if input_video is None: return {"latents": noise} pipe.load_models_to_device(["vae"]) # Support batch: input_video can be list of lists (batch of videos) or list of PIL (single video) is_batch = isinstance(input_video, (list, tuple)) and len(input_video) > 0 and isinstance(input_video[0], (list, tuple)) if is_batch: # Batch of videos: each element is list of PIL images; preprocess_video returns (1, C, T, H, W) videos_tensor = torch.cat([ pipe.preprocess_video(v) for v in input_video ], dim=0) # (B, C, T, H, W) input_video = videos_tensor else: input_video = pipe.preprocess_video(input_video) input_latents = pipe.vae.encode(input_video, device=pipe.device, tiled=tiled, tile_size=tile_size, tile_stride=tile_stride).to(dtype=pipe.torch_dtype, device=pipe.device) if vace_reference_image is not None: vace_reference_image = pipe.preprocess_video([vace_reference_image]) vace_reference_latents = pipe.vae.encode(vace_reference_image, device=pipe.device).to(dtype=pipe.torch_dtype, device=pipe.device) input_latents = torch.concat([vace_reference_latents, input_latents], dim=2) if pipe.scheduler.training: return {"latents": noise, "input_latents": input_latents} else: latents = pipe.scheduler.add_noise(input_latents, noise, timestep=pipe.scheduler.timesteps[0]) return {"latents": latents} class WanVideoUnit_PromptEmbedder(PipelineUnit): def __init__(self): super().__init__( seperate_cfg=True, input_params_posi={"prompt": "prompt", "positive": "positive"}, input_params_nega={"prompt": "negative_prompt", "positive": "positive"}, onload_model_names=("text_encoder",) ) def process(self, pipe: WanVideoPipeline, prompt, positive) -> dict: pipe.load_models_to_device(self.onload_model_names) prompt_emb = pipe.prompter.encode_prompt(prompt, positive=positive, device=pipe.device) return {"context": prompt_emb} class WanVideoUnit_ImageEmbedder(PipelineUnit): def __init__(self): super().__init__( input_params=("input_image", "end_image", "num_frames", "height", "width", "tiled", "tile_size", "tile_stride"), onload_model_names=("image_encoder", "vae") ) def process(self, pipe: WanVideoPipeline, input_image, end_image, num_frames, height, width, tiled, tile_size, tile_stride): if input_image is None: return {} pipe.load_models_to_device(self.onload_model_names) # Some base T2V checkpoints do not provide an image encoder. # In that case, image conditioning is not supported — safely skip. if getattr(pipe, "image_encoder", None) is None: return {} image = pipe.preprocess_image(input_image.resize((width, height))).to(pipe.device) clip_context = pipe.image_encoder.encode_image([image]) msk = torch.ones(1, num_frames, height//8, width//8, device=pipe.device) msk[:, 1:] = 0 if end_image is not None: end_image = pipe.preprocess_image(end_image.resize((width, height))).to(pipe.device) vae_input = torch.concat([image.transpose(0,1), torch.zeros(3, num_frames-2, height, width).to(image.device), end_image.transpose(0,1)],dim=1) if pipe.dit.has_image_pos_emb: clip_context = torch.concat([clip_context, pipe.image_encoder.encode_image([end_image])], dim=1) msk[:, -1:] = 1 else: vae_input = torch.concat([image.transpose(0, 1), torch.zeros(3, num_frames-1, height, width).to(image.device)], dim=1) msk = torch.concat([torch.repeat_interleave(msk[:, 0:1], repeats=4, dim=1), msk[:, 1:]], dim=1) msk = msk.view(1, msk.shape[1] // 4, 4, height//8, width//8) msk = msk.transpose(1, 2)[0] y = pipe.vae.encode([vae_input.to(dtype=pipe.torch_dtype, device=pipe.device)], device=pipe.device, tiled=tiled, tile_size=tile_size, tile_stride=tile_stride)[0] y = y.to(dtype=pipe.torch_dtype, device=pipe.device) y = torch.concat([msk, y]) y = y.unsqueeze(0) clip_context = clip_context.to(dtype=pipe.torch_dtype, device=pipe.device) y = y.to(dtype=pipe.torch_dtype, device=pipe.device) return {"clip_feature": clip_context, "y": y} class WanVideoUnit_FunControl(PipelineUnit): def __init__(self): super().__init__( input_params=("control_video", "num_frames", "height", "width", "tiled", "tile_size", "tile_stride", "clip_feature", "y"), onload_model_names=("vae") ) def process(self, pipe: WanVideoPipeline, control_video, num_frames, height, width, tiled, tile_size, tile_stride, clip_feature, y): if control_video is None: return {} pipe.load_models_to_device(self.onload_model_names) control_video = pipe.preprocess_video(control_video) control_latents = pipe.vae.encode(control_video, device=pipe.device, tiled=tiled, tile_size=tile_size, tile_stride=tile_stride).to(dtype=pipe.torch_dtype, device=pipe.device) control_latents = control_latents.to(dtype=pipe.torch_dtype, device=pipe.device) if clip_feature is None or y is None: clip_feature = torch.zeros((1, 257, 1280), dtype=pipe.torch_dtype, device=pipe.device) y = torch.zeros((1, 16, (num_frames - 1) // 4 + 1, height//8, width//8), dtype=pipe.torch_dtype, device=pipe.device) else: y = y[:, -16:] y = torch.concat([control_latents, y], dim=1) return {"clip_feature": clip_feature, "y": y} class WanVideoUnit_FunReference(PipelineUnit): def __init__(self): super().__init__( input_params=("reference_image", "height", "width", "reference_image"), onload_model_names=("vae") ) def process(self, pipe: WanVideoPipeline, reference_image, height, width): if reference_image is None: return {} # Some base T2V checkpoints do not provide an image encoder. # In that case, reference-image conditioning is not supported — safely skip. if getattr(pipe, "image_encoder", None) is None: return {} pipe.load_models_to_device(["vae"]) reference_image = reference_image.resize((width, height)) reference_latents = pipe.preprocess_video([reference_image]) reference_latents = pipe.vae.encode(reference_latents, device=pipe.device) clip_feature = pipe.preprocess_image(reference_image) clip_feature = pipe.image_encoder.encode_image([clip_feature]) return {"reference_latents": reference_latents, "clip_feature": clip_feature} class WanVideoUnit_FunCameraControl(PipelineUnit): def __init__(self): super().__init__( input_params=("height", "width", "num_frames", "camera_control_direction", "camera_control_speed", "camera_control_origin", "camera_control_poses", "latents", "input_image") ) def process(self, pipe: WanVideoPipeline, height, width, num_frames, camera_control_direction, camera_control_speed, camera_control_origin, camera_control_poses, latents, input_image): if getattr(pipe.dit, "control_adapter", None) is None: return {} if camera_control_direction is None and camera_control_poses is None: return {} if camera_control_poses is not None: # Build plucker embedding from explicit trajectory try: from ..models.wan_video_camera_controller import process_pose_file # type: ignore camera_control_plucker_embedding = process_pose_file(camera_control_poses, width=width, height=height, device="cpu") except Exception as e: # Fallback: ignore camera control if malformed return {} else: camera_control_plucker_embedding = pipe.dit.control_adapter.process_camera_coordinates( camera_control_direction, num_frames, height, width, camera_control_speed, camera_control_origin) control_camera_video = camera_control_plucker_embedding[:num_frames].permute([3, 0, 1, 2]).unsqueeze(0) control_camera_latents = torch.concat( [ torch.repeat_interleave(control_camera_video[:, :, 0:1], repeats=4, dim=2), control_camera_video[:, :, 1:] ], dim=2 ).transpose(1, 2) b, f, c, h, w = control_camera_latents.shape control_camera_latents = control_camera_latents.contiguous().view(b, f // 4, 4, c, h, w).transpose(2, 3) control_camera_latents = control_camera_latents.contiguous().view(b, f // 4, c * 4, h, w).transpose(1, 2) control_camera_latents_input = control_camera_latents.to(device=pipe.device, dtype=pipe.torch_dtype) input_image = input_image.resize((width, height)) input_latents = pipe.preprocess_video([input_image]) input_latents = pipe.vae.encode(input_latents, device=pipe.device) y = torch.zeros_like(latents).to(pipe.device) y[:, :, :1] = input_latents y = y.to(dtype=pipe.torch_dtype, device=pipe.device) return {"control_camera_latents_input": control_camera_latents_input, "y": y} class WanVideoUnit_SpeedControl(PipelineUnit): def __init__(self): super().__init__(input_params=("motion_bucket_id",)) def process(self, pipe: WanVideoPipeline, motion_bucket_id): if motion_bucket_id is None: return {} motion_bucket_id = torch.Tensor((motion_bucket_id,)).to(dtype=pipe.torch_dtype, device=pipe.device) return {"motion_bucket_id": motion_bucket_id} class WanVideoUnit_VACE(PipelineUnit): def __init__(self): super().__init__( input_params=("vace_video", "vace_video_mask", "vace_reference_image", "vace_scale", "height", "width", "num_frames", "tiled", "tile_size", "tile_stride"), onload_model_names=("vae",) ) def process( self, pipe: WanVideoPipeline, vace_video, vace_video_mask, vace_reference_image, vace_scale, height, width, num_frames, tiled, tile_size, tile_stride ): if vace_video is not None or vace_video_mask is not None or vace_reference_image is not None: pipe.load_models_to_device(["vae"]) if vace_video is None: vace_video = torch.zeros((1, 3, num_frames, height, width), dtype=pipe.torch_dtype, device=pipe.device) else: vace_video = pipe.preprocess_video(vace_video) if vace_video_mask is None: vace_video_mask = torch.ones_like(vace_video) else: vace_video_mask = pipe.preprocess_video(vace_video_mask, min_value=0, max_value=1) inactive = vace_video * (1 - vace_video_mask) + 0 * vace_video_mask reactive = vace_video * vace_video_mask + 0 * (1 - vace_video_mask) inactive = pipe.vae.encode(inactive, device=pipe.device, tiled=tiled, tile_size=tile_size, tile_stride=tile_stride).to(dtype=pipe.torch_dtype, device=pipe.device) reactive = pipe.vae.encode(reactive, device=pipe.device, tiled=tiled, tile_size=tile_size, tile_stride=tile_stride).to(dtype=pipe.torch_dtype, device=pipe.device) vace_video_latents = torch.concat((inactive, reactive), dim=1) vace_mask_latents = rearrange(vace_video_mask[0,0], "T (H P) (W Q) -> 1 (P Q) T H W", P=8, Q=8) vace_mask_latents = torch.nn.functional.interpolate(vace_mask_latents, size=((vace_mask_latents.shape[2] + 3) // 4, vace_mask_latents.shape[3], vace_mask_latents.shape[4]), mode='nearest-exact') if vace_reference_image is None: pass else: vace_reference_image = pipe.preprocess_video([vace_reference_image]) vace_reference_latents = pipe.vae.encode(vace_reference_image, device=pipe.device, tiled=tiled, tile_size=tile_size, tile_stride=tile_stride).to(dtype=pipe.torch_dtype, device=pipe.device) vace_reference_latents = torch.concat((vace_reference_latents, torch.zeros_like(vace_reference_latents)), dim=1) vace_video_latents = torch.concat((vace_reference_latents, vace_video_latents), dim=2) vace_mask_latents = torch.concat((torch.zeros_like(vace_mask_latents[:, :, :1]), vace_mask_latents), dim=2) vace_context = torch.concat((vace_video_latents, vace_mask_latents), dim=1) return {"vace_context": vace_context, "vace_scale": vace_scale} else: return {"vace_context": None, "vace_scale": vace_scale} class WanVideoUnit_UnifiedSequenceParallel(PipelineUnit): def __init__(self): super().__init__(input_params=()) def process(self, pipe: WanVideoPipeline): if hasattr(pipe, "use_unified_sequence_parallel"): if pipe.use_unified_sequence_parallel: return {"use_unified_sequence_parallel": True} return {} class WanVideoUnit_TeaCache(PipelineUnit): def __init__(self): super().__init__( seperate_cfg=True, input_params_posi={"num_inference_steps": "num_inference_steps", "tea_cache_l1_thresh": "tea_cache_l1_thresh", "tea_cache_model_id": "tea_cache_model_id"}, input_params_nega={"num_inference_steps": "num_inference_steps", "tea_cache_l1_thresh": "tea_cache_l1_thresh", "tea_cache_model_id": "tea_cache_model_id"}, ) def process(self, pipe: WanVideoPipeline, num_inference_steps, tea_cache_l1_thresh, tea_cache_model_id): if tea_cache_l1_thresh is None: return {} return {"tea_cache": TeaCache(num_inference_steps, rel_l1_thresh=tea_cache_l1_thresh, model_id=tea_cache_model_id)} class WanVideoUnit_CfgMerger(PipelineUnit): def __init__(self): super().__init__(take_over=True) self.concat_tensor_names = ["context", "clip_feature", "y", "reference_latents"] def process(self, pipe: WanVideoPipeline, inputs_shared, inputs_posi, inputs_nega): if not inputs_shared["cfg_merge"]: return inputs_shared, inputs_posi, inputs_nega for name in self.concat_tensor_names: tensor_posi = inputs_posi.get(name) tensor_nega = inputs_nega.get(name) tensor_shared = inputs_shared.get(name) if tensor_posi is not None and tensor_nega is not None: inputs_shared[name] = torch.concat((tensor_posi, tensor_nega), dim=0) elif tensor_shared is not None: inputs_shared[name] = torch.concat((tensor_shared, tensor_shared), dim=0) inputs_posi.clear() inputs_nega.clear() return inputs_shared, inputs_posi, inputs_nega class TeaCache: def __init__(self, num_inference_steps, rel_l1_thresh, model_id): self.num_inference_steps = num_inference_steps self.step = 0 self.accumulated_rel_l1_distance = 0 self.previous_modulated_input = None self.rel_l1_thresh = rel_l1_thresh self.previous_residual = None self.previous_hidden_states = None self.coefficients_dict = { "Wan2.1-T2V-1.3B": [-5.21862437e+04, 9.23041404e+03, -5.28275948e+02, 1.36987616e+01, -4.99875664e-02], "Wan2.1-T2V-14B": [-3.03318725e+05, 4.90537029e+04, -2.65530556e+03, 5.87365115e+01, -3.15583525e-01], "Wan2.1-I2V-14B-480P": [2.57151496e+05, -3.54229917e+04, 1.40286849e+03, -1.35890334e+01, 1.32517977e-01], "Wan2.1-I2V-14B-720P": [ 8.10705460e+03, 2.13393892e+03, -3.72934672e+02, 1.66203073e+01, -4.17769401e-02], } if model_id not in self.coefficients_dict: supported_model_ids = ", ".join([i for i in self.coefficients_dict]) raise ValueError(f"{model_id} is not a supported TeaCache model id. Please choose a valid model id in ({supported_model_ids}).") self.coefficients = self.coefficients_dict[model_id] def check(self, dit: WanModel, x, t_mod): modulated_inp = t_mod.clone() if self.step == 0 or self.step == self.num_inference_steps - 1: should_calc = True self.accumulated_rel_l1_distance = 0 else: coefficients = self.coefficients rescale_func = np.poly1d(coefficients) self.accumulated_rel_l1_distance += rescale_func(((modulated_inp-self.previous_modulated_input).abs().mean() / self.previous_modulated_input.abs().mean()).cpu().item()) if self.accumulated_rel_l1_distance < self.rel_l1_thresh: should_calc = False else: should_calc = True self.accumulated_rel_l1_distance = 0 self.previous_modulated_input = modulated_inp self.step += 1 if self.step == self.num_inference_steps: self.step = 0 if should_calc: self.previous_hidden_states = x.clone() return not should_calc def store(self, hidden_states): self.previous_residual = hidden_states - self.previous_hidden_states self.previous_hidden_states = None def update(self, hidden_states): hidden_states = hidden_states + self.previous_residual return hidden_states class TemporalTiler_BCTHW: def __init__(self): pass def build_1d_mask(self, length, left_bound, right_bound, border_width): x = torch.ones((length,)) if not left_bound: x[:border_width] = (torch.arange(border_width) + 1) / border_width if not right_bound: x[-border_width:] = torch.flip((torch.arange(border_width) + 1) / border_width, dims=(0,)) return x def build_mask(self, data, is_bound, border_width): _, _, T, _, _ = data.shape t = self.build_1d_mask(T, is_bound[0], is_bound[1], border_width[0]) mask = repeat(t, "T -> 1 1 T 1 1") return mask def run(self, model_fn, sliding_window_size, sliding_window_stride, computation_device, computation_dtype, model_kwargs, tensor_names, batch_size=None): tensor_names = [tensor_name for tensor_name in tensor_names if model_kwargs.get(tensor_name) is not None] tensor_dict = {tensor_name: model_kwargs[tensor_name] for tensor_name in tensor_names} B, C, T, H, W = tensor_dict[tensor_names[0]].shape if batch_size is not None: B *= batch_size data_device, data_dtype = tensor_dict[tensor_names[0]].device, tensor_dict[tensor_names[0]].dtype value = torch.zeros((B, C, T, H, W), device=data_device, dtype=data_dtype) weight = torch.zeros((1, 1, T, 1, 1), device=data_device, dtype=data_dtype) for t in range(0, T, sliding_window_stride): if t - sliding_window_stride >= 0 and t - sliding_window_stride + sliding_window_size >= T: continue t_ = min(t + sliding_window_size, T) model_kwargs.update({ tensor_name: tensor_dict[tensor_name][:, :, t: t_:, :].to(device=computation_device, dtype=computation_dtype) \ for tensor_name in tensor_names }) model_output = model_fn(**model_kwargs).to(device=data_device, dtype=data_dtype) mask = self.build_mask( model_output, is_bound=(t == 0, t_ == T), border_width=(sliding_window_size - sliding_window_stride,) ).to(device=data_device, dtype=data_dtype) value[:, :, t: t_, :, :] += model_output * mask weight[:, :, t: t_, :, :] += mask value /= weight model_kwargs.update(tensor_dict) return value def model_fn_wan_video( dit: WanModel, motion_controller: WanMotionControllerModel = None, vace: VaceWanModel = None, latents: torch.Tensor = None, timestep: torch.Tensor = None, context: torch.Tensor = None, clip_feature: Optional[torch.Tensor] = None, y: Optional[torch.Tensor] = None, reference_latents = None, vace_context = None, vace_scale = 1.0, tea_cache: TeaCache = None, use_unified_sequence_parallel: bool = False, motion_bucket_id: Optional[torch.Tensor] = None, sliding_window_size: Optional[int] = None, sliding_window_stride: Optional[int] = None, cfg_merge: bool = False, use_gradient_checkpointing: bool = False, use_gradient_checkpointing_offload: bool = False, control_camera_latents_input = None, actions = None, **kwargs, ): if sliding_window_size is not None and sliding_window_stride is not None: model_kwargs = dict( dit=dit, motion_controller=motion_controller, vace=vace, latents=latents, timestep=timestep, context=context, clip_feature=clip_feature, y=y, reference_latents=reference_latents, vace_context=vace_context, vace_scale=vace_scale, tea_cache=tea_cache, use_unified_sequence_parallel=use_unified_sequence_parallel, motion_bucket_id=motion_bucket_id, ) return TemporalTiler_BCTHW().run( model_fn_wan_video, sliding_window_size, sliding_window_stride, latents.device, latents.dtype, model_kwargs=model_kwargs, tensor_names=["latents", "y"], batch_size=2 if cfg_merge else 1 ) if use_unified_sequence_parallel: import torch.distributed as dist from xfuser.core.distributed import (get_sequence_parallel_rank, get_sequence_parallel_world_size, get_sp_group) t = dit.time_embedding(sinusoidal_embedding_1d(dit.freq_dim, timestep)) t_mod = dit.time_projection(t).unflatten(1, (6, dit.dim)) if motion_bucket_id is not None and motion_controller is not None: t_mod = t_mod + motion_controller(motion_bucket_id).unflatten(1, (6, dit.dim)) context = dit.text_embedding(context) x = latents # Merged cfg if x.shape[0] != context.shape[0]: x = torch.concat([x] * context.shape[0], dim=0) if timestep.shape[0] != context.shape[0]: timestep = torch.concat([timestep] * context.shape[0], dim=0) if dit.has_image_input: x = torch.cat([x, y], dim=1) # (b, c_x + c_y, f, h, w) clip_embdding = dit.img_emb(clip_feature) context = torch.cat([clip_embdding, context], dim=1) # Add camera control x, (f, h, w) = dit.patchify(x, control_camera_latents_input) # Reference image if reference_latents is not None: if len(reference_latents.shape) == 5: reference_latents = reference_latents[:, :, 0] reference_latents = dit.ref_conv(reference_latents).flatten(2).transpose(1, 2) x = torch.concat([reference_latents, x], dim=1) f += 1 # Build time position encoding with negative indices for context frames # Get num_context_frames from kwargs (passed from pipeline) num_context_frames = kwargs.get("num_context_frames", 0) import os context_position = kwargs.get("context_position", os.environ.get("CONTEXT_POSITION", "prefix")) if isinstance(context_position, str): context_position = context_position.lower() if context_position not in ("prefix", "suffix"): context_position = "prefix" # MoC [2508.21058]: reweight context chunks by query–chunk similarity before blocks use_moc = kwargs.get("use_moc", False) moc_module = kwargs.get("moc_module", None) if use_moc and moc_module is not None and num_context_frames > 0 and f > num_context_frames: x = moc_module(x, num_context_frames, int(f), int(h), int(w), context_position) # FramePack-Weight (FAR-style): see diffsynth.models.memory.framepack_weight use_framepack_memory = kwargs.get("use_framepack_memory", False) context_temporal_decay = float(kwargs.get("context_temporal_decay", 1.0) or 1.0) context_attention_weight = float(kwargs.get("context_attention_weight", 1.0) or 1.0) x = apply_framepack_token_weights( x, num_context_frames=num_context_frames, f=int(f), h=int(h), w=int(w), context_position=context_position, use_framepack_memory=bool(use_framepack_memory), context_temporal_decay=context_temporal_decay, context_attention_weight=context_attention_weight, ) # Spatial memory: legacy pool OR SpatialGridMemory; inject to text context (concat_text) # or directly read memory from target tokens (cross_attn_readout). use_spatial_memory = kwargs.get("use_spatial_memory", False) spatial_memory_tokens = int(kwargs.get("spatial_memory_tokens", 64) or 64) use_spatial_memory_legacy = bool(kwargs.get("use_spatial_memory_legacy", False)) spatial_memory_module = kwargs.get("spatial_memory_module", None) spatial_inject_mode = str(kwargs.get("spatial_memory_inject_mode", "concat_text") or "concat_text") spatial_readout_module = kwargs.get("spatial_memory_readout_module", None) if ( use_spatial_memory and spatial_inject_mode != "none" and spatial_memory_tokens > 0 and num_context_frames > 0 and f > num_context_frames ): spatial_per_frame = int(h) * int(w) context_tokens = num_context_frames * spatial_per_frame if context_position == "suffix": x_context = x[:, -context_tokens:, :] else: x_context = x[:, :context_tokens, :] if spatial_memory_module is not None and not use_spatial_memory_legacy: mem = spatial_memory_module(x_context, num_context_frames, int(h), int(w)) else: x_ctx = x_context.reshape(x_context.shape[0], num_context_frames, spatial_per_frame, x_context.shape[-1]) mem = x_ctx.mean(dim=1) if mem.shape[1] != spatial_memory_tokens: mem_t = mem.transpose(1, 2) # (B, D, S) mem_t = torch.nn.functional.adaptive_avg_pool1d(mem_t, spatial_memory_tokens) mem = mem_t.transpose(1, 2) if spatial_inject_mode == "cross_attn_readout": target_tokens = (int(f) - int(num_context_frames)) * int(h) * int(w) if target_tokens > 0 and target_tokens < x.shape[1]: if context_position == "suffix": x_target = x[:, :target_tokens, :] x_context = x[:, target_tokens:, :] x_target = apply_spatial_cross_attn_readout(x_target, mem, spatial_readout_module) x = torch.cat([x_target, x_context], dim=1) else: x_context = x[:, :x.shape[1] - target_tokens, :] x_target = x[:, x.shape[1] - target_tokens:, :] x_target = apply_spatial_cross_attn_readout(x_target, mem, spatial_readout_module) x = torch.cat([x_context, x_target], dim=1) else: context = inject_spatial_memory(context, mem, "concat_text") else: context = inject_spatial_memory(context, mem, spatial_inject_mode) if num_context_frames > 0 and f > num_context_frames: # Context frames: use negative temporal indices [-k, ..., -1] to represent "past" frames (prefix mode) # OR use positive indices [target_frames, ..., target_frames+k-1] to represent "future" frames (suffix mode) # Target frames: use positive temporal indices [0, ..., N] to represent "current/future" frames target_frames = f - num_context_frames # Construct positive position indices for target: [0, ..., target_frames-1] target_ids = torch.arange(0, target_frames, device=x.device, dtype=torch.float64) # Experiment 1_2: if context is concatenated at END (suffix), use positive indices after target if context_position == "suffix": # Suffix mode: Context is after target, so use positive indices [target_frames, ..., target_frames+num_context_frames-1] # Example: target_frames=21, num_context_frames=2 => context_ids = [21, 22] context_ids = torch.arange(target_frames, target_frames + num_context_frames, device=x.device, dtype=torch.float64) t_ids = torch.cat([target_ids, context_ids]) else: # Prefix mode: Context is before target, so use negative indices [-num_context_frames, ..., -1] # Example: num_context_frames=2 => context_ids = [-2, -1] context_ids = torch.arange(-num_context_frames, 0, device=x.device, dtype=torch.float64) t_ids = torch.cat([context_ids, target_ids]) # Generate RoPE frequencies for custom position indices (including negative) # dit.freqs[0] is precomputed RoPE frequencies (complex64), we need to generate for custom positions # Use the same logic as precompute_freqs_cis but for arbitrary positions # Get the frequency dimension from precomputed freqs time_freq_dim_complex = dit.freqs[0].shape[-1] # This is the complex dimension: (head_dim - 2*(head_dim//3)) // 2 time_freq_dim_original = time_freq_dim_complex * 2 # Original dimension: head_dim - 2*(head_dim//3) # Compute base frequencies (same as in precompute_freqs_cis) theta = 10000.0 base_freqs = 1.0 / (theta ** (torch.arange(0, time_freq_dim_original, 2, device=x.device, dtype=torch.float64) [:time_freq_dim_complex].double() / time_freq_dim_original)) # Compute frequencies for each position: outer product of positions and base_freqs t_freqs = torch.outer(t_ids, base_freqs) # (f, freq_dim_complex) # Convert to complex form (RoPE format): polar(ones, freqs) t_freqs_cis = torch.polar(torch.ones_like(t_freqs), t_freqs) # complex64, (f, freq_dim_complex) # Reshape and expand to match spatial dimensions: (f, 1, 1, freq_dim) -> (f, h, w, freq_dim) freqs_t = t_freqs_cis.view(f, 1, 1, -1).expand(f, h, w, -1).to(x.device) else: # Standard mode: use precomputed frequencies for all frames freqs_t = dit.freqs[0][:f].view(f, 1, 1, -1).expand(f, h, w, -1).to(x.device) # Spatial frequencies remain the same freqs_h = dit.freqs[1][:h].view(1, h, 1, -1).expand(f, h, w, -1).to(x.device) freqs_w = dit.freqs[2][:w].view(1, 1, w, -1).expand(f, h, w, -1).to(x.device) # Concatenate all frequencies (all tensors are now on the same device) freqs = torch.cat([freqs_t, freqs_h, freqs_w], dim=-1).reshape(f * h * w, 1, -1) # Unified implicit (memory_design_advanced): single ctx-learning injection into t_mod # When use_non_fov_compressed: compressor input = non_fov_context_tokens (prev chunk non-FOV frames). # Otherwise: compressor input = x_ctx (explicit FOV context tokens). use_unified_implicit = kwargs.get("use_unified_implicit", False) unified_encoder = kwargs.get("unified_implicit_encoder", None) context_learning_injector = kwargs.get("context_learning_injector", None) use_non_fov_compressed = kwargs.get("use_non_fov_compressed", False) non_fov_context_tokens = kwargs.get("non_fov_context_tokens", None) if use_unified_implicit and unified_encoder is not None and context_learning_injector is not None and f > num_context_frames: if use_non_fov_compressed and non_fov_context_tokens is not None: z = unified_encoder(non_fov_context_tokens) elif num_context_frames > 0: spatial_per_frame = int(h) * int(w) context_tokens = num_context_frames * spatial_per_frame if context_position == "suffix": x_ctx = x[:, -context_tokens:, :] else: x_ctx = x[:, :context_tokens, :] z = unified_encoder(x_ctx) else: z = None if z is not None: delta_t_mod = context_learning_injector(z) t_mod = t_mod + delta_t_mod # TeaCache if tea_cache is not None: tea_cache_update = tea_cache.check(dit, x, t_mod) else: tea_cache_update = False if vace_context is not None: vace_hints = vace(x, vace_context, context, t_mod, freqs) # blocks if use_unified_sequence_parallel: if dist.is_initialized() and dist.get_world_size() > 1: x = torch.chunk(x, get_sequence_parallel_world_size(), dim=1)[get_sequence_parallel_rank()] if tea_cache_update: x = tea_cache.update(x) else: def create_custom_forward(module): def custom_forward(*inputs): return module(*inputs) return custom_forward if actions is not None: actions = torch.tensor(actions, device=x.device, dtype=x.dtype) actions = actions.unsqueeze(0) for block_id, block in enumerate(dit.blocks): if use_gradient_checkpointing_offload: with torch.autograd.graph.save_on_cpu(): if actions is not None: x = torch.utils.checkpoint.checkpoint( create_custom_forward(block), x, context, t_mod, freqs, actions, use_reentrant=False, ) else: x = torch.utils.checkpoint.checkpoint( create_custom_forward(block), x, context, t_mod, freqs, use_reentrant=False, ) elif use_gradient_checkpointing: if actions is not None: x = torch.utils.checkpoint.checkpoint( create_custom_forward(block), x, context, t_mod, freqs, actions, use_reentrant=False, ) else: x = torch.utils.checkpoint.checkpoint( create_custom_forward(block), x, context, t_mod, freqs, use_reentrant=False, ) else: if actions is not None: x = block(x, context, t_mod, freqs, actions) else: x = block(x, context, t_mod, freqs) if vace_context is not None and block_id in vace.vace_layers_mapping: current_vace_hint = vace_hints[vace.vace_layers_mapping[block_id]] if use_unified_sequence_parallel and dist.is_initialized() and dist.get_world_size() > 1: current_vace_hint = torch.chunk(current_vace_hint, get_sequence_parallel_world_size(), dim=1)[get_sequence_parallel_rank()] x = x + current_vace_hint * vace_scale if tea_cache is not None: tea_cache.store(x) x = dit.head(x, t) if use_unified_sequence_parallel: if dist.is_initialized() and dist.get_world_size() > 1: x = get_sp_group().all_gather(x, dim=1) # Remove reference latents if reference_latents is not None: x = x[:, reference_latents.shape[1]:] f -= 1 x = dit.unpatchify(x, (f, h, w)) return x