Files
ModelTC-ComfyUI-Lightx2vWra…/nodes.py
T
2025-04-26 21:29:22 +08:00

851 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os
import torch
import gc
from typing import cast, Any
import logging
import json
import numpy as np
import comfy.model_management as mm
from comfy.utils import ProgressBar
# import folder_paths
from tqdm import tqdm
from easydict import EasyDict
# Import LightX2V modules
from .lightx2v.lightx2v.utils.profiler import ProfilingContext
from .lightx2v.lightx2v.models.input_encoders.hf.t5.model import T5EncoderModel
from .lightx2v.lightx2v.models.input_encoders.hf.xlm_roberta.model import (
CLIPModel as ClipVisionModel,
)
from .lightx2v.lightx2v.models.video_encoders.hf.wan.vae import WanVAE
from .lightx2v.lightx2v.models.networks.wan.model import WanModel
from .lightx2v.lightx2v.models.networks.wan.lora_adapter import WanLoraWrapper
from .lightx2v.lightx2v.models.schedulers.wan.scheduler import WanScheduler
from .lightx2v.lightx2v.models.schedulers.wan.feature_caching.scheduler import (
WanSchedulerTeaCaching,
)
class WanVideoTeaCache:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"rel_l1_thresh": (
"FLOAT",
{
"default": 0.3,
"min": 0.0,
"max": 1.0,
"step": 0.001,
"tooltip": "Higher values will make TeaCache more aggressive, faster, but may cause artifacts. Good value range for 1.3B: 0.05 - 0.08, for other models 0.15-0.30",
},
),
"start_step": (
"INT",
{
"default": 1,
"min": 0,
"max": 9999,
"step": 1,
"tooltip": "Start percentage of the steps to apply TeaCache",
},
),
"end_step": (
"INT",
{
"default": -1,
"min": -1,
"max": 9999,
"step": 1,
"tooltip": "End steps to apply TeaCache",
},
),
"cache_device": (
["main_device", "offload_device"],
{"default": "offload_device", "tooltip": "Device to cache to"},
),
"use_coefficients": (
"BOOLEAN",
{
"default": True,
"tooltip": "Use calculated coefficients for more accuracy. When enabled therel_l1_thresh should be about 10 times higher than without",
},
),
},
"optional": {
"mode": (
["e", "e0"],
{
"default": "e",
"tooltip": "Choice between using e (time embeds, default) or e0 (modulated time embeds)",
},
),
},
}
RETURN_TYPES = ("LIGHT_TEACACHEARGS",)
RETURN_NAMES = ("teacache_args",)
FUNCTION = "process"
CATEGORY = "LightX2V"
EXPERIMENTAL = True
def process(
self,
rel_l1_thresh,
start_step,
end_step,
cache_device,
use_coefficients,
mode="e",
):
if cache_device == "main_device":
teacache_device = mm.get_torch_device()
else:
teacache_device = mm.unet_offload_device()
teacache_args = {
"rel_l1_thresh": rel_l1_thresh,
"start_step": start_step,
"end_step": end_step,
"cache_device": teacache_device,
"use_coefficients": use_coefficients,
"mode": mode,
}
return (teacache_args,)
class Lightx2vWanVideoT5EncoderLoader:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"t5_model_path": (
"STRING",
{
"default": "/mnt/aigc/shared_data/cache/huggingface/hub/Wan2.1-I2V-14B-480P/models_t5_umt5-xxl-enc-bf16.pth"
},
),
"tokenizer_path": (
"STRING",
{
"default": "/mnt/aigc/shared_data/cache/huggingface/hub/Wan2.1-I2V-14B-480P/google/umt5-xxl"
},
),
"text_len": (
"INT",
{"default": 512, "min": 64, "max": 2048, "step": 1},
),
"precision": (["bf16", "fp16", "fp32"], {"default": "bf16"}),
"device": (["cuda", "cpu"], {"default": "cuda"}),
"additional_param": ("STRING", {"default": "default_value"}),
}
}
RETURN_TYPES = ("LIGHT_T5_ENCODER",)
RETURN_NAMES = ("t5_encoder",)
FUNCTION = "load_t5_encoder"
CATEGORY = "LightX2V"
def load_t5_encoder(
self,
t5_model_path,
tokenizer_path,
text_len,
precision,
device,
additional_param,
):
# Map precision to torch dtype
dtype_map = {
"bf16": torch.bfloat16,
"fp16": torch.float16,
"fp32": torch.float32,
}
dtype = dtype_map[precision]
# Resolve device
if device == "cuda":
device = mm.get_torch_device()
else:
device = torch.device("cpu")
# Load the T5 encoder
t5_encoder = T5EncoderModel(
text_len=text_len,
dtype=dtype,
device=device, # type:ignore
checkpoint_path=t5_model_path,
tokenizer_path=tokenizer_path,
shard_fn=None,
)
return (t5_encoder,)
class Lightx2vWanVideoT5Encoder:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"t5_encoder": ("LIGHT_T5_ENCODER",),
"prompt": (
"STRING",
{
"multiline": True,
"default": "Summer beach vacation style, a white cat wearing sunglasses sits on a surfboard. The fluffy-furred feline gazes directly at the camera with a relaxed expression. Blurred beach scenery forms the background featuring crystal-clear waters, distant green hills, and a blue sky dotted with white clouds. The cat assumes a naturally relaxed posture, as if savoring the sea breeze and warm sunlight. A close-up shot highlights the feline's intricate details and the refreshing atmosphere of the seaside.",
},
),
"negative_prompt": (
"STRING",
{
"multiline": True,
"default": "色调艳丽,过曝,静态,细节模糊不清,字幕,风格,作品,画作,画面,静止,整体发灰,最差质量,低质量,JPEG压缩残留,丑陋的,残缺的,多余的手指,画得不好的手部,画得不好的脸部,畸形的,毁容的,形态畸形的肢体,手指融合,静止不动的画面,杂乱的背景,三条腿,背景人很多,倒着走",
},
),
}
}
RETURN_TYPES = ("LIGHT_TEXT_EMBEDDINGS",)
RETURN_NAMES = ("text_embeddings",)
FUNCTION = "encode_text"
CATEGORY = "LightX2V"
def encode_text(self, t5_encoder, prompt, negative_prompt):
# Create a config object with required attributes
class Config:
def __init__(self, cpu_offload=False):
self.cpu_offload = cpu_offload
# NOTE(xxx): adapt the config if cpu_offload is set, t5 model must be on cuda device
config = Config(cpu_offload=False)
# Encode the text
context = t5_encoder.infer([prompt], config)
context_null = t5_encoder.infer(
[negative_prompt if negative_prompt else ""], config
)
# Create text embeddings dictionary
text_embeddings = {"context": context, "context_null": context_null}
print(f"Text Encoder Output Shape: {context[0].shape}")
return (text_embeddings,)
class Lightx2vWanVideoVaeLoader:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"vae_model_path": (
"STRING",
{
"default": "/mnt/aigc/shared_data/cache/huggingface/hub/Wan2.1-I2V-14B-480P/Wan2.1_VAE.pth"
},
),
"precision": (["bf16", "fp16", "fp32"], {"default": "fp16"}),
"device": (["cuda", "cpu"], {"default": "cuda"}),
"parallel": ("BOOLEAN", {"default": False}),
}
}
RETURN_TYPES = ("LIGHT_WAN_VAE",)
RETURN_NAMES = ("wan_vae",)
FUNCTION = "load_vae"
CATEGORY = "LightX2V"
def load_vae(self, vae_model_path, precision, device, parallel):
# Map precision to torch dtype
dtype_map = {
"bf16": torch.bfloat16,
"fp16": torch.float16,
"fp32": torch.float32,
}
dtype = dtype_map[precision]
# Resolve device
if device == "cuda":
device = mm.get_torch_device()
else:
device = torch.device("cpu")
# Load the VAE
vae = WanVAE(
z_dim=16,
vae_pth=vae_model_path,
dtype=dtype,
device=device, # type:ignore
parallel=parallel,
)
return (vae,)
class Lightx2vWanVideoVaeDecoder:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"wan_vae": ("LIGHT_WAN_VAE",),
"latent": ("LIGHT_LATENT",),
}
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("images",)
FUNCTION = "decode_latent"
CATEGORY = "LightX2V"
def decode_latent(self, wan_vae, latent):
config = EasyDict({"cpu_offload": False})
# 获取潜在表示和生成器
latents = latent["samples"]
generator = latent["generator"]
# 使用VAE解码潜在表示
with torch.no_grad():
# 解码得到视频帧
decoded_images = wan_vae.decode(latents, generator=generator, config=config)
# 将像素值从 [-1, 1] 归一化到 [0, 1]
images = (decoded_images + 1) / 2
# 重新排列维度为ComfyUI标准的图像格式 [T, H, W, C]
# 从 [1, C, T, H, W] 转换为 [T, H, W, C]
images = images.squeeze(0).permute(1, 2, 3, 0).cpu()
# 确保像素值在有效范围内
images = torch.clamp(images, 0, 1)
# 清理缓存以释放GPU内存
torch.cuda.empty_cache()
gc.collect()
return (images,)
class Lightx2vWanVideoClipVisionEncoderLoader:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"clip_model_path": (
"STRING",
{
"default": "/mnt/aigc/shared_data/cache/huggingface/hub/Wan2.1-I2V-14B-480P/models_clip_open-clip-xlm-roberta-large-vit-huge-14.pth"
},
),
"tokenizer_path": (
"STRING",
{
"default": "/mnt/aigc/shared_data/cache/huggingface/hub/Wan2.1-I2V-14B-480P/xlm-roberta-large"
},
),
"precision": (["fp16", "fp32"], {"default": "fp16"}),
"device": (["cuda", "cpu"], {"default": "cuda"}),
}
}
RETURN_TYPES = ("LIGHT_CLIP_VISION_ENCODER",)
RETURN_NAMES = ("clip_vision_encoder",)
FUNCTION = "load_clip_vision_encoder"
CATEGORY = "LightX2V"
def load_clip_vision_encoder(
self, clip_model_path, tokenizer_path, precision, device
):
# Map precision to torch dtype
dtype_map = {"fp16": torch.float16, "fp32": torch.float32}
dtype = dtype_map[precision]
# Resolve device
if device == "cuda":
device = mm.get_torch_device()
else:
device = torch.device("cpu")
# Load the CLIP vision encoder
clip_vision_encoder = ClipVisionModel(
dtype=dtype,
device=device,
checkpoint_path=clip_model_path,
tokenizer_path=tokenizer_path,
)
return (clip_vision_encoder,)
class Lightx2vWanVideoImageEncoder:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"vae": ("LIGHT_WAN_VAE",),
"clip_vision_encoder": ("LIGHT_CLIP_VISION_ENCODER",),
"image": ("IMAGE",),
"width": (
"INT",
{
"default": 832,
"min": 64,
"max": 2048,
"step": 8,
"tooltip": "Width of the image to encode",
},
),
"height": (
"INT",
{
"default": 480,
"min": 64,
"max": 29048,
"step": 8,
"tooltip": "Height of the image to encode",
},
),
"num_frames": (
"INT",
{
"default": 81,
"min": 1,
"max": 10000,
"step": 4,
"tooltip": "Number of frames to encode",
},
),
}
}
RETURN_TYPES = ("LIGHT_IMAGE_EMBEDDINGS",)
RETURN_NAMES = ("image_embeddings",)
FUNCTION = "encode_image"
CATEGORY = "LightX2V"
def encode_image(
self,
vae,
image,
clip_vision_encoder,
height,
width,
num_frames,
):
# 创建配置对象
config = EasyDict(
{
"cpu_offload": False,
"target_height": height,
"target_width": width,
"target_video_length": num_frames,
"vae_stride": (4, 8, 8),
"patch_size": (1, 2, 2),
}
)
# skip lint
config = cast(Any, config)
# 将图像转换为期望的张量格式
device = mm.get_torch_device()
img = image[0].permute(2, 0, 1).to(device) # [C, H, W]
img = img.sub_(0.5).div_(0.5) # 归一化到 [-1, 1]
# 使用CLIP视觉编码器编码图像
clip_encoder_out = (
clip_vision_encoder.visual([img[:, None, :, :]], config)
.squeeze(0)
.to(torch.bfloat16)
)
# 计算宽高比和尺寸
h, w = img.shape[1:]
aspect_ratio = h / w
max_area = config.target_height * config.target_width
lat_h = round(
np.sqrt(max_area * aspect_ratio)
// config.vae_stride[1]
// config.patch_size[1]
* config.patch_size[1]
)
lat_w = round(
np.sqrt(max_area / aspect_ratio)
// config.vae_stride[2]
// config.patch_size[2]
* config.patch_size[2]
)
# XXX: trick
config.lat_h = lat_h
config.lat_w = lat_w
h = lat_h * config.vae_stride[1]
w = lat_w * config.vae_stride[2]
# TODO(xxx):81?
msk = torch.ones(1, 81, lat_h, lat_w, device=device)
msk[:, 1:] = 0
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, lat_h, lat_w)
msk = msk.transpose(1, 2)[0]
resized_img = torch.nn.functional.interpolate(
img[None].cpu(), size=(h, w), mode="bicubic"
).transpose(0, 1)
padding = torch.zeros(3, 80, h, w, device=device)
concat_img = torch.concat([resized_img.to(device), padding], dim=1)
vae_encode_out = vae.encode([concat_img], config)[0]
# TODO(xxx): hard code
vae_encode_out = torch.concat([msk, vae_encode_out]).to(torch.bfloat16)
image_embeddings = {
"clip_encoder_out": clip_encoder_out,
"vae_encode_out": vae_encode_out,
"config": config,
}
print(f"Image Encoder Output Shape: {clip_encoder_out.shape}")
print(f"VAE Encoder Output Shape: {vae_encode_out.shape}")
print(f"Latent Height: {lat_h}, Latent Width: {lat_w}")
print(f"Image Shape: {img.shape}")
print(f"Configuration: {config}")
return (image_embeddings,)
class Lightx2vWanVideoEmptyEmbeds:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"width": (
"INT",
{
"default": 832,
"min": 64,
"max": 2048,
"step": 8,
"tooltip": "Width of the image to encode",
},
),
"height": (
"INT",
{
"default": 480,
"min": 64,
"max": 29048,
"step": 8,
"tooltip": "Height of the image to encode",
},
),
"num_frames": (
"INT",
{
"default": 81,
"min": 1,
"max": 10000,
"step": 4,
"tooltip": "Number of frames to encode",
},
),
}
}
RETURN_TYPES = ("LIGHT_IMAGE_EMBEDDINGS",)
RETURN_NAMES = ("image_embeddings",)
FUNCTION = "process"
CATEGORY = "LightX2V"
def process(self, num_frames, width, height, control_embeds=None):
config = EasyDict(
{
"target_height": height,
"target_width": width,
"target_video_length": num_frames,
"vae_stride": (4, 8, 8),
"patch_size": (1, 2, 2),
}
)
return ({"config": config},)
class Lightx2vWanVideoModelLoader:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model_path": (
"STRING",
{
"default": "/mnt/aigc/shared_data/cache/huggingface/hub/Wan2.1-I2V-14B-480P"
},
),
"model_type": (["t2v", "i2v"], {"default": "i2v"}),
"precision": (["bf16", "fp16", "fp32"], {"default": "bf16"}),
"device": (["cuda", "cpu"], {"default": "cuda"}),
"attention_type": (
["sdpa", "flash_attn2", "flash_attn3"],
{"default": "flash_attn3"},
),
"cpu_offload": ("BOOLEAN", {"default": False}),
"mm_type": ("STRING", {"default": "Default"}),
},
"optional": {
"teacache_args": ("LIGHT_TEACACHEARGS", {"default": None}),
"lora_path": ("STRING", {"default": None}),
"lora_strength": (
"FLOAT",
{"default": 1.0, "min": 0.0, "max": 2.0, "step": 0.01},
),
},
}
RETURN_TYPES = ("LIGHT_WAN_MODEL",)
RETURN_NAMES = ("wan_model",)
FUNCTION = "load_model"
CATEGORY = "LightX2V"
def load_model(
self,
model_path,
model_type,
precision,
device,
attention_type,
mm_type,
lora_path=None,
lora_strength=1.0,
cpu_offload=False,
teacache_args=None,
):
# 映射精度到torch dtype
dtype_map = {
"bf16": torch.bfloat16,
"fp16": torch.float16,
"fp32": torch.float32,
}
dtype = dtype_map[precision]
# 解析设备
if device == "cuda":
device = mm.get_torch_device()
else:
device = torch.device("cpu")
# 首先在模型路径中查找config.json
model_path_dir = os.path.dirname(model_path)
config_json_path = os.path.join(model_path_dir, "config.json")
config_json = {}
if os.path.exists(config_json_path):
with open(config_json_path, "r") as f:
config_json = json.load(f)
else:
logging.error(f"Config file not found at {config_json_path}")
raise FileNotFoundError(f"Config file not found at {config_json_path}")
feature_caching = "Tea" if teacache_args is not None else "NoCaching"
if teacache_args:
teacache_thresh = teacache_args["rel_l1_thresh"]
else:
teacache_thresh = 0.26
# 创建配置字典
config = {
"do_mm_calib": False,
"cpu_offload": cpu_offload,
"parallel_attn_type": None, # [None, "ulysses", "ring"]
"parallel_vae": False,
"max_area": False,
"vae_stride": (4, 8, 8),
"patch_size": (1, 2, 2),
"feature_caching": feature_caching, # ["NoCaching", "TaylorSeer", "Tea"]
"teacache_thresh": teacache_thresh,
"use_ret_steps": False,
"use_bfloat16": dtype == torch.bfloat16,
"mm_config": {
"mm_type": mm_type,
"weight_auto_quant": False if mm_type == "Default" else True,
},
"model_path": model_path,
"task": model_type,
"model_cls": "wan2.1",
"device": device,
"attention_type": attention_type,
"lora_path": lora_path if lora_path and lora_path.strip() else None,
"strength_model": lora_strength,
}
# merge model dir config.json and config dict
config.update(**config_json)
# NOTE(xxx): adapt to Lightx2v
config = EasyDict(config)
logging.info(f"Loaded config:\n {config}")
logging.info(f"Loading WanModel from {model_path} with type {model_type}")
model = WanModel(model_path, config, device)
# 如果指定了LoRA路径,应用LoRA
if lora_path and os.path.exists(lora_path):
logging.info(
f"Applying LoRA from {lora_path} with strength {lora_strength}"
)
lora_wrapper = WanLoraWrapper(model)
lora_name = lora_wrapper.load_lora(lora_path)
lora_wrapper.apply_lora(lora_name, lora_strength)
logging.info(f"LoRA {lora_name} applied successfully")
wan_model = {"wan_model": model, "config": config}
return (wan_model,)
class Lightx2vWanVideoSampler:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model": ("LIGHT_WAN_MODEL",),
"text_embeddings": ("LIGHT_TEXT_EMBEDDINGS",),
"image_embeddings": ("LIGHT_IMAGE_EMBEDDINGS",),
"steps": ("INT", {"default": 20, "min": 1, "max": 100, "step": 1}),
"shift": ("FLOAT", {"default": 5.0}),
"cfg_scale": (
"FLOAT",
{"default": 5, "min": 0.0, "max": 20.0, "step": 0.1},
),
"seed": ("INT", {"default": 42}),
}
}
RETURN_TYPES = ("LIGHT_LATENT",)
RETURN_NAMES = ("latent",)
FUNCTION = "sample"
CATEGORY = "LightX2V"
def sample(
self,
model,
text_embeddings,
steps,
shift,
cfg_scale,
seed,
image_embeddings,
):
# Update model config
model_config = model.get("config")
image_config = image_embeddings.get("config")
model_config.update(image_config)
model_config = cast(Any, model_config)
logging.info(f"Loaded config:\n {model_config}")
# wan model
wan_model = cast(WanModel, model.get("wan_model"))
# clip vision result
clip_encoder_out = image_embeddings.get("clip_encoder_out", None)
# text result
vae_encode_out = image_embeddings.get("vae_encode_out", None)
if model_config.task == "i2v" and (
clip_encoder_out is None or vae_encode_out is None
):
raise ValueError("clip_encoder_out must be provided for i2v task")
model_config.infer_steps = steps
model_config.sample_shift = shift
model_config.sample_guide_scale = cfg_scale
model_config.seed = seed
# wan_runner.set_target_shape
if model_config.task == "i2v":
model_config.target_shape = (16, 21, model_config.lat_h, model_config.lat_w)
elif model_config.task == "t2v":
model_config.target_shape = (
16,
(model_config.target_video_length - 1) // 4 + 1,
int(model_config.target_height) // model_config.vae_stride[1],
int(model_config.target_width) // model_config.vae_stride[2],
)
# wan_runner.init_scheduler
if model_config.feature_caching == "NoCaching":
scheduler = WanScheduler(model_config)
elif model_config.feature_caching == "Tea":
scheduler = WanSchedulerTeaCaching(model_config)
else:
raise NotImplementedError(
f"Unsupported feature_caching type: {model_config.feature_caching}" # type:ignore
)
# setup scheduler
wan_model.set_scheduler(scheduler)
# Set up inputs
inputs = {
"text_encoder_output": text_embeddings,
"image_encoder_output": image_embeddings,
}
# Prepare for sampling
scheduler.prepare(inputs.get("image_encoder_output"))
# Run sampling
progress = ProgressBar(steps)
for step_index in tqdm(
range(scheduler.infer_steps), desc="inference", unit="step"
):
with ProfilingContext("scheduler.step_pre"):
scheduler.step_pre(step_index=step_index)
with ProfilingContext("model.infer"):
wan_model.infer(inputs)
with ProfilingContext("scheduler.step_post"):
scheduler.step_post()
progress.update(1)
scheduler.clear()
del inputs, wan_model, text_embeddings, image_embeddings
torch.cuda.empty_cache()
return ({"samples": scheduler.latents, "generator": scheduler.generator},)
# Register the nodes
NODE_CLASS_MAPPINGS = {
"Lightx2vWanVideoT5EncoderLoader": Lightx2vWanVideoT5EncoderLoader,
"Lightx2vWanVideoT5Encoder": Lightx2vWanVideoT5Encoder,
"Lightx2vWanVideoClipVisionEncoderLoader": Lightx2vWanVideoClipVisionEncoderLoader,
"Lightx2vWanVideoVaeLoader": Lightx2vWanVideoVaeLoader,
"Lightx2vTeaCache": WanVideoTeaCache,
"Lightx2vWanVideoEmptyEmbeds": Lightx2vWanVideoEmptyEmbeds,
"Lightx2vWanVideoImageEncoder": Lightx2vWanVideoImageEncoder,
"Lightx2vWanVideoVaeDecoder": Lightx2vWanVideoVaeDecoder,
"Lightx2vWanVideoModelLoader": Lightx2vWanVideoModelLoader,
"Lightx2vWanVideoSampler": Lightx2vWanVideoSampler,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"Lightx2vWanVideoT5EncoderLoader": "LightX2V WAN T5 Encoder Loader",
"Lightx2vWanVideoT5Encoder": "LightX2V WAN T5 Encoder",
"Lightx2vWanVideoClipVisionEncoderLoader": "LightX2V WAN CLIP Vision Encoder Loader",
"Lightx2vWanVideoClipVisionEncoder": "LightX2V WAN CLIP Vision Encoder",
"Lightx2vWanVideoVaeLoader": "LightX2V WAN VAE Loader",
"Lightx2vWanVideoImageEncoder": "LightX2V WAN Image Encoder",
"Lightx2vWanVideoVaeDecoder": "LightX2V WAN VAE Decoder",
"Lightx2vWanVideoModelLoader": "LightX2V WAN Model Loader",
"Lightx2vWanVideoSampler": "LightX2V WAN Video Sampler",
"Lightx2vTeaCache": "LightX2V WAN Tea Cache",
"Lightx2vWanVideoEmptyEmbeds": "LightX2V WAN Video Empty Embeds",
}