Files
2026-02-05 18:12:52 +08:00

463 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
视频合并节点
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": ("",)}