feat: add return_video option in LightX2VDefaultConfig; refactor TalkObjectInput and TalkObjectsBuilder for improved audio and mask handling; introduce TalkObjectsFromJSON and TalkObjectsFromFiles for flexible configuration input
This commit is contained in:
+1
-1
@@ -1,4 +1,4 @@
|
||||
line-length = 150
|
||||
indent-width = 4
|
||||
|
||||
extend-select = ["I"]
|
||||
lint.extend-select = ["I"]
|
||||
|
||||
@@ -137,6 +137,7 @@ class LightX2VDefaultConfig:
|
||||
"seq_parallel": False,
|
||||
"cfg_parallel": False,
|
||||
"audio_sr": 16000,
|
||||
"return_video": True,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
from easydict import EasyDict
|
||||
|
||||
from .bridge import (
|
||||
ModularConfigManager,
|
||||
)
|
||||
from .data_models import (
|
||||
CombinedConfig,
|
||||
InferenceConfig,
|
||||
LoRAConfig,
|
||||
MemoryOptimizationConfig,
|
||||
QuantizationConfig,
|
||||
TalkObject,
|
||||
TalkObjectsConfig,
|
||||
TeaCacheConfig,
|
||||
)
|
||||
from .file_handlers import (
|
||||
AudioFileHandler,
|
||||
ComfyUIFileResolver,
|
||||
MaskFileHandler,
|
||||
TempFileManager,
|
||||
)
|
||||
from .model_utils import get_lora_full_path, get_model_full_path
|
||||
|
||||
|
||||
class ConfigValidator:
|
||||
"""Validator for configuration parameters."""
|
||||
|
||||
@staticmethod
|
||||
def validate_inference_config(config: InferenceConfig) -> InferenceConfig:
|
||||
"""Validate and adjust inference configuration."""
|
||||
# Ensure video length is valid
|
||||
if config.video_length < 16:
|
||||
logging.warning("Video length is too short, setting to 16")
|
||||
config.video_length = 16
|
||||
|
||||
# Adjust video length to be compatible with model requirements
|
||||
remainder = (config.video_length - 1) % 4
|
||||
if remainder != 0:
|
||||
config.video_length = config.video_length + (4 - remainder)
|
||||
|
||||
# Set FPS based on model class
|
||||
if config.model_cls == "hunyuan":
|
||||
config.fps = 24
|
||||
else:
|
||||
config.fps = 16
|
||||
|
||||
# Handle random seed
|
||||
if config.seed == -1:
|
||||
config.seed = np.random.randint(0, 2**32 - 1)
|
||||
|
||||
return config
|
||||
|
||||
@staticmethod
|
||||
def validate_dimensions(height: int, width: int) -> Tuple[int, int]:
|
||||
"""Validate and adjust video dimensions."""
|
||||
# Ensure dimensions are multiples of 8
|
||||
height = (height // 8) * 8
|
||||
width = (width // 8) * 8
|
||||
|
||||
# Ensure minimum dimensions
|
||||
height = max(64, height)
|
||||
width = max(64, width)
|
||||
|
||||
# Ensure maximum dimensions
|
||||
height = min(2048, height)
|
||||
width = min(2048, width)
|
||||
|
||||
return height, width
|
||||
|
||||
|
||||
class InferenceConfigBuilder:
|
||||
"""Builder for inference configuration."""
|
||||
|
||||
def __init__(self):
|
||||
self.validator = ConfigValidator()
|
||||
|
||||
def build(
|
||||
self,
|
||||
model_cls: str,
|
||||
model_name: str,
|
||||
task: str,
|
||||
infer_steps: int,
|
||||
seed: int,
|
||||
cfg_scale: float,
|
||||
cfg_scale2: float,
|
||||
sample_shift: int,
|
||||
height: int,
|
||||
width: int,
|
||||
duration: float,
|
||||
attention_type: str,
|
||||
**optional_params,
|
||||
) -> InferenceConfig:
|
||||
"""Build inference configuration from parameters."""
|
||||
# Get model path
|
||||
model_path = get_model_full_path(model_name)
|
||||
|
||||
# Calculate video length from duration
|
||||
fps = 24 if model_cls == "hunyuan" else 16
|
||||
video_length = int(round(duration * fps))
|
||||
|
||||
# Validate dimensions
|
||||
height, width = self.validator.validate_dimensions(height, width)
|
||||
|
||||
# Create base config
|
||||
config = InferenceConfig(
|
||||
model_cls=model_cls,
|
||||
model_path=model_path,
|
||||
task=task,
|
||||
infer_steps=infer_steps,
|
||||
seed=seed,
|
||||
cfg_scale=cfg_scale,
|
||||
cfg_scale2=cfg_scale2,
|
||||
sample_shift=sample_shift,
|
||||
height=height,
|
||||
width=width,
|
||||
video_length=video_length,
|
||||
fps=fps,
|
||||
video_duration=duration,
|
||||
attention_type=attention_type,
|
||||
)
|
||||
|
||||
# Handle optional parameters
|
||||
self._apply_optional_params(config, optional_params)
|
||||
|
||||
# Validate final config
|
||||
config = self.validator.validate_inference_config(config)
|
||||
|
||||
return config
|
||||
|
||||
def _apply_optional_params(self, config: InferenceConfig, optional_params: Dict[str, Any]):
|
||||
"""Apply optional parameters to config."""
|
||||
# Handle denoising steps
|
||||
if "denoising_steps" in optional_params:
|
||||
steps_str = optional_params["denoising_steps"]
|
||||
if steps_str and steps_str.strip():
|
||||
try:
|
||||
steps_list = [int(s.strip()) for s in steps_str.split(",")]
|
||||
config.denoising_step_list = steps_list
|
||||
config.infer_steps = len(steps_list)
|
||||
except ValueError:
|
||||
logging.warning(f"Invalid denoising steps: {steps_str}")
|
||||
|
||||
# Handle other optional params
|
||||
for param in ["resize_mode", "fixed_area", "segment_length", "prev_frame_length", "use_tiny_vae"]:
|
||||
if param in optional_params:
|
||||
setattr(config, param, optional_params[param])
|
||||
|
||||
# Special handling for seko models
|
||||
if "seko" in config.model_cls:
|
||||
config.video_length = optional_params.get("segment_length", 81)
|
||||
config.use_31_block = False
|
||||
if "prev_frame_length" in optional_params:
|
||||
config.prev_frame_length = optional_params["prev_frame_length"]
|
||||
|
||||
|
||||
class TalkObjectConfigBuilder:
|
||||
"""Builder for talk object configurations."""
|
||||
|
||||
def __init__(self):
|
||||
self.audio_handler = AudioFileHandler()
|
||||
self.mask_handler = MaskFileHandler()
|
||||
self.resolver = ComfyUIFileResolver()
|
||||
|
||||
def build_from_input(self, name: str, audio: Optional[Any] = None, mask: Optional[Any] = None, save_to_input: bool = True) -> TalkObject:
|
||||
"""Build talk object from input data."""
|
||||
if audio is None:
|
||||
return None
|
||||
|
||||
talk_object = TalkObject(name=name)
|
||||
|
||||
# Process audio
|
||||
if save_to_input and audio is not None:
|
||||
audio_path = self._save_audio_to_input(name, audio)
|
||||
if audio_path:
|
||||
talk_object.audio = audio_path
|
||||
talk_object.source_type = "file"
|
||||
else:
|
||||
talk_object.audio = audio
|
||||
talk_object.source_type = "data"
|
||||
|
||||
# Process mask
|
||||
if mask is not None:
|
||||
if save_to_input:
|
||||
mask_path = self._save_mask_to_input(name, mask)
|
||||
if mask_path:
|
||||
talk_object.mask = mask_path
|
||||
else:
|
||||
talk_object.mask = mask
|
||||
|
||||
return talk_object
|
||||
|
||||
def build_from_json(self, json_config: str) -> Optional[TalkObjectsConfig]:
|
||||
"""Build talk objects configuration from JSON."""
|
||||
try:
|
||||
objects_data = json.loads(json_config)
|
||||
if not isinstance(objects_data, list):
|
||||
logging.error("JSON config must be a list")
|
||||
return None
|
||||
|
||||
config = TalkObjectsConfig()
|
||||
|
||||
for obj_data in objects_data:
|
||||
if not isinstance(obj_data, dict) or "audio" not in obj_data:
|
||||
continue
|
||||
|
||||
talk_obj = TalkObject(name=obj_data.get("name", "unknown"), audio=obj_data["audio"], mask=obj_data.get("mask"), source_type="path")
|
||||
config.add_object(talk_obj)
|
||||
|
||||
return config if config.talk_objects else None
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logging.error(f"Failed to parse JSON: {e}")
|
||||
|
||||
def build_from_files(self, audio_files: str, mask_files: str = "", names: str = "") -> Optional[TalkObjectsConfig]:
|
||||
"""Build talk objects configuration from file lists."""
|
||||
audio_list = [f.strip() for f in audio_files.split("\n") if f.strip()]
|
||||
if not audio_list:
|
||||
return None
|
||||
|
||||
mask_list = [f.strip() for f in mask_files.split("\n") if f.strip()] if mask_files else []
|
||||
name_list = [n.strip() for n in names.split("\n") if n.strip()] if names else []
|
||||
|
||||
config = TalkObjectsConfig()
|
||||
|
||||
for i, audio_file in enumerate(audio_list):
|
||||
talk_obj = TalkObject(
|
||||
name=name_list[i] if i < len(name_list) else f"person_{i + 1}",
|
||||
audio=audio_file,
|
||||
mask=mask_list[i] if i < len(mask_list) else None,
|
||||
source_type="file",
|
||||
)
|
||||
config.add_object(talk_obj)
|
||||
|
||||
return config
|
||||
|
||||
def _save_audio_to_input(self, name: str, audio_data: Any) -> Optional[str]:
|
||||
"""Save audio data to input directory."""
|
||||
try:
|
||||
filename = f"{name}_audio_{uuid.uuid4().hex[:8]}.wav"
|
||||
return self.resolver.save_to_input(audio_data, filename, self.audio_handler)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to save audio: {e}")
|
||||
return None
|
||||
|
||||
def _save_mask_to_input(self, name: str, mask_data: Any) -> Optional[str]:
|
||||
"""Save mask data to input directory."""
|
||||
try:
|
||||
filename = f"{name}_mask_{uuid.uuid4().hex[:8]}.png"
|
||||
return self.resolver.save_to_input(mask_data, filename, self.mask_handler)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to save mask: {e}")
|
||||
return None
|
||||
|
||||
|
||||
class ConfigBuilder:
|
||||
"""Main configuration builder that combines all configs."""
|
||||
|
||||
def __init__(self):
|
||||
self.manager = ModularConfigManager()
|
||||
self.temp_manager = TempFileManager()
|
||||
|
||||
def combine_configs(
|
||||
self,
|
||||
inference_config: InferenceConfig,
|
||||
teacache_config: Optional[TeaCacheConfig] = None,
|
||||
quantization_config: Optional[QuantizationConfig] = None,
|
||||
memory_config: Optional[MemoryOptimizationConfig] = None,
|
||||
lora_chain: Optional[List[Dict[str, Any]]] = None,
|
||||
talk_objects_config: Optional[TalkObjectsConfig] = None,
|
||||
) -> EasyDict:
|
||||
"""Combine all configurations into final config."""
|
||||
# Create combined config object
|
||||
combined = CombinedConfig(
|
||||
inference=inference_config,
|
||||
teacache=teacache_config,
|
||||
quantization=quantization_config,
|
||||
memory=memory_config,
|
||||
talk_objects=talk_objects_config,
|
||||
)
|
||||
|
||||
# Add LoRA configs
|
||||
if lora_chain:
|
||||
for lora_dict in lora_chain:
|
||||
lora_config = LoRAConfig(path=lora_dict["path"], strength=lora_dict.get("strength", 1.0))
|
||||
combined.lora_configs.append(lora_config)
|
||||
|
||||
# Build final config using existing manager
|
||||
configs_dict = {
|
||||
"inference": inference_config.to_dict() if inference_config else {},
|
||||
"teacache": teacache_config.to_dict() if teacache_config else None,
|
||||
"quantization": quantization_config.to_dict() if quantization_config else None,
|
||||
"memory": memory_config.to_dict() if memory_config else None,
|
||||
}
|
||||
|
||||
# Filter out None values
|
||||
configs_dict = {k: v for k, v in configs_dict.items() if v is not None}
|
||||
|
||||
# Use existing manager to build config
|
||||
final_config = self.manager.build_final_config(configs_dict)
|
||||
|
||||
# Add additional configs
|
||||
if lora_chain:
|
||||
final_config.lora_configs = lora_chain
|
||||
|
||||
if talk_objects_config:
|
||||
final_config.update(talk_objects_config.to_dict())
|
||||
|
||||
return final_config
|
||||
|
||||
def get_config_hash(self, config: EasyDict) -> str:
|
||||
"""Generate hash for configuration to detect changes."""
|
||||
relevant_configs = {
|
||||
"model_cls": getattr(config, "model_cls", None),
|
||||
"model_path": getattr(config, "model_path", None),
|
||||
"task": getattr(config, "task", None),
|
||||
"t5_quantized": getattr(config, "t5_quantized", False),
|
||||
"clip_quantized": getattr(config, "clip_quantized", False),
|
||||
"lora_configs": getattr(config, "lora_configs", None),
|
||||
"mm_config": getattr(config, "mm_config", None),
|
||||
"cross_attn_1_type": getattr(config, "cross_attn_1_type", None),
|
||||
"cross_attn_2_type": getattr(config, "cross_attn_2_type", None),
|
||||
"self_attn_1_type": getattr(config, "self_attn_1_type", None),
|
||||
"self_attn_2_type": getattr(config, "self_attn_2_type", None),
|
||||
"cpu_offload": getattr(config, "cpu_offload", False),
|
||||
"offload_granularity": getattr(config, "offload_granularity", None),
|
||||
"offload_ratio": getattr(config, "offload_ratio", None),
|
||||
"t5_cpu_offload": getattr(config, "t5_cpu_offload", False),
|
||||
"t5_offload_granularity": getattr(config, "t5_offload_granularity", None),
|
||||
"audio_encoder_cpu_offload": getattr(config, "audio_encoder_cpu_offload", False),
|
||||
"audio_adapter_cpu_offload": getattr(config, "audio_adapter_cpu_offload", False),
|
||||
"vae_cpu_offload": getattr(config, "vae_cpu_offload", False),
|
||||
"use_tiling_vae": getattr(config, "use_tiling_vae", False),
|
||||
"unload_after_inference": getattr(config, "unload_after_inference", False),
|
||||
"enable_rotary_chunk": getattr(config, "enable_rotary_chunk", False),
|
||||
"rotary_chunk_size": getattr(config, "rotary_chunk_size", None),
|
||||
"clean_cuda_cache": getattr(config, "clean_cuda_cache", False),
|
||||
"torch_compile": getattr(config, "torch_compile", False),
|
||||
"threshold": getattr(config, "threshold", None),
|
||||
"use_ret_steps": getattr(config, "use_ret_steps", False),
|
||||
"t5_quant_scheme": getattr(config, "t5_quant_scheme", None),
|
||||
"clip_quant_scheme": getattr(config, "clip_quant_scheme", None),
|
||||
"adapter_quant_scheme": getattr(config, "adapter_quant_scheme", None),
|
||||
"adapter_quantized": getattr(config, "adapter_quantized", False),
|
||||
"feature_caching": getattr(config, "feature_caching", None),
|
||||
}
|
||||
|
||||
config_str = json.dumps(relevant_configs, sort_keys=True)
|
||||
return hashlib.md5(config_str.encode()).hexdigest()
|
||||
|
||||
|
||||
class LoRAChainBuilder:
|
||||
"""Builder for LoRA chain configurations."""
|
||||
|
||||
@staticmethod
|
||||
def build_chain(lora_name: str, strength: float, existing_chain: Optional[List[Dict]] = None) -> List[Dict]:
|
||||
"""Build or extend a LoRA chain."""
|
||||
if existing_chain is None:
|
||||
chain = []
|
||||
else:
|
||||
chain = existing_chain.copy()
|
||||
|
||||
lora_path = get_lora_full_path(lora_name)
|
||||
if lora_path:
|
||||
lora_config = {"path": lora_path, "strength": strength}
|
||||
chain.append(lora_config)
|
||||
|
||||
return chain
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
"""Data models for LightX2V ComfyUI wrapper."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
|
||||
@dataclass
|
||||
class TalkObject:
|
||||
"""Single talk object containing audio and optional mask."""
|
||||
|
||||
name: str
|
||||
audio: Optional[Union[str, Dict[str, Any], torch.Tensor, np.ndarray]] = None
|
||||
mask: Optional[Union[str, torch.Tensor, np.ndarray]] = None
|
||||
source_type: str = "data" # "data", "file", or "path"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for pipeline."""
|
||||
result = {"name": self.name}
|
||||
|
||||
if isinstance(self.audio, str):
|
||||
result["audio"] = self.audio
|
||||
elif self.audio is not None:
|
||||
result["audio_data"] = self.audio
|
||||
|
||||
if isinstance(self.mask, str):
|
||||
result["mask"] = self.mask
|
||||
elif self.mask is not None:
|
||||
result["mask_data"] = self.mask
|
||||
|
||||
result["source_type"] = self.source_type
|
||||
return result
|
||||
|
||||
|
||||
@dataclass
|
||||
class InferenceConfig:
|
||||
"""Basic inference configuration."""
|
||||
|
||||
model_cls: str = "wan2.1"
|
||||
model_path: str = ""
|
||||
task: str = "i2v"
|
||||
infer_steps: int = 4
|
||||
seed: int = 42
|
||||
cfg_scale: float = 5.0
|
||||
cfg_scale2: float = 5.0
|
||||
sample_shift: int = 5
|
||||
height: int = 1280
|
||||
width: int = 720
|
||||
video_length: int = 81
|
||||
fps: int = 16
|
||||
video_duration: float = 5.0
|
||||
attention_type: str = "torch_sdpa"
|
||||
use_31_block: bool = True
|
||||
|
||||
# Optional parameters
|
||||
denoising_step_list: Optional[List[int]] = None
|
||||
resize_mode: str = "adaptive"
|
||||
fixed_area: str = "720p"
|
||||
segment_length: int = 81
|
||||
prev_frame_length: int = 5
|
||||
use_tiny_vae: bool = False
|
||||
|
||||
# Runtime parameters
|
||||
prompt: str = ""
|
||||
negative_prompt: str = ""
|
||||
image_path: Optional[str] = None
|
||||
audio_path: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary, excluding None values."""
|
||||
result = {}
|
||||
for key, value in self.__dict__.items():
|
||||
if value is not None:
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
@dataclass
|
||||
class TeaCacheConfig:
|
||||
"""TeaCache configuration."""
|
||||
|
||||
enable: bool = False
|
||||
threshold: float = 0.26
|
||||
use_ret_steps: bool = False
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {"enable": self.enable, "threshold": self.threshold, "use_ret_steps": self.use_ret_steps}
|
||||
|
||||
|
||||
@dataclass
|
||||
class QuantizationConfig:
|
||||
"""Quantization configuration."""
|
||||
|
||||
quant_op: str = "none"
|
||||
dit_quant_scheme: str = "bf16"
|
||||
t5_quant_scheme: str = "bf16"
|
||||
clip_quant_scheme: str = "fp16"
|
||||
adapter_quant_scheme: str = "bf16"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"quant_op": self.quant_op,
|
||||
"dit_quant_scheme": self.dit_quant_scheme,
|
||||
"t5_quant_scheme": self.t5_quant_scheme,
|
||||
"clip_quant_scheme": self.clip_quant_scheme,
|
||||
"adapter_quant_scheme": self.adapter_quant_scheme,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class MemoryOptimizationConfig:
|
||||
"""Memory optimization configuration."""
|
||||
|
||||
enable_rotary_chunk: bool = False
|
||||
rotary_chunk_size: int = 100
|
||||
clean_cuda_cache: bool = False
|
||||
cpu_offload: bool = True
|
||||
offload_granularity: str = "block"
|
||||
offload_ratio: float = 1.0
|
||||
t5_cpu_offload: bool = True
|
||||
t5_offload_granularity: str = "model"
|
||||
audio_encoder_cpu_offload: bool = True
|
||||
audio_adapter_cpu_offload: bool = True
|
||||
vae_cpu_offload: bool = True
|
||||
use_tiling_vae: bool = True
|
||||
lazy_load: bool = False
|
||||
unload_after_inference: bool = False
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return self.__dict__.copy()
|
||||
|
||||
|
||||
@dataclass
|
||||
class LoRAConfig:
|
||||
"""LoRA configuration."""
|
||||
|
||||
path: str
|
||||
strength: float = 1.0
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {"path": self.path, "strength": self.strength}
|
||||
|
||||
|
||||
@dataclass
|
||||
class TalkObjectsConfig:
|
||||
"""Configuration for multiple talk objects."""
|
||||
|
||||
talk_objects: List[TalkObject] = field(default_factory=list)
|
||||
|
||||
def add_object(self, talk_object: TalkObject):
|
||||
"""Add a talk object to the configuration."""
|
||||
self.talk_objects.append(talk_object)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for pipeline."""
|
||||
return {"talk_objects": [obj for obj in self.talk_objects]}
|
||||
|
||||
def to_list(self) -> List[Dict[str, Any]]:
|
||||
"""Get list of talk object dictionaries."""
|
||||
return [obj for obj in self.talk_objects]
|
||||
|
||||
|
||||
@dataclass
|
||||
class CombinedConfig:
|
||||
"""Combined configuration for all modules."""
|
||||
|
||||
inference: Optional[InferenceConfig] = None
|
||||
teacache: Optional[TeaCacheConfig] = None
|
||||
quantization: Optional[QuantizationConfig] = None
|
||||
memory: Optional[MemoryOptimizationConfig] = None
|
||||
lora_configs: List[LoRAConfig] = field(default_factory=list)
|
||||
talk_objects: Optional[TalkObjectsConfig] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for pipeline."""
|
||||
result = {}
|
||||
|
||||
if self.inference:
|
||||
result.update(self.inference.to_dict())
|
||||
|
||||
if self.teacache:
|
||||
result["teacache"] = self.teacache.to_dict()
|
||||
|
||||
if self.quantization:
|
||||
result["quantization"] = self.quantization.to_dict()
|
||||
|
||||
if self.memory:
|
||||
result["memory"] = self.memory.to_dict()
|
||||
|
||||
if self.lora_configs:
|
||||
result["lora_configs"] = [lora.to_dict() for lora in self.lora_configs]
|
||||
|
||||
if self.talk_objects:
|
||||
result["talk_objects"] = self.talk_objects.to_list()
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,361 @@
|
||||
"""Unified file handlers for LightX2V ComfyUI wrapper."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from abc import ABC, abstractmethod
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
|
||||
import numpy as np
|
||||
import scipy.io.wavfile as wavfile
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
|
||||
class FileHandler(ABC):
|
||||
"""Base class for file handling."""
|
||||
|
||||
@abstractmethod
|
||||
def save(self, data: Any, path: str) -> str:
|
||||
"""Save data to file."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def load(self, path: str) -> Any:
|
||||
"""Load data from file."""
|
||||
pass
|
||||
|
||||
|
||||
class AudioFileHandler(FileHandler):
|
||||
"""Handler for audio files."""
|
||||
|
||||
def __init__(self):
|
||||
self.supported_formats = [".wav", ".mp3", ".flac", ".m4a"]
|
||||
|
||||
def save(self, audio_data: Union[Dict, torch.Tensor, np.ndarray, Tuple], path: str, sample_rate: Optional[int] = None) -> str:
|
||||
"""Save audio data to file.
|
||||
|
||||
Args:
|
||||
audio_data: Audio data in various formats
|
||||
path: Output file path
|
||||
sample_rate: Sample rate (required if not in audio_data)
|
||||
|
||||
Returns:
|
||||
Path to saved file
|
||||
"""
|
||||
|
||||
waveform, sr = self._extract_audio_data(audio_data, sample_rate)
|
||||
|
||||
# Ensure waveform is in correct shape
|
||||
waveform = self._normalize_waveform(waveform)
|
||||
|
||||
# Always save as WAV format
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
if ext != ".wav":
|
||||
path = path.rsplit(".", 1)[0] + ".wav"
|
||||
logging.info(f"Audio will be saved as WAV format: {path}")
|
||||
|
||||
# Ensure waveform is in int16 format for WAV
|
||||
if waveform.dtype != np.int16:
|
||||
# Normalize to [-1, 1] range if not already
|
||||
if waveform.dtype == np.float32 or waveform.dtype == np.float64:
|
||||
# Clip to [-1, 1] to avoid overflow
|
||||
waveform = np.clip(waveform, -1.0, 1.0)
|
||||
waveform = (waveform * 32767).astype(np.int16)
|
||||
else:
|
||||
# Assume uint8 or other integer type
|
||||
waveform = waveform.astype(np.int16)
|
||||
|
||||
wavfile.write(path, sr, waveform)
|
||||
logging.info(f"Audio saved to {path}")
|
||||
return path
|
||||
|
||||
def load(self, path: str) -> Tuple[np.ndarray, int]:
|
||||
"""Load audio from file.
|
||||
|
||||
Returns:
|
||||
Tuple of (waveform, sample_rate)
|
||||
"""
|
||||
sample_rate, waveform = wavfile.read(path)
|
||||
return waveform, sample_rate
|
||||
|
||||
def _extract_audio_data(self, audio_data: Any, sample_rate: Optional[int] = None) -> Tuple[np.ndarray, int]:
|
||||
"""Extract waveform and sample rate from various audio formats.
|
||||
|
||||
Handles three main sources:
|
||||
1. ComfyUI LoadAudio output: {"waveform": tensor, "sample_rate": int}
|
||||
2. Tuple format: (waveform, sample_rate)
|
||||
3. Raw waveform with separate sample_rate
|
||||
"""
|
||||
if isinstance(audio_data, dict):
|
||||
if "waveform" in audio_data and "sample_rate" in audio_data:
|
||||
waveform = audio_data["waveform"]
|
||||
sr = audio_data["sample_rate"]
|
||||
|
||||
# Handle ComfyUI LoadAudio format specifically
|
||||
# ComfyUI returns waveform with shape [batch, channels, samples]
|
||||
if isinstance(waveform, torch.Tensor):
|
||||
if waveform.dim() == 3: # [batch, channels, samples]
|
||||
waveform = waveform[0] # Take first batch
|
||||
if waveform.dim() == 2 and waveform.shape[0] <= 2: # [channels, samples]
|
||||
waveform = waveform.transpose(0, 1) # -> [samples, channels]
|
||||
waveform = waveform.cpu().numpy()
|
||||
else:
|
||||
raise ValueError("Audio dict must contain 'waveform' and 'sample_rate'")
|
||||
elif isinstance(audio_data, tuple) and len(audio_data) == 2:
|
||||
waveform, sr = audio_data
|
||||
if isinstance(waveform, torch.Tensor):
|
||||
waveform = waveform.cpu().numpy()
|
||||
elif sample_rate is not None:
|
||||
waveform = audio_data
|
||||
sr = sample_rate
|
||||
if isinstance(waveform, torch.Tensor):
|
||||
waveform = waveform.cpu().numpy()
|
||||
else:
|
||||
raise ValueError("Sample rate must be provided for raw audio data")
|
||||
|
||||
return waveform, sr
|
||||
|
||||
def _normalize_waveform(self, waveform: np.ndarray) -> np.ndarray:
|
||||
"""Normalize waveform shape for saving.
|
||||
|
||||
Ensures waveform is in shape [samples, channels] or [samples] for mono.
|
||||
"""
|
||||
# Already converted to numpy in _extract_audio_data
|
||||
|
||||
# Handle different shapes
|
||||
if waveform.ndim == 3: # Shouldn't happen, but handle it
|
||||
waveform = waveform[0]
|
||||
|
||||
if waveform.ndim == 2:
|
||||
# Check if it's [channels, samples] format (channels < samples typically)
|
||||
if waveform.shape[0] <= 2 and waveform.shape[0] < waveform.shape[1]:
|
||||
waveform = waveform.T # -> [samples, channels]
|
||||
# If mono with extra dimension, squeeze it
|
||||
if waveform.shape[1] == 1:
|
||||
waveform = waveform.squeeze()
|
||||
|
||||
return waveform
|
||||
|
||||
def _save_with_wave(self, path: str, waveform: np.ndarray, sample_rate: int):
|
||||
"""Save audio using wave module."""
|
||||
import wave
|
||||
|
||||
with wave.open(path, "wb") as wav_file:
|
||||
wav_file.setnchannels(1 if waveform.ndim == 1 else waveform.shape[-1])
|
||||
wav_file.setsampwidth(2) # 16-bit
|
||||
wav_file.setframerate(sample_rate)
|
||||
|
||||
if waveform.dtype != np.int16:
|
||||
waveform = (waveform * 32767).astype(np.int16)
|
||||
|
||||
wav_file.writeframes(waveform.tobytes())
|
||||
|
||||
def _load_with_wave(self, path: str) -> Tuple[np.ndarray, int]:
|
||||
"""Load audio using wave module."""
|
||||
import wave
|
||||
|
||||
with wave.open(path, "rb") as wav_file:
|
||||
sample_rate = wav_file.getframerate()
|
||||
frames = wav_file.readframes(wav_file.getnframes())
|
||||
waveform = np.frombuffer(frames, dtype=np.int16)
|
||||
|
||||
if wav_file.getnchannels() > 1:
|
||||
waveform = waveform.reshape(-1, wav_file.getnchannels())
|
||||
|
||||
return waveform, sample_rate
|
||||
|
||||
|
||||
class ImageFileHandler(FileHandler):
|
||||
"""Handler for image files."""
|
||||
|
||||
def __init__(self):
|
||||
self.supported_formats = [".png", ".jpg", ".jpeg", ".bmp", ".tiff"]
|
||||
|
||||
def save(self, image_data: Union[torch.Tensor, np.ndarray, Image.Image], path: str) -> str:
|
||||
"""Save image data to file.
|
||||
|
||||
Args:
|
||||
image_data: Image data in various formats
|
||||
path: Output file path
|
||||
|
||||
Returns:
|
||||
Path to saved file
|
||||
"""
|
||||
if isinstance(image_data, torch.Tensor):
|
||||
# Convert from tensor [H, W, C] or [C, H, W]
|
||||
if image_data.dim() == 4: # [batch, ...]
|
||||
image_data = image_data[0]
|
||||
|
||||
image_np = image_data.cpu().numpy()
|
||||
|
||||
# Handle channel ordering
|
||||
if image_np.shape[0] in [1, 3, 4]: # [C, H, W]
|
||||
image_np = np.transpose(image_np, (1, 2, 0))
|
||||
|
||||
# Convert to uint8
|
||||
if image_np.dtype != np.uint8:
|
||||
image_np = (image_np * 255).astype(np.uint8)
|
||||
|
||||
image = Image.fromarray(image_np.squeeze())
|
||||
|
||||
elif isinstance(image_data, np.ndarray):
|
||||
if image_data.dtype != np.uint8:
|
||||
image_data = (image_data * 255).astype(np.uint8)
|
||||
image = Image.fromarray(image_data.squeeze())
|
||||
|
||||
elif isinstance(image_data, Image.Image):
|
||||
image = image_data
|
||||
else:
|
||||
raise ValueError(f"Unsupported image format: {type(image_data)}")
|
||||
|
||||
image.save(path)
|
||||
logging.info(f"Image saved to {path}")
|
||||
return path
|
||||
|
||||
def load(self, path: str) -> Image.Image:
|
||||
"""Load image from file."""
|
||||
return Image.open(path)
|
||||
|
||||
|
||||
class MaskFileHandler(ImageFileHandler):
|
||||
"""Handler specifically for mask files."""
|
||||
|
||||
def save(self, mask_data: Union[torch.Tensor, np.ndarray], path: str) -> str:
|
||||
"""Save mask data to file.
|
||||
|
||||
Args:
|
||||
mask_data: Mask data (single channel)
|
||||
path: Output file path
|
||||
|
||||
Returns:
|
||||
Path to saved file
|
||||
"""
|
||||
if isinstance(mask_data, torch.Tensor):
|
||||
if mask_data.dim() == 3: # [batch, H, W]
|
||||
mask_data = mask_data[0]
|
||||
mask_np = (mask_data.cpu().numpy() * 255).astype(np.uint8)
|
||||
elif isinstance(mask_data, np.ndarray):
|
||||
mask_np = (mask_data * 255).astype(np.uint8)
|
||||
else:
|
||||
mask_np = mask_data
|
||||
|
||||
mask_image = Image.fromarray(mask_np)
|
||||
mask_image.save(path)
|
||||
logging.info(f"Mask saved to {path}")
|
||||
return path
|
||||
|
||||
|
||||
class TempFileManager:
|
||||
"""Manager for temporary files with automatic cleanup."""
|
||||
|
||||
def __init__(self):
|
||||
self.temp_files: List[str] = []
|
||||
|
||||
@contextmanager
|
||||
def temp_file(self, suffix: str = "", prefix: str = "lightx2v_", delete: bool = True):
|
||||
"""Context manager for temporary file creation.
|
||||
|
||||
Args:
|
||||
suffix: File suffix
|
||||
prefix: File prefix
|
||||
delete: Whether to delete on exit
|
||||
|
||||
Yields:
|
||||
Path to temporary file
|
||||
"""
|
||||
temp_file = tempfile.NamedTemporaryFile(suffix=suffix, prefix=prefix, delete=False)
|
||||
temp_path = temp_file.name
|
||||
temp_file.close()
|
||||
|
||||
self.temp_files.append(temp_path)
|
||||
|
||||
try:
|
||||
yield temp_path
|
||||
finally:
|
||||
if delete:
|
||||
self.cleanup_file(temp_path)
|
||||
|
||||
def create_temp_file(self, suffix: str = "", prefix: str = "lightx2v_") -> str:
|
||||
"""Create a temporary file that will be tracked for cleanup.
|
||||
|
||||
Args:
|
||||
suffix: File suffix
|
||||
prefix: File prefix
|
||||
|
||||
Returns:
|
||||
Path to temporary file
|
||||
"""
|
||||
with tempfile.NamedTemporaryFile(suffix=suffix, prefix=prefix, delete=False) as tmp:
|
||||
temp_path = tmp.name
|
||||
|
||||
self.temp_files.append(temp_path)
|
||||
return temp_path
|
||||
|
||||
def cleanup_file(self, path: str):
|
||||
"""Clean up a specific file."""
|
||||
if path in self.temp_files:
|
||||
self.temp_files.remove(path)
|
||||
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
os.unlink(path)
|
||||
logging.debug(f"Cleaned up temp file: {path}")
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to clean up {path}: {e}")
|
||||
|
||||
def cleanup_all(self):
|
||||
"""Clean up all tracked temporary files."""
|
||||
for temp_file in self.temp_files[:]:
|
||||
self.cleanup_file(temp_file)
|
||||
|
||||
self.temp_files.clear()
|
||||
|
||||
def __del__(self):
|
||||
"""Cleanup on deletion."""
|
||||
self.cleanup_all()
|
||||
|
||||
|
||||
class ComfyUIFileResolver:
|
||||
"""Resolve file paths for ComfyUI input/output directories."""
|
||||
|
||||
@staticmethod
|
||||
def get_input_directory() -> str:
|
||||
"""Get ComfyUI input directory."""
|
||||
try:
|
||||
import folder_paths
|
||||
|
||||
return folder_paths.get_input_directory()
|
||||
except ImportError:
|
||||
# Fallback if not in ComfyUI environment
|
||||
return "input"
|
||||
|
||||
@staticmethod
|
||||
def resolve_input_path(filename: str) -> str:
|
||||
"""Resolve a filename to full path in input directory."""
|
||||
if os.path.isabs(filename):
|
||||
return filename
|
||||
|
||||
input_dir = ComfyUIFileResolver.get_input_directory()
|
||||
return os.path.join(input_dir, filename)
|
||||
|
||||
@staticmethod
|
||||
def save_to_input(data: Any, filename: str, handler: FileHandler) -> str:
|
||||
"""Save data to ComfyUI input directory.
|
||||
|
||||
Args:
|
||||
data: Data to save
|
||||
filename: Target filename
|
||||
handler: File handler to use
|
||||
|
||||
Returns:
|
||||
Full path to saved file
|
||||
"""
|
||||
input_dir = ComfyUIFileResolver.get_input_directory()
|
||||
full_path = os.path.join(input_dir, filename)
|
||||
|
||||
# Create directory if needed
|
||||
os.makedirs(input_dir, exist_ok=True)
|
||||
|
||||
return handler.save(data, full_path)
|
||||
+1
-1
Submodule lightx2v updated: f7eb4f12e6...749f8c64ef
@@ -1,30 +1,37 @@
|
||||
"""Modular ComfyUI nodes for LightX2V without presets."""
|
||||
|
||||
import gc
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from comfy.utils import ProgressBar
|
||||
from PIL import Image
|
||||
|
||||
from .bridge import (
|
||||
ModularConfigManager,
|
||||
get_available_attn_ops,
|
||||
get_available_quant_ops,
|
||||
from .bridge import get_available_attn_ops, get_available_quant_ops
|
||||
from .config_builder import (
|
||||
ConfigBuilder,
|
||||
InferenceConfigBuilder,
|
||||
LoRAChainBuilder,
|
||||
TalkObjectConfigBuilder,
|
||||
)
|
||||
from .data_models import (
|
||||
InferenceConfig,
|
||||
MemoryOptimizationConfig,
|
||||
QuantizationConfig,
|
||||
TalkObjectsConfig,
|
||||
TeaCacheConfig,
|
||||
)
|
||||
from .file_handlers import (
|
||||
AudioFileHandler,
|
||||
ComfyUIFileResolver,
|
||||
ImageFileHandler,
|
||||
TempFileManager,
|
||||
)
|
||||
from .lightx2v.lightx2v.infer import init_runner
|
||||
from .model_utils import (
|
||||
get_lora_full_path,
|
||||
get_model_full_path,
|
||||
scan_loras,
|
||||
scan_models,
|
||||
support_model_cls_list,
|
||||
)
|
||||
from .model_utils import scan_loras, scan_models, support_model_cls_list
|
||||
|
||||
|
||||
class LightX2VInferenceConfig:
|
||||
@@ -211,61 +218,30 @@ class LightX2VInferenceConfig:
|
||||
use_tiny_vae=False,
|
||||
):
|
||||
"""Create basic inference configuration."""
|
||||
model_path = get_model_full_path(model_name)
|
||||
builder = InferenceConfigBuilder()
|
||||
|
||||
if model_cls == "hunyuan":
|
||||
fps = 24
|
||||
else:
|
||||
fps = 16
|
||||
config = builder.build(
|
||||
model_cls=model_cls,
|
||||
model_name=model_name,
|
||||
task=task,
|
||||
infer_steps=infer_steps,
|
||||
seed=seed,
|
||||
cfg_scale=cfg_scale,
|
||||
cfg_scale2=cfg_scale2,
|
||||
sample_shift=sample_shift,
|
||||
height=height,
|
||||
width=width,
|
||||
duration=duration,
|
||||
attention_type=attention_type,
|
||||
denoising_steps=denoising_steps,
|
||||
resize_mode=resize_mode,
|
||||
fixed_area=fixed_area,
|
||||
segment_length=segment_length,
|
||||
prev_frame_length=prev_frame_length,
|
||||
use_tiny_vae=use_tiny_vae,
|
||||
)
|
||||
|
||||
video_length = int(round(duration * fps))
|
||||
|
||||
if video_length < 16:
|
||||
logging.warning("Video length is too short, setting to 16")
|
||||
video_length = 16
|
||||
|
||||
remainder = (video_length - 1) % 4
|
||||
if remainder != 0:
|
||||
video_length = video_length + (4 - remainder)
|
||||
|
||||
# TODO(xxx):
|
||||
use_31_block = True
|
||||
if "seko" in model_cls:
|
||||
video_length = segment_length
|
||||
use_31_block = False
|
||||
|
||||
config = {
|
||||
"model_cls": model_cls,
|
||||
"model_path": model_path,
|
||||
"task": task,
|
||||
"infer_steps": infer_steps,
|
||||
"seed": seed if seed != -1 else np.random.randint(0, 2**32 - 1),
|
||||
"cfg_scale": cfg_scale,
|
||||
"cfg_scale2": cfg_scale2,
|
||||
"sample_shift": sample_shift,
|
||||
"height": height,
|
||||
"width": width,
|
||||
"video_length": video_length,
|
||||
"fps": fps,
|
||||
"video_duration": duration,
|
||||
"resize_mode": resize_mode,
|
||||
"fixed_area": fixed_area,
|
||||
"use_31_block": use_31_block,
|
||||
"attention_type": attention_type,
|
||||
"use_tiny_vae": use_tiny_vae,
|
||||
}
|
||||
if "seko" in [model_cls]:
|
||||
config["prev_frame_length"] = prev_frame_length
|
||||
|
||||
if denoising_steps and denoising_steps.strip():
|
||||
try:
|
||||
steps_list = [int(s.strip()) for s in denoising_steps.split(",")]
|
||||
config["denoising_step_list"] = steps_list
|
||||
config["infer_steps"] = len(steps_list)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return (config,)
|
||||
return (config.to_dict(),)
|
||||
|
||||
|
||||
class LightX2VTeaCache:
|
||||
@@ -305,12 +281,9 @@ class LightX2VTeaCache:
|
||||
CATEGORY = "LightX2V/Config"
|
||||
|
||||
def create_config(self, enable, threshold, use_ret_steps):
|
||||
config = {
|
||||
"enable": enable,
|
||||
"threshold": threshold,
|
||||
"use_ret_steps": use_ret_steps,
|
||||
}
|
||||
return (config,)
|
||||
"""Create TeaCache configuration."""
|
||||
config = TeaCacheConfig(enable=enable, threshold=threshold, use_ret_steps=use_ret_steps)
|
||||
return (config.to_dict(),)
|
||||
|
||||
|
||||
class LightX2VQuantization:
|
||||
@@ -383,14 +356,14 @@ class LightX2VQuantization:
|
||||
adapter_quant_scheme,
|
||||
):
|
||||
"""Create quantization configuration."""
|
||||
config = {
|
||||
"dit_quant_scheme": dit_quant_scheme,
|
||||
"t5_quant_scheme": t5_quant_scheme,
|
||||
"clip_quant_scheme": clip_quant_scheme,
|
||||
"adapter_quant_scheme": adapter_quant_scheme,
|
||||
"quant_op": quant_op,
|
||||
}
|
||||
return (config,)
|
||||
config = QuantizationConfig(
|
||||
quant_op=quant_op,
|
||||
dit_quant_scheme=dit_quant_scheme,
|
||||
t5_quant_scheme=t5_quant_scheme,
|
||||
clip_quant_scheme=clip_quant_scheme,
|
||||
adapter_quant_scheme=adapter_quant_scheme,
|
||||
)
|
||||
return (config.to_dict(),)
|
||||
|
||||
|
||||
class LightX2VMemoryOptimization:
|
||||
@@ -481,23 +454,24 @@ class LightX2VMemoryOptimization:
|
||||
lazy_load=False,
|
||||
unload_after_inference=False,
|
||||
):
|
||||
config = {
|
||||
"enable_rotary_chunk": enable_rotary_chunk,
|
||||
"rotary_chunk_size": rotary_chunk_size,
|
||||
"clean_cuda_cache": clean_cuda_cache,
|
||||
"cpu_offload": cpu_offload,
|
||||
"offload_granularity": offload_granularity,
|
||||
"offload_ratio": offload_ratio,
|
||||
"t5_cpu_offload": t5_cpu_offload,
|
||||
"t5_offload_granularity": t5_offload_granularity,
|
||||
"audio_encoder_cpu_offload": audio_encoder_cpu_offload,
|
||||
"audio_adapter_cpu_offload": audio_adapter_cpu_offload,
|
||||
"vae_cpu_offload": vae_cpu_offload,
|
||||
"use_tiling_vae": use_tiling_vae,
|
||||
"lazy_load": lazy_load,
|
||||
"unload_after_inference": unload_after_inference,
|
||||
}
|
||||
return (config,)
|
||||
"""Create memory optimization configuration."""
|
||||
config = MemoryOptimizationConfig(
|
||||
enable_rotary_chunk=enable_rotary_chunk,
|
||||
rotary_chunk_size=rotary_chunk_size,
|
||||
clean_cuda_cache=clean_cuda_cache,
|
||||
cpu_offload=cpu_offload,
|
||||
offload_granularity=offload_granularity,
|
||||
offload_ratio=offload_ratio,
|
||||
t5_cpu_offload=t5_cpu_offload,
|
||||
t5_offload_granularity=t5_offload_granularity,
|
||||
audio_encoder_cpu_offload=audio_encoder_cpu_offload,
|
||||
audio_adapter_cpu_offload=audio_adapter_cpu_offload,
|
||||
vae_cpu_offload=vae_cpu_offload,
|
||||
use_tiling_vae=use_tiling_vae,
|
||||
lazy_load=lazy_load,
|
||||
unload_after_inference=unload_after_inference,
|
||||
)
|
||||
return (config.to_dict(),)
|
||||
|
||||
|
||||
class LightX2VLoRALoader:
|
||||
@@ -539,35 +513,22 @@ class LightX2VLoRALoader:
|
||||
CATEGORY = "LightX2V/LoRA"
|
||||
|
||||
def load_lora(self, lora_name, strength, lora_chain=None):
|
||||
if lora_chain is None:
|
||||
lora_chain = []
|
||||
else:
|
||||
lora_chain = lora_chain.copy()
|
||||
|
||||
lora_path = get_lora_full_path(lora_name)
|
||||
|
||||
if lora_path:
|
||||
lora_config = {"path": lora_path, "strength": strength}
|
||||
lora_chain.append(lora_config)
|
||||
|
||||
return (lora_chain,)
|
||||
"""Load and chain LoRA configurations."""
|
||||
chain = LoRAChainBuilder.build_chain(lora_name=lora_name, strength=strength, existing_chain=lora_chain)
|
||||
return (chain,)
|
||||
|
||||
|
||||
class TalkObjectInput:
|
||||
"""单个谈话对象(音频+遮罩)输入节点"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"audio_source": (["upload", "url"], {"default": "upload", "tooltip": "音频输入方式"}),
|
||||
"mask_source": (["upload", "url", "none"], {"default": "upload", "tooltip": "遮罩输入方式"}),
|
||||
"name": ("STRING", {"default": "person_1", "tooltip": "说话人名称标识"}),
|
||||
},
|
||||
"optional": {
|
||||
"audio": ("AUDIO", {"tooltip": "上传的音频文件"}),
|
||||
"audio_url": ("STRING", {"default": "", "tooltip": "音频文件URL路径"}),
|
||||
"mask": ("MASK", {"tooltip": "上传的遮罩图像"}),
|
||||
"mask_url": ("STRING", {"default": "", "tooltip": "遮罩图像URL路径"}),
|
||||
"mask": ("MASK", {"tooltip": "上传的遮罩图像(可选)"}),
|
||||
"save_to_input": ("BOOLEAN", {"default": True, "tooltip": "是否保存到input文件夹"}),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -576,76 +537,104 @@ class TalkObjectInput:
|
||||
FUNCTION = "create_talk_object"
|
||||
CATEGORY = "LightX2V/Audio"
|
||||
|
||||
def create_talk_object(self, audio_source, mask_source, audio=None, audio_url="", mask=None, mask_url=""):
|
||||
"""创建单个谈话对象配置"""
|
||||
talk_object = {}
|
||||
def create_talk_object(self, name, audio=None, mask=None, save_to_input=True):
|
||||
"""Create a talk object from input data."""
|
||||
builder = TalkObjectConfigBuilder()
|
||||
|
||||
# 处理音频
|
||||
if audio_source == "upload" and audio is not None:
|
||||
# 音频将在推理时转换为临时文件
|
||||
talk_object["audio_data"] = audio
|
||||
talk_object["audio_type"] = "upload"
|
||||
elif audio_source == "url" and audio_url:
|
||||
talk_object["audio"] = audio_url
|
||||
talk_object["audio_type"] = "url"
|
||||
else:
|
||||
return (None,) # 无效的音频输入
|
||||
talk_object = builder.build_from_input(name=name, audio=audio, mask=mask, save_to_input=save_to_input)
|
||||
|
||||
# 处理遮罩
|
||||
if mask_source == "upload" and mask is not None:
|
||||
talk_object["mask_data"] = mask
|
||||
talk_object["mask_type"] = "upload"
|
||||
elif mask_source == "url" and mask_url:
|
||||
talk_object["mask"] = mask_url
|
||||
talk_object["mask_type"] = "url"
|
||||
elif mask_source == "none":
|
||||
# 不使用遮罩
|
||||
talk_object["mask"] = None
|
||||
talk_object["mask_type"] = "none"
|
||||
|
||||
return (talk_object,)
|
||||
if talk_object:
|
||||
return (talk_object.to_dict(),)
|
||||
return (None,)
|
||||
|
||||
|
||||
class TalkObjectsBuilder:
|
||||
"""构建多人对话配置的节点"""
|
||||
class TalkObjectsCombiner:
|
||||
"""组合多个谈话对象为配置"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
inputs = {"required": {}, "optional": {}}
|
||||
|
||||
# 预定义10个TALK_OBJECT输入槽
|
||||
for i in range(1, 11):
|
||||
inputs["optional"][f"talk_object_{i}"] = ("TALK_OBJECT", {"tooltip": f"谈话对象{i}"})
|
||||
|
||||
return inputs
|
||||
|
||||
RETURN_TYPES = ("TALK_OBJECTS_CONFIG",)
|
||||
RETURN_NAMES = ("talk_objects_config",)
|
||||
FUNCTION = "combine_talk_objects"
|
||||
CATEGORY = "LightX2V/Audio"
|
||||
|
||||
def combine_talk_objects(self, **kwargs):
|
||||
config = TalkObjectsConfig()
|
||||
|
||||
for i in range(1, 11):
|
||||
talk_obj = kwargs.get(f"talk_object_{i}")
|
||||
|
||||
if talk_obj is not None:
|
||||
config.add_object(talk_obj)
|
||||
|
||||
if not config.talk_objects:
|
||||
return (None,)
|
||||
|
||||
return (config,)
|
||||
|
||||
|
||||
class TalkObjectsFromJSON:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"number": ("INT", {"default": 1, "min": 0, "max": 10, "tooltip": "谈话对象数量,0表示无,1表示单人,2+表示多人"}),
|
||||
},
|
||||
"optional": {
|
||||
# 支持最多10个谈话对象
|
||||
**{f"talk_object_{i}": ("TALK_OBJECT",) for i in range(1, 11)}
|
||||
"json_config": (
|
||||
"STRING",
|
||||
{
|
||||
"multiline": True,
|
||||
"default": '[{"name": "person1", "audio": "/path/to/audio1.wav", "mask": "/path/to/mask1.png"}]',
|
||||
"tooltip": "JSON格式的谈话对象配置",
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("TALK_OBJECTS_CONFIG",)
|
||||
RETURN_NAMES = ("talk_objects_config",)
|
||||
FUNCTION = "build_talk_objects"
|
||||
FUNCTION = "parse_json_config"
|
||||
CATEGORY = "LightX2V/Audio"
|
||||
|
||||
def build_talk_objects(self, number, **kwargs):
|
||||
"""构建谈话对象列表"""
|
||||
if number == 0:
|
||||
return (None,)
|
||||
def parse_json_config(self, json_config):
|
||||
builder = TalkObjectConfigBuilder()
|
||||
talk_objects_config = builder.build_from_json(json_config)
|
||||
return (talk_objects_config,)
|
||||
|
||||
talk_objects = []
|
||||
for i in range(1, number + 1):
|
||||
talk_obj = kwargs.get(f"talk_object_{i}")
|
||||
if talk_obj is not None:
|
||||
talk_objects.append(talk_obj)
|
||||
|
||||
if not talk_objects:
|
||||
return (None,)
|
||||
class TalkObjectsFromFiles:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"audio_files": ("STRING", {"multiline": True, "default": "audio1.wav\naudio2.wav", "tooltip": "音频文件列表(每行一个)"}),
|
||||
},
|
||||
"optional": {
|
||||
"mask_files": ("STRING", {"multiline": True, "default": "mask1.png\nmask2.png", "tooltip": "遮罩文件列表(每行一个,可选)"}),
|
||||
"names": ("STRING", {"multiline": True, "default": "person1\nperson2", "tooltip": "人物名称列表(每行一个,可选)"}),
|
||||
},
|
||||
}
|
||||
|
||||
return ({"talk_objects": talk_objects},)
|
||||
RETURN_TYPES = ("TALK_OBJECTS_CONFIG",)
|
||||
RETURN_NAMES = ("talk_objects_config",)
|
||||
FUNCTION = "build_from_files"
|
||||
CATEGORY = "LightX2V/Audio"
|
||||
|
||||
def build_from_files(self, audio_files, mask_files="", names=""):
|
||||
builder = TalkObjectConfigBuilder()
|
||||
talk_objects_config = builder.build_from_files(audio_files, mask_files, names)
|
||||
return (talk_objects_config,)
|
||||
|
||||
|
||||
class LightX2VConfigCombiner:
|
||||
def __init__(self):
|
||||
self.config_manager = ModularConfigManager()
|
||||
self.config_builder = ConfigBuilder()
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
@@ -688,36 +677,37 @@ class LightX2VConfigCombiner:
|
||||
lora_chain=None,
|
||||
talk_objects_config=None,
|
||||
):
|
||||
configs = {
|
||||
"inference": inference_config,
|
||||
}
|
||||
if teacache_config:
|
||||
configs["teacache"] = teacache_config
|
||||
if quantization_config:
|
||||
configs["quantization"] = quantization_config
|
||||
if memory_config:
|
||||
configs["memory"] = memory_config
|
||||
"""Combine multiple configurations into final config."""
|
||||
# Convert dict configs back to objects if needed
|
||||
|
||||
config = self.config_manager.build_final_config(configs)
|
||||
# Create objects from dicts
|
||||
inf_config = InferenceConfig(**inference_config) if isinstance(inference_config, dict) else None
|
||||
tea_config = TeaCacheConfig(**teacache_config) if teacache_config and isinstance(teacache_config, dict) else None
|
||||
quant_config = QuantizationConfig(**quantization_config) if quantization_config and isinstance(quantization_config, dict) else None
|
||||
mem_config = MemoryOptimizationConfig(**memory_config) if memory_config and isinstance(memory_config, dict) else None
|
||||
|
||||
if lora_chain:
|
||||
config.lora_configs = lora_chain
|
||||
|
||||
if talk_objects_config:
|
||||
config.talk_objects_config = talk_objects_config
|
||||
|
||||
logging.info("lightx2v config: " + json.dumps(config, indent=2, ensure_ascii=False))
|
||||
config = self.config_builder.combine_configs(
|
||||
inference_config=inf_config,
|
||||
teacache_config=tea_config,
|
||||
quantization_config=quant_config,
|
||||
memory_config=mem_config,
|
||||
lora_chain=lora_chain,
|
||||
talk_objects_config=talk_objects_config,
|
||||
)
|
||||
|
||||
return (config,)
|
||||
|
||||
|
||||
class LightX2VModularInference:
|
||||
# 类变量,所有实例共享
|
||||
_current_runner = None
|
||||
_current_config_hash = None
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
self.config_builder = ConfigBuilder()
|
||||
self.temp_manager = TempFileManager()
|
||||
self.image_handler = ImageFileHandler()
|
||||
self.audio_handler = AudioFileHandler()
|
||||
self.resolver = ComfyUIFileResolver()
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
@@ -751,43 +741,8 @@ class LightX2VModularInference:
|
||||
CATEGORY = "LightX2V/Inference"
|
||||
|
||||
def _get_config_hash(self, config) -> str:
|
||||
relevant_configs = {
|
||||
"model_cls": getattr(config, "model_cls", None),
|
||||
"model_path": getattr(config, "model_path", None),
|
||||
"task": getattr(config, "task", None),
|
||||
"t5_quantized": getattr(config, "t5_quantized", False),
|
||||
"clip_quantized": getattr(config, "clip_quantized", False),
|
||||
"lora_configs": getattr(config, "lora_configs", None),
|
||||
"mm_config": getattr(config, "mm_config", None),
|
||||
"cross_attn_1_type": getattr(config, "cross_attn_1_type", None),
|
||||
"cross_attn_2_type": getattr(config, "cross_attn_2_type", None),
|
||||
"self_attn_1_type": getattr(config, "self_attn_1_type", None),
|
||||
"self_attn_2_type": getattr(config, "self_attn_2_type", None),
|
||||
"cpu_offload": getattr(config, "cpu_offload", False),
|
||||
"offload_granularity": getattr(config, "offload_granularity", None),
|
||||
"offload_ratio": getattr(config, "offload_ratio", None),
|
||||
"t5_cpu_offload": getattr(config, "t5_cpu_offload", False),
|
||||
"t5_offload_granularity": getattr(config, "t5_offload_granularity", None),
|
||||
"audio_encoder_cpu_offload": getattr(config, "audio_encoder_cpu_offload", False),
|
||||
"audio_adapter_cpu_offload": getattr(config, "audio_adapter_cpu_offload", False),
|
||||
"vae_cpu_offload": getattr(config, "vae_cpu_offload", False),
|
||||
"use_tiling_vae": getattr(config, "use_tiling_vae", False),
|
||||
"unload_after_inference": getattr(config, "unload_after_inference", False),
|
||||
"enable_rotary_chunk": getattr(config, "enable_rotary_chunk", False),
|
||||
"rotary_chunk_size": getattr(config, "rotary_chunk_size", None),
|
||||
"clean_cuda_cache": getattr(config, "clean_cuda_cache", False),
|
||||
"torch_compile": getattr(config, "torch_compile", False),
|
||||
"threshold": getattr(config, "threshold", None),
|
||||
"use_ret_steps": getattr(config, "use_ret_steps", False),
|
||||
"t5_quant_scheme": getattr(config, "t5_quant_scheme", None),
|
||||
"clip_quant_scheme": getattr(config, "clip_quant_scheme", None),
|
||||
"adapter_quant_scheme": getattr(config, "adapter_quant_scheme", None),
|
||||
"adapter_quantized": getattr(config, "adapter_quantized", False),
|
||||
"feature_caching": getattr(config, "feature_caching", None),
|
||||
}
|
||||
|
||||
config_str = json.dumps(relevant_configs, sort_keys=True)
|
||||
return hashlib.md5(config_str.encode()).hexdigest()
|
||||
"""Get hash of configuration to detect changes."""
|
||||
return self.config_builder.get_config_hash(config)
|
||||
|
||||
def generate(
|
||||
self,
|
||||
@@ -798,157 +753,73 @@ class LightX2VModularInference:
|
||||
audio=None,
|
||||
**kwargs,
|
||||
):
|
||||
# config type is EasyDict
|
||||
config = combined_config
|
||||
|
||||
config.prompt = prompt
|
||||
config.negative_prompt = negative_prompt
|
||||
|
||||
if config.task == "i2v" and image is None:
|
||||
raise ValueError("i2v task requires input image")
|
||||
|
||||
temp_files = []
|
||||
|
||||
try:
|
||||
# Handle image input
|
||||
if config.task == "i2v" and image is not None:
|
||||
image_np = (image[0].cpu().numpy() * 255).astype(np.uint8)
|
||||
pil_image = Image.fromarray(image_np)
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
||||
pil_image.save(tmp.name)
|
||||
config.image_path = tmp.name
|
||||
temp_files.append(tmp.name)
|
||||
logging.info(f"Image saved to {tmp.name}")
|
||||
temp_path = self.temp_manager.create_temp_file(suffix=".png")
|
||||
pil_image.save(temp_path)
|
||||
config.image_path = temp_path
|
||||
logging.info(f"Image saved to {temp_path}")
|
||||
|
||||
# Handle audio input for seko models
|
||||
if audio is not None and hasattr(config, "model_cls") and "seko" in config.model_cls:
|
||||
if isinstance(audio, dict) and "waveform" in audio and "sample_rate" in audio:
|
||||
waveform = audio["waveform"]
|
||||
sample_rate = audio["sample_rate"]
|
||||
temp_path = self.temp_manager.create_temp_file(suffix=".wav")
|
||||
self.audio_handler.save(audio, temp_path)
|
||||
config.audio_path = temp_path
|
||||
logging.info(f"Audio saved to {temp_path}")
|
||||
|
||||
# Handle different waveform shapes
|
||||
if isinstance(waveform, torch.Tensor):
|
||||
if waveform.dim() == 3: # [batch, channels, samples]
|
||||
waveform = waveform[0] # Take first batch
|
||||
if waveform.dim() == 2: # [channels, samples]
|
||||
# Convert to [samples, channels] for wav file
|
||||
waveform = waveform.transpose(0, 1)
|
||||
waveform = waveform.cpu().numpy()
|
||||
elif isinstance(audio, tuple) and len(audio) == 2:
|
||||
# Legacy format support
|
||||
waveform, sample_rate = audio
|
||||
if isinstance(waveform, torch.Tensor):
|
||||
waveform = waveform.cpu().numpy()
|
||||
else:
|
||||
raise ValueError(f"Unsupported audio format: {type(audio)}")
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
|
||||
try:
|
||||
import scipy.io.wavfile as wavfile
|
||||
except ImportError:
|
||||
import wave
|
||||
|
||||
with wave.open(tmp.name, "wb") as wav_file:
|
||||
wav_file.setnchannels(1 if waveform.ndim == 1 else waveform.shape[-1])
|
||||
wav_file.setsampwidth(2) # 16-bit
|
||||
wav_file.setframerate(sample_rate)
|
||||
if waveform.dtype != np.int16:
|
||||
waveform = (waveform * 32767).astype(np.int16)
|
||||
wav_file.writeframes(waveform.tobytes())
|
||||
else:
|
||||
if waveform.ndim == 1:
|
||||
wavfile.write(tmp.name, sample_rate, waveform)
|
||||
else:
|
||||
if waveform.shape[0] < waveform.shape[1]:
|
||||
waveform = waveform.T
|
||||
wavfile.write(tmp.name, sample_rate, waveform)
|
||||
|
||||
config.audio_path = tmp.name
|
||||
temp_files.append(tmp.name)
|
||||
|
||||
logging.info(f"Audio saved to {tmp.name}")
|
||||
|
||||
# 处理多人对话对象
|
||||
if hasattr(config, "talk_objects_config") and config.talk_objects_config:
|
||||
talk_objects = config.talk_objects_config.get("talk_objects", [])
|
||||
# Handle talk objects
|
||||
if hasattr(config, "talk_objects") and config.talk_objects:
|
||||
talk_objects = config.talk_objects
|
||||
processed_talk_objects = []
|
||||
|
||||
for talk_obj in talk_objects:
|
||||
processed_obj = {}
|
||||
|
||||
# 处理音频
|
||||
if talk_obj.get("audio_type") == "upload" and "audio_data" in talk_obj:
|
||||
audio_data = talk_obj["audio_data"]
|
||||
if "audio" in talk_obj:
|
||||
processed_obj["audio"] = talk_obj["audio"]
|
||||
|
||||
# 处理音频数据,转换为临时文件
|
||||
if isinstance(audio_data, dict) and "waveform" in audio_data and "sample_rate" in audio_data:
|
||||
waveform = audio_data["waveform"]
|
||||
sample_rate = audio_data["sample_rate"]
|
||||
|
||||
if isinstance(waveform, torch.Tensor):
|
||||
if waveform.dim() == 3:
|
||||
waveform = waveform[0]
|
||||
if waveform.dim() == 2:
|
||||
waveform = waveform.transpose(0, 1)
|
||||
waveform = waveform.cpu().numpy()
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
|
||||
try:
|
||||
import scipy.io.wavfile as wavfile
|
||||
|
||||
if waveform.ndim == 1:
|
||||
wavfile.write(tmp.name, sample_rate, waveform)
|
||||
else:
|
||||
if waveform.shape[0] < waveform.shape[1]:
|
||||
waveform = waveform.T
|
||||
wavfile.write(tmp.name, sample_rate, waveform)
|
||||
except ImportError:
|
||||
import wave
|
||||
|
||||
with wave.open(tmp.name, "wb") as wav_file:
|
||||
wav_file.setnchannels(1 if waveform.ndim == 1 else waveform.shape[-1])
|
||||
wav_file.setsampwidth(2)
|
||||
wav_file.setframerate(sample_rate)
|
||||
if waveform.dtype != np.int16:
|
||||
waveform = (waveform * 32767).astype(np.int16)
|
||||
wav_file.writeframes(waveform.tobytes())
|
||||
|
||||
processed_obj["audio"] = tmp.name
|
||||
temp_files.append(tmp.name)
|
||||
logging.info(f"Talk object audio saved to {tmp.name}")
|
||||
elif talk_obj.get("audio_type") == "url":
|
||||
processed_obj["audio"] = talk_obj.get("audio")
|
||||
|
||||
# 处理遮罩
|
||||
if talk_obj.get("mask_type") == "upload" and "mask_data" in talk_obj:
|
||||
mask_data = talk_obj["mask_data"]
|
||||
|
||||
# 假设 mask_data 是一个 tensor 或 numpy array
|
||||
if isinstance(mask_data, torch.Tensor):
|
||||
mask_np = (mask_data[0].cpu().numpy() * 255).astype(np.uint8)
|
||||
elif isinstance(mask_data, np.ndarray):
|
||||
mask_np = (mask_data * 255).astype(np.uint8)
|
||||
else:
|
||||
mask_np = mask_data
|
||||
|
||||
mask_image = Image.fromarray(mask_np)
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
||||
mask_image.save(tmp.name)
|
||||
processed_obj["mask"] = tmp.name
|
||||
temp_files.append(tmp.name)
|
||||
logging.info(f"Talk object mask saved to {tmp.name}")
|
||||
elif talk_obj.get("mask_type") == "url":
|
||||
processed_obj["mask"] = talk_obj.get("mask")
|
||||
elif talk_obj.get("mask_type") == "none":
|
||||
processed_obj["mask"] = None
|
||||
if "mask" in talk_obj:
|
||||
processed_obj["mask"] = talk_obj["mask"]
|
||||
|
||||
if "audio" in processed_obj:
|
||||
processed_talk_objects.append(processed_obj)
|
||||
|
||||
# 设置 talk_objects 到 config
|
||||
# Resolve paths
|
||||
for obj in processed_talk_objects:
|
||||
if "audio" in obj and obj["audio"]:
|
||||
audio_path = obj["audio"]
|
||||
if not os.path.isabs(audio_path) and not audio_path.startswith("/tmp"):
|
||||
obj["audio"] = self.resolver.resolve_input_path(audio_path)
|
||||
logging.info(f"Resolved audio path: {audio_path} -> {obj['audio']}")
|
||||
if not os.path.exists(obj["audio"]):
|
||||
logging.warning(f"Audio file not found: {obj['audio']}")
|
||||
|
||||
if "mask" in obj and obj["mask"]:
|
||||
mask_path = obj["mask"]
|
||||
if not os.path.isabs(mask_path) and not mask_path.startswith("/tmp"):
|
||||
obj["mask"] = self.resolver.resolve_input_path(mask_path)
|
||||
logging.info(f"Resolved mask path: {mask_path} -> {obj['mask']}")
|
||||
if not os.path.exists(obj["mask"]):
|
||||
logging.warning(f"Mask file not found: {obj['mask']}")
|
||||
|
||||
if processed_talk_objects:
|
||||
config.talk_objects = processed_talk_objects
|
||||
logging.info(f"Processed {len(processed_talk_objects)} talk objects")
|
||||
|
||||
logging.info("lightx2v config: " + json.dumps(config, indent=2, ensure_ascii=False))
|
||||
|
||||
config_hash = self._get_config_hash(config)
|
||||
needs_reinit = (
|
||||
self.__class__._current_runner is None or self.__class__._current_config_hash != config_hash or getattr(config, "lazy_load", False)
|
||||
@@ -970,13 +841,13 @@ class LightX2VModularInference:
|
||||
|
||||
progress = ProgressBar(100)
|
||||
|
||||
def update_progress(current_step, total):
|
||||
def update_progress(current_step, _total):
|
||||
progress.update_absolute(current_step)
|
||||
|
||||
if hasattr(self.__class__._current_runner, "set_progress_callback"):
|
||||
self.__class__._current_runner.set_progress_callback(update_progress)
|
||||
|
||||
result_dict = self.__class__._current_runner.run_pipeline(save_video=False)
|
||||
result_dict = self.__class__._current_runner.run_pipeline()
|
||||
images = result_dict.get("video", None)
|
||||
audio = result_dict.get("audio", None)
|
||||
|
||||
@@ -995,12 +866,8 @@ class LightX2VModularInference:
|
||||
raise
|
||||
|
||||
finally:
|
||||
for temp_file in temp_files:
|
||||
if os.path.exists(temp_file):
|
||||
try:
|
||||
os.unlink(temp_file)
|
||||
except Exception:
|
||||
pass
|
||||
# Cleanup is handled by TempFileManager destructor
|
||||
pass
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
@@ -1011,8 +878,10 @@ NODE_CLASS_MAPPINGS = {
|
||||
"LightX2VLoRALoader": LightX2VLoRALoader,
|
||||
"LightX2VConfigCombiner": LightX2VConfigCombiner,
|
||||
"LightX2VModularInference": LightX2VModularInference,
|
||||
"TalkObjectInput": TalkObjectInput,
|
||||
"TalkObjectsBuilder": TalkObjectsBuilder,
|
||||
"LightX2VTalkObjectInput": TalkObjectInput,
|
||||
"LightX2VTalkObjectsCombiner": TalkObjectsCombiner,
|
||||
"LightX2VTalkObjectsFromJSON": TalkObjectsFromJSON,
|
||||
"LightX2VTalkObjectsFromFiles": TalkObjectsFromFiles,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
@@ -1023,6 +892,8 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"LightX2VLoRALoader": "LightX2V LoRA Loader",
|
||||
"LightX2VConfigCombiner": "LightX2V Config Combiner",
|
||||
"LightX2VModularInference": "LightX2V Modular Inference",
|
||||
"TalkObjectInput": "Talk Object Input",
|
||||
"TalkObjectsBuilder": "Talk Objects Builder",
|
||||
"LightX2VTalkObjectInput": "LightX2V Talk Object Input (Single)",
|
||||
"LightX2VTalkObjectsCombiner": "LightX2V Talk Objects Combiner",
|
||||
"LightX2VTalkObjectsFromFiles": "LightX2V Talk Objects From Files",
|
||||
"LightX2VTalkObjectsFromJSON": "LightX2V Talk Objects From JSON (API)",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user