ADD 增加视频合并节点
PERF 生成视频支持输出尾帧用于生成下一段视频
This commit is contained in:
@@ -9,7 +9,8 @@ from .nodes.text_nodes import StringEmptyJudgement, LoadText, RandomLineSelector
|
|||||||
from .nodes.util_nodes import LogToDB, TaskIdGenerate, TraverseFolder, UnloadAllModels, VodToLocalNode, \
|
from .nodes.util_nodes import LogToDB, TaskIdGenerate, TraverseFolder, UnloadAllModels, VodToLocalNode, \
|
||||||
PlugAndPlayWebhook
|
PlugAndPlayWebhook
|
||||||
from .nodes.video_lipsync_nodes import HeyGemF2F, HeyGemF2FFromFile
|
from .nodes.video_lipsync_nodes import HeyGemF2F, HeyGemF2FFromFile
|
||||||
from .nodes.video_nodes import VideoCut, VideoCutByFramePoint, VideoChangeFPS, VideoStartPointDurationCompute
|
from .nodes.video_nodes import VideoCut, VideoCutByFramePoint, VideoChangeFPS, VideoStartPointDurationCompute, \
|
||||||
|
VideoMerge
|
||||||
|
|
||||||
NODE_CLASS_MAPPINGS = {
|
NODE_CLASS_MAPPINGS = {
|
||||||
"FaceOccDetect": FaceDetect,
|
"FaceOccDetect": FaceDetect,
|
||||||
@@ -47,7 +48,8 @@ NODE_CLASS_MAPPINGS = {
|
|||||||
"ModalEditCustom": ModalEditCustom,
|
"ModalEditCustom": ModalEditCustom,
|
||||||
"ModalMidJourneyGenerateImage": ModalMidJourneyGenerateImage,
|
"ModalMidJourneyGenerateImage": ModalMidJourneyGenerateImage,
|
||||||
"ModalMidJourneyDescribeImage": ModalMidJourneyDescribeImage,
|
"ModalMidJourneyDescribeImage": ModalMidJourneyDescribeImage,
|
||||||
"JMCustom": JMCustom
|
"JMCustom": JMCustom,
|
||||||
|
"VideoMerge": VideoMerge
|
||||||
}
|
}
|
||||||
|
|
||||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||||
@@ -86,5 +88,6 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
|||||||
"ModalEditCustom": "自定义Prompt修改图片",
|
"ModalEditCustom": "自定义Prompt修改图片",
|
||||||
"ModalMidJourneyGenerateImage": "Prompt修图",
|
"ModalMidJourneyGenerateImage": "Prompt修图",
|
||||||
"ModalMidJourneyDescribeImage": "反推生图提示词",
|
"ModalMidJourneyDescribeImage": "反推生图提示词",
|
||||||
"JMCustom": "Prompt生视频"
|
"JMCustom": "Prompt生视频",
|
||||||
|
"VideoMerge":"顺序合并视频"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import time
|
|||||||
import uuid
|
import uuid
|
||||||
from time import sleep
|
from time import sleep
|
||||||
|
|
||||||
|
import cv2
|
||||||
import folder_paths
|
import folder_paths
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import requests
|
import requests
|
||||||
@@ -15,6 +16,7 @@ import yaml
|
|||||||
from PIL import Image
|
from PIL import Image
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from qcloud_cos import CosConfig, CosS3Client
|
from qcloud_cos import CosConfig, CosS3Client
|
||||||
|
from torchvision.transforms import transforms
|
||||||
from tqdm import tqdm
|
from tqdm import tqdm
|
||||||
|
|
||||||
|
|
||||||
@@ -119,12 +121,63 @@ class JMUtils:
|
|||||||
|
|
||||||
def tensor_to_io(srlf, tensor: torch.Tensor):
|
def tensor_to_io(srlf, tensor: torch.Tensor):
|
||||||
# 转换为PIL图像
|
# 转换为PIL图像
|
||||||
img = Image.fromarray(np.clip(255. * tensor.cpu().numpy().squeeze(), 0, 255).astype(np.uint8))
|
img = Image.fromarray(np.clip(255. * tensor.cpu().squeeze().numpy(), 0, 255).astype(np.uint8))
|
||||||
image_data = io.BytesIO()
|
image_data = io.BytesIO()
|
||||||
img.save(image_data, format='PNG')
|
img.save(image_data, format='PNG')
|
||||||
image_data.seek(0)
|
image_data.seek(0)
|
||||||
return image_data
|
return image_data
|
||||||
|
|
||||||
|
def read_video_last_frame_to_tensor(self, video_path: str) -> torch.Tensor:
|
||||||
|
"""
|
||||||
|
读取视频文件的最后一帧并将其转换为BCHW格式的PyTorch张量。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
video_path (str): 视频文件的路径。
|
||||||
|
|
||||||
|
返回:
|
||||||
|
torch.Tensor: 形状为[1, H, W, C]的张量,其中H和W分别是视频帧的高度和宽度,通道顺序为RGB。
|
||||||
|
|
||||||
|
异常:
|
||||||
|
FileNotFoundError: 如果指定的视频文件不存在。
|
||||||
|
ValueError: 如果视频文件为空或无法读取帧。
|
||||||
|
"""
|
||||||
|
# 打开视频文件
|
||||||
|
cap = cv2.VideoCapture(video_path)
|
||||||
|
|
||||||
|
if not cap.isOpened():
|
||||||
|
raise FileNotFoundError(f"无法打开视频文件: {video_path}")
|
||||||
|
|
||||||
|
# 获取视频总帧数
|
||||||
|
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||||
|
|
||||||
|
if total_frames == 0:
|
||||||
|
cap.release()
|
||||||
|
raise ValueError("视频文件为空或无法确定帧数")
|
||||||
|
|
||||||
|
# 设置读取位置到最后一帧
|
||||||
|
cap.set(cv2.CAP_PROP_POS_FRAMES, total_frames - 1)
|
||||||
|
|
||||||
|
# 读取最后一帧
|
||||||
|
ret, frame = cap.read()
|
||||||
|
|
||||||
|
# 释放资源
|
||||||
|
cap.release()
|
||||||
|
|
||||||
|
if not ret or frame is None:
|
||||||
|
raise ValueError(f"无法读取视频的最后一帧,可能视频已损坏")
|
||||||
|
|
||||||
|
# 转换BGR到RGB (OpenCV默认读取为BGR)
|
||||||
|
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||||
|
|
||||||
|
# 转换为PyTorch张量并调整维度为BCHW
|
||||||
|
transform = transforms.Compose([
|
||||||
|
transforms.ToTensor() # 转换为[C, H, W]格式的张量,值范围从0到1
|
||||||
|
])
|
||||||
|
|
||||||
|
tensor = transform(frame_rgb).unsqueeze(0).permute(0, 2, 3, 1) # 添加批次维度,变为[1, H, W, C]
|
||||||
|
|
||||||
|
return tensor
|
||||||
|
|
||||||
def download_video(self, url, timeout=30, retries=3, path=None):
|
def download_video(self, url, timeout=30, retries=3, path=None):
|
||||||
"""下载视频到临时文件并返回文件路径"""
|
"""下载视频到临时文件并返回文件路径"""
|
||||||
for attempt in range(retries):
|
for attempt in range(retries):
|
||||||
@@ -159,7 +212,7 @@ class JMUtils:
|
|||||||
bar.update(size)
|
bar.update(size)
|
||||||
|
|
||||||
print(f"视频下载完成: {temp_path}")
|
print(f"视频下载完成: {temp_path}")
|
||||||
return temp_path
|
return temp_path, self.read_video_last_frame_to_tensor(temp_path)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"下载错误 (尝试 {attempt + 1}/{retries}): {str(e)}")
|
print(f"下载错误 (尝试 {attempt + 1}/{retries}): {str(e)}")
|
||||||
@@ -336,8 +389,8 @@ class JMCustom:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
RETURN_TYPES = ("STRING",)
|
RETURN_TYPES = ("STRING", "IMAGE",)
|
||||||
RETURN_NAMES = ("视频存储路径",)
|
RETURN_NAMES = ("视频存储路径", "视频最后一帧")
|
||||||
FUNCTION = "gen"
|
FUNCTION = "gen"
|
||||||
CATEGORY = "不忘科技-自定义节点🚩/视频/即梦"
|
CATEGORY = "不忘科技-自定义节点🚩/视频/即梦"
|
||||||
|
|
||||||
@@ -368,5 +421,6 @@ class JMCustom:
|
|||||||
sleep(interval)
|
sleep(interval)
|
||||||
if not job_data:
|
if not job_data:
|
||||||
raise Exception("即梦任务等待超时")
|
raise Exception("即梦任务等待超时")
|
||||||
return (
|
video_path, last_scene = client.download_video(job_data, path=os.path.join(folder_paths.get_output_directory(),
|
||||||
client.download_video(job_data, path=os.path.join(folder_paths.get_output_directory(), f"{uuid.uuid4()}.mp4")),)
|
f"{uuid.uuid4()}.mp4"))
|
||||||
|
return (video_path, last_scene,)
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import glob
|
import glob
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
@@ -6,8 +7,11 @@ import subprocess
|
|||||||
import traceback
|
import traceback
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List
|
||||||
|
|
||||||
import ffmpy
|
import ffmpy
|
||||||
|
import folder_paths
|
||||||
import loguru
|
import loguru
|
||||||
import torchvision.io
|
import torchvision.io
|
||||||
|
|
||||||
@@ -22,7 +26,7 @@ class VideoCut:
|
|||||||
return {
|
return {
|
||||||
"required": {
|
"required": {
|
||||||
"video_path": (
|
"video_path": (
|
||||||
"STRING", {"placeholder": "X://insert/path/here.mp4", "vhs_path_extensions": video_extensions}),
|
"STRING", {"placeholder": "X://insert/path/here.mp4", "vhs_path_extensions": video_extensions}),
|
||||||
"start": ("STRING", {"default": "00:00:00.000"}),
|
"start": ("STRING", {"default": "00:00:00.000"}),
|
||||||
"end": ("STRING", {"default": "00:00:10.000"}),
|
"end": ("STRING", {"default": "00:00:10.000"}),
|
||||||
},
|
},
|
||||||
@@ -119,7 +123,8 @@ class VideoCut:
|
|||||||
os.remove(files[0])
|
os.remove(files[0])
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
return (video/255.0, {"waveform": audio, "sample_rate": info["audio_fps"]} if "audio_fps" in info else None,)
|
return (
|
||||||
|
video / 255.0, {"waveform": audio, "sample_rate": info["audio_fps"]} if "audio_fps" in info else None,)
|
||||||
except:
|
except:
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
raise Exception("Cut Failed")
|
raise Exception("Cut Failed")
|
||||||
@@ -133,7 +138,7 @@ class VideoCutByFramePoint:
|
|||||||
return {
|
return {
|
||||||
"required": {
|
"required": {
|
||||||
"video_path": (
|
"video_path": (
|
||||||
"STRING", {"placeholder": "X://insert/path/here.mp4", "vhs_path_extensions": video_extensions}),
|
"STRING", {"placeholder": "X://insert/path/here.mp4", "vhs_path_extensions": video_extensions}),
|
||||||
"start_point": ("FLOAT", {"default": "0.0"}),
|
"start_point": ("FLOAT", {"default": "0.0"}),
|
||||||
"duration": ("FLOAT", {"default": "10.0"}),
|
"duration": ("FLOAT", {"default": "10.0"}),
|
||||||
"fps": ("INT", {"default": "25"}),
|
"fps": ("INT", {"default": "25"}),
|
||||||
@@ -234,7 +239,7 @@ class VideoCutByFramePoint:
|
|||||||
os.remove(output)
|
os.remove(output)
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
return (video/255.0, {"waveform": audio, "sample_rate": info["audio_fps"]},)
|
return (video / 255.0, {"waveform": audio, "sample_rate": info["audio_fps"]},)
|
||||||
except:
|
except:
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
raise Exception("Cut Failed")
|
raise Exception("Cut Failed")
|
||||||
@@ -248,7 +253,7 @@ class VideoChangeFPS:
|
|||||||
return {
|
return {
|
||||||
"required": {
|
"required": {
|
||||||
"video_path": (
|
"video_path": (
|
||||||
"STRING", {"placeholder": "X://insert/path/here.mp4", "vhs_path_extensions": video_extensions}),
|
"STRING", {"placeholder": "X://insert/path/here.mp4", "vhs_path_extensions": video_extensions}),
|
||||||
"fps": ("INT", {"default": 30}),
|
"fps": ("INT", {"default": 30}),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -392,3 +397,76 @@ class VideoStartPointDurationCompute:
|
|||||||
duration = duration + end_padding
|
duration = duration + end_padding
|
||||||
loguru.logger.info("audio duration with padding %.3f s" % duration)
|
loguru.logger.info("audio duration with padding %.3f s" % duration)
|
||||||
return (start_point, duration * fps,)
|
return (start_point, duration * fps,)
|
||||||
|
|
||||||
|
|
||||||
|
def merge_videos(input_paths: List[str], output_path: str) -> str:
|
||||||
|
"""
|
||||||
|
按顺序拼接多个视频文件到一个输出文件
|
||||||
|
|
||||||
|
参数:
|
||||||
|
input_paths: 视频文件路径列表,按拼接顺序排列
|
||||||
|
output_path: 输出视频文件路径
|
||||||
|
"""
|
||||||
|
# 检查所有输入文件是否存在
|
||||||
|
for path in input_paths:
|
||||||
|
if not Path(path).exists():
|
||||||
|
raise FileNotFoundError(f"输入文件不存在: {path}")
|
||||||
|
|
||||||
|
# 创建临时文件列表
|
||||||
|
temp_filelist = os.path.join(os.path.dirname(__file__),"filelist.txt")
|
||||||
|
with open(temp_filelist, "w", encoding="utf-8") as f:
|
||||||
|
for path in input_paths:
|
||||||
|
# 处理路径中的引号和特殊字符
|
||||||
|
escaped_path = path.replace("'", r"'\''")
|
||||||
|
f.write(f"file '{escaped_path}'\n")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 使用ffmpeg执行拼接操作
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg",
|
||||||
|
"-f", "concat",
|
||||||
|
"-safe", "0",
|
||||||
|
"-i", str(temp_filelist),
|
||||||
|
"-c", "copy",
|
||||||
|
output_path
|
||||||
|
]
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"视频拼接成功,输出文件: {output_path}")
|
||||||
|
print("ffmpeg 输出:", result.stderr)
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f"视频拼接失败: {e.stderr}")
|
||||||
|
raise e
|
||||||
|
finally:
|
||||||
|
if os.path.exists(temp_filelist):
|
||||||
|
os.remove(temp_filelist)
|
||||||
|
|
||||||
|
|
||||||
|
class VideoMerge:
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"video_list": ("STRING", {"default": "[]"})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
RETURN_TYPES = ("STRING",)
|
||||||
|
RETURN_NAMES = ("视频路径",)
|
||||||
|
|
||||||
|
FUNCTION = "process"
|
||||||
|
|
||||||
|
CATEGORY = "不忘科技-自定义节点🚩/视频"
|
||||||
|
|
||||||
|
def process(self, video_list):
|
||||||
|
if isinstance(video_list, str):
|
||||||
|
video_list = json.loads(video_list)
|
||||||
|
return (merge_videos(video_list, os.path.join(folder_paths.get_output_directory(), f"merged_{uuid.uuid4()}.mp4")),)
|
||||||
|
|||||||
Reference in New Issue
Block a user