Files
2025-08-03 21:21:14 +08:00

260 lines
9.2 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.
import cv2
import torch
import numpy as np
import tempfile
import subprocess
import os
class SplitVideoByFrames:
"""
用OpenCV拆分视频为多个片段,每段帧数不超过max_frames_per_clip,音频输出为ComfyUI官方格式
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"video_path": ("STRING", {"default": "your_video.mp4"}),
"max_frames_per_clip": ("INT", {"default": 30, "min": 1, "max": 1000}),
}
}
RETURN_TYPES = ("INT", "LIST", "AUDIO")
RETURN_NAMES = ("num_clips", "clips", "audio")
FUNCTION = "split_video"
CATEGORY = "云服务"
def split_video(self, video_path, max_frames_per_clip):
# 1. 提取音频为wav临时文件
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp_audio:
audio_path = tmp_audio.name
ffmpeg_bin = "ffmpeg" # 假设已在环境变量
cmd = [ffmpeg_bin, '-y', '-i', video_path, '-vn', '-acodec', 'pcm_s16le', '-ar', '44100', '-ac', '2', '-f', 'wav', audio_path]
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
# 检查音频文件是否有效
if not os.path.exists(audio_path) or os.path.getsize(audio_path) == 0:
print(f"警告:视频 {video_path} 无音轨或音频提取失败,返回空音频。")
waveform = torch.zeros((1, 2, 1), dtype=torch.float32) # 1帧2通道空音频
sample_rate = 44100
audio_dict = {"waveform": waveform, "sample_rate": sample_rate}
else:
try:
import torchaudio
waveform, sample_rate = torchaudio.load(audio_path)
except Exception as e:
print(f"警告:音频文件读取失败,返回空音频。错误信息: {e}")
waveform = torch.zeros((1, 2, 1), dtype=torch.float32)
sample_rate = 44100
audio_dict = {"waveform": waveform, "sample_rate": sample_rate}
os.remove(audio_path)
# 保证shape为(1,channels,samples)
if waveform.dim() == 2:
waveform = waveform.unsqueeze(0)
audio_dict = {"waveform": waveform, "sample_rate": sample_rate}
# 3. 视频分帧(不做resize,假设视频分辨率一致)
cap = cv2.VideoCapture(video_path)
if not cap.isOpened():
raise FileNotFoundError(f"无法打开视频文件: {video_path}")
frames = []
clips = []
while True:
ret, frame = cap.read()
if not ret:
break
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
np_image = frame.astype(np.float32) / 255.0
frames.append(torch.from_numpy(np_image))
if len(frames) == max_frames_per_clip:
clips.append({"frames": torch.stack(frames, dim=0), "audio": audio_dict})
frames = []
if frames:
clips.append({"frames": torch.stack(frames, dim=0), "audio": audio_dict})
cap.release()
return (len(clips), clips, audio_dict)
class GetVideoClipByIndex:
"""
输入clips和索引,输出该片段的图片数组(IMAGE)、音频(官方格式)和帧数(num_frames)
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"index": ("INT", {"default": 0, "min": 0}),
"clips": ("LIST", {}),
}
}
RETURN_TYPES = ("IMAGE", "AUDIO", "INT")
RETURN_NAMES = ("images", "audio", "num_frames")
FUNCTION = "get_clip"
CATEGORY = "云服务"
def get_clip(self, index, clips):
if not clips or index < 0 or index >= len(clips):
raise IndexError("索引超出clips范围")
clip = clips[index]
frames = clip["frames"]
audio = clip["audio"]
print(f"获取片段 {index},帧数: {frames.shape[0]}, 音频采样率: {audio['sample_rate']}")
num_frames = frames.shape[0]
return (frames, audio, num_frames)
class CreateEmptyImageBatch:
"""
创建一个空的图像batch,若传入images则追加到空batch后返回。
"""
@classmethod
def INPUT_TYPES(cls):
return {
"optional": {
"images": ("IMAGE", {}),
}
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image_batch",)
FUNCTION = "create"
CATEGORY = "云服务/集合工具"
def create(self, images=None):
import torch
def to_3ch(img):
if img is None:
return img
if img.dim() == 3:
if img.shape[-1] == 1:
img = img.repeat(1, 1, 3)
elif img.shape[-1] == 4:
img = img[..., :3]
elif img.dim() == 4:
if img.shape[-1] == 1:
img = img.repeat(1, 1, 1, 3)
elif img.shape[-1] == 4:
img = img[..., :3]
return img
batch = torch.empty((0, 0, 0, 0), dtype=torch.float32)
if images is not None:
images = to_3ch(images)
if images.dim() == 3:
images = images.unsqueeze(0)
# 直接用AppendImagesToBatch的逻辑拼接
if batch.numel() == 0 or batch.shape[0] == 0:
return (images,)
else:
new_batch = torch.cat([batch, images], dim=0)
return (new_batch,)
return (batch,)
class AppendImagesToBatch:
"""
向已有图像batch追加图片,支持单张或多张图片,输出合并后的IMAGE batch。
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image_batch": ("IMAGE", {}),
"images_to_add": ("IMAGE", {}),
}
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image_batch",)
FUNCTION = "append"
CATEGORY = "云服务/集合工具"
def append(self, image_batch, images_to_add):
import torch
# 如果 image_batch 是空的(shape为(0, 0, 0, 0)),直接返回 images_to_add
if image_batch.numel() == 0 or image_batch.shape[0] == 0:
if images_to_add.dim() == 3:
images_to_add = images_to_add.unsqueeze(0)
return (images_to_add,)
if image_batch.dim() == 3:
image_batch = image_batch.unsqueeze(0)
if images_to_add.dim() == 3:
images_to_add = images_to_add.unsqueeze(0)
new_batch = torch.cat([image_batch, images_to_add], dim=0)
return (new_batch,)
class GetFirstImageFromBatch:
"""
获取图片batch的首帧或末帧,输出单帧IMAGE batch(shape为(1, H, W, C))及宽高。
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image_batch": ("IMAGE", {}),
"mode": (["first", "last"], {"default": "first"}),
}
}
RETURN_TYPES = ("IMAGE", "INT", "INT")
RETURN_NAMES = ("image", "width", "height")
FUNCTION = "get"
CATEGORY = "云服务/集合工具"
def get(self, image_batch, mode="first"):
import torch
if image_batch.dim() == 3:
img = image_batch.unsqueeze(0)
else:
if image_batch.shape[0] == 0:
raise ValueError("image_batch is empty!")
if mode == "first":
img = image_batch[0:1]
else:
img = image_batch[-1:]
# img shape: (1, H, W, C)
_, h, w, _ = img.shape
return (img, w, h)
class RemoveFirstOrLastImageFromBatch:
"""
删除图片batch的首帧或末帧,输出剩余IMAGE batch。
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image_batch": ("IMAGE", {}),
"mode": (["first", "last"], {"default": "first"}),
}
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image_batch",)
FUNCTION = "remove"
CATEGORY = "云服务/集合工具"
def remove(self, image_batch, mode="first"):
import torch
if image_batch.dim() == 3:
# 只有一张,删除后为空batch
return (torch.empty((0, *image_batch.shape), dtype=image_batch.dtype, device=image_batch.device),)
if image_batch.shape[0] == 0:
return (image_batch,)
if mode == "first":
return (image_batch[1:],)
else:
return (image_batch[:-1],)
NODE_CLASS_MAPPINGS = {
"SplitVideoByFrames": SplitVideoByFrames,
"GetVideoClipByIndex": GetVideoClipByIndex,
"CreateEmptyImageBatch": CreateEmptyImageBatch,
"AppendImagesToBatch": AppendImagesToBatch,
"GetFirstImageFromBatch": GetFirstImageFromBatch,
"RemoveFirstOrLastImageFromBatch": RemoveFirstOrLastImageFromBatch
}
NODE_DISPLAY_NAME_MAPPINGS = {
"SplitVideoByFrames": "🎬 视频分段拆帧",
"GetVideoClipByIndex": "🖼️ 获取片段图片和音频",
"CreateEmptyImageBatch": "📂 创建空图像Batch",
"AppendImagesToBatch": "➕ 追加图片到Batch",
"GetFirstImageFromBatch": "🔍 获取首/末帧图片",
"RemoveFirstOrLastImageFromBatch": "❌ 删除首/末帧图片"
}