1060 lines
36 KiB
Python
1060 lines
36 KiB
Python
"""Universal bridge between LightX2V and ComfyUI for automatic adaptation."""
|
|
|
|
import json
|
|
import os
|
|
import torch
|
|
import numpy as np
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Union, Optional
|
|
from PIL import Image
|
|
import tempfile
|
|
from easydict import EasyDict
|
|
import asyncio
|
|
import gc
|
|
|
|
# from .config import get_available_attn_ops, get_available_quant_ops
|
|
|
|
from ..lightx2v.lightx2v.utils.set_config import get_default_config
|
|
from ..lightx2v.lightx2v.infer import init_runner
|
|
|
|
|
|
class LightX2VBridge:
|
|
"""Universal bridge for LightX2V and ComfyUI integration."""
|
|
|
|
def __init__(self):
|
|
self._model_registry = None
|
|
self._config_registry = None
|
|
self._quantization_registry = None
|
|
self._current_runner = None # Only store one runner at a time
|
|
self._current_runner_key = None # Track current runner identity
|
|
|
|
@property
|
|
def model_registry(self):
|
|
"""Lazy load model registry."""
|
|
if self._model_registry is None:
|
|
self._model_registry = self._discover_models()
|
|
return self._model_registry
|
|
|
|
@property
|
|
def config_registry(self):
|
|
"""Lazy load config registry."""
|
|
if self._config_registry is None:
|
|
self._config_registry = self._discover_configs()
|
|
return self._config_registry
|
|
|
|
@property
|
|
def quantization_registry(self):
|
|
"""Lazy load quantization registry."""
|
|
if self._quantization_registry is None:
|
|
self._quantization_registry = self._discover_quantization_configs()
|
|
return self._quantization_registry
|
|
|
|
def _discover_models(self) -> List[str]:
|
|
"""Discover available model classes from LightX2V."""
|
|
return ["wan2.1", "hunyuan", "wan2.1_audio", "wan2.1_distill"]
|
|
|
|
def _discover_configs(self) -> Dict[str, Dict]:
|
|
"""Discover all available config files for single-card inference."""
|
|
configs = {}
|
|
base_dir = Path(__file__).parent.parent / "lightx2v" / "configs"
|
|
|
|
if base_dir.exists():
|
|
for config_file in base_dir.rglob("*.json"):
|
|
# Create a descriptive key
|
|
relative_path = config_file.relative_to(base_dir)
|
|
path_str = str(relative_path)
|
|
|
|
# 排除分布式推理和部署相关的配置
|
|
if any(exclude in path_str for exclude in ["deploy", "causvid", "skyreels", "cogvideox"]):
|
|
continue
|
|
|
|
# 包含蒸馏模型配置
|
|
if "distill" in path_str:
|
|
config_info = {
|
|
"path": config_file,
|
|
"model_type": "wan2.1_distill",
|
|
"task": self._extract_task_from_path(path_str),
|
|
"is_quantized": False,
|
|
"is_distilled": True,
|
|
"description": self._generate_config_description(relative_path, is_distilled=True),
|
|
}
|
|
key = str(relative_path).replace("/", "_").replace(".json", "")
|
|
configs[key] = config_info
|
|
continue
|
|
|
|
# 排除量化配置(单独处理)
|
|
if "quantization" in path_str:
|
|
continue
|
|
|
|
# 创建配置信息
|
|
config_info = {
|
|
"path": config_file,
|
|
"model_type": relative_path.parts[0], # wan, hunyuan, etc.
|
|
"task": self._extract_task_from_path(path_str),
|
|
"is_quantized": False,
|
|
"description": self._generate_config_description(relative_path),
|
|
}
|
|
|
|
key = str(relative_path).replace("/", "_").replace(".json", "")
|
|
configs[key] = config_info
|
|
|
|
return configs
|
|
|
|
def _discover_quantization_configs(self) -> Dict[str, Dict]:
|
|
"""Discover quantization configurations."""
|
|
quant_configs = {}
|
|
base_dir = Path(__file__).parent.parent / "lightx2v" / "configs" / "quantization"
|
|
|
|
if base_dir.exists():
|
|
for config_file in base_dir.rglob("*.json"):
|
|
relative_path = config_file.relative_to(base_dir.parent)
|
|
path_str = str(relative_path)
|
|
|
|
config_info = {
|
|
"path": config_file,
|
|
"model_type": relative_path.parts[1], # wan, hunyuan, etc.
|
|
"task": self._extract_task_from_path(path_str),
|
|
"is_quantized": True,
|
|
"description": self._generate_config_description(relative_path, is_quantized=True),
|
|
}
|
|
|
|
key = f"quant_{str(relative_path).replace('/', '_').replace('.json', '')}"
|
|
quant_configs[key] = config_info
|
|
|
|
return quant_configs
|
|
|
|
def _extract_task_from_path(self, path_str: str) -> str:
|
|
"""Extract task type from config path."""
|
|
if "i2v" in path_str:
|
|
return "i2v"
|
|
elif "t2v" in path_str:
|
|
return "t2v"
|
|
else:
|
|
return "unknown"
|
|
|
|
def _generate_config_description(
|
|
self,
|
|
relative_path: Path,
|
|
is_quantized: bool = False,
|
|
is_distilled: bool = False,
|
|
) -> str:
|
|
"""Generate human-readable description for config."""
|
|
parts = relative_path.parts
|
|
model_type = parts[0] if not is_quantized else parts[1]
|
|
task = self._extract_task_from_path(str(relative_path))
|
|
|
|
desc = f"{model_type.upper()} {task.upper()}"
|
|
if is_quantized:
|
|
desc += " (Quantized)"
|
|
if is_distilled:
|
|
desc += " (Distilled 4-step)"
|
|
|
|
return desc
|
|
|
|
def get_quantization_model_path(self, model_type: str, mm_type: str) -> str:
|
|
"""Get quantization model path based on model type and mm_type."""
|
|
# 定义量化模型的固定路径结构
|
|
base_path = Path(__file__).parent.parent / "lightx2v" / "models" / "quantized"
|
|
|
|
# 根据mm_type确定具体的模型文件名
|
|
mm_type_to_filename = {
|
|
"W-int8-channel-sym-A-int8-channel-sym-dynamic-Vllm": "int8_dynamic_vllm.safetensors",
|
|
"W-int8-channel-sym-A-fp16-dynamic-Vllm": "int8_fp16_dynamic_vllm.safetensors",
|
|
"W-fp16-A-fp16-dynamic-Vllm": "fp16_dynamic_vllm.safetensors",
|
|
}
|
|
|
|
filename = mm_type_to_filename.get(mm_type, "default_quantized.safetensors")
|
|
model_path = base_path / model_type / filename
|
|
|
|
return str(model_path)
|
|
|
|
def load_config(self, config_path: Union[str, Path]) -> Dict:
|
|
"""Load a config file."""
|
|
with open(config_path, "r") as f:
|
|
return json.load(f)
|
|
|
|
def get_runner(self, config: EasyDict):
|
|
"""Get or create a runner for the given config."""
|
|
# Create a simple key based on essential parameters only
|
|
# Only model_cls and model_path are essential for runner identity
|
|
key = f"{config.model_cls}_{config.model_path}" # type:ignore
|
|
|
|
# If we have a different runner, clear the old one first
|
|
if self._current_runner_key != key:
|
|
self.clear_cache()
|
|
self._current_runner = init_runner(config)
|
|
self._current_runner_key = key
|
|
|
|
return self._current_runner
|
|
|
|
def clear_cache(self):
|
|
"""Clear cached runner to free memory."""
|
|
if self._current_runner is not None:
|
|
# Clean up the runner if it has cleanup methods
|
|
self._current_runner = None
|
|
self._current_runner_key = None
|
|
torch.cuda.empty_cache()
|
|
|
|
|
|
class InputOutputConverter:
|
|
"""Convert between ComfyUI and LightX2V formats."""
|
|
|
|
@staticmethod
|
|
def comfy_image_to_pil(comfy_image: torch.Tensor) -> Image.Image:
|
|
"""Convert ComfyUI image tensor to PIL Image.
|
|
|
|
ComfyUI format: [B, H, W, C] float32 0-1
|
|
"""
|
|
# Take first image from batch
|
|
image_np = (comfy_image[0].cpu().numpy() * 255).astype(np.uint8)
|
|
return Image.fromarray(image_np)
|
|
|
|
@staticmethod
|
|
def comfy_audio_to_path(comfy_audio: Dict) -> str:
|
|
"""Convert ComfyUI audio to file path."""
|
|
# ComfyUI audio format: {"waveform": tensor, "sample_rate": int}
|
|
waveform = comfy_audio["waveform"]
|
|
sample_rate = comfy_audio["sample_rate"]
|
|
|
|
# Save to temporary file
|
|
import torchaudio
|
|
|
|
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
|
|
torchaudio.save(tmp.name, waveform, sample_rate)
|
|
return tmp.name
|
|
|
|
@staticmethod
|
|
def video_to_latent(video_path: str) -> Dict[str, Any]:
|
|
"""Convert video path to ComfyUI latent format."""
|
|
# For now, return the path wrapped in latent format
|
|
# This allows downstream nodes to handle the video
|
|
return {"samples": video_path, "type": "video", "format": "path"}
|
|
|
|
@staticmethod
|
|
def tensor_to_latent(video_tensor: torch.Tensor) -> Dict[str, Any]:
|
|
"""Convert video tensor to ComfyUI latent format."""
|
|
return {"samples": video_tensor, "type": "video", "format": "tensor"}
|
|
|
|
|
|
class ConfigManager:
|
|
"""Manage configuration merging and parameter mapping."""
|
|
|
|
# Mapping from ComfyUI parameter names to LightX2V config keys
|
|
PARAM_MAPPING = {
|
|
"steps": "infer_steps",
|
|
"cfg_scale": "sample_guide_scale",
|
|
"seed": "seed",
|
|
"height": "target_height",
|
|
"width": "target_width",
|
|
"video_length": "target_video_length",
|
|
}
|
|
|
|
# 用户可配置的参数(在ComfyUI中显示)
|
|
USER_CONFIGURABLE_PARAMS = {
|
|
"steps": {
|
|
"type": "INT",
|
|
"default": 20,
|
|
"min": 1,
|
|
"max": 200,
|
|
"tooltip": "推理步数 (-1使用默认值)",
|
|
},
|
|
"cfg_scale": {
|
|
"type": "FLOAT",
|
|
"default": 7,
|
|
"min": 0.1,
|
|
"max": 30,
|
|
"step": 0.1,
|
|
"tooltip": "CFG引导强度 (-1使用默认值)",
|
|
},
|
|
"seed": {
|
|
"type": "INT",
|
|
"default": 42,
|
|
"min": -1,
|
|
"max": 2**32 - 1,
|
|
"tooltip": "随机种子 (-1随机)",
|
|
},
|
|
"height": {
|
|
"type": "INT",
|
|
"default": 640,
|
|
"min": 64,
|
|
"max": 2048,
|
|
"step": 8,
|
|
"tooltip": "视频高度 (-1使用默认值)",
|
|
},
|
|
"width": {
|
|
"type": "INT",
|
|
"default": 640,
|
|
"min": 64,
|
|
"max": 2048,
|
|
"step": 8,
|
|
"tooltip": "视频宽度 (-1使用默认值)",
|
|
},
|
|
"video_length": {
|
|
"type": "INT",
|
|
"default": 1,
|
|
"min": 1,
|
|
"max": 300,
|
|
"tooltip": "视频帧数 (-1使用默认值)",
|
|
},
|
|
"sample_shift": {
|
|
"type": "INT",
|
|
"default": 5,
|
|
"min": 0,
|
|
"max": 20,
|
|
"tooltip": "采样偏移 (-1使用默认值)",
|
|
},
|
|
}
|
|
|
|
@classmethod
|
|
def create_config(
|
|
cls,
|
|
model_cls: str,
|
|
model_path: str,
|
|
task: str,
|
|
base_config: Dict,
|
|
overrides: Dict,
|
|
quantization_config: Optional[Dict] = None,
|
|
) -> EasyDict:
|
|
"""Create a complete config for LightX2V runner."""
|
|
# Start with default config
|
|
config = get_default_config()
|
|
|
|
# Add required fields
|
|
config.update(
|
|
{
|
|
"model_cls": model_cls,
|
|
"model_path": model_path,
|
|
"task": task,
|
|
"mode": "infer",
|
|
}
|
|
)
|
|
|
|
# Apply base config from file
|
|
config.update(base_config)
|
|
|
|
# Apply quantization config if provided
|
|
if quantization_config:
|
|
config.update(quantization_config)
|
|
# 自动设置量化模型路径
|
|
if "mm_config" in quantization_config and "mm_type" in quantization_config["mm_config"]:
|
|
mm_type = quantization_config["mm_config"]["mm_type"]
|
|
config["dit_quantized_ckpt"] = cls._get_quantization_model_path(model_cls, mm_type)
|
|
|
|
# Apply ComfyUI overrides
|
|
for comfy_key, value in overrides.items():
|
|
if value is not None and value != -1: # -1 means use default
|
|
if comfy_key in cls.PARAM_MAPPING:
|
|
lightx2v_key = cls.PARAM_MAPPING[comfy_key]
|
|
config[lightx2v_key] = value
|
|
else:
|
|
# Direct mapping for unknown parameters
|
|
config[comfy_key] = value
|
|
|
|
# 检查并加载模型路径中的config.json
|
|
config = cls._load_model_config(config)
|
|
|
|
return EasyDict(config)
|
|
|
|
@classmethod
|
|
def _load_model_config(cls, config: Dict) -> Dict:
|
|
"""Load and merge config.json from model path if it exists."""
|
|
model_path = config.get("model_path", "")
|
|
if not model_path:
|
|
return config
|
|
|
|
model_config_path = os.path.join(model_path, "config.json")
|
|
print(f"Loading model config from: {model_config_path}", flush=True)
|
|
if os.path.exists(model_config_path):
|
|
try:
|
|
with open(model_config_path, "r") as f:
|
|
model_config = json.load(f)
|
|
# 合并模型配置,模型配置优先级更高
|
|
config.update(model_config)
|
|
print(f"Loaded model config from: {model_config_path}")
|
|
except Exception as e:
|
|
print(f"Failed to load model config from {model_config_path}: {e}")
|
|
|
|
return config
|
|
|
|
@classmethod
|
|
def _get_quantization_model_path(cls, model_cls: str, mm_type: str) -> str:
|
|
"""Get quantization model path based on model class and mm_type."""
|
|
bridge = LightX2VBridge()
|
|
return bridge.get_quantization_model_path(model_cls, mm_type)
|
|
|
|
@classmethod
|
|
def parse_custom_config(cls, custom_config_str: str) -> Dict:
|
|
"""Parse custom config string."""
|
|
if not custom_config_str.strip():
|
|
return {}
|
|
|
|
try:
|
|
return json.loads(custom_config_str)
|
|
except json.JSONDecodeError as e:
|
|
print(f"Failed to parse custom config: {e}")
|
|
return {}
|
|
|
|
|
|
class LightX2VConfigBuilder:
|
|
"""Build LightX2V configuration from various sources."""
|
|
|
|
def __init__(self):
|
|
self.bridge = LightX2VBridge()
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
"""Define inputs for the config builder."""
|
|
bridge = LightX2VBridge()
|
|
|
|
# Get available models and configs
|
|
model_choices = bridge.model_registry
|
|
|
|
# 构建配置选项
|
|
config_choices = ["custom"]
|
|
config_descriptions = {}
|
|
|
|
# 添加普通配置
|
|
for key, config_info in bridge.config_registry.items():
|
|
config_choices.append(key)
|
|
config_descriptions[key] = config_info["description"]
|
|
|
|
required_inputs = {
|
|
"model_cls": (model_choices, {"tooltip": "选择模型类型"}),
|
|
"model_path": (
|
|
"STRING",
|
|
{"default": "", "tooltip": "模型权重路径(留空使用默认路径)"},
|
|
),
|
|
"task": (
|
|
["t2v", "i2v"],
|
|
{
|
|
"default": "t2v",
|
|
"tooltip": "任务类型: 文本到视频或图像到视频",
|
|
},
|
|
),
|
|
"config_preset": (
|
|
config_choices,
|
|
{
|
|
"default": "custom",
|
|
"tooltip": "配置预设或'custom'自定义",
|
|
},
|
|
),
|
|
}
|
|
|
|
# 添加用户可配置参数
|
|
for param_name, param_config in ConfigManager.USER_CONFIGURABLE_PARAMS.items():
|
|
required_inputs[param_name] = (
|
|
param_config["type"],
|
|
{
|
|
"default": param_config["default"],
|
|
"min": param_config["min"],
|
|
"max": param_config["max"],
|
|
"tooltip": param_config["tooltip"],
|
|
},
|
|
)
|
|
if "step" in param_config:
|
|
required_inputs[param_name][1]["step"] = param_config["step"]
|
|
|
|
optional_inputs = {
|
|
"custom_config": (
|
|
"STRING",
|
|
{
|
|
"multiline": True,
|
|
"default": "{}",
|
|
"tooltip": "自定义配置(JSON格式)",
|
|
},
|
|
),
|
|
"quantization_config": (
|
|
"LIGHTX2V_CONFIG",
|
|
{"tooltip": "量化配置"},
|
|
),
|
|
"attention_config": (
|
|
"LIGHTX2V_CONFIG",
|
|
{"tooltip": "注意力机制配置"},
|
|
),
|
|
"caching_config": (
|
|
"LIGHTX2V_CONFIG",
|
|
{"tooltip": "缓存配置"},
|
|
),
|
|
"distill_config": (
|
|
"LIGHTX2V_CONFIG",
|
|
{"tooltip": "蒸馏配置"},
|
|
),
|
|
"lora_path": (
|
|
"STRING",
|
|
{"default": "", "tooltip": "LoRA权重路径"},
|
|
),
|
|
"strength_model": (
|
|
"FLOAT",
|
|
{
|
|
"default": 1.0,
|
|
"min": 0.0,
|
|
"max": 2.0,
|
|
"step": 0.1,
|
|
"tooltip": "LoRA模型强度",
|
|
},
|
|
),
|
|
}
|
|
|
|
return {
|
|
"required": required_inputs,
|
|
"optional": optional_inputs,
|
|
}
|
|
|
|
RETURN_TYPES = ("LIGHTX2V_CONFIG",)
|
|
RETURN_NAMES = ("config",)
|
|
FUNCTION = "build_config"
|
|
CATEGORY = "LightX2V/Config"
|
|
|
|
def build_config(
|
|
self,
|
|
model_cls,
|
|
model_path,
|
|
task,
|
|
config_preset,
|
|
steps,
|
|
cfg_scale,
|
|
seed,
|
|
height,
|
|
width,
|
|
video_length,
|
|
sample_shift,
|
|
custom_config="{}",
|
|
quantization_config=None,
|
|
attention_config=None,
|
|
caching_config=None,
|
|
distill_config=None,
|
|
lora_path="",
|
|
strength_model=1.0,
|
|
**kwargs,
|
|
):
|
|
"""Build configuration for LightX2V inference."""
|
|
|
|
# 加载基础配置
|
|
if config_preset == "custom":
|
|
base_config = {}
|
|
else:
|
|
config_info = self.bridge.config_registry.get(config_preset)
|
|
if config_info:
|
|
base_config = self.bridge.load_config(config_info["path"])
|
|
else:
|
|
raise ValueError(f"配置预设 '{config_preset}' 未找到")
|
|
|
|
# 解析自定义配置
|
|
custom_cfg = ConfigManager.parse_custom_config(custom_config)
|
|
base_config.update(custom_cfg)
|
|
|
|
# 合并其他配置
|
|
if quantization_config:
|
|
base_config.update(quantization_config)
|
|
if attention_config:
|
|
base_config.update(attention_config)
|
|
if caching_config:
|
|
base_config.update(caching_config)
|
|
if distill_config:
|
|
base_config.update(distill_config)
|
|
|
|
# 准备覆盖参数
|
|
overrides = {
|
|
"seed": seed if seed != -1 else None,
|
|
"steps": steps,
|
|
"cfg_scale": cfg_scale,
|
|
"height": height,
|
|
"width": width,
|
|
"video_length": video_length,
|
|
"sample_shift": sample_shift,
|
|
"lora_path": lora_path if lora_path else None,
|
|
"strength_model": strength_model,
|
|
}
|
|
|
|
# 创建最终配置
|
|
config = ConfigManager.create_config(model_cls, model_path, task, base_config, overrides)
|
|
|
|
return (config,)
|
|
|
|
|
|
class LightX2VQuantizationConfig:
|
|
"""Configuration node for quantization settings."""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
"""Define inputs for quantization config."""
|
|
bridge = LightX2VBridge()
|
|
|
|
# Get available quantization configs
|
|
quant_choices = ["custom"]
|
|
for key, config_info in bridge.quantization_registry.items():
|
|
quant_choices.append(key)
|
|
|
|
return {
|
|
"required": {
|
|
"quantization_preset": (
|
|
quant_choices,
|
|
{
|
|
"default": "custom",
|
|
"tooltip": "量化配置预设",
|
|
},
|
|
),
|
|
},
|
|
"optional": {
|
|
"mm_type": (
|
|
[
|
|
"W-int8-channel-sym-A-int8-channel-sym-dynamic-Vllm",
|
|
"W-int8-channel-sym-A-fp16-dynamic-Vllm",
|
|
"W-fp16-A-fp16-dynamic-Vllm",
|
|
],
|
|
{
|
|
"default": "W-int8-channel-sym-A-int8-channel-sym-dynamic-Vllm",
|
|
"tooltip": "量化类型",
|
|
},
|
|
),
|
|
"dit_quantized_ckpt": (
|
|
"STRING",
|
|
{"default": "", "tooltip": "量化模型路径"},
|
|
),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("LIGHTX2V_CONFIG",)
|
|
RETURN_NAMES = ("quantization_config",)
|
|
FUNCTION = "build_quantization_config"
|
|
CATEGORY = "LightX2V/Config"
|
|
|
|
def build_quantization_config(
|
|
self,
|
|
quantization_preset,
|
|
mm_type="W-int8-channel-sym-A-int8-channel-sym-dynamic-Vllm",
|
|
dit_quantized_ckpt="",
|
|
):
|
|
"""Build quantization configuration."""
|
|
bridge = LightX2VBridge()
|
|
|
|
if quantization_preset == "custom":
|
|
config: dict[str, Any] = {
|
|
"mm_config": {"mm_type": mm_type},
|
|
}
|
|
if dit_quantized_ckpt:
|
|
config["dit_quantized_ckpt"] = dit_quantized_ckpt
|
|
else:
|
|
config_info = bridge.quantization_registry.get(quantization_preset)
|
|
if config_info:
|
|
config = bridge.load_config(config_info["path"])
|
|
else:
|
|
raise ValueError(f"量化配置 '{quantization_preset}' 未找到")
|
|
|
|
return (config,)
|
|
|
|
|
|
class LightX2VAttentionConfig:
|
|
"""Configuration node for attention mechanisms."""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
"""Define inputs for attention config."""
|
|
return {
|
|
"required": {
|
|
"self_attn_1_type": (
|
|
["flash_attn3", "sage_attn2", "radial_attn", "sparge_attn"],
|
|
{
|
|
"default": "flash_attn3",
|
|
"tooltip": "自注意力类型",
|
|
},
|
|
),
|
|
"cross_attn_1_type": (
|
|
["flash_attn3", "sage_attn2", "radial_attn", "sparge_attn"],
|
|
{
|
|
"default": "flash_attn3",
|
|
"tooltip": "交叉注意力1类型",
|
|
},
|
|
),
|
|
"cross_attn_2_type": (
|
|
["flash_attn3", "sage_attn2", "radial_attn", "sparge_attn"],
|
|
{
|
|
"default": "flash_attn3",
|
|
"tooltip": "交叉注意力2类型",
|
|
},
|
|
),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("LIGHTX2V_CONFIG",)
|
|
RETURN_NAMES = ("attention_config",)
|
|
FUNCTION = "build_attention_config"
|
|
CATEGORY = "LightX2V/Config"
|
|
|
|
def build_attention_config(
|
|
self,
|
|
self_attn_1_type,
|
|
cross_attn_1_type,
|
|
cross_attn_2_type,
|
|
):
|
|
"""Build attention configuration."""
|
|
config = {
|
|
"self_attn_1_type": self_attn_1_type,
|
|
"cross_attn_1_type": cross_attn_1_type,
|
|
"cross_attn_2_type": cross_attn_2_type,
|
|
}
|
|
|
|
return (config,)
|
|
|
|
|
|
class LightX2VCachingConfig:
|
|
"""Configuration node for caching mechanisms."""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
"""Define inputs for caching config."""
|
|
return {
|
|
"required": {
|
|
"feature_caching": (
|
|
["NoCaching", "Tea", "TaylorSeer", "Ada", "Custom"],
|
|
{
|
|
"default": "NoCaching",
|
|
"tooltip": "特征缓存类型",
|
|
},
|
|
),
|
|
},
|
|
"optional": {
|
|
"teacache_thresh": (
|
|
"FLOAT",
|
|
{
|
|
"default": 0.26,
|
|
"min": 0.0,
|
|
"max": 1.0,
|
|
"step": 0.01,
|
|
"tooltip": "TeaCache阈值",
|
|
},
|
|
),
|
|
"use_ret_steps": (
|
|
"BOOLEAN",
|
|
{
|
|
"default": True,
|
|
"tooltip": "使用返回步骤",
|
|
},
|
|
),
|
|
"coefficients": (
|
|
"STRING",
|
|
{
|
|
"multiline": True,
|
|
"default": "",
|
|
"tooltip": "系数配置(JSON格式)",
|
|
},
|
|
),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("LIGHTX2V_CONFIG",)
|
|
RETURN_NAMES = ("caching_config",)
|
|
FUNCTION = "build_caching_config"
|
|
CATEGORY = "LightX2V/Config"
|
|
|
|
def build_caching_config(
|
|
self,
|
|
feature_caching,
|
|
teacache_thresh=0.26,
|
|
use_ret_steps=True,
|
|
coefficients="",
|
|
):
|
|
"""Build caching configuration."""
|
|
config = {}
|
|
|
|
if feature_caching != "NoCaching":
|
|
config["feature_caching"] = feature_caching
|
|
|
|
if feature_caching == "Tea":
|
|
config["teacache_thresh"] = teacache_thresh
|
|
config["use_ret_steps"] = use_ret_steps
|
|
|
|
if coefficients:
|
|
try:
|
|
config["coefficients"] = json.loads(coefficients)
|
|
except json.JSONDecodeError:
|
|
print(f"Failed to parse coefficients: {coefficients}")
|
|
else:
|
|
config["feature_caching"] = "NoCaching"
|
|
|
|
return (config,)
|
|
|
|
|
|
class LightX2VInference:
|
|
"""LightX2V inference node that generates images directly."""
|
|
|
|
def __init__(self):
|
|
self.bridge = LightX2VBridge()
|
|
self.converter = InputOutputConverter()
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
"""Define inputs for the inference node."""
|
|
return {
|
|
"required": {
|
|
"config": ("LIGHTX2V_CONFIG", {"tooltip": "LightX2V配置"}),
|
|
"prompt": (
|
|
"STRING",
|
|
{
|
|
"multiline": True,
|
|
"default": "",
|
|
"tooltip": "生成提示词",
|
|
},
|
|
),
|
|
"negative_prompt": (
|
|
"STRING",
|
|
{"multiline": True, "default": "", "tooltip": "负面提示词"},
|
|
),
|
|
},
|
|
"optional": {
|
|
"image": ("IMAGE", {"tooltip": "i2v任务的输入图像"}),
|
|
"audio": (
|
|
"AUDIO",
|
|
{"tooltip": "音频驱动生成的输入音频"},
|
|
),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE",)
|
|
RETURN_NAMES = ("images",)
|
|
FUNCTION = "generate"
|
|
CATEGORY = "LightX2V/Inference"
|
|
|
|
def generate(
|
|
self,
|
|
config,
|
|
prompt,
|
|
negative_prompt,
|
|
image=None,
|
|
audio=None,
|
|
**kwargs,
|
|
):
|
|
"""Generate images using LightX2V."""
|
|
|
|
# 直接使用传入的config (已经是EasyDict)
|
|
# 添加运行时需要的参数
|
|
config.prompt = prompt
|
|
config.negative_prompt = negative_prompt
|
|
config.mode = "infer"
|
|
|
|
# 设置环境变量(从run_wan_i2v.sh中提取)
|
|
os.environ["TOKENIZERS_PARALLELISM"] = "false"
|
|
if "DTYPE" not in os.environ:
|
|
os.environ["DTYPE"] = "BF16" # 可通过配置更改
|
|
if "ENABLE_GRAPH_MODE" not in os.environ:
|
|
os.environ["ENABLE_GRAPH_MODE"] = "false"
|
|
if "ENABLE_PROFILING_DEBUG" not in os.environ:
|
|
os.environ["ENABLE_PROFILING_DEBUG"] = "true"
|
|
|
|
# 临时文件列表,用于清理
|
|
temp_files = []
|
|
|
|
try:
|
|
# 处理i2v任务的图像输入
|
|
if config.task == "i2v" and image is not None:
|
|
# 保存图像到临时文件
|
|
pil_image = self.converter.comfy_image_to_pil(image)
|
|
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
|
pil_image.save(tmp.name)
|
|
config.image_path = tmp.name
|
|
temp_files.append(tmp.name)
|
|
elif config.task == "i2v" and image is None:
|
|
raise ValueError("i2v任务需要输入图像")
|
|
|
|
# 处理音频输入
|
|
if audio is not None and "audio" in config.model_cls:
|
|
audio_path = self.converter.comfy_audio_to_path(audio)
|
|
config.audio_path = audio_path
|
|
temp_files.append(audio_path)
|
|
|
|
# 获取或创建runner
|
|
runner = self.bridge.get_runner(config)
|
|
|
|
if runner is None:
|
|
raise RuntimeError("Failed to initialize runner")
|
|
|
|
# 运行生成 - 我们需要创建一个自定义的pipeline来获取latents
|
|
# 由于原始的run_pipeline方法会删除latents和generator,我们需要分步执行
|
|
if asyncio.iscoroutinefunction(runner.run_pipeline):
|
|
# 在同步环境中运行异步方法
|
|
try:
|
|
# 使用现有的事件循环(如果存在)或创建新的
|
|
loop = asyncio.get_event_loop()
|
|
if loop.is_closed():
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
except RuntimeError:
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
|
|
try:
|
|
# 分步执行pipeline,在删除latents前获取它们
|
|
# 这里我们使用try/except来捕获可能的属性访问错误
|
|
try:
|
|
# 尝试分步执行
|
|
if hasattr(runner, "run_input_encoder"):
|
|
runner.inputs = loop.run_until_complete(runner.run_input_encoder())
|
|
if hasattr(runner, "set_target_shape"):
|
|
kwargs = runner.set_target_shape()
|
|
if hasattr(runner, "run_dit"):
|
|
latents, generator = loop.run_until_complete(runner.run_dit(kwargs))
|
|
images = loop.run_until_complete(runner.run_vae_decoder(latents, generator))
|
|
# 直接解码latents为图像
|
|
return self._decode_latents_to_images(images)
|
|
except (AttributeError, TypeError):
|
|
raise RuntimeError("Failed to get latents from generation")
|
|
except Exception as e:
|
|
print(f"Error during pipeline execution: {e}")
|
|
raise
|
|
else:
|
|
try:
|
|
if hasattr(runner, "run_input_encoder"):
|
|
runner.inputs = runner.run_input_encoder()
|
|
if hasattr(runner, "set_target_shape"):
|
|
kwargs = runner.set_target_shape()
|
|
if hasattr(runner, "run_dit"):
|
|
latents, generator = runner.run_dit(kwargs)
|
|
# 直接解码latents为图像
|
|
images = runner.run_vae_decoder(latents, generator)
|
|
return self._decode_latents_to_images(images)
|
|
except (AttributeError, TypeError):
|
|
raise RuntimeError("Failed to get latents from generation")
|
|
|
|
except Exception as e:
|
|
print(f"Error in LightX2V generation: {e}")
|
|
raise
|
|
finally:
|
|
# 清理临时文件
|
|
for temp_file in temp_files:
|
|
if os.path.exists(temp_file):
|
|
try:
|
|
os.unlink(temp_file)
|
|
except Exception:
|
|
pass
|
|
|
|
def _decode_latents_to_images(self, decoded_images):
|
|
"""Decode latents to images using VAE."""
|
|
|
|
# 归一化从 [-1, 1] 到 [0, 1]
|
|
images = (decoded_images + 1) / 2
|
|
|
|
# 重新排列维度为ComfyUI格式 [T, H, W, C]
|
|
images = images.squeeze(0).permute(1, 2, 3, 0).cpu()
|
|
images = torch.clamp(images, 0, 1)
|
|
|
|
return (images,)
|
|
|
|
|
|
class LightX2VDistillConfig:
|
|
"""Configuration node for distillation settings."""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
"""Define inputs for distillation config."""
|
|
return {
|
|
"required": {
|
|
"enable_distill": (
|
|
"BOOLEAN",
|
|
{
|
|
"default": True,
|
|
"tooltip": "启用蒸馏模型",
|
|
},
|
|
),
|
|
"infer_steps": (
|
|
"INT",
|
|
{
|
|
"default": 4,
|
|
"min": 1,
|
|
"max": 50,
|
|
"tooltip": "推理步数(蒸馏模型通常使用4步)",
|
|
},
|
|
),
|
|
"denoising_steps": (
|
|
"STRING",
|
|
{
|
|
"default": "[999, 750, 500, 250]",
|
|
"tooltip": "去噪步骤列表(JSON格式)",
|
|
},
|
|
),
|
|
},
|
|
"optional": {
|
|
"enable_cfg": (
|
|
"BOOLEAN",
|
|
{
|
|
"default": False,
|
|
"tooltip": "启用CFG(分类器自由引导)",
|
|
},
|
|
),
|
|
"enable_dynamic_cfg": (
|
|
"BOOLEAN",
|
|
{
|
|
"default": False,
|
|
"tooltip": "启用动态CFG",
|
|
},
|
|
),
|
|
"cfg_scale": (
|
|
"FLOAT",
|
|
{
|
|
"default": 4.0,
|
|
"min": 0.1,
|
|
"max": 30.0,
|
|
"step": 0.1,
|
|
"tooltip": "动态CFG缩放比例",
|
|
},
|
|
),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("LIGHTX2V_CONFIG",)
|
|
RETURN_NAMES = ("distill_config",)
|
|
FUNCTION = "build_distill_config"
|
|
CATEGORY = "LightX2V/Config"
|
|
|
|
def build_distill_config(
|
|
self,
|
|
enable_distill,
|
|
infer_steps=4,
|
|
denoising_steps="[999, 750, 500, 250]",
|
|
enable_cfg=False,
|
|
enable_dynamic_cfg=False,
|
|
cfg_scale=4.0,
|
|
):
|
|
"""Build distillation configuration."""
|
|
config = {}
|
|
|
|
if enable_distill:
|
|
config["infer_steps"] = infer_steps
|
|
config["enable_cfg"] = enable_cfg
|
|
config["enable_dynamic_cfg"] = enable_dynamic_cfg
|
|
|
|
if enable_dynamic_cfg:
|
|
config["cfg_scale"] = cfg_scale
|
|
|
|
try:
|
|
config["denoising_step_list"] = json.loads(denoising_steps)
|
|
# 确保去噪步骤列表长度与推理步数匹配
|
|
if len(config["denoising_step_list"]) != infer_steps:
|
|
print(f"Warning: denoising_step_list length ({len(config['denoising_step_list'])}) doesn't match infer_steps ({infer_steps})")
|
|
except json.JSONDecodeError:
|
|
print(f"Failed to parse denoising steps, using default: {denoising_steps}")
|
|
config["denoising_step_list"] = [999, 750, 500, 250]
|
|
|
|
return (config,)
|
|
|
|
|
|
# Node class mapping
|
|
NODE_CLASS_MAPPINGS = {
|
|
# Modular nodes
|
|
"LightX2VConfigBuilder": LightX2VConfigBuilder,
|
|
"LightX2VQuantizationConfig": LightX2VQuantizationConfig,
|
|
"LightX2VAttentionConfig": LightX2VAttentionConfig,
|
|
"LightX2VCachingConfig": LightX2VCachingConfig,
|
|
"LightX2VDistillConfig": LightX2VDistillConfig,
|
|
"LightX2VInference": LightX2VInference,
|
|
}
|
|
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
# Modular nodes
|
|
"LightX2VConfigBuilder": "LightX2V Config Builder",
|
|
"LightX2VQuantizationConfig": "LightX2V Quantization Config",
|
|
"LightX2VAttentionConfig": "LightX2V Attention Config",
|
|
"LightX2VCachingConfig": "LightX2V Caching Config",
|
|
"LightX2VDistillConfig": "LightX2V Distill Config",
|
|
"LightX2VInference": "LightX2V Inference",
|
|
}
|