ADD 增加视频合并节点

PERF 生成视频支持输出尾帧用于生成下一段视频
This commit is contained in:
2025-07-18 18:21:13 +08:00
parent 13f0176465
commit ed5ceae99c
3 changed files with 149 additions and 14 deletions

View File

@@ -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":"顺序合并视频"
} }

View File

@@ -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,)

View File

@@ -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")),)