Files
2026-04-09 20:16:52 +08:00

492 lines
20 KiB
Python

# -*- coding: utf-8 -*-
"""
ComfyUI custom nodes for Sony Woosh - 修复 description 为 None 的错误
"""
import os
import sys
import warnings
import torch
import folder_paths
from typing import Dict, Any, List, Tuple
import comfy.model_management as model_management
# ============================================================
# 定义 synchformer 权重文件的查找路径(按优先级)
# ============================================================
SYNCHFORMER_WEIGHT_CANDIDATES = [
r"F:\ComfyUI-WorkFisher-V2\ComfyUI-WorkFisher-V2\hf_cache\models--hkchengrex--MMAudio\snapshots\default\ext_weights\synchformer_state_dict.pth",
r"F:\ComfyUI-WorkFisher-V2\ComfyUI-WorkFisher-V2\hf_cache\models\default\synchformer_state_dict.pth",
os.environ.get("SYNCHFORMER_WEIGHT_PATH", ""),
os.path.join(folder_paths.models_dir, "synchformer_state_dict.pth"),
os.path.join(os.getcwd(), "synchformer_state_dict.pth"),
]
SYNCHFORMER_WEIGHT_PATH = None
for candidate in SYNCHFORMER_WEIGHT_CANDIDATES:
if candidate and os.path.exists(candidate):
SYNCHFORMER_WEIGHT_PATH = candidate
break
if SYNCHFORMER_WEIGHT_PATH is None:
raise FileNotFoundError(
"找不到 synchformer 权重文件 (synchformer_state_dict.pth)。\n"
"请从以下地址下载:\n"
" https://hf-mirror.com/hkchengrex/MMAudio/resolve/main/ext_weights/synchformer_state_dict.pth\n"
"然后将其放置到正确位置。"
)
print(f"[Woosh] 使用 synchformer 权重文件: {SYNCHFORMER_WEIGHT_PATH}")
# 补丁 huggingface_hub
try:
import huggingface_hub
original_hf_hub_download = huggingface_hub.hf_hub_download
def patched_hf_hub_download(repo_id, filename, **kwargs):
if filename == "synchformer_state_dict.pth" and repo_id == "hkchengrex/MMAudio":
return SYNCHFORMER_WEIGHT_PATH
return original_hf_hub_download(repo_id, filename, **kwargs)
huggingface_hub.hf_hub_download = patched_hf_hub_download
except ImportError:
pass
# ============================================================
# 官方 Woosh 导入
# ============================================================
try:
from woosh.model.ldm import LatentDiffusionModel
from woosh.model.video_kontext import VideoKontext
from woosh.components.base import LoadConfig
from woosh.inference.flowmatching_sampler import flowmatching_integrate
from woosh.utils.video import SynchformerProcessor
from woosh.utils.videoio import extract_video_frames, remux_video
except ImportError as e:
raise ImportError(
"无法导入 Woosh 官方包。请先正确安装:\n"
" git clone https://github.com/SonyResearch/Woosh\n"
" cd Woosh\n"
" pip install -e .\n"
f"原始错误: {e}"
)
try:
from torchvision.io import read_video
TORCHVISION_AVAILABLE = True
except ImportError:
TORCHVISION_AVAILABLE = False
# ============================================================
# 辅助函数
# ============================================================
def _get_device():
try:
return model_management.get_torch_device()
except Exception:
return torch.device("cuda" if torch.cuda.is_available() else "cpu")
def _models_root():
env_path = os.environ.get("WOOSH_CKPT", "").strip()
if env_path:
return os.path.abspath(env_path)
return os.path.join(folder_paths.models_dir, "woosh", "checkpoints")
def _resolve_model_dir(model_key: str) -> str:
root = _models_root()
candidates = {
"woosh-flow": ["Woosh-Flow", "woosh-flow", "flow"],
"woosh-dflow": ["Woosh-DFlow", "woosh-dflow", "dflow"],
"woosh-vflow": ["Woosh-VFlow", "Woosh-VFlow-8s", "woosh-vflow", "vflow"],
}
if model_key not in candidates:
raise ValueError(f"未知的模型键: {model_key}")
for name in candidates[model_key]:
path = os.path.join(root, name)
if os.path.isdir(path):
return path
raise FileNotFoundError(
f"找不到模型目录 '{model_key}'。\n"
f"搜索根目录: {root}\n"
f"期望的文件夹名: {candidates[model_key]}\n"
"请确保已将官方权重解压到上述位置。"
)
def _make_audio_output(waveform: torch.Tensor, sample_rate: int) -> Dict[str, Any]:
if waveform.dim() == 1:
waveform = waveform.unsqueeze(0)
if waveform.dim() == 2:
waveform = waveform.unsqueeze(0)
waveform = waveform.float().cpu()
return {"waveform": waveform, "sample_rate": sample_rate}
def _normalize_audio(waveform: torch.Tensor) -> torch.Tensor:
max_val = waveform.abs().max()
if max_val > 1.0 and max_val > 1e-6:
waveform = waveform / max_val
return waveform
def _call_flowmatching(ldm, noise, cond, cfg, device, dtype):
result = flowmatching_integrate(
ldm,
noise=noise,
cond=cond,
cfg=cfg,
atol=0.001,
rtol=0.001,
device=device,
dtype=dtype,
)
if isinstance(result, tuple):
return result[0]
else:
return result
# ============================================================
# 模型管理器
# ============================================================
class ModelCache:
_models = {}
_feature_extractors = {}
@classmethod
def get(cls, model_type: str):
if model_type in cls._models:
return cls._models[model_type]
key_map = {
"Woosh-Flow": "woosh-flow",
"Woosh-DFlow": "woosh-dflow",
"Woosh-VFlow": "woosh-vflow",
}
if model_type not in key_map:
raise ValueError(f"不支持的模型类型: {model_type}")
model_key = key_map[model_type]
model_dir = _resolve_model_dir(model_key)
device = _get_device()
if model_type == "Woosh-VFlow":
ldm = VideoKontext(LoadConfig(path=model_dir))
ldm = ldm.eval().to(device)
if model_type not in cls._feature_extractors:
featuresModel = SynchformerProcessor(frame_rate=24).eval().to(device)
cls._feature_extractors[model_type] = featuresModel
else:
ldm = LatentDiffusionModel(LoadConfig(path=model_dir))
ldm = ldm.eval().to(device)
cls._feature_extractors[model_type] = None
cls._models[model_type] = ldm
return ldm
@classmethod
def get_feature_extractor(cls, model_type: str):
if model_type not in cls._feature_extractors:
cls.get(model_type)
return cls._feature_extractors.get(model_type)
# ============================================================
# 节点1: 文本生成音频
# ============================================================
class WooshTextToAudioSony:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"prompt": ("STRING", {"multiline": True, "default": "a whoosh sound effect"}),
"duration": ("FLOAT", {"default": 3.0, "min": 1.0, "max": 5.0, "step": 0.1}),
"guidance_scale": ("FLOAT", {"default": 4.5, "min": 1.0, "max": 10.0, "step": 0.5}),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffff}),
"model_type": (["Woosh-Flow", "Woosh-DFlow"], {"default": "Woosh-Flow"}),
}
}
RETURN_TYPES = ("AUDIO",)
RETURN_NAMES = ("audio",)
FUNCTION = "generate"
CATEGORY = "audio/generation"
def generate(self, prompt, duration, guidance_scale, seed, model_type):
device = _get_device()
torch.manual_seed(seed)
if device.type == "cuda":
torch.cuda.manual_seed_all(seed)
ldm = ModelCache.get(model_type)
steps = 501
noise = torch.randn(1, 128, steps, device=device)
cond = ldm.get_cond(
{"audio": None, "description": [prompt]},
no_dropout=True,
device=device,
)
with torch.inference_mode():
x_fake = _call_flowmatching(
ldm,
noise=noise,
cond=cond,
cfg=guidance_scale,
device=device,
dtype=torch.float32 if device.type == "mps" else torch.float64,
)
waveform_full = ldm.autoencoder.inverse(x_fake)
if waveform_full.dim() == 2:
waveform_full = waveform_full.unsqueeze(1)
waveform_full = waveform_full.squeeze(0).cpu()
if waveform_full.dim() == 1:
waveform_full = waveform_full.unsqueeze(0)
full_duration = waveform_full.shape[-1] / 48000
print(f"[Woosh Text] 生成完整音频时长: {full_duration:.2f} 秒")
target_samples = int(duration * 48000)
if target_samples < waveform_full.shape[-1]:
waveform = waveform_full[:, :target_samples]
print(f"[Woosh Text] 裁剪后音频时长: {waveform.shape[-1] / 48000:.2f} 秒")
else:
waveform = waveform_full
waveform = _normalize_audio(waveform)
sample_rate = 48000
return (_make_audio_output(waveform, sample_rate),)
# ============================================================
# 节点2: 短视频 -> 音频(修复 description 为 None 的问题)
# ============================================================
class WooshVideoToAudioSony:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"video": ("VIDEO",),
"guidance_scale": ("FLOAT", {"default": 4.5, "min": 1.0, "max": 10.0, "step": 0.5}),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffff}),
},
"optional": {
"prompt": ("STRING", {"multiline": True, "default": ""}),
}
}
RETURN_TYPES = ("AUDIO",)
RETURN_NAMES = ("audio",)
FUNCTION = "generate"
CATEGORY = "audio/generation"
def generate(self, video, guidance_scale, seed, prompt=""):
device = _get_device()
torch.manual_seed(seed)
if device.type == "cuda":
torch.cuda.manual_seed_all(seed)
model_type = "Woosh-VFlow"
ldm = ModelCache.get(model_type)
featuresModel = ModelCache.get_feature_extractor(model_type)
if featuresModel is None:
raise RuntimeError("视频特征提取器加载失败")
frames, fps = self._load_video_frames_with_fps(video)
if frames is None:
raise RuntimeError("无法加载视频输入")
frames = frames.to(device)
with torch.inference_mode():
features = featuresModel(frames, fps)
# 修复:description 必须为列表,即使为空字符串也要用 [""] 而不是 None
cond = ldm.get_cond(
{
"audio": None,
"description": [prompt] if prompt else [""],
"synch_out": features["synch_out"],
},
no_dropout=True,
device=device,
)
video_duration = frames.shape[0] / fps
steps = max(1, int(round(video_duration * 100.125)))
noise = torch.randn(1, 128, steps, device=device)
with torch.inference_mode():
x_fake = _call_flowmatching(
ldm,
noise=noise,
cond=cond,
cfg=guidance_scale,
device=device,
dtype=torch.float32 if device.type == "mps" else torch.float64,
)
waveform = ldm.autoencoder.inverse(x_fake)
if waveform.dim() == 2:
waveform = waveform.unsqueeze(1)
waveform = waveform.squeeze(0).cpu()
if waveform.dim() == 1:
waveform = waveform.unsqueeze(0)
waveform = _normalize_audio(waveform)
sample_rate = 48000
return (_make_audio_output(waveform, sample_rate),)
def _load_video_frames_with_fps(self, video_input):
if hasattr(video_input, '__class__') and video_input.__class__.__name__ == 'VideoFromFile':
video_path = None
if hasattr(video_input, '_VideoFromFile__file'):
video_path = getattr(video_input, '_VideoFromFile__file')
if video_path is None and hasattr(video_input, 'get_stream_source'):
video_path = video_input.get_stream_source()
if video_path is None:
for attr in ['path', 'filename', 'file_path', 'video_path']:
if hasattr(video_input, attr):
video_path = getattr(video_input, attr)
if video_path and os.path.exists(video_path):
break
if video_path is None:
raise ValueError(f"无法从 VideoFromFile 对象中提取文件路径。可用属性: {dir(video_input)}")
if not TORCHVISION_AVAILABLE:
raise RuntimeError("处理视频需要 torchvision")
frames, _, info = read_video(video_path, pts_unit="sec")
fps = info.get("video_fps", 24.0)
frames = frames.float() / 255.0
return frames, fps
elif isinstance(video_input, str):
if not TORCHVISION_AVAILABLE:
raise RuntimeError("处理视频路径需要 torchvision")
frames, _, info = read_video(video_input, pts_unit="sec")
fps = info.get("video_fps", 24.0)
frames = frames.float() / 255.0
return frames, fps
elif isinstance(video_input, torch.Tensor):
tensor = video_input.detach().float()
if tensor.max() > 1.0:
tensor = tensor / 255.0
if tensor.ndim == 4:
if tensor.shape[0] == 1:
tensor = tensor[0]
if tensor.shape[-1] not in (1, 3):
if tensor.shape[1] in (1, 3):
tensor = tensor.permute(0, 2, 3, 1)
elif tensor.ndim == 3:
pass
else:
raise ValueError(f"不支持的视频张量形状: {tensor.shape}")
return tensor, 24.0
else:
raise TypeError(f"不支持的视频类型: {type(video_input)}")
def _load_video_frames(self, video_input):
frames, _ = self._load_video_frames_with_fps(video_input)
return frames
# ============================================================
# 节点3: 长视频 -> 音频
# ============================================================
class WooshLongVideoToAudioSony:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"video": ("VIDEO",),
"segment_duration": ("FLOAT", {"default": 8.0, "min": 1.0, "max": 30.0, "step": 0.5}),
"overlap_duration": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 5.0, "step": 0.1}),
"guidance_scale": ("FLOAT", {"default": 4.5, "min": 1.0, "max": 10.0, "step": 0.5}),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffff}),
},
"optional": {
"prompt": ("STRING", {"multiline": True, "default": ""}),
}
}
RETURN_TYPES = ("AUDIO",)
RETURN_NAMES = ("audio",)
FUNCTION = "generate"
CATEGORY = "audio/generation"
def generate(self, video, segment_duration, overlap_duration, guidance_scale, seed, prompt=""):
base_node = WooshVideoToAudioSony()
frames, fps = base_node._load_video_frames_with_fps(video)
if frames is None:
raise RuntimeError("无法加载视频")
total_frames = frames.shape[0]
total_duration = total_frames / fps
if total_duration <= segment_duration + 0.1:
return base_node.generate(video, guidance_scale, seed, prompt)
step_duration = segment_duration - overlap_duration
if step_duration <= 0:
step_duration = segment_duration / 2
overlap_duration = segment_duration / 2
warnings.warn(f"重叠时长过长,已自动调整为 {overlap_duration:.1f}s")
segments = []
start_time = 0.0
while start_time < total_duration:
end_time = min(start_time + segment_duration, total_duration)
start_frame = int(start_time * fps)
end_frame = int(end_time * fps)
seg_frames = frames[start_frame:end_frame]
segments.append(seg_frames)
start_time += step_duration
if len(segments) > 100:
warnings.warn("片段数超过 100,强制停止")
break
audio_segments = []
for idx, seg_frames in enumerate(segments):
print(f"生成音频片段 {idx+1}/{len(segments)}...")
audio_dict = base_node.generate(seg_frames, guidance_scale, seed + idx, prompt)
audio_segments.append(audio_dict[0])
final_audio = self._stitch_audio(audio_segments, overlap_duration)
return (final_audio,)
def _stitch_audio(self, segments: List[Dict], overlap_sec: float) -> Dict:
if not segments:
raise ValueError("没有音频片段可拼接")
sr = segments[0]["sample_rate"]
for seg in segments:
if seg["sample_rate"] != sr:
raise RuntimeError("片段采样率不一致")
overlap_samples = int(overlap_sec * sr)
waveforms = [seg["waveform"].squeeze(0) for seg in segments]
channels = waveforms[0].shape[0]
total_len = 0
for i, w in enumerate(waveforms):
if i == 0:
total_len += w.shape[1]
else:
total_len += w.shape[1] - overlap_samples
final_wave = torch.zeros((channels, total_len), dtype=torch.float32)
pos = 0
for i, w in enumerate(waveforms):
seg_len = w.shape[1]
if i == 0:
final_wave[:, pos:pos+seg_len] = w
pos += seg_len
else:
start_overlap = pos - overlap_samples
end_overlap = pos
fade_out = torch.linspace(1, 0, overlap_samples, device=w.device).view(1, -1)
final_wave[:, start_overlap:end_overlap] *= fade_out
fade_in = torch.linspace(0, 1, overlap_samples, device=w.device).view(1, -1)
final_wave[:, start_overlap:end_overlap] += w[:, :overlap_samples] * fade_in
if seg_len > overlap_samples:
final_wave[:, pos:pos+seg_len-overlap_samples] = w[:, overlap_samples:]
pos += seg_len - overlap_samples
final_wave = _normalize_audio(final_wave)
final_wave = final_wave.unsqueeze(0)
return {"waveform": final_wave, "sample_rate": sr}
# ============================================================
# 节点注册
# ============================================================
NODE_CLASS_MAPPINGS = {
"WooshTextToAudioSony": WooshTextToAudioSony,
"WooshVideoToAudioSony": WooshVideoToAudioSony,
"WooshLongVideoToAudioSony": WooshLongVideoToAudioSony,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"WooshTextToAudioSony": "Woosh Text to Audio (Sony)",
"WooshVideoToAudioSony": "Woosh Video to Audio (Sony)",
"WooshLongVideoToAudioSony": "Woosh Long Video to Audio (Sony)",
}