463 lines
20 KiB
Python
463 lines
20 KiB
Python
"""
|
||
视频合并节点
|
||
1. CreateAndSaveVideo: 从图片创建视频并保存,返回路径
|
||
2. ConcatVideoFiles: 使用 FFmpeg 合并多个视频文件(多行文本输入路径)
|
||
3. SaveAudioGetPath: 保存音频并返回文件路径
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import subprocess
|
||
import tempfile
|
||
import folder_paths
|
||
from fractions import Fraction
|
||
from comfy.cli_args import args
|
||
CATEGORY_TYPE = "🎈LAOGOU/Group"
|
||
|
||
class LG_CreateAndSaveVideo:
|
||
"""
|
||
从图片创建视频并保存到文件,返回文件路径
|
||
"""
|
||
|
||
@classmethod
|
||
def INPUT_TYPES(cls):
|
||
return {
|
||
"required": {
|
||
"images": ("IMAGE",),
|
||
"fps": ("FLOAT", {"default": 30.0, "min": 1.0, "max": 120.0, "step": 1.0}),
|
||
"filename_prefix": ("STRING", {"default": "video/segment"}),
|
||
},
|
||
"optional": {
|
||
"audio": ("AUDIO",),
|
||
},
|
||
"hidden": {
|
||
"prompt": "PROMPT",
|
||
"extra_pnginfo": "EXTRA_PNGINFO"
|
||
}
|
||
}
|
||
|
||
RETURN_TYPES = ("STRING",)
|
||
RETURN_NAMES = ("file_path",)
|
||
FUNCTION = "create_and_save"
|
||
CATEGORY = CATEGORY_TYPE
|
||
DESCRIPTION = "从图片创建视频并保存,返回文件路径"
|
||
|
||
def create_and_save(self, images, fps, filename_prefix, audio=None, prompt=None, extra_pnginfo=None):
|
||
try:
|
||
from comfy_api.latest._input_impl.video_types import VideoFromComponents
|
||
from comfy_api.latest._util.video_types import VideoComponents, VideoContainer, VideoCodec
|
||
except ImportError:
|
||
from comfy_api.input_impl import VideoFromComponents
|
||
from comfy_api._util.video_types import VideoComponents, VideoContainer, VideoCodec
|
||
|
||
# 创建视频
|
||
components = VideoComponents(images=images, audio=audio, frame_rate=Fraction(fps))
|
||
video = VideoFromComponents(components)
|
||
|
||
# 保存
|
||
width, height = images.shape[2], images.shape[1]
|
||
output_dir = folder_paths.get_output_directory()
|
||
|
||
full_output_folder, filename, counter, subfolder, _ = folder_paths.get_save_image_path(
|
||
filename_prefix, output_dir, width, height
|
||
)
|
||
|
||
saved_metadata = None
|
||
if not args.disable_metadata:
|
||
metadata = {}
|
||
if extra_pnginfo is not None:
|
||
metadata.update(extra_pnginfo)
|
||
if prompt is not None:
|
||
metadata["prompt"] = prompt
|
||
if len(metadata) > 0:
|
||
saved_metadata = metadata
|
||
|
||
file = f"{filename}_{counter:05}_.mp4"
|
||
file_path = os.path.join(full_output_folder, file)
|
||
|
||
video.save_to(
|
||
file_path,
|
||
format=VideoContainer.MP4,
|
||
codec=VideoCodec.H264,
|
||
metadata=saved_metadata
|
||
)
|
||
|
||
return (file_path,)
|
||
|
||
|
||
class LG_ConcatVideoFiles:
|
||
"""
|
||
使用 FFmpeg 合并多个视频文件
|
||
支持字符串列表输入或多行文本输入
|
||
可选添加音频轨道
|
||
"""
|
||
|
||
@classmethod
|
||
def INPUT_TYPES(cls):
|
||
return {
|
||
"required": {
|
||
"video_paths": ("STRING", {"forceInput": True}),
|
||
"filename_prefix": ("STRING", {"default": "video/merged"}),
|
||
},
|
||
"optional": {
|
||
"reencode": ("BOOLEAN", {"default": False,
|
||
"tooltip": "False=直接拼接(快),True=重新编码(兼容性好)"}),
|
||
"audio_path": ("STRING", {"forceInput": True,
|
||
"tooltip": "可选的音频文件路径,将替换或添加到合并后的视频"}),
|
||
"audio_mode": (["replace", "mix"], {"default": "replace",
|
||
"tooltip": "replace=替换原音频,mix=混合原音频和新音频"}),
|
||
"audio_volume": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 2.0, "step": 0.1,
|
||
"tooltip": "音频音量,1.0为原始音量"}),
|
||
}
|
||
}
|
||
|
||
RETURN_TYPES = ("STRING",)
|
||
RETURN_NAMES = ("output_path",)
|
||
OUTPUT_NODE = True
|
||
FUNCTION = "concat_files"
|
||
CATEGORY = CATEGORY_TYPE
|
||
INPUT_IS_LIST = True
|
||
DESCRIPTION = "使用 FFmpeg 合并多个视频文件,支持列表输入,可选添加音频"
|
||
|
||
def concat_files(self, video_paths, filename_prefix, reencode=None, audio_path=None, audio_mode=None, audio_volume=None):
|
||
# 处理其他参数(因为 INPUT_IS_LIST=True,所有参数都是列表)
|
||
filename_prefix = filename_prefix[0] if isinstance(filename_prefix, list) else filename_prefix
|
||
reencode = reencode[0] if isinstance(reencode, list) and reencode else False
|
||
audio_path = audio_path[0] if isinstance(audio_path, list) and audio_path else None
|
||
audio_mode = audio_mode[0] if isinstance(audio_mode, list) and audio_mode else "replace"
|
||
audio_volume = audio_volume[0] if isinstance(audio_volume, list) and audio_volume else 1.0
|
||
|
||
# 展平并处理路径列表
|
||
paths = []
|
||
for item in video_paths:
|
||
if isinstance(item, list):
|
||
# 嵌套列表
|
||
for p in item:
|
||
if isinstance(p, str) and p.strip():
|
||
paths.append(p.strip())
|
||
elif isinstance(item, str):
|
||
# 可能是多行文本或单个路径
|
||
if '\n' in item:
|
||
paths.extend([p.strip() for p in item.split('\n') if p.strip()])
|
||
elif item.strip():
|
||
paths.append(item.strip())
|
||
|
||
if len(paths) == 0:
|
||
raise ValueError("没有输入任何视频路径")
|
||
|
||
# 验证文件存在
|
||
for p in paths:
|
||
if not os.path.exists(p):
|
||
raise FileNotFoundError(f"视频文件不存在: {p}")
|
||
|
||
# 验证音频文件存在,不存在则跳过音频处理(不报错)
|
||
if audio_path and not os.path.exists(audio_path):
|
||
print(f"[ConcatVideoFiles] 警告:音频文件不存在,跳过音频合并: {audio_path}")
|
||
audio_path = None
|
||
|
||
output_dir = folder_paths.get_output_directory()
|
||
full_output_folder, filename, counter, subfolder, _ = folder_paths.get_save_image_path(
|
||
filename_prefix, output_dir, 0, 0
|
||
)
|
||
output_file = os.path.join(full_output_folder, f"{filename}_{counter:05}_.mp4")
|
||
|
||
# 创建 FFmpeg concat 列表
|
||
with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
|
||
for p in paths:
|
||
escaped_path = p.replace("'", "'\\''")
|
||
f.write(f"file '{escaped_path}'\n")
|
||
concat_file = f.name
|
||
|
||
try:
|
||
if audio_path:
|
||
# 有音频输入时,需要两步处理:先合并视频,再添加音频
|
||
# 创建临时文件用于中间结果
|
||
temp_video = tempfile.NamedTemporaryFile(suffix='.mp4', delete=False).name
|
||
|
||
# 第一步:合并视频片段
|
||
if reencode:
|
||
cmd1 = [
|
||
'ffmpeg', '-y', '-f', 'concat', '-safe', '0', '-i', concat_file,
|
||
'-c:v', 'libx264', '-preset', 'medium', '-crf', '23',
|
||
'-an', # 不包含音频
|
||
'-movflags', '+faststart',
|
||
temp_video
|
||
]
|
||
else:
|
||
cmd1 = [
|
||
'ffmpeg', '-y', '-f', 'concat', '-safe', '0', '-i', concat_file,
|
||
'-c:v', 'copy', '-an',
|
||
'-movflags', '+faststart',
|
||
temp_video
|
||
]
|
||
|
||
result = subprocess.run(cmd1, capture_output=True, text=True)
|
||
if result.returncode != 0:
|
||
raise RuntimeError(f"FFmpeg 合并视频错误:\n{result.stderr}")
|
||
|
||
# 第二步:添加音频
|
||
if audio_mode == "mix":
|
||
# 混合模式:先合并带原音频的视频,再混合新音频
|
||
# 重新合并带音频的视频
|
||
temp_video_with_audio = tempfile.NamedTemporaryFile(suffix='.mp4', delete=False).name
|
||
if reencode:
|
||
cmd_audio = [
|
||
'ffmpeg', '-y', '-f', 'concat', '-safe', '0', '-i', concat_file,
|
||
'-c:v', 'libx264', '-preset', 'medium', '-crf', '23',
|
||
'-c:a', 'aac', '-b:a', '128k',
|
||
'-movflags', '+faststart',
|
||
temp_video_with_audio
|
||
]
|
||
else:
|
||
cmd_audio = [
|
||
'ffmpeg', '-y', '-f', 'concat', '-safe', '0', '-i', concat_file,
|
||
'-c', 'copy',
|
||
'-movflags', '+faststart',
|
||
temp_video_with_audio
|
||
]
|
||
result = subprocess.run(cmd_audio, capture_output=True, text=True)
|
||
if result.returncode != 0:
|
||
raise RuntimeError(f"FFmpeg 合并音频错误:\n{result.stderr}")
|
||
|
||
# 混合两个音频
|
||
cmd2 = [
|
||
'ffmpeg', '-y',
|
||
'-i', temp_video_with_audio,
|
||
'-i', audio_path,
|
||
'-filter_complex', f'[0:a][1:a]amix=inputs=2:duration=first:dropout_transition=2,volume={audio_volume}[a]',
|
||
'-map', '0:v', '-map', '[a]',
|
||
'-c:v', 'copy',
|
||
'-c:a', 'aac', '-b:a', '128k',
|
||
'-movflags', '+faststart',
|
||
'-shortest',
|
||
output_file
|
||
]
|
||
try:
|
||
result = subprocess.run(cmd2, capture_output=True, text=True)
|
||
if result.returncode != 0:
|
||
raise RuntimeError(f"FFmpeg 混合音频错误:\n{result.stderr}")
|
||
finally:
|
||
if os.path.exists(temp_video_with_audio):
|
||
os.unlink(temp_video_with_audio)
|
||
else:
|
||
# 替换模式:直接用新音频替换
|
||
volume_filter = f'volume={audio_volume}' if audio_volume != 1.0 else None
|
||
cmd2 = [
|
||
'ffmpeg', '-y',
|
||
'-i', temp_video,
|
||
'-i', audio_path,
|
||
'-map', '0:v', '-map', '1:a',
|
||
'-c:v', 'copy',
|
||
]
|
||
if volume_filter:
|
||
cmd2.extend(['-af', volume_filter])
|
||
cmd2.extend([
|
||
'-c:a', 'aac', '-b:a', '128k',
|
||
'-movflags', '+faststart',
|
||
'-shortest',
|
||
output_file
|
||
])
|
||
result = subprocess.run(cmd2, capture_output=True, text=True)
|
||
if result.returncode != 0:
|
||
raise RuntimeError(f"FFmpeg 添加音频错误:\n{result.stderr}")
|
||
|
||
# 清理临时文件
|
||
if os.path.exists(temp_video):
|
||
os.unlink(temp_video)
|
||
else:
|
||
# 无音频输入,原有逻辑
|
||
if reencode:
|
||
cmd = [
|
||
'ffmpeg', '-y', '-f', 'concat', '-safe', '0', '-i', concat_file,
|
||
'-c:v', 'libx264', '-preset', 'medium', '-crf', '23',
|
||
'-c:a', 'aac', '-b:a', '128k',
|
||
'-movflags', '+faststart',
|
||
output_file
|
||
]
|
||
else:
|
||
cmd = [
|
||
'ffmpeg', '-y', '-f', 'concat', '-safe', '0', '-i', concat_file,
|
||
'-c', 'copy',
|
||
'-movflags', '+faststart',
|
||
output_file
|
||
]
|
||
|
||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||
if result.returncode != 0:
|
||
raise RuntimeError(f"FFmpeg 错误:\n{result.stderr}")
|
||
finally:
|
||
if os.path.exists(concat_file):
|
||
os.unlink(concat_file)
|
||
|
||
return {"ui": {"images": [{"filename": os.path.basename(output_file), "subfolder": subfolder, "type": "output"}], "animated": (True,)},
|
||
"result": (output_file,)}
|
||
|
||
|
||
class LG_SaveAudioGetPath:
|
||
"""
|
||
保存音频文件并返回文件路径
|
||
支持 FLAC、MP3、OPUS 格式
|
||
音频为空或无效时返回空字符串,不会中断工作流
|
||
"""
|
||
|
||
@classmethod
|
||
def INPUT_TYPES(cls):
|
||
return {
|
||
"required": {
|
||
"filename_prefix": ("STRING", {"default": "audio/ComfyUI"}),
|
||
"format": (["flac", "mp3", "opus"], {"default": "flac"}),
|
||
},
|
||
"optional": {
|
||
"audio": ("AUDIO",),
|
||
"quality": (["64k", "96k", "128k", "192k", "320k", "V0"], {"default": "128k",
|
||
"tooltip": "MP3/OPUS 比特率,FLAC 忽略此参数"}),
|
||
},
|
||
"hidden": {
|
||
"prompt": "PROMPT",
|
||
"extra_pnginfo": "EXTRA_PNGINFO"
|
||
}
|
||
}
|
||
|
||
RETURN_TYPES = ("STRING",)
|
||
RETURN_NAMES = ("file_path",)
|
||
OUTPUT_NODE = True
|
||
FUNCTION = "save_audio"
|
||
CATEGORY = CATEGORY_TYPE
|
||
DESCRIPTION = "保存音频文件并返回文件路径,音频无效时返回空字符串"
|
||
|
||
def save_audio(self, filename_prefix, format="flac", audio=None, quality="128k", prompt=None, extra_pnginfo=None):
|
||
# 检查音频是否有效
|
||
if audio is None:
|
||
print("[SaveAudioGetPath] 警告:音频输入为空,跳过保存")
|
||
return {"ui": {"audio": []}, "result": ("",)}
|
||
|
||
# 检查音频数据是否有效
|
||
try:
|
||
if "waveform" not in audio or audio["waveform"] is None:
|
||
print("[SaveAudioGetPath] 警告:音频数据无效(无 waveform),跳过保存")
|
||
return {"ui": {"audio": []}, "result": ("",)}
|
||
|
||
if audio["waveform"].numel() == 0:
|
||
print("[SaveAudioGetPath] 警告:音频数据为空,跳过保存")
|
||
return {"ui": {"audio": []}, "result": ("",)}
|
||
except Exception as e:
|
||
print(f"[SaveAudioGetPath] 警告:检查音频数据时出错 ({e}),跳过保存")
|
||
return {"ui": {"audio": []}, "result": ("",)}
|
||
|
||
import json
|
||
from io import BytesIO
|
||
import av
|
||
|
||
try:
|
||
import torchaudio
|
||
TORCH_AUDIO_AVAILABLE = True
|
||
except:
|
||
TORCH_AUDIO_AVAILABLE = False
|
||
|
||
try:
|
||
output_dir = folder_paths.get_output_directory()
|
||
full_output_folder, filename, counter, subfolder, _ = folder_paths.get_save_image_path(
|
||
filename_prefix, output_dir
|
||
)
|
||
|
||
# 构建元数据
|
||
metadata = {}
|
||
if not args.disable_metadata:
|
||
if prompt is not None:
|
||
metadata["prompt"] = json.dumps(prompt)
|
||
if extra_pnginfo is not None:
|
||
for x in extra_pnginfo:
|
||
metadata[x] = json.dumps(extra_pnginfo[x])
|
||
|
||
# Opus 支持的采样率
|
||
OPUS_RATES = [8000, 12000, 16000, 24000, 48000]
|
||
|
||
results = []
|
||
file_paths = []
|
||
|
||
for batch_number, waveform in enumerate(audio["waveform"].cpu()):
|
||
# 检查单个 waveform 是否有效
|
||
if waveform.numel() == 0:
|
||
print(f"[SaveAudioGetPath] 警告:批次 {batch_number} 的音频为空,跳过")
|
||
continue
|
||
|
||
filename_with_batch_num = filename.replace("%batch_num%", str(batch_number))
|
||
file = f"{filename_with_batch_num}_{counter:05}_.{format}"
|
||
output_path = os.path.join(full_output_folder, file)
|
||
|
||
sample_rate = audio["sample_rate"]
|
||
|
||
# 处理 Opus 采样率要求
|
||
if format == "opus":
|
||
if sample_rate > 48000:
|
||
sample_rate = 48000
|
||
elif sample_rate not in OPUS_RATES:
|
||
for rate in sorted(OPUS_RATES):
|
||
if rate > sample_rate:
|
||
sample_rate = rate
|
||
break
|
||
if sample_rate not in OPUS_RATES:
|
||
sample_rate = 48000
|
||
|
||
# 重采样
|
||
if sample_rate != audio["sample_rate"]:
|
||
if not TORCH_AUDIO_AVAILABLE:
|
||
print("[SaveAudioGetPath] 警告:torchaudio 不可用,无法重采样,跳过保存")
|
||
return {"ui": {"audio": []}, "result": ("",)}
|
||
waveform = torchaudio.functional.resample(waveform, audio["sample_rate"], sample_rate)
|
||
|
||
# 创建输出
|
||
output_buffer = BytesIO()
|
||
output_container = av.open(output_buffer, mode="w", format=format)
|
||
|
||
# 设置元数据
|
||
for key, value in metadata.items():
|
||
output_container.metadata[key] = value
|
||
|
||
layout = "mono" if waveform.shape[0] == 1 else "stereo"
|
||
|
||
# 设置输出流
|
||
if format == "opus":
|
||
out_stream = output_container.add_stream("libopus", rate=sample_rate, layout=layout)
|
||
bit_rates = {"64k": 64000, "96k": 96000, "128k": 128000, "192k": 192000, "320k": 320000}
|
||
out_stream.bit_rate = bit_rates.get(quality, 128000)
|
||
elif format == "mp3":
|
||
out_stream = output_container.add_stream("libmp3lame", rate=sample_rate, layout=layout)
|
||
if quality == "V0":
|
||
out_stream.codec_context.qscale = 1
|
||
else:
|
||
bit_rates = {"64k": 64000, "96k": 96000, "128k": 128000, "192k": 192000, "320k": 320000}
|
||
out_stream.bit_rate = bit_rates.get(quality, 128000)
|
||
else: # flac
|
||
out_stream = output_container.add_stream("flac", rate=sample_rate, layout=layout)
|
||
|
||
frame = av.AudioFrame.from_ndarray(
|
||
waveform.movedim(0, 1).reshape(1, -1).float().numpy(),
|
||
format="flt",
|
||
layout=layout,
|
||
)
|
||
frame.sample_rate = sample_rate
|
||
frame.pts = 0
|
||
output_container.mux(out_stream.encode(frame))
|
||
output_container.mux(out_stream.encode(None)) # Flush
|
||
output_container.close()
|
||
|
||
# 写入文件
|
||
output_buffer.seek(0)
|
||
with open(output_path, "wb") as f:
|
||
f.write(output_buffer.getbuffer())
|
||
|
||
results.append({"filename": file, "subfolder": subfolder, "type": "output"})
|
||
file_paths.append(output_path)
|
||
counter += 1
|
||
|
||
# 返回第一个文件的路径(通常只有一个)
|
||
return {"ui": {"audio": results}, "result": (file_paths[0] if file_paths else "",)}
|
||
|
||
except Exception as e:
|
||
print(f"[SaveAudioGetPath] 警告:保存音频时出错 ({e}),返回空路径")
|
||
return {"ui": {"audio": []}, "result": ("",)}
|
||
|
||
|