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, \
|
||||
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":"顺序合并视频"
|
||||
}
|
||||
|
||||
@@ -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,)
|
||||
@@ -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")),)
|
||||
|
||||
Reference in New Issue
Block a user