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, \
PlugAndPlayWebhook
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 = {
"FaceOccDetect": FaceDetect,
@@ -47,7 +48,8 @@ NODE_CLASS_MAPPINGS = {
"ModalEditCustom": ModalEditCustom,
"ModalMidJourneyGenerateImage": ModalMidJourneyGenerateImage,
"ModalMidJourneyDescribeImage": ModalMidJourneyDescribeImage,
"JMCustom": JMCustom
"JMCustom": JMCustom,
"VideoMerge": VideoMerge
}
NODE_DISPLAY_NAME_MAPPINGS = {
@@ -86,5 +88,6 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"ModalEditCustom": "自定义Prompt修改图片",
"ModalMidJourneyGenerateImage": "Prompt修图",
"ModalMidJourneyDescribeImage": "反推生图提示词",
"JMCustom": "Prompt生视频"
"JMCustom": "Prompt生视频",
"VideoMerge":"顺序合并视频"
}

View File

@@ -7,6 +7,7 @@ import time
import uuid
from time import sleep
import cv2
import folder_paths
import numpy as np
import requests
@@ -15,6 +16,7 @@ import yaml
from PIL import Image
from loguru import logger
from qcloud_cos import CosConfig, CosS3Client
from torchvision.transforms import transforms
from tqdm import tqdm
@@ -119,12 +121,63 @@ class JMUtils:
def tensor_to_io(srlf, tensor: torch.Tensor):
# 转换为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()
img.save(image_data, format='PNG')
image_data.seek(0)
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):
"""下载视频到临时文件并返回文件路径"""
for attempt in range(retries):
@@ -159,7 +212,7 @@ class JMUtils:
bar.update(size)
print(f"视频下载完成: {temp_path}")
return temp_path
return temp_path, self.read_video_last_frame_to_tensor(temp_path)
except Exception as e:
print(f"下载错误 (尝试 {attempt + 1}/{retries}): {str(e)}")
@@ -336,8 +389,8 @@ class JMCustom:
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("视频存储路径",)
RETURN_TYPES = ("STRING", "IMAGE",)
RETURN_NAMES = ("视频存储路径", "视频最后一帧")
FUNCTION = "gen"
CATEGORY = "不忘科技-自定义节点🚩/视频/即梦"
@@ -368,5 +421,6 @@ class JMCustom:
sleep(interval)
if not job_data:
raise Exception("即梦任务等待超时")
return (
client.download_video(job_data, path=os.path.join(folder_paths.get_output_directory(), f"{uuid.uuid4()}.mp4")),)
video_path, last_scene = client.download_video(job_data, path=os.path.join(folder_paths.get_output_directory(),
f"{uuid.uuid4()}.mp4"))
return (video_path, last_scene,)

View File

@@ -1,4 +1,5 @@
import glob
import json
import os
import re
import shutil
@@ -6,8 +7,11 @@ import subprocess
import traceback
import uuid
from datetime import datetime
from pathlib import Path
from typing import List
import ffmpy
import folder_paths
import loguru
import torchvision.io
@@ -22,7 +26,7 @@ class VideoCut:
return {
"required": {
"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"}),
"end": ("STRING", {"default": "00:00:10.000"}),
},
@@ -119,7 +123,8 @@ class VideoCut:
os.remove(files[0])
except:
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:
traceback.print_exc()
raise Exception("Cut Failed")
@@ -133,7 +138,7 @@ class VideoCutByFramePoint:
return {
"required": {
"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"}),
"duration": ("FLOAT", {"default": "10.0"}),
"fps": ("INT", {"default": "25"}),
@@ -234,7 +239,7 @@ class VideoCutByFramePoint:
os.remove(output)
except:
pass
return (video/255.0, {"waveform": audio, "sample_rate": info["audio_fps"]},)
return (video / 255.0, {"waveform": audio, "sample_rate": info["audio_fps"]},)
except:
traceback.print_exc()
raise Exception("Cut Failed")
@@ -248,7 +253,7 @@ class VideoChangeFPS:
return {
"required": {
"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}),
},
}
@@ -392,3 +397,76 @@ class VideoStartPointDurationCompute:
duration = duration + end_padding
loguru.logger.info("audio duration with padding %.3f s" % duration)
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")),)