From ed5ceae99c6961d4deb186cf77e854a7c2614684 Mon Sep 17 00:00:00 2001 From: "kyj@bowong.ai" Date: Fri, 18 Jul 2025 18:21:13 +0800 Subject: [PATCH] =?UTF-8?q?ADD=20=E5=A2=9E=E5=8A=A0=E8=A7=86=E9=A2=91?= =?UTF-8?q?=E5=90=88=E5=B9=B6=E8=8A=82=E7=82=B9=20PERF=20=E7=94=9F?= =?UTF-8?q?=E6=88=90=E8=A7=86=E9=A2=91=E6=94=AF=E6=8C=81=E8=BE=93=E5=87=BA?= =?UTF-8?q?=E5=B0=BE=E5=B8=A7=E7=94=A8=E4=BA=8E=E7=94=9F=E6=88=90=E4=B8=8B?= =?UTF-8?q?=E4=B8=80=E6=AE=B5=E8=A7=86=E9=A2=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- __init__.py | 9 ++-- nodes/image_gesture_nodes.py | 66 ++++++++++++++++++++++++--- nodes/video_nodes.py | 88 ++++++++++++++++++++++++++++++++++-- 3 files changed, 149 insertions(+), 14 deletions(-) diff --git a/__init__.py b/__init__.py index 3305108..08ddce7 100644 --- a/__init__.py +++ b/__init__.py @@ -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":"顺序合并视频" } diff --git a/nodes/image_gesture_nodes.py b/nodes/image_gesture_nodes.py index 1b31523..9a70805 100644 --- a/nodes/image_gesture_nodes.py +++ b/nodes/image_gesture_nodes.py @@ -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,) \ No newline at end of file diff --git a/nodes/video_nodes.py b/nodes/video_nodes.py index 6c2f218..55642ac 100644 --- a/nodes/video_nodes.py +++ b/nodes/video_nodes.py @@ -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")),)