Added FileBackedDict, which provides a dictionary-like interface with file-backed persistence. Supports nested dictionaries and ensures data is stored and retrieved from disk. video.py: Implemented audio resampling in CombineAudioVideoFiles, including video and audio resampling logic. Refined create_video_from_images to use ffmpeg for combining image sequences into video. Updated CombineFoldersWithFramesToVideoFiles to handle both duration and fps-based input. Added additional parameters for video quality and pixel format. torchvision_nodes.py: Centralized the imports for NODE_CLASS_MAPPINGS and NODE_DISPLAY_NAME_MAPPINGS across the module. Adjusted default values for certain fields to improve user experience. youtube_nodes.py & modules/youtube.py: Extended the YouTube upload functionality to support optional thumbnail uploads. Improved initialize_upload function to return video_id after a successful upload. Introduced the ability to upload a thumbnail alongside the video. web/ui_modifications.js: Reworked the login form handling to prevent page reload and improved element management. Added dynamic graph creation in the handleGraphCreateNew function. web/showMarkdown.js: Fixed the import of marked.js, using Marked as the module name to avoid potential conflicts. modules/xttscli.py: Increased the batch_size for text-to-speech processing from 2 to 4, enabling faster concurrent execution.
3298 lines
125 KiB
Python
3298 lines
125 KiB
Python
### info for code completion AI ###
|
|
"""
|
|
all of these classes are plugins for comfyui and follow the same pattern
|
|
all of the images are torch tensors and it is unknown and unimportant if they are on the cpu or gpu
|
|
all image inputs are (B,W,H,C)
|
|
|
|
avoid numpy and PIL as much as possible
|
|
"""
|
|
import shutil
|
|
|
|
import fractions
|
|
import os
|
|
from abc import ABC, ABCMeta, abstractmethod
|
|
from os.path import splitext, join
|
|
from typing import List, Tuple, Union
|
|
import cv2
|
|
import torch
|
|
import torchaudio
|
|
# from cv2 import VideoWriter, VideoWriter_fourcc, VideoCapture
|
|
# from line_profiler import profile
|
|
from torch import dtype
|
|
# from custom_nodes.EternalKernelLiteGraphNodes.image import torch_image_show
|
|
from torchvision.models.optical_flow import raft_large, Raft_Large_Weights, raft_small, Raft_Small_Weights
|
|
|
|
import os
|
|
import torch
|
|
from torchvision.utils import draw_keypoints
|
|
from skimage.metrics import structural_similarity as ssim
|
|
import cv2
|
|
import numpy as np
|
|
import cv2
|
|
import subprocess
|
|
import imageio_ffmpeg as ffmpeg
|
|
# from comfy_extras.nodes_video_model import SVD_img2vid_Conditioning
|
|
|
|
from . import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
|
|
|
# NODE_CLASS_MAPPINGS = {} # this is the dictionary that will be used to register the nodes
|
|
|
|
# setup directories
|
|
root_p = os.path.dirname(os.path.dirname(os.path.dirname(os.path.realpath(__file__))))
|
|
# input_dir = os.path.join(root_p, 'input', 'video')
|
|
# output_dir = os.path.join(root_p, 'output', 'video')
|
|
|
|
video_dir = os.path.join(root_p, 'video')
|
|
|
|
if not os.path.exists(video_dir):
|
|
os.makedirs(video_dir)
|
|
|
|
|
|
# video_folders = lambda s: sorted(
|
|
# [name for name in os.listdir(s.input_dir) if os.path.isdir(os.path.join(s.input_dir, name))])
|
|
# turn the above in the a full function
|
|
def video_folders(s):
|
|
ret = sorted(
|
|
[name for name in os.listdir(s.input_dir) if os.path.isdir(os.path.join(s.input_dir, name))])
|
|
return ret
|
|
|
|
|
|
def video_files(s):
|
|
# any files in in_video_folders ending in .mp4
|
|
ret = sorted([name for name in os.listdir(s.input_dir) if
|
|
os.path.isfile(os.path.join(s.input_dir, name)) and name.endswith('.mp4')])
|
|
return ret
|
|
|
|
|
|
def run_ffmpeg_command(args):
|
|
"""
|
|
Executes a command using the ffmpeg executable.
|
|
|
|
Parameters:
|
|
args (list): List of command-line arguments for ffmpeg.
|
|
"""
|
|
if isinstance(args, str):
|
|
args = args.split()
|
|
cmd = [ffmpeg.get_ffmpeg_exe()] + args
|
|
subprocess.run(cmd)
|
|
|
|
|
|
# start with an ABC to define the common widget interface
|
|
|
|
class ABCWidgetMetaclass(ABCMeta):
|
|
"""A metaclass that automatically registers classes."""
|
|
|
|
def __init__(cls, name, bases, attrs):
|
|
if (ABC in bases) or (ABCVideoWidget in bases) or ("ABC" in name):
|
|
pass
|
|
else:
|
|
NODE_CLASS_MAPPINGS[str(name)] = cls
|
|
cls.CATEGORY = "ETK/video"
|
|
super().__init__(name, bases, attrs)
|
|
|
|
|
|
class ABCVideoWidget(ABC, metaclass=ABCWidgetMetaclass):
|
|
"""Abstract base class for simple construction"""
|
|
|
|
input_dir = video_dir
|
|
output_dir = video_dir
|
|
|
|
CATEGORY = "video"
|
|
RETURN_TYPES = ("VIDEO",)
|
|
FUNCTION = "handler"
|
|
|
|
@classmethod
|
|
@abstractmethod
|
|
def INPUT_TYPES(cls):
|
|
"""All subclasses must provide a INPUT_TYPES classmethod"""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def handler(self, one: object, two: object):
|
|
"""All subclasses must provide a handler method"""
|
|
pass
|
|
|
|
|
|
class ABCVideoFileToABCVideoFolder(ABCVideoWidget, metaclass=ABCWidgetMetaclass):
|
|
"""Abstract base class for simple construction"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {"required":
|
|
{
|
|
"video_in": (video_files(cls),),
|
|
"folder_out": (video_folders(cls),),
|
|
},
|
|
|
|
"optional":
|
|
{"text":
|
|
("STRING", {"multiline": False}),
|
|
}
|
|
}
|
|
|
|
@abstractmethod
|
|
def handler(self, one, two, text):
|
|
return (one, two,)
|
|
|
|
|
|
class ABCABCVideoFolderToImage(ABCVideoWidget, metaclass=ABCWidgetMetaclass):
|
|
"""Abstract base class for simple construction"""
|
|
RETURN_TYPES = ("IMAGE",)
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {"required":
|
|
{
|
|
"folder_in": ("STRING", {"default": "full_path_to_folder"}),
|
|
"idx_slice_start": ("INT", {"min": 0, "max": 99999, "step": 1, "default": 0}),
|
|
"idx_slice_stop": ("INT", {"min": 0, "max": 99999, "step": 1, "default": 1}),
|
|
"slice_idx_step": ("INT", {"min": 0, "max": 9999, "step": 1, "default": 1}),
|
|
"idx": ("INT", {"min": 0, "max": 99999, "step": 1, "default": 0}),
|
|
},
|
|
|
|
"optional":
|
|
{"text":
|
|
("STRING", {"multiline": False}),
|
|
}
|
|
}
|
|
|
|
@abstractmethod
|
|
def handler(self, one, two, text):
|
|
return (one,)
|
|
|
|
|
|
class ABCABCVideoFileToImage(ABCVideoWidget, metaclass=ABCWidgetMetaclass):
|
|
"""Abstract base class for simple construction"""
|
|
RETURN_TYPES = ("IMAGE",)
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {"required":
|
|
{
|
|
"video_in": ("STRING", {"default": "full_path_to_video"}),
|
|
"idx_slice_start": ("INT", {"min": 0, "max": 99999, "step": 1, "default": 0}),
|
|
"idx_slice_stop": ("INT", {"min": 0, "max": 99999, "step": 1, "default": 1}),
|
|
"slice_idx_step": ("INT", {"min": 0, "max": 9999, "step": 1, "default": 1}),
|
|
"idx": ("INT", {"min": 0, "max": 99999, "step": 1, "default": 0}),
|
|
"func_only": ([False, True], {"default": False}),
|
|
},
|
|
|
|
}
|
|
|
|
@abstractmethod
|
|
def handler(self, one, two, text):
|
|
return (one,)
|
|
|
|
|
|
## end of abstract base classes ##
|
|
|
|
class VideoToFramesFolder(ABCVideoFileToABCVideoFolder, metaclass=ABCWidgetMetaclass):
|
|
"""use cv2 to open the video and use it as a source for image stacks"""
|
|
|
|
RETURN_TYPES = ("IMAGE",)
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.old = self.INPUT_TYPES
|
|
# self.INPUT_TYPES = lambda s: self.old().update({})
|
|
|
|
def handler(self, video_in, folder_out, text):
|
|
import time
|
|
start = time.time()
|
|
video_in_path = os.path.join(self.input_dir, video_in)
|
|
video_out_path = os.path.join(self.output_dir, folder_out)
|
|
|
|
vidcap = cv2.VideoCapture(video_in_path)
|
|
success, image = vidcap.read()
|
|
count = 0
|
|
while success:
|
|
cv2.imwrite(os.path.join(video_out_path, "frame%d.jpg" % count), image)
|
|
success, image = vidcap.read()
|
|
count += 1
|
|
|
|
vidcap.release()
|
|
end = time.time()
|
|
print("time elapsed: ", end - start)
|
|
return (folder_out,)
|
|
|
|
|
|
class VideoToFramesFolderFFMPEG(ABCVideoFileToABCVideoFolder, metaclass=ABCWidgetMetaclass):
|
|
"""use ffmpeg to open the video and use it as a source for image stacks, use PNG or jpeg with quality 100"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {"required":
|
|
{
|
|
"video_in": ("STRING", {"default": "full_path_to_folder"}),
|
|
"folder_out": ("STRING", {"default": "full_path_to_folder"}),
|
|
"format": (["PNG", "JPEG"], {"default": "PNG"}),
|
|
},
|
|
|
|
}
|
|
|
|
RETURN_TYPES = ("STRING",)
|
|
RETURN_NAMES = ("folder_out",)
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.old = self.INPUT_TYPES
|
|
# self.INPUT_TYPES = lambda s: self.old().update({})
|
|
|
|
def handler(self, video_in, folder_out, format):
|
|
import time
|
|
start = time.time()
|
|
video_in_path = os.path.join(self.input_dir, video_in)
|
|
video_out_path = os.path.join(self.output_dir, folder_out)
|
|
|
|
if not os.path.exists(video_out_path):
|
|
os.makedirs(video_out_path)
|
|
|
|
# specifically this calls ffmpeg with the following arguments:
|
|
# -i means input file
|
|
# -qscale:v means quality scale video, default is 2, which is a good compromise between size and quality
|
|
# 1 is the quality scale value, which is a value between 1 and 31, where 1 is the highest quality
|
|
|
|
start = time.time()
|
|
|
|
if format == "JPEG":
|
|
subprocess.call(
|
|
["ffmpeg", "-i", video_in_path, "-qscale:v", "1", os.path.join(video_out_path, "frame%05d.jpg")])
|
|
end = time.time()
|
|
print("ffmpeg jpg process took {} seconds".format(end - start))
|
|
|
|
elif format == "PNG":
|
|
subprocess.call(["ffmpeg", "-i", video_in_path, os.path.join(video_out_path, "frame%05d.png")])
|
|
end = time.time()
|
|
print("ffmpeg png process took {} seconds".format(end - start))
|
|
|
|
else:
|
|
raise ValueError("format must be either PNG or JPEG")
|
|
|
|
return (folder_out,)
|
|
|
|
|
|
class VideoFramesFolderToImageStack(metaclass=ABCWidgetMetaclass):
|
|
"""Use cv2 to open the video and save the videos individual frames"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
from addict import Dict
|
|
ret = Dict()
|
|
ret.required = Dict()
|
|
ret.required.folder_in = ("STRING", {"default": "full_path_to_folder"})
|
|
ret.required.use_subfolders = ([True, False], {"default": False})
|
|
ret.required.by_date_or_name = (["DATE", "NAME"], {"default": "DATE"})
|
|
ret.required.idx_slice_start = ("INT", {"min": 0, "max": 99999, "step": 1, "default": 0})
|
|
ret.required.idx_slice_stop = ("INT", {"min": 0, "max": 99999, "step": 1, "default": 1})
|
|
ret.required.slice_idx_step = ("INT", {"min": 0, "max": 9999, "step": 1, "default": 1})
|
|
ret.required.idx = ("INT", {"min": 0, "max": 99999, "step": 1, "default": 0})
|
|
ret.required.use_float16 = ([True, False], {"default": False})
|
|
|
|
return ret
|
|
|
|
CATEGORY = "video"
|
|
RETURN_TYPES = ("IMAGE", "LIST",)
|
|
RETURN_NAMES = ("image_stack", "[img_fname]",)
|
|
|
|
FUNCTION = "handler"
|
|
|
|
@torch.inference_mode()
|
|
def handler(self, folder_in,
|
|
use_subfolders,
|
|
by_date_or_name,
|
|
idx_slice_start,
|
|
idx_slice_stop,
|
|
slice_idx_step,
|
|
idx,
|
|
use_float16
|
|
):
|
|
"""
|
|
Return slices of time from the video, sequences of images, defined by the start, stop and step
|
|
- torch tensors (T,H,W,C)
|
|
"""
|
|
|
|
import glob
|
|
idx = idx * slice_idx_step
|
|
|
|
folder_in_path = folder_in
|
|
image_list = []
|
|
image_fname_list = []
|
|
|
|
p = os.path.abspath(folder_in_path)
|
|
# List and sort all PNG or JPG files in the folder
|
|
if not use_subfolders and by_date_or_name == "NAME":
|
|
file_fps = sorted(
|
|
glob.glob(os.path.join(p, '*.png')) + glob.glob(os.path.join(p, '*.jpg')))
|
|
elif not use_subfolders and by_date_or_name == "DATE":
|
|
file_fps = sorted(
|
|
glob.glob(os.path.join(p, '*.png')) + glob.glob(os.path.join(p, '*.jpg')),
|
|
key=os.path.getmtime)
|
|
elif use_subfolders and by_date_or_name == "NAME":
|
|
file_fps = sorted(
|
|
glob.glob(os.path.join(p, '**/*.png'), recursive=True) +
|
|
glob.glob(os.path.join(p, '**/*.jpg'), recursive=True))
|
|
elif use_subfolders and by_date_or_name == "DATE":
|
|
file_fps = sorted(
|
|
glob.glob(os.path.join(p, '**/*.png'), recursive=True) +
|
|
glob.glob(os.path.join(p, '**/*.jpg'), recursive=True),
|
|
key=os.path.getmtime)
|
|
|
|
# Ensure the slice doesn't go beyond the end of the file list
|
|
|
|
if idx_slice_stop + idx > len(file_fps):
|
|
idx_slice_stop = len(file_fps) - idx
|
|
|
|
slice_size = idx_slice_stop - idx_slice_start
|
|
# for i in range(idx_slice_start + idx, idx_slice_stop + idx):
|
|
for fn in file_fps[idx_slice_start + idx:idx_slice_start + idx + slice_size]:
|
|
image = cv2.imread(fn)
|
|
image_fname_list.append(os.path.basename(fn))
|
|
if image is not None:
|
|
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) # converting to RGB format
|
|
image_list.append(image)
|
|
|
|
# Convert list of images to tensor (T,H,W,C)
|
|
if use_float16:
|
|
tensor = torch.stack([torch.tensor(img, dtype=torch.float16) for img in image_list])
|
|
else:
|
|
tensor = torch.stack([torch.tensor(img, dtype=torch.float32) for img in image_list])
|
|
del image_list
|
|
tensor = tensor / 255.0
|
|
return (tensor, image_fname_list,)
|
|
|
|
|
|
class SmoothStackTemporalByDistance2(ABCABCVideoFolderToImage, metaclass=ABCWidgetMetaclass):
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {"required":
|
|
{
|
|
"image_stack": ("IMAGE",),
|
|
"top_thresh_normed": ("FLOAT", {"min": 0.0, "max": 100000.0, "step": 0.01, "default": 1.0}),
|
|
"bottom_thresh_normed": ("FLOAT", {"min": 0.0, "max": 100000.0, "step": 0.01, "default": 1.0}),
|
|
"max_iterations": ("INT", {"min": 0, "max": 1000, "step": 1, "default": 1}),
|
|
"target_deviation": ("FLOAT", {"min": 0.0, "max": 100000.0, "step": 0.01, "default": 0.0}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE", "STRING")
|
|
|
|
def handler(self, image_stack, top_thresh_normed=0.9, bottom_thresh_normed=0.10, max_iterations=1,
|
|
target_deviation=0.0):
|
|
import torch
|
|
# Code for printing memory size and available GPU memory stays the same
|
|
|
|
image_stack = image_stack.to(torch.float16)
|
|
info = ""
|
|
iteration = 0
|
|
while True:
|
|
# Compute the first-order differences (gradient) along the time dimension (0)
|
|
first_order_diff = torch.gradient(image_stack, dim=0)[0]
|
|
|
|
# Compute the Euclidean norm of the first-order differences
|
|
euclidean_diff_first_order = torch.norm(first_order_diff.view(first_order_diff.shape[0], -1), dim=1).clone()
|
|
|
|
euclidean_diff_first_order_range = euclidean_diff_first_order.max() - euclidean_diff_first_order.min()
|
|
euclidean_diff_first_order_normed = (euclidean_diff_first_order - euclidean_diff_first_order.min()) / \
|
|
euclidean_diff_first_order_range
|
|
|
|
euclidean_diff_first_order_normed = euclidean_diff_first_order_normed[:-1]
|
|
|
|
start_mean = euclidean_diff_first_order.mean()
|
|
|
|
start_std = euclidean_diff_first_order.std()
|
|
|
|
if start_std <= target_deviation or iteration >= max_iterations:
|
|
break
|
|
|
|
# Identify the frames with differences greater than the top threshold, excluding the last index
|
|
outlier_indices_top = torch.where(euclidean_diff_first_order_normed > top_thresh_normed)[0]
|
|
|
|
# Interpolate frames where the differences are greater than the top threshold
|
|
interpolated_frames_top = 0.5 * (image_stack[outlier_indices_top] + image_stack[outlier_indices_top + 1])
|
|
image_stack[outlier_indices_top + 1] = interpolated_frames_top
|
|
|
|
# Identify the frames with differences less than the bottom threshold, excluding the last index
|
|
outlier_indices_bottom = torch.where(euclidean_diff_first_order_normed < bottom_thresh_normed)[0]
|
|
|
|
# Create a mask to delete frames where the differences are less than the bottom threshold
|
|
mask = torch.ones(image_stack.size(0), dtype=torch.bool)
|
|
mask[outlier_indices_bottom + 1] = False
|
|
image_stack = image_stack[mask]
|
|
# now remove the first and last frames
|
|
# image_stack = image_stack[1:-1]
|
|
# print the shape and stats for image_stack
|
|
|
|
# relate how many frames where changed via top threshold and bottom threshold
|
|
# also report on the mean and std of the euclidean_diff_first_order
|
|
|
|
info += f"Number of frames changed via top threshold: {len(outlier_indices_top)}\n " \
|
|
f"Number of frames changed via bottom threshold: {len(outlier_indices_bottom)}\n " \
|
|
f"Mean of euclidean_diff_first_order: {start_mean}\n " \
|
|
f"Std of euclidean_diff_first_order: {start_std}\n " \
|
|
f"\n\n"
|
|
print(info)
|
|
iteration += 1
|
|
|
|
if image_stack.shape[0] == 0:
|
|
print("image_stack is empty, returning None")
|
|
return (None, None,)
|
|
print(
|
|
f"image_stack shape: {image_stack.shape}, image_stack stats: {image_stack.min()}, {image_stack.max()}")
|
|
|
|
return (image_stack.to(torch.float32), info,)
|
|
|
|
|
|
class SmoothStackTemporalByDistance(ABCABCVideoFolderToImage, metaclass=ABCWidgetMetaclass):
|
|
"""
|
|
Smooth the temporal dimension of the image stack by calculating the distance between each frame
|
|
and the next frame and then averaging the frames that are within a certain distance of each other.
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {"required":
|
|
{
|
|
"image_stack": ("IMAGE",),
|
|
"top_thresh_normed": ("FLOAT", {"min": 0.0, "max": 100000.0, "step": 0.1, "default": 1.0}),
|
|
"bottom_thresh_normed": ("FLOAT", {"min": 0.0, "max": 100000.0, "step": 0.1, "default": 1.0}),
|
|
"iterations": ("INT", {"min": 0, "max": 1000, "step": 1, "default": 1}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE", "STRING")
|
|
|
|
def handler(self, image_stack,
|
|
top_thresh_normed=0.9, bottom_thresh_normed=0.10, iterations=1):
|
|
import torch
|
|
# calculate and print the total size in memory in bytes of the tensor
|
|
print(
|
|
f"Size of tensor in memory in GB: {image_stack.element_size() * image_stack.nelement() / (1024 ** 3)} GB, dtype: {image_stack.dtype}")
|
|
# print the available memory on the GPU in GB
|
|
print(f"Available memory on GPU in GB: {torch.cuda.get_device_properties(0).total_memory / (1024 ** 3)} GB")
|
|
|
|
# image_stack = image_stack.clone()
|
|
image_stack = image_stack.to(torch.float16)
|
|
info = ""
|
|
for _ in range(iterations):
|
|
# Compute the first-order differences (gradient) along the time dimension (0)
|
|
first_order_diff = torch.gradient(image_stack, dim=0)[0]
|
|
|
|
# Compute the Euclidean norm of the first-order differences
|
|
euclidean_diff_first_order = torch.norm(first_order_diff.view(first_order_diff.shape[0], -1), dim=1).clone()
|
|
|
|
euclidean_diff_first_order_range = euclidean_diff_first_order.max() - euclidean_diff_first_order.min()
|
|
euclidean_diff_first_order_normed = (euclidean_diff_first_order - euclidean_diff_first_order.min()) / \
|
|
euclidean_diff_first_order_range
|
|
|
|
euclidean_diff_first_order_normed = euclidean_diff_first_order_normed[:-1]
|
|
|
|
start_mean = euclidean_diff_first_order.mean()
|
|
start_std = euclidean_diff_first_order.std()
|
|
|
|
# Identify the frames with differences greater than the top threshold, excluding the last index
|
|
outlier_indices_top = torch.where(euclidean_diff_first_order_normed > top_thresh_normed)[0]
|
|
|
|
# Interpolate frames where the differences are greater than the top threshold
|
|
interpolated_frames_top = 0.5 * (image_stack[outlier_indices_top] + image_stack[outlier_indices_top + 1])
|
|
image_stack[outlier_indices_top + 1] = interpolated_frames_top
|
|
|
|
# Identify the frames with differences less than the bottom threshold, excluding the last index
|
|
outlier_indices_bottom = torch.where(euclidean_diff_first_order_normed < bottom_thresh_normed)[0]
|
|
|
|
# Create a mask to delete frames where the differences are less than the bottom threshold
|
|
mask = torch.ones(image_stack.size(0), dtype=torch.bool)
|
|
mask[outlier_indices_bottom + 1] = False
|
|
image_stack = image_stack[mask]
|
|
# now remove the first and last frames
|
|
# image_stack = image_stack[1:-1]
|
|
# print the shape and stats for image_stack
|
|
|
|
# relate how many frames where changed via top threshold and bottom threshold
|
|
# also report on the mean and std of the euclidean_diff_first_order
|
|
info += f"Number of frames changed via top threshold: {len(outlier_indices_top)}\n " \
|
|
f"Number of frames changed via bottom threshold: {len(outlier_indices_bottom)}\n " \
|
|
f"Mean of euclidean_diff_first_order: {start_mean}\n " \
|
|
f"Std of euclidean_diff_first_order: {start_std}\n " \
|
|
f"\n\n"
|
|
print(info)
|
|
|
|
if image_stack.shape[0] == 0:
|
|
print("image_stack is empty, returning None")
|
|
return (None, None,)
|
|
print(
|
|
f"image_stack shape: {image_stack.shape}, image_stack stats: {image_stack.min()}, {image_stack.max()}")
|
|
|
|
return (image_stack.to(torch.float32), info,)
|
|
|
|
|
|
class GetImageStackStatistics(ABCABCVideoFolderToImage, metaclass=ABCWidgetMetaclass):
|
|
"""Get statistics of an image stack"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {"required":
|
|
{
|
|
"image_stack": ("IMAGE",),
|
|
"method": (["SSIM", "MSE", "REGIONAL"], {"default": "SSIM"}),
|
|
},
|
|
|
|
}
|
|
|
|
RETURN_TYPES = ("STRING", "LIST", "IMAGE",)
|
|
|
|
def handler(self, image_stack, method):
|
|
import numpy as np
|
|
import torch
|
|
# if the dtype is not float32
|
|
if image_stack.dtype != torch.float32:
|
|
image_stack = image_stack.to(torch.float32)
|
|
|
|
image_stack_BCWH = image_stack.permute(0, 3, 1, 2)
|
|
image_stack_BCWH_DIFF = image_stack_BCWH[1:] - image_stack_BCWH[:-1]
|
|
|
|
# we want to calculate the mean squared error
|
|
# of the image stack
|
|
# so we need to calculate the difference between each image in the stack and the previous image
|
|
# we have that in image_stack_BCWH_DIFF
|
|
# now we need to calculate the mean squared error of each image in the stack
|
|
# which will give us a tensor of shape (B)
|
|
# where B is the number of images in the stack
|
|
|
|
if method == "MSE":
|
|
stack_mse_B = torch.mean(image_stack_BCWH_DIFF ** 2, dim=(1, 2, 3))
|
|
# find the max possible value of the mean squared error
|
|
|
|
elif method == "REGIONAL":
|
|
"""
|
|
this method resamples the image stack of B,W,H,C to B,8,8,C
|
|
then calculates the mean squared error of each region of the image
|
|
by further slicing into [b,1,1,C] x 64 regional representations
|
|
the greatest mean squared from each region is returned
|
|
"""
|
|
# the input image stack is defined as "image_stack" and it is a torch tensor, B,H,W,C
|
|
|
|
B, H, W, C = image_stack.shape
|
|
levels = [3, 4, 5, 6, 7, 8, 16, 32, 64]
|
|
max_mse_value_in_all_regions_and_frames_for_this_level = 0
|
|
level_stats = {k: {"max_mse": None, "max_std_dev": None} for k in levels}
|
|
for size in levels:
|
|
# need to re order the channels for the interpolation
|
|
resampled_stack = image_stack.permute(0, 3, 1, 2)
|
|
|
|
# Resample the image stack to 8x8 pixels
|
|
resampled_stack = torch.nn.functional.interpolate(resampled_stack, size=(size, size), mode='bilinear',
|
|
align_corners=False)
|
|
|
|
# but the channel order back to the original
|
|
resampled_stack = resampled_stack.permute(0, 2, 3, 1)
|
|
|
|
# Reshape the resampled stack to (B, 64, C)
|
|
regional_stack = resampled_stack.view(B, size ** 2, C)
|
|
|
|
# Calculate the mean squared difference between consecutive frames for each region
|
|
mse_values = torch.mean((regional_stack[1:] - regional_stack[:-1]) ** 2, dim=2)
|
|
|
|
# max mse for each frame
|
|
max_mse_ea_frame_at_level = torch.max(mse_values, dim=1)[0]
|
|
max_dev_ea_frame_at_level = torch.std(mse_values, dim=1)
|
|
|
|
# Calculate the standard deviation of the mean squared error for each region along the time dimension
|
|
max_std_dev_of_all_regions_over_time = torch.max(torch.std(mse_values, dim=0))
|
|
|
|
# in terms of video
|
|
# represents the maximum amount of change in the image stack, basically the maximum amount of motion
|
|
max_mse_value_in_all_regions_and_frames_for_this_level = torch.max(mse_values.view(-1))
|
|
|
|
level_stats[size]["max_r_mse_ea_frame"] = max_mse_ea_frame_at_level
|
|
level_stats[size]["max_r_dev_ea_frame"] = max_dev_ea_frame_at_level
|
|
level_stats[size]["max_mse"] = max_mse_value_in_all_regions_and_frames_for_this_level
|
|
level_stats[size]["max_std_dev"] = max_std_dev_of_all_regions_over_time
|
|
|
|
all_l_max_mse_ea_frame = [level_stats[k]["max_r_mse_ea_frame"].tolist() for k in levels]
|
|
all_l_max_std_ea_frame = [level_stats[k]["max_r_dev_ea_frame"].tolist() for k in levels]
|
|
|
|
max_l_r_mse_per_batch = [] # Initialize list to store the maximum mean squared error for each batch_num
|
|
for batch_num in range(B - 1):
|
|
max_value = float('-inf') # Initialize max_value to negative infinity for each batch_num
|
|
for level in levels:
|
|
mse_for_level_and_batch = level_stats[level]["max_r_mse_ea_frame"][batch_num]
|
|
# Update max_value if the current mse_for_level_and_batch is greater
|
|
if mse_for_level_and_batch > max_value:
|
|
max_value = mse_for_level_and_batch
|
|
max_l_r_mse_per_batch.append(
|
|
max_value) # Append the maximum value for the current batch_num to the list
|
|
|
|
max_l_r_dev_per_batch = [] # Initialize list to store the maximum standard deviation for each batch_num
|
|
for batch_num in range(B - 1):
|
|
max_value = float('-inf')
|
|
for level in levels:
|
|
dev_for_level_and_batch = level_stats[level]["max_r_dev_ea_frame"][batch_num]
|
|
if dev_for_level_and_batch > max_value:
|
|
max_value = dev_for_level_and_batch
|
|
max_l_r_dev_per_batch.append(max_value)
|
|
|
|
# all_f_max_mse_ea_frame = [level_stats[k]["max_mse_ea_frame"] for k in levels]
|
|
|
|
max_mse_value_in_all_regions_and_frames_for_all_levels = torch.max(torch.tensor(all_l_max_mse_ea_frame))
|
|
max_std_dev_value_in_all_regions_and_frames_for_all_levels = torch.max(torch.tensor(all_l_max_std_ea_frame))
|
|
|
|
out_str = f"max difference between any region over time: {max_mse_value_in_all_regions_and_frames_for_this_level}\nmax of how much the amount of motion changes over time: {max_std_dev_of_all_regions_over_time}"
|
|
out_list = [max_mse_value_in_all_regions_and_frames_for_all_levels,
|
|
max_std_dev_value_in_all_regions_and_frames_for_all_levels,
|
|
max_l_r_mse_per_batch,
|
|
max_l_r_dev_per_batch
|
|
]
|
|
|
|
# return the resampled image stack
|
|
|
|
out_image = None
|
|
|
|
return (out_str, out_list, out_image,)
|
|
|
|
|
|
|
|
else:
|
|
# not implimented
|
|
raise NotImplementedError
|
|
|
|
image_stack_diff_len = len(stack_mse_B)
|
|
required_image_info_width = 512 + image_stack_diff_len
|
|
|
|
mean = torch.mean(stack_mse_B)
|
|
std = torch.std(stack_mse_B)
|
|
median = torch.median(stack_mse_B)
|
|
max_mse = torch.max(stack_mse_B)
|
|
min_mse = torch.min(stack_mse_B)
|
|
|
|
out_str = f"mean: {mean}\n std: {std}\n median: {median}\n max: {max_mse}\n min: {min_mse}\n len: {image_stack_diff_len}"
|
|
|
|
# create the histogram
|
|
hist_values, _ = np.histogram(stack_mse_B.numpy(), bins=min(100, image_stack_diff_len))
|
|
hist_values = hist_values / hist_values.max() # Normalize to [0, 1]
|
|
|
|
# Create an image tensor with shape (1, 512, required_width, 3) filled with zeros
|
|
out_hist_image = torch.zeros((1, 512, required_image_info_width, 3))
|
|
|
|
# Determine the scaling factors for width and height for the histogram
|
|
scale_width = 512 // len(hist_values)
|
|
scale_height = out_hist_image.shape[1]
|
|
|
|
# Fill the image tensor with the histogram
|
|
for i, value in enumerate(hist_values):
|
|
height = 512 - int(value * scale_height)
|
|
start_x = int(i * scale_width)
|
|
end_x = int((i + 1) * scale_width)
|
|
out_hist_image[0, height:, start_x:end_x - 1, :] = 1 # Color as white
|
|
|
|
# repeat each element of the stack_mse_B tensor by scale_width
|
|
stack_mse_B = stack_mse_B.repeat_interleave(scale_width)
|
|
|
|
# Create a plot for the difference for each frame pair
|
|
plot_offset = 512
|
|
for i, value in enumerate(stack_mse_B):
|
|
height = 512 - int(value * (scale_height / .5)) # Normalize by the max value
|
|
start_x = plot_offset + i
|
|
end_x = plot_offset + i + 1
|
|
out_hist_image[0, height:, start_x:end_x, :] = (1 * height) / 512 # Color as white
|
|
|
|
return (out_str, out_hist_image,)
|
|
|
|
|
|
# @profile
|
|
class VideoFileToImageStack(ABCABCVideoFileToImage, metaclass=ABCWidgetMetaclass):
|
|
"""Use cv2 to open a video and save the videos individual frames"""
|
|
RETURN_TYPES = ("IMAGE", "FUNC",)
|
|
RETURN_NAMES = ("image_stack", "FUNC(**kwargs)",)
|
|
|
|
def handler(self, video_in, idx_slice_start, idx_slice_stop, slice_idx_step, idx, func_only=False):
|
|
"""
|
|
Return slices of time from the video, sequences of images, defined by the start, stop and step
|
|
- torch tensors (T,H,W,C)
|
|
"""
|
|
import sys
|
|
import gc
|
|
# print memory usage
|
|
print(f"Memory usage: {sys.getsizeof(self)} bytes")
|
|
|
|
if func_only:
|
|
return (None, VideoFileToImageStack.handler,)
|
|
|
|
image_list = []
|
|
video_in_fp = video_in
|
|
vidcap = cv2.VideoCapture(video_in_fp)
|
|
max_frames = int(vidcap.get(cv2.CAP_PROP_FRAME_COUNT))
|
|
|
|
if not vidcap.isOpened():
|
|
print("Error: Unable to open video file")
|
|
return
|
|
|
|
start = (idx * slice_idx_step) + idx_slice_start
|
|
stop = (idx * slice_idx_step) + idx_slice_stop
|
|
slice_length = stop - start
|
|
|
|
if (start + slice_length) > max_frames:
|
|
raise ValueError(f"idx_slice_stop ({idx_slice_stop}) is greater than the max frames ({max_frames})")
|
|
|
|
for frame_count in range(start, start + slice_length):
|
|
# Seek to frame_count frame
|
|
vidcap.set(cv2.CAP_PROP_POS_FRAMES, frame_count)
|
|
|
|
success, image = vidcap.read()
|
|
if not success:
|
|
print("Error: Unable to read frame from video file")
|
|
break
|
|
# image_list.append(image)
|
|
|
|
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
|
|
image_list.append(image)
|
|
print(f"Memory usage: {sys.getsizeof(self)} bytes")
|
|
|
|
vidcap.release()
|
|
# Convert list of images to tensor (T,H,W,C)
|
|
try:
|
|
tensor = torch.stack([torch.tensor(img, dtype=torch.float32) for img in image_list])
|
|
except RuntimeError as e:
|
|
print("Error: Unable to convert image list to tensor")
|
|
raise e
|
|
|
|
tensor = tensor / 255.0
|
|
del image_list
|
|
del vidcap
|
|
del image
|
|
gc.collect()
|
|
# print memory usage
|
|
print(f"Memory usage: {sys.getsizeof(self)} bytes")
|
|
return (tensor, VideoFileToImageStack.handler,)
|
|
|
|
|
|
class ImageStackToVideoFramesFolder(metaclass=ABCWidgetMetaclass):
|
|
"""Really just saves the image stack to a folder"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(self):
|
|
return {"required":
|
|
{
|
|
"image_stack": ("IMAGE",),
|
|
"folder_out": ("STRING", {"multiline": False, "default": "video01"}),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("STRING",)
|
|
CATEGORY = "video"
|
|
OUTPUT_NODE = True
|
|
FUNCTION = "handler"
|
|
|
|
def handler(self, **kwargs):
|
|
import tempfile
|
|
import uuid
|
|
import shutil
|
|
|
|
image_stack = kwargs["image_stack"]
|
|
folder_out = kwargs["folder_out"]
|
|
folder_out_path = video_dir
|
|
|
|
# full path to the output folder
|
|
folder_out_path = os.path.join(video_dir, folder_out)
|
|
|
|
# Create the output folder if it doesn't exist
|
|
if not os.path.exists(folder_out_path):
|
|
os.makedirs(folder_out_path)
|
|
|
|
# move any current files in the output folder to a random folder inside the video folder
|
|
random_folder = os.path.join(folder_out_path, str(uuid.uuid4()))
|
|
os.makedirs(random_folder)
|
|
for file in os.listdir(folder_out_path):
|
|
file_path = os.path.join(folder_out_path, file)
|
|
if os.path.isfile(file_path):
|
|
shutil.move(file_path, random_folder)
|
|
|
|
# Save each image in the image stack to the output folder
|
|
for i in range(image_stack.shape[0]):
|
|
image = image_stack[i].numpy()
|
|
# make sure the image is in the correct color format
|
|
image = cv2.cvtColor(image, cv2.COLOR_RGB2BGR)
|
|
# convert to 0-255
|
|
image = image * 255
|
|
# use zfill to get a good ordering of the frames
|
|
file_name = os.path.join(folder_out_path, "frame" + str(i).zfill(6) + ".jpg")
|
|
# use the best compression nearly lossless
|
|
cv2.imwrite(file_name, image, [cv2.IMWRITE_JPEG_QUALITY, 99])
|
|
|
|
image_stack = None
|
|
del image_stack
|
|
return (folder_out,)
|
|
|
|
|
|
class SmoothStackTemporal(ABCABCVideoFolderToImage, metaclass=ABCWidgetMetaclass):
|
|
"""
|
|
Smooth the temporal dimension of the image stack by calculating the distance between each frame
|
|
and the next frame and then averaging the frames that are within a certain distance of each other.
|
|
Supports adaptive mode with target deviation and baseline mode.
|
|
Can also perform frame interpolation using RIFE VFI.
|
|
"""
|
|
|
|
rife_kwargs = {
|
|
"ckpt_name": "rife49.pth",
|
|
"clear_cache_after_n_frames": 1000,
|
|
"multiplier": 2,
|
|
"fast_mode": False,
|
|
"ensemble": False,
|
|
"scale_factor": 1.0,
|
|
"optional_interpolation_states": None,
|
|
}
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {"required":
|
|
{
|
|
"image_stack": ("IMAGE",),
|
|
"top_threshold": ("FLOAT", {"min": 0.0, "max": 100000.0, "step": 0.01, "default": 1.0}),
|
|
"bottom_threshold": ("FLOAT", {"min": 0.0, "max": 100000.0, "step": 0.01, "default": 1.0}),
|
|
"max_iters": ("INT", {"min": 0, "max": 1000, "step": 1, "default": 1}),
|
|
"target_deviation": ("FLOAT", {"min": 0.0, "max": 100000.0, "step": 0.01, "default": 0.0}),
|
|
"interp_frames": ("BOOLEAN", {"default": False}),
|
|
"mode": (["adaptive", "baseline"],),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE", "STRING")
|
|
|
|
def compute_stats(self, image_stack):
|
|
stats_handler = GetImageStackStatistics().handler
|
|
res = stats_handler(image_stack=image_stack, method="REGIONAL")
|
|
stats_str, stats_list, _ = res
|
|
region_diff = torch.tensor(stats_list[2]) # The list of each frame pairs maximum regional difference
|
|
max_diff = stats_list[0] # Overall maximum difference
|
|
max_dev = stats_list[1] # Overall regional deviation
|
|
return stats_str, region_diff, max_diff, max_dev
|
|
|
|
def compute_baseline_motion(self, region_diff):
|
|
values, counts = torch.unique(region_diff, return_counts=True)
|
|
baseline_motion = values[torch.argmax(counts)].item()
|
|
return baseline_motion
|
|
|
|
def normalize_diff(self, region_diff):
|
|
region_diff_range = region_diff.max() - region_diff.min()
|
|
if region_diff_range == 0:
|
|
region_diff_range = 1 # Avoid division by zero
|
|
region_diff_norm = (region_diff - region_diff.min()) / region_diff_range
|
|
return region_diff_norm
|
|
|
|
def process_top_outliers(self, image_stack, region_diff_norm, top_threshold, rife_vfi):
|
|
import torch.nn.functional as F
|
|
|
|
top_outliers = torch.where(region_diff_norm > top_threshold)[0]
|
|
new_image_stack = []
|
|
|
|
for idx in range(image_stack.shape[0]):
|
|
new_image_stack.append(image_stack[idx])
|
|
if idx in top_outliers and idx < image_stack.shape[0] - 1: # Ensure we don't go out of bounds
|
|
frame_pair = image_stack[idx:idx + 2]
|
|
interpolated_frames = rife_vfi(frames=frame_pair, **self.rife_kwargs)[0]
|
|
|
|
# Calculate the MSE for each interpolated frame with the first and last frames of the pair
|
|
mse_first = [F.mse_loss(interpolated_frame, frame_pair[0]) for interpolated_frame in
|
|
interpolated_frames]
|
|
mse_last = [F.mse_loss(interpolated_frame, frame_pair[1]) for interpolated_frame in interpolated_frames]
|
|
|
|
# Find the interpolated frame with the smallest difference between mse_first and mse_last
|
|
mse_diff = [abs(mse_f - mse_l) for mse_f, mse_l in zip(mse_first, mse_last)]
|
|
middle_frame_index = torch.argmin(torch.tensor(mse_diff)).item()
|
|
|
|
choice = interpolated_frames[middle_frame_index]
|
|
new_image_stack.append(choice)
|
|
|
|
new_image_stack = torch.stack(new_image_stack)
|
|
return new_image_stack, len(top_outliers)
|
|
|
|
def drop_bottom_outliers(self, image_stack, region_diff_norm, bottom_threshold):
|
|
bottom_outliers = torch.where(region_diff_norm < bottom_threshold)[0]
|
|
if len(bottom_outliers) > 0:
|
|
mask = torch.ones(image_stack.size(0), dtype=torch.bool)
|
|
mask[bottom_outliers + 1] = False
|
|
image_stack = image_stack[mask]
|
|
return image_stack, len(bottom_outliers)
|
|
|
|
def log_info(self, iteration, top_outliers_count, bottom_outliers_count, mean_diff, std_diff, max_diff, max_dev,
|
|
stats_str):
|
|
info = f"Iteration {iteration}:\n"
|
|
info += f"Frames changed by top threshold: {top_outliers_count}\n"
|
|
info += f"Frames dropped by bottom threshold: {bottom_outliers_count}\n"
|
|
info += f"Mean of region_diff: {mean_diff}\n"
|
|
info += f"Std of region_diff: {std_diff}\n"
|
|
info += f"Overall max difference: {max_diff}\n"
|
|
info += f"Overall max deviation: {max_dev}\n"
|
|
info += f"Image stack statistics: {stats_str}\n\n"
|
|
return info
|
|
|
|
def handler(self, image_stack, top_threshold=0.9, bottom_threshold=0.10, max_iters=1, target_deviation=0.0,
|
|
interp_frames=False, mode="adaptive"):
|
|
import torch
|
|
import nodes
|
|
|
|
image_stack = image_stack.to(torch.float16)
|
|
log = ""
|
|
iteration = 0
|
|
|
|
if interp_frames:
|
|
rife_vfi = nodes.NODE_CLASS_MAPPINGS["RIFE VFI"]().vfi
|
|
|
|
if mode == "baseline":
|
|
# Compute initial statistics and baseline motion
|
|
stats_str, region_diff, max_diff, max_dev = self.compute_stats(image_stack)
|
|
baseline_motion = self.compute_baseline_motion(region_diff)
|
|
target_deviation = baseline_motion
|
|
log += f"Baseline motion (mode of max regional differences): {baseline_motion}\n"
|
|
|
|
while True:
|
|
# Step 1: Compute statistics
|
|
stats_str, region_diff, max_diff, max_dev = self.compute_stats(image_stack)
|
|
|
|
# Step 2: Normalize differences
|
|
region_diff_norm = self.normalize_diff(region_diff)
|
|
|
|
mean_diff = region_diff.mean().item()
|
|
std_diff = region_diff.std().item()
|
|
|
|
# Step 3: Check if the target deviation is met or if max iterations are reached
|
|
if std_diff <= target_deviation or iteration >= max_iters:
|
|
log += f"std_diff <= target_deviation or iteration >= max_iters"
|
|
break
|
|
|
|
# Step 4: Identify and process top outliers
|
|
image_stack, top_outliers_count = self.process_top_outliers(image_stack, region_diff_norm, top_threshold,
|
|
rife_vfi)
|
|
|
|
# Step 5: Identify and drop bottom outliers
|
|
image_stack, bottom_outliers_count = self.drop_bottom_outliers(image_stack, region_diff_norm,
|
|
bottom_threshold)
|
|
|
|
# Log information
|
|
log += self.log_info(iteration, top_outliers_count, bottom_outliers_count, mean_diff, std_diff, max_diff,
|
|
max_dev, stats_str)
|
|
|
|
# Increment the iteration counter
|
|
iteration += 1
|
|
|
|
# Step 6: Check if the image stack is empty
|
|
if image_stack.shape[0] == 0:
|
|
return (None, "Image stack is empty after processing. No frames left.")
|
|
|
|
return (image_stack.to(torch.float32), log)
|
|
|
|
|
|
class MovingCircle(metaclass=ABCWidgetMetaclass):
|
|
# VideoWidget class definition
|
|
|
|
def calculate_start(self, x1, y1, width, height):
|
|
# Convert to 0-1 range
|
|
start_x = x1 * width
|
|
start_y = y1 * height
|
|
return (start_x, start_y)
|
|
|
|
def calculate_end(self, x2, y2, width, height):
|
|
# Convert to 0-1 range
|
|
end_x = x2 * width
|
|
end_y = y2 * height
|
|
return (end_x, end_y)
|
|
|
|
def generate_frames(self, num_frames, width, height):
|
|
# Match tensor shape from example
|
|
frames = torch.zeros(num_frames, width, height, 3)
|
|
return frames
|
|
|
|
def draw_circle_or_oval(self, kp, width, height, radius, aspect_ratio, color=(255, 255, 255)):
|
|
import torch.nn.functional as F
|
|
# Initialize black background
|
|
img = torch.zeros((3, height, width), dtype=torch.uint8)
|
|
|
|
# Draw circle as keypoints
|
|
keypoints = torch.tensor([[kp[0], kp[1]]]).unsqueeze(0)
|
|
img = draw_keypoints(img, keypoints, colors=color, radius=radius)
|
|
|
|
# convert to (w,h,c)
|
|
# img = img.permute(1, 2, 0).unsqueeze(0)
|
|
img = img.unsqueeze(0)
|
|
|
|
# Calculate new size
|
|
if aspect_ratio < 1:
|
|
new_size = (height, round(width / aspect_ratio))
|
|
else:
|
|
new_size = (round(height * aspect_ratio), width)
|
|
|
|
# Stretch the image
|
|
img_stretched = F.interpolate(img, size=new_size, mode='bilinear', align_corners=False)
|
|
|
|
# Shrink it back
|
|
img_shrunk = F.interpolate(img_stretched, size=(height, width), mode='bilinear', align_corners=False)
|
|
|
|
# Convert back to float values
|
|
|
|
img_shrunk = img_shrunk.float() / 255
|
|
|
|
img_shrunk = img_shrunk.permute(0, 2, 3, 1)
|
|
return img_shrunk
|
|
|
|
def draw_circle(self, kp, width, height, radius, color=(255, 255, 255)):
|
|
# Initialize black background
|
|
img = torch.zeros((3, height, width), dtype=torch.uint8)
|
|
# Draw circle as keypoints
|
|
keypoints = torch.tensor([[kp[0], kp[1]]]).unsqueeze(0)
|
|
img = draw_keypoints(img, keypoints, colors=color, radius=radius)
|
|
# convert to (w,h,c)
|
|
img = img.permute(1, 2, 0).unsqueeze(0)
|
|
# convert back to float values
|
|
img = img.float() / 255
|
|
|
|
return img
|
|
|
|
# Metadata for documentation
|
|
RETURN_TYPES = ("IMAGE",)
|
|
CATEGORY = "ETK/video"
|
|
FUNCTION = "handler"
|
|
|
|
# Match parent class method
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
# Validate types, ranges
|
|
return {"required":
|
|
{
|
|
"x1": ("FLOAT", {"min": 0, "max": 1, "step": 0.1}),
|
|
"y1": ("FLOAT", {"min": 0, "max": 1, "step": 0.1}),
|
|
"x2": ("FLOAT", {"min": 0, "max": 1, "step": 0.1}),
|
|
"y2": ("FLOAT", {"min": 0, "max": 1, "step": 0.1}),
|
|
"num_frames": ("INT", {"min": 1}),
|
|
"width": ("INT", {"min": 1}),
|
|
"height": ("INT", {"min": 1}),
|
|
"radius": ("INT", {"min": 1}),
|
|
"color": ("STRING", {"default": "255 255 255"}),
|
|
"aspect_ratio": ("FLOAT", {"min": 0.1, "max": 10})
|
|
|
|
}}
|
|
|
|
def handler(self, x1, y1, x2, y2, num_frames, width, height, radius, color="255 255 255", aspect_ratio=1):
|
|
import torch
|
|
# Avoid errors on bad values
|
|
assert 0 <= x1 <= 1
|
|
assert 0 <= y1 <= 1
|
|
|
|
# Split calculations
|
|
start = torch.tensor(self.calculate_start(x1, y1, width, height))
|
|
|
|
# Split calculations
|
|
end = torch.tensor(self.calculate_end(x2, y2, width, height))
|
|
|
|
# color is supposed to be a tuple
|
|
color = tuple(map(lambda x: int(x), color.split()))
|
|
# Initialize for compositing
|
|
|
|
frames = []
|
|
for i in range(num_frames):
|
|
# Leverage PyTorch
|
|
cur_pos = start + (end - start) * i / num_frames
|
|
circle = self.draw_circle_or_oval(cur_pos, width, height, radius, aspect_ratio, color)
|
|
# Use broadcasting to overlay
|
|
frames.append(circle)
|
|
|
|
# cat
|
|
frames = torch.cat(frames, dim=0)
|
|
|
|
return (frames,)
|
|
|
|
if __name__ == "__main__":
|
|
import doctest
|
|
|
|
doctest.testmod()
|
|
|
|
|
|
class FindFrameFolders(metaclass=ABCWidgetMetaclass):
|
|
"""Finds all folders directly off a given root path containing sequential image files"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(self):
|
|
return {
|
|
"required": {
|
|
"root_path": ("STRING", {"default": "path to root of project"})
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("LIST",)
|
|
RETURN_NAMES = ("image_sequence_folders",)
|
|
CATEGORY = "video"
|
|
OUTPUT_NODE = True
|
|
FUNCTION = "handler"
|
|
|
|
def handler(self, **kwargs):
|
|
root_path = kwargs["root_path"]
|
|
image_sequence_folders = []
|
|
|
|
for folder_name in os.listdir(root_path):
|
|
folder_path = os.path.join(root_path, folder_name)
|
|
if os.path.isdir(folder_path):
|
|
image_files = [file for file in os.listdir(folder_path) if file.endswith(('.png', '.jpg', '.jpeg'))]
|
|
if len(image_files) >= 2:
|
|
image_sequence_folders.append(folder_path)
|
|
|
|
return (image_sequence_folders,)
|
|
|
|
|
|
class CombineAudioAndVideoFiles(metaclass=ABCWidgetMetaclass):
|
|
"""Combines lists of audio files and video files using imageio-ffmpeg"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"audio_files": ("LIST", {"default": []}),
|
|
"video_files": ("LIST", {"default": []}),
|
|
"resample": (["None", "video", "audio", ],),
|
|
"fps": ("FLOAT", {"default": 30, "min": 1, "max": 120}),
|
|
},
|
|
"optional": {
|
|
"move_to_folder": ("STRING", {"default": ""}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("LIST",)
|
|
RETURN_NAMES = ("combined_video_paths",)
|
|
CATEGORY = "video"
|
|
OUTPUT_NODE = True
|
|
FUNCTION = "handler"
|
|
|
|
@classmethod
|
|
def has_audio_stream(cls, video_file):
|
|
"""Check if the video file has an audio stream."""
|
|
command = ['ffprobe', '-v', 'error', '-select_streams', 'a', '-show_entries', 'stream=codec_name', '-of',
|
|
'default=nw=1:nk=1', video_file]
|
|
try:
|
|
output = subprocess.check_output(command, stderr=subprocess.STDOUT)
|
|
return bool(output.strip())
|
|
except subprocess.CalledProcessError:
|
|
return False
|
|
|
|
import subprocess
|
|
import os
|
|
from os.path import splitext, join
|
|
import shutil
|
|
|
|
def get_duration(self, file):
|
|
"""Get the duration of a file in seconds."""
|
|
command = ['ffprobe', '-v', 'error', '-show_entries', 'format=duration', '-of',
|
|
'default=nw=1:nk=1', file]
|
|
try:
|
|
output = subprocess.check_output(command, stderr=subprocess.STDOUT)
|
|
return float(output.strip())
|
|
except subprocess.CalledProcessError:
|
|
return None
|
|
|
|
def handler(self, **kwargs):
|
|
audio_files = kwargs["audio_files"]
|
|
video_files = kwargs["video_files"]
|
|
move_to_folder = kwargs.get("move_to_folder", "")
|
|
resample = kwargs.get("resample", "None")
|
|
fps = kwargs.get("fps", 30.0)
|
|
|
|
combined_video_paths = []
|
|
video_audio_pairs = list(zip(video_files, audio_files))
|
|
|
|
ffmpeg_exe = ffmpeg.get_ffmpeg_exe()
|
|
if ffmpeg_exe is None:
|
|
raise RuntimeError("FFmpeg could not be found.")
|
|
|
|
for video_file, audio_file in video_audio_pairs:
|
|
video_file = f'{video_file}'
|
|
audio_file = f'{audio_file}'
|
|
|
|
video_out_file_fp = f'{splitext(video_file)[0]}_v_and_a.mp4'
|
|
input_args = ['-i', video_file, '-i', audio_file]
|
|
|
|
if self.has_audio_stream(video_file):
|
|
# The video has an audio stream, proceed with mixing
|
|
filter_complex_arg = '[0:a][1:a]amix=inputs=2:duration=longest[a]'
|
|
output_args = ['-c:v', 'copy', '-map', '0:v:0', '-map', '[a]', '-c:a', 'aac', '-strict', 'experimental',
|
|
'-y', video_out_file_fp]
|
|
else:
|
|
filter_complex_arg = ''
|
|
output_args = ['-c:v', 'copy', '-map', '0:v:0', '-map', '1:a:0', '-c:a', 'aac', '-strict',
|
|
'experimental', '-y', video_out_file_fp]
|
|
|
|
if resample == "video":
|
|
video_duration = self.get_duration(video_file)
|
|
audio_duration = self.get_duration(audio_file)
|
|
|
|
if video_duration and audio_duration:
|
|
speed = audio_duration / video_duration
|
|
filter_complex_arg = f'[0:v]setpts={speed}*PTS[v];[v][1:a]concat=n=1:v=1:a=1[v][a]'
|
|
output_args = ['-map', '[v]', '-map', '[a]', '-c:v', 'libx264', '-c:a', 'aac', '-strict',
|
|
'experimental', '-y', video_out_file_fp]
|
|
else:
|
|
raise RuntimeError("Could not determine duration of video or audio file.")
|
|
elif resample == "None":
|
|
output_args.extend(['-r', str(fps)])
|
|
elif resample == "audio":
|
|
# Not implemented: Finding silence in audio and removing it
|
|
raise NotImplementedError(
|
|
"Resampling audio is not implemented. Consider finding silence in the audio and removing it.")
|
|
|
|
try:
|
|
# try to run it with the list of parts, using the list
|
|
if filter_complex_arg:
|
|
commands_to_run = [ffmpeg_exe, *input_args, '-filter_complex', filter_complex_arg, *output_args]
|
|
else:
|
|
commands_to_run = [ffmpeg_exe, *input_args, *output_args]
|
|
|
|
commands_to_run = [str(c).replace("\"", "") for c in commands_to_run]
|
|
subprocess.run(commands_to_run, check=True)
|
|
except subprocess.CalledProcessError as e:
|
|
print(f"An error occurred: {e}")
|
|
|
|
if move_to_folder:
|
|
destination_path = join(move_to_folder, os.path.basename(video_out_file_fp))
|
|
|
|
if video_out_file_fp != destination_path:
|
|
shutil.move(video_out_file_fp, destination_path)
|
|
|
|
combined_video_paths.append(destination_path)
|
|
else:
|
|
combined_video_paths.append(video_out_file_fp)
|
|
|
|
os.chdir("..")
|
|
os.chdir("..")
|
|
os.chdir("..")
|
|
|
|
return (combined_video_paths,)
|
|
|
|
|
|
class CombineFoldersWithFramesToVideoFiles(metaclass=ABCWidgetMetaclass):
|
|
"""Combines lists of folders containing frames of individual video files using imageio and ffmpeg to create video files"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"folders_with_frames": ("LIST", {"default": []}),
|
|
"fps/length": ("FLOAT", {"default": 30, "min": 1, "max": 10000000000}),
|
|
"bitrate": ("STRING", {"default": "20000k"}),
|
|
"codec": (
|
|
["libx264", "libx265", "mpeg4", "vp9", "huffyuv", "flv1", "h264_nvenc", "hevc_nvenc", "rawvideo"],
|
|
{"default": "libx264"}
|
|
),
|
|
"file_extension": (
|
|
[".mp4", ".mov", ".avi", ".flv", ".mkv", ".webm"],
|
|
{"default": ".mp4"}
|
|
),
|
|
"fps or length": (["fps", "length"], {"default": "fps"}),
|
|
},
|
|
"optional": {
|
|
"[duration or fps]": ("LIST", {"default": []}),
|
|
"q": ("INT", {"default": 20, "min": 0, "max": 31}),
|
|
"pix_fmt": ("STRING", {"default": "yuv420p"}),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("LIST", "STRING",)
|
|
RETURN_NAMES = ("combined_video_paths",)
|
|
CATEGORY = "video"
|
|
OUTPUT_NODE = True
|
|
FUNCTION = "handler"
|
|
|
|
def handler(self, **kwargs):
|
|
folders_with_frames = kwargs.get("folders_with_frames", [])
|
|
fps_length_value = kwargs.get("fps/length", 30)
|
|
file_extension = kwargs.get("file_extension", ".mkv")
|
|
fps_or_length = kwargs.get("fps or length", "fps")
|
|
duration_or_fps_list = kwargs.get("[duration or fps]", [])
|
|
codec = kwargs.get("codec", "libx264")
|
|
q = kwargs.get("q", 20)
|
|
pix_fmt = kwargs.get("pix_fmt", "yuv420p")
|
|
|
|
combined_video_paths = []
|
|
messages = []
|
|
|
|
ffmpeg_exe = 'ffmpeg' # Ensure ffmpeg is in your PATH or provide the full path to the ffmpeg executable
|
|
if not shutil.which(ffmpeg_exe):
|
|
raise RuntimeError("FFmpeg could not be found.")
|
|
|
|
for i, folder_path in enumerate(folders_with_frames):
|
|
try:
|
|
output_file = os.path.join(os.path.dirname(folder_path),
|
|
f"{os.path.basename(folder_path)}tmp_{i}{file_extension}")
|
|
if fps_or_length == 'fps':
|
|
fps = fps_length_value
|
|
if duration_or_fps_list:
|
|
fps = duration_or_fps_list[i]
|
|
else:
|
|
frame_files = sorted(
|
|
[f for f in os.listdir(folder_path) if f.lower().endswith(('.jpg', '.jpeg', '.png'))]
|
|
)
|
|
total_frames = len(frame_files)
|
|
fps = total_frames / fps_length_value if fps_length_value != 0 else 30
|
|
if duration_or_fps_list:
|
|
fps = total_frames / duration_or_fps_list[i]
|
|
|
|
create_video_from_images(folder_path, output_file, fps, codec, q, pix_fmt)
|
|
combined_video_paths.append(output_file)
|
|
except Exception as e:
|
|
messages.append(f"Error processing folder {folder_path}: {str(e)}")
|
|
|
|
return (combined_video_paths, "\n".join(messages),)
|
|
|
|
|
|
def create_file_list(folder_path):
|
|
frame_files = sorted(
|
|
[f for f in os.listdir(folder_path) if f.lower().endswith(('.jpg', '.jpeg', '.png'))]
|
|
)
|
|
list_file_path = os.path.join(folder_path, 'filelist.txt')
|
|
with open(list_file_path, 'w') as f:
|
|
for frame_file in frame_files:
|
|
f.write(f"file '{os.path.join(folder_path, frame_file)}'\n")
|
|
return list_file_path
|
|
|
|
|
|
import subprocess
|
|
|
|
|
|
def create_video_from_images(folder_path, output_file, fps, codec, q, pix_fmt):
|
|
list_file_path = create_file_list(folder_path)
|
|
ffmpeg_exe = 'ffmpeg' # Ensure ffmpeg is in your PATH or provide the full path to the ffmpeg executable
|
|
|
|
command = [
|
|
ffmpeg_exe, '-y', '-f', 'concat', '-safe', '0', '-i', list_file_path,
|
|
'-vf', f"fps={fps}", '-c:v', codec, '-pix_fmt', pix_fmt, "-qp", str(q), output_file
|
|
]
|
|
|
|
try:
|
|
subprocess.run(command, check=True)
|
|
except subprocess.CalledProcessError as e:
|
|
raise RuntimeError(f"ffmpeg failed with error: {e.stderr.decode()}")
|
|
|
|
|
|
class ImageStackToVideoFile(metaclass=ABCWidgetMetaclass):
|
|
"""Really just saves the image stack to a video file"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(self):
|
|
return {"required":
|
|
{
|
|
"image_stack": ("IMAGE",),
|
|
"video_out": ("STRING", {"multiline": False, "default": "video01.mp4"}),
|
|
"fps or length": (["fps", "length"], {"default": "fps"}),
|
|
"fps/length": ("FLOAT", {"default": 30, "min": 1, "max": 10000000000}),
|
|
"bitrate": ("STRING", {"default": "20000k"}),
|
|
"codec": (
|
|
["libx264", "libx265", "mpeg4", "libvpx-vp9", "huffyuv", "flv1"],
|
|
{"default": "libx264"}
|
|
),
|
|
},
|
|
"optional":
|
|
{
|
|
"audio_file": ("STRING", {"default": "NONE"}),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("FUNC", "STRING",)
|
|
RETURN_NAMES = ("FUNC", "video_out_path",)
|
|
CATEGORY = "video"
|
|
OUTPUT_NODE = True
|
|
FUNCTION = "handler"
|
|
|
|
def handler(self, **kwargs):
|
|
import os
|
|
import numpy as np
|
|
import uuid
|
|
import shutil
|
|
import imageio
|
|
import audioread
|
|
import cv2
|
|
import torchvision
|
|
|
|
use_torchvision = False
|
|
|
|
# video_dir = kwargs["video_dir"]
|
|
|
|
audio_file = kwargs.get("audio_file")
|
|
if audio_file == "NONE":
|
|
audio_file = None
|
|
|
|
image_stack = kwargs["image_stack"]
|
|
|
|
fps_or_length = kwargs["fps or length"]
|
|
fps_length_value = kwargs["fps/length"]
|
|
bitrate = kwargs["bitrate"]
|
|
codec = kwargs["codec"]
|
|
video_out = kwargs["video_out"]
|
|
|
|
if fps_or_length == "fps":
|
|
fps_length_value = np.array([fps_length_value], dtype=np.float16)[0]
|
|
|
|
video_out_path = video_out
|
|
|
|
if os.path.exists(video_out_path):
|
|
random_folder = os.path.join(os.path.dirname(video_out_path), str(uuid.uuid4()))
|
|
os.makedirs(random_folder)
|
|
shutil.move(video_out_path, random_folder)
|
|
|
|
if audio_file is not None:
|
|
audio_file = audio_file
|
|
if not os.path.exists(audio_file):
|
|
# raise Exception(f"Audio file {audio_file} does not exist")
|
|
audio_length = 0
|
|
else:
|
|
with audioread.audio_open(audio_file) as audio_info:
|
|
audio_length = audio_info.duration
|
|
|
|
frames_length = image_stack.shape[0]
|
|
if fps_or_length == "fps":
|
|
video_length = frames_length / fps_length_value
|
|
else:
|
|
video_length = fps_length_value
|
|
|
|
if fps_or_length == "fps":
|
|
video_fps = fps_length_value
|
|
else:
|
|
video_fps = frames_length / fps_length_value
|
|
|
|
if use_torchvision == False:
|
|
writer = imageio.get_writer(video_out_path, fps=float(video_fps))
|
|
|
|
# convert the image stack to uint8
|
|
image_stack = (image_stack * 255).to(torch.uint8)
|
|
|
|
for frame_data in image_stack:
|
|
frame = np.array(frame_data) # Convert torch tensor to numpy array if needed
|
|
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
|
writer.append_data(frame)
|
|
|
|
if audio_file is not None:
|
|
difference = video_length - audio_length
|
|
|
|
needed_extra_frames = int(difference * video_fps)
|
|
|
|
if needed_extra_frames > 0:
|
|
for i in range(needed_extra_frames):
|
|
writer.append_data(frame)
|
|
|
|
writer.close()
|
|
|
|
args = [
|
|
"-i", video_out_path,
|
|
"-i", audio_file,
|
|
"-c:v", codec,
|
|
"-b:v", str(bitrate),
|
|
"-c:a", "aac",
|
|
"-strict", "experimental",
|
|
"-y",
|
|
video_out_path.replace(".mp4", "_with_audio.mp4")
|
|
]
|
|
run_ffmpeg_command(args)
|
|
else:
|
|
writer.close()
|
|
|
|
if use_torchvision:
|
|
audio_tensor = None
|
|
if audio_file is not None:
|
|
audio_tensor, aud_sr = torchaudio.load(audio_file)
|
|
fps_frac = fractions.Fraction(float(video_fps))
|
|
torchvision.io.write_video(filename=video_out_path,
|
|
video_array=image_stack * 255,
|
|
fps=fps_frac,
|
|
audio_array=audio_tensor,
|
|
video_codec=codec,
|
|
audio_codec="aac",
|
|
audio_fps=aud_sr
|
|
)
|
|
|
|
return (self, video_out_path,)
|
|
|
|
|
|
class CreateConsistentVideo(metaclass=ABCWidgetMetaclass):
|
|
"""Creates a single video from a folder of videos with a consistent rate of change"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"video_folder": ("STRING", {"default": ""}),
|
|
"rate_of_change_target": ("FLOAT", {"default": 0.5}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("STRING", "LIST", "STRING",)
|
|
RETURN_NAMES = ("joined_video_path", "individual_video_paths", "error_messages")
|
|
CATEGORY = "video"
|
|
OUTPUT_NODE = True
|
|
FUNCTION = "handler"
|
|
|
|
def handler(self, **kwargs):
|
|
import asyncio
|
|
import os
|
|
|
|
video_folder = kwargs["video_folder"]
|
|
rate_of_change_target = kwargs["rate_of_change_target"]
|
|
|
|
individual_video_paths = []
|
|
error_messages = []
|
|
|
|
async def process_video(file_path):
|
|
try:
|
|
# Calculate visual differences
|
|
difference = calculate_visual_difference(file_path)
|
|
|
|
# Adjust frame rate based on the rate of change target
|
|
adjusted_video_path = adjust_frame_rate(
|
|
video_path=file_path,
|
|
target_rate_of_change=rate_of_change_target,
|
|
visual_difference=difference
|
|
|
|
)
|
|
|
|
return adjusted_video_path
|
|
|
|
except Exception as e:
|
|
error_messages.append(str(e))
|
|
return None
|
|
|
|
# Gather video files
|
|
|
|
video_files = [f for f in os.listdir(video_folder) if
|
|
f.lower().endswith(('.mp4', '.mov', '.avi', '.flv', '.mkv', '.webm'))]
|
|
# now get their full path
|
|
video_files = [os.path.join(video_folder, f) for f in video_files]
|
|
|
|
# Use Asyncio to process videos in parallel
|
|
# There is no current loop so we need to create a NEW one
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
processed_videos = loop.run_until_complete(asyncio.gather(
|
|
*[process_video(file_path) for file_path in video_files]
|
|
))
|
|
|
|
# Combine processed videos into the final video
|
|
joined_video_path = combine_videos(processed_videos, video_folder, video_folder)
|
|
|
|
return (joined_video_path, individual_video_paths, "\n".join(error_messages),)
|
|
|
|
|
|
def calculate_visual_difference(video_path):
|
|
# Open the video file
|
|
cap = cv2.VideoCapture(video_path)
|
|
if not cap.isOpened():
|
|
raise Exception("Could not open the video file")
|
|
|
|
# Initialize variables to hold previous frame
|
|
prev_frame_tensor = None
|
|
total_difference = 0
|
|
|
|
# Iterate through frames
|
|
while True:
|
|
ret, frame = cap.read()
|
|
if not ret:
|
|
break
|
|
|
|
# Convert frame to a PyTorch tensor and normalize
|
|
frame_tensor = torch.from_numpy(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)).permute(2, 0, 1).float() / 255
|
|
|
|
# If previous frame exists, calculate the difference using SSIM
|
|
if prev_frame_tensor is not None:
|
|
difference = get_img_diff_torch(prev_frame_tensor, frame_tensor)
|
|
total_difference += difference
|
|
|
|
# Update previous frame
|
|
prev_frame_tensor = frame_tensor
|
|
|
|
# Close the video file
|
|
cap.release()
|
|
|
|
return total_difference
|
|
|
|
|
|
def get_img_diff_torch(image1: torch.Tensor, image2: torch.Tensor, K1=0.01, K2=0.03, L=255):
|
|
# Convert images to grayscale using weighted average of color channels
|
|
gray1 = 0.2989 * image1[0] + 0.5870 * image1[1] + 0.1140 * image1[2]
|
|
gray2 = 0.2989 * image2[0] + 0.5870 * image2[1] + 0.1140 * image2[2]
|
|
|
|
# Calculate mean and variance of input images
|
|
mu1 = torch.mean(gray1)
|
|
mu2 = torch.mean(gray2)
|
|
var1 = torch.var(gray1)
|
|
var2 = torch.var(gray2)
|
|
covar = np.cov(gray1.cpu().numpy().ravel(), gray2.cpu().numpy().ravel())[0][1]
|
|
|
|
# Set constants
|
|
C1 = (K1 * L) ** 2
|
|
C2 = (K2 * L) ** 2
|
|
|
|
# Calculate SSIM
|
|
numerator = (2 * mu1 * mu2 + C1) * (2 * covar + C2)
|
|
denominator = (mu1 ** 2 + mu2 ** 2 + C1) * (var1 + var2 + C2)
|
|
ssim = numerator / denominator
|
|
|
|
# Return difference score
|
|
return 1 - ssim
|
|
|
|
|
|
def adjust_frame_rate(video_path, target_rate_of_change, visual_difference):
|
|
filename, _ = os.path.splitext(os.path.basename(video_path))
|
|
base_dir = os.path.dirname(video_path)
|
|
output_filename = f"{filename}_adjusted.mp4"
|
|
output_filename = os.path.join(base_dir, output_filename)
|
|
|
|
# use the full path
|
|
|
|
cap = cv2.VideoCapture(video_path)
|
|
if not cap.isOpened():
|
|
raise Exception("Could not open the video file")
|
|
|
|
original_fps = int(cap.get(cv2.CAP_PROP_FPS))
|
|
frame_size = (int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)), int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)))
|
|
|
|
if visual_difference == 0:
|
|
desired_fps = float(original_fps)
|
|
else:
|
|
desired_fps = max(float(original_fps * target_rate_of_change / visual_difference), 1.0)
|
|
|
|
desired_fps = int(desired_fps * 8) / 8
|
|
|
|
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
|
|
|
|
out = cv2.VideoWriter(output_filename, fourcc, desired_fps, frame_size)
|
|
|
|
while True:
|
|
ret, frame = cap.read()
|
|
if not ret:
|
|
break
|
|
out.write(frame)
|
|
|
|
cap.release()
|
|
out.release()
|
|
|
|
return output_filename
|
|
|
|
|
|
def combine_videos(video_paths, base_input_directory, output_path):
|
|
import subprocess
|
|
import tempfile
|
|
|
|
# Create a temporary text file
|
|
with tempfile.NamedTemporaryFile(mode="w+t", delete=False) as temp_file:
|
|
for video_path in video_paths:
|
|
temp_file.write(f"file '{video_path}'\n")
|
|
|
|
# TODO: Check cross-platform compatibility (linux)
|
|
cmd = f"ffmpeg -f concat -safe 0 -i \"{temp_file.name}\" \"{output_path}combined{os.path.splitext(video_paths[0])[1]}\""
|
|
|
|
try:
|
|
subprocess.run(cmd, shell=True, check=True)
|
|
return output_path
|
|
except subprocess.CalledProcessError as e:
|
|
return str(e)
|
|
finally:
|
|
# Clean up the temporary file
|
|
temp_file.close()
|
|
os.remove(temp_file.name)
|
|
|
|
|
|
def transcribe_to_segs(input_filename):
|
|
import whisper as wh
|
|
import folder_paths as fp
|
|
|
|
if not os.sep in input_filename:
|
|
input_dir = fp.input_directory
|
|
full_path = os.path.join(input_dir, input_filename)
|
|
else:
|
|
full_path = input_filename
|
|
# check that the file exists
|
|
|
|
if not os.path.exists(full_path):
|
|
raise ValueError(f"File {full_path} does not exist.")
|
|
# check that it is a file
|
|
if not os.path.isfile(full_path):
|
|
raise ValueError(f"{full_path} is not a file.")
|
|
|
|
model = wh.load_model("medium.en")
|
|
|
|
res = wh.transcribe(model=model, audio=full_path, word_timestamps=True)
|
|
segs = res["segments"]
|
|
|
|
return segs
|
|
|
|
|
|
class WhisperTranscription(metaclass=ABCWidgetMetaclass):
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {"required": {"input_filename": ("STRING", {"default": "fullpath"})}}
|
|
|
|
RETURN_TYPES = ("STRING", "STRING",)
|
|
RETURN_NAMES = ("lines_out", "words_out",)
|
|
FUNCTION = "transcribe_movie"
|
|
CATEGORY = "transcription"
|
|
|
|
def transcribe_movie(self, input_filename):
|
|
|
|
full_path = os.path.abspath(input_filename)
|
|
|
|
try:
|
|
segs = transcribe_to_segs(full_path)
|
|
|
|
lines_out = []
|
|
for seg in segs:
|
|
lines_out.append(f"{seg['start']:3.3f} - {seg['end']:3.3f}: {seg['text']}")
|
|
|
|
lines_out = '\n'.join(lines_out)
|
|
|
|
words_out = []
|
|
for seg in segs:
|
|
words_out.append([])
|
|
for word in seg['words']:
|
|
line = f"{word['start']:3.3f} - {word['end']:3.3f}: {word['word']}"
|
|
words_out[-1].append(line)
|
|
|
|
words_out = '\n'.join(['\n'.join(x) for x in words_out])
|
|
|
|
return (lines_out, words_out,)
|
|
|
|
except Exception as e:
|
|
print(e)
|
|
return ("Error", str(e))
|
|
|
|
|
|
class WhisperTranscribeToFrameIDXandWords(metaclass=ABCWidgetMetaclass):
|
|
"""Just like WhisperTranscription, but returns a list of frame numbers and words instead of a string."""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
# needs full path to video file
|
|
# needs fps for conversion
|
|
ret = {"required": {"input_filename": ("STRING", {"default": "fullpath"}),
|
|
"fps": ("INT", {"default": 30})}}
|
|
return ret
|
|
|
|
RETURN_TYPES = ("LIST", "LIST", "LIST",)
|
|
RETURN_NAMES = ("frame_idx", "words_out", "combined",)
|
|
FUNCTION = "transcribe_movie"
|
|
CATEGORY = "transcription"
|
|
|
|
def transcribe_movie(self, input_filename, fps):
|
|
import folder_paths as fp
|
|
input_dir = fp.input_directory
|
|
full_path = os.path.join(input_dir, input_filename)
|
|
try:
|
|
segs = transcribe_to_segs(full_path)
|
|
frame_idx = []
|
|
words_out = []
|
|
combined = []
|
|
for seg in segs:
|
|
for word in seg['words']:
|
|
start_frame = int(word['start'] * fps)
|
|
end_frame = int(word['end'] * fps)
|
|
word_text = word['word']
|
|
|
|
frame_idx.append((start_frame, end_frame))
|
|
words_out.append(word_text)
|
|
combined.append((start_frame, end_frame, word_text))
|
|
|
|
# now we fill in the gaps so the entire video is covered by some word
|
|
# we do this by finding if there is a gap then changing the frame stamps of the last word and the next word
|
|
# to cover the gap
|
|
for i in range(len(frame_idx) - 1):
|
|
if frame_idx[i][1] < frame_idx[i + 1][0]:
|
|
# there is a gap
|
|
gap_len = frame_idx[i + 1][0] - frame_idx[i][1]
|
|
to_previous_word = gap_len // 2
|
|
to_next_word = gap_len - to_previous_word
|
|
|
|
frame_idx[i] = (frame_idx[i][0], frame_idx[i][1] + to_previous_word)
|
|
frame_idx[i + 1] = (frame_idx[i + 1][0] - to_next_word, frame_idx[i + 1][1])
|
|
|
|
combined[i] = (combined[i][0], combined[i][1] + to_previous_word, combined[i][2])
|
|
combined[i + 1] = (combined[i + 1][0] - to_next_word, combined[i + 1][1], combined[i + 1][2])
|
|
|
|
# leave words_out alone
|
|
|
|
return (frame_idx, words_out, combined,)
|
|
except Exception as e:
|
|
print(e)
|
|
return ("Error",)
|
|
|
|
|
|
# @profile
|
|
def get_text_frames(word_idx, words_data, x: callable, y: callable, size: (), text_kwargs: dict, style="default"):
|
|
from .image import TextRender as text_render_class
|
|
text_render = text_render_class().render_text
|
|
|
|
start = words_data[word_idx][0]
|
|
end = words_data[word_idx][1]
|
|
word_text = words_data[word_idx][2]
|
|
frame_length = end - start
|
|
|
|
# if x and y are not callables
|
|
if not callable(x):
|
|
x = lambda i: x
|
|
if not callable(y):
|
|
y = lambda i: y
|
|
|
|
out_frames = []
|
|
for i in range(start, end):
|
|
it_dict = dict()
|
|
it_dict["text"] = word_text
|
|
it_dict["x"] = x(i / float(frame_length))
|
|
it_dict["y"] = y(i / float(frame_length))
|
|
it_dict["width"] = size[0]
|
|
it_dict["height"] = size[1]
|
|
|
|
it_dict.update(text_kwargs)
|
|
# it_dict["size"] = 128
|
|
# it_dict["color"] = "#ffffff"
|
|
# it_dict["func_only"] = False
|
|
# it_dict["stroke fill"] = "#050505"
|
|
# it_dict["stroke width"] = 5
|
|
|
|
text_img = text_render(**it_dict)
|
|
out_frames.append(text_img[0])
|
|
return out_frames
|
|
|
|
|
|
class AddTranscriptionToVideo(metaclass=ABCWidgetMetaclass):
|
|
"""
|
|
Add a transcription to a video. Requires a video and transcription data.
|
|
transcription data must be [(start_frame, end_frame, word), ...]
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
# needs full path to video file
|
|
# needs fps for conversion
|
|
ret = {"required": {"video_file": ("STRING", {"default": "fullpath"}),
|
|
"words": ("LIST", {"default": None}),
|
|
}
|
|
}
|
|
return ret
|
|
|
|
RETURN_TYPES = ("IMAGE", "IMAGE",)
|
|
RETURN_NAMES = ("1 fps preview", "Full Video")
|
|
FUNCTION = "add_transcription_to_video"
|
|
CATEGORY = "transcription"
|
|
|
|
# @profile
|
|
def add_transcription_to_video(self, **kwargs):
|
|
import torch
|
|
import gc
|
|
|
|
video_file = kwargs["video_file"]
|
|
words = kwargs["words"]
|
|
|
|
def create_video_frames_folder(video_file):
|
|
fnc = VideoToFramesFolderFFMPEG().handler
|
|
call_dict = dict()
|
|
call_dict["video_in"] = video_file
|
|
call_dict["folder_out"] = os.path.join(video_file, os.sep, "frames_tmp")
|
|
call_dict["format"] = "PNG"
|
|
|
|
tmp_video_folder = fnc(**call_dict)[0]
|
|
del fnc
|
|
del call_dict
|
|
gc.collect()
|
|
return tmp_video_folder
|
|
|
|
def get_video_frames(start, end):
|
|
fnc = VideoFramesFolderToImageStack().handler
|
|
call_dict = dict()
|
|
call_dict["use_subfolders"] = False
|
|
call_dict["by_date_or_name"] = "NAME"
|
|
call_dict["folder_in"] = os.path.join(video_file, os.sep, "frames_tmp")
|
|
call_dict["idx_slice_start"] = 0
|
|
call_dict["idx_slice_stop"] = end - start
|
|
call_dict["slice_idx_step"] = 1
|
|
call_dict["idx"] = start
|
|
call_dict["use_float16"] = False
|
|
|
|
ret = fnc(**call_dict)[0]
|
|
|
|
return ret
|
|
|
|
def overlay_text_on_video(video_frames, text_frames):
|
|
import torch
|
|
# Create a mask for the text frames (where text is black)
|
|
text_mask = (text_frames == 0) * 1 # Assuming black is represented as 0 in text_frames
|
|
|
|
# Use the ~ operator to invert the boolean mask
|
|
# inverted_mask = text_mask
|
|
|
|
# Overlay text_frames on video_frames using the inverted_mask
|
|
overlaid_frames = video_frames * text_mask + text_frames
|
|
|
|
return overlaid_frames
|
|
|
|
frames_out = []
|
|
num_frames = 0
|
|
frames_folder = create_video_frames_folder(video_file)
|
|
for w_idx in range(len(words)):
|
|
word_start = words[w_idx][0]
|
|
word_end = words[w_idx][1]
|
|
word = words[w_idx][2].strip()
|
|
print(f"word_start: {word_start}, word_end: {word_end}, word: {word}")
|
|
|
|
words_video_frames = get_video_frames(word_start, word_end)
|
|
i_width = words_video_frames.shape[2]
|
|
i_height = words_video_frames.shape[1]
|
|
text_frames = get_text_frames(word_idx=w_idx,
|
|
words_data=words,
|
|
x=lambda i: 0,
|
|
y=lambda i: 0,
|
|
size=(i_width, i_height),
|
|
text_kwargs={"size": 128, "color": "#ffffff", "func_only": False,
|
|
"stroke fill": "#050505", "stroke width": 5,
|
|
"font_name": "c:/Windows/Fonts/arial.ttf"})
|
|
|
|
text_frames = torch.cat(text_frames)
|
|
|
|
# Overlay text on video frames
|
|
video_frames_with_text = overlay_text_on_video(words_video_frames, text_frames)
|
|
frames_out.append(video_frames_with_text)
|
|
num_frames += video_frames_with_text.shape[0]
|
|
gc.collect()
|
|
|
|
# free memory
|
|
del words_video_frames
|
|
del text_frames
|
|
del video_frames_with_text
|
|
|
|
num_stks = len(frames_out)
|
|
# write to disk
|
|
for i, stk in enumerate(frames_out):
|
|
torch.save(stk, f"tmp_str_{i}.pt")
|
|
del frames_out
|
|
gc.collect()
|
|
torch.cuda.empty_cache()
|
|
|
|
# pre allocate tensor
|
|
y = torch.zeros((num_frames, i_height, i_width, 3), dtype=torch.float32, device="cpu")
|
|
# load each save and put into tensor
|
|
new_start = 0
|
|
for i in range(num_stks):
|
|
stk = torch.load(f"tmp_str_{i}.pt")
|
|
y[new_start:new_start + stk.shape[0]] = stk
|
|
new_start = new_start + stk.shape[0]
|
|
del stk
|
|
gc.collect()
|
|
torch.cuda.empty_cache()
|
|
|
|
preview = y[::30].clone()
|
|
|
|
return (preview, y,)
|
|
|
|
|
|
class TransformImageStack(metaclass=ABCWidgetMetaclass):
|
|
"""
|
|
Transform a stack of images
|
|
given x change (in pixels)
|
|
given y change (in pixels)
|
|
|
|
|
|
"""
|
|
|
|
# TODO: support sheer,rotate,scale
|
|
# TODO: support transparancy
|
|
# TODO: support fill color specification
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
ret = {"required": {"ImageStack": ("IMAGE", {"default": None}),
|
|
"pan x": ("INT", {"default": 0, "min": -2048}),
|
|
"pan y": ("INT", {"default": 0, "min": -2048}),
|
|
},
|
|
}
|
|
return ret
|
|
|
|
RETURN_TYPES = ("IMAGE",)
|
|
FUNCTION = "transform_image_stack"
|
|
|
|
def transform_image_stack(self, **kwargs):
|
|
import os
|
|
import torch
|
|
from PIL import Image
|
|
import torchvision.transforms as transforms
|
|
import torchvision.transforms.functional as TF
|
|
|
|
x_image = kwargs["ImageStack"]
|
|
x_image = x_image.detach().clone()
|
|
x = kwargs["pan x"]
|
|
y = kwargs["pan y"]
|
|
|
|
time_index, H, W, C = x_image.shape # Assuming x_image has shape (time_index, H, W, C)
|
|
out_list = []
|
|
|
|
stop_x = x
|
|
stop_y = y
|
|
dx_step = stop_x / time_index - 1
|
|
dy_step = stop_y / time_index - 1
|
|
|
|
for t in range(time_index):
|
|
tx = dx_step * t
|
|
ty = dy_step * t
|
|
|
|
current_frame = x_image[t].detach().clone()
|
|
# Change from (H, W, C) to (C, H, W)
|
|
current_frame = current_frame.permute(2, 0, 1)
|
|
|
|
transformed_frame = TF.affine(current_frame, angle=0, translate=(tx, ty), scale=1,
|
|
shear=[0, 0]).detach().clone()
|
|
|
|
# Convert back to (H, W, C)
|
|
transformed_frame = transformed_frame.permute(1, 2, 0)
|
|
|
|
# Store in the list
|
|
x_image[t] = transformed_frame
|
|
|
|
return (x_image.detach().clone(),)
|
|
|
|
|
|
class PanByStep(metaclass=ABCWidgetMetaclass):
|
|
"""
|
|
Transform a stack of images
|
|
given x change (in pixels)
|
|
given y change (in pixels)
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
ret = {"required": {"ImageStack": ("IMAGE", {"default": None}),
|
|
"pan x": ("INT", {"default": 0, "min": -2048}),
|
|
"pan y": ("INT", {"default": 0, "min": -2048}),
|
|
"wrap": ("BOOLEAN", {"default": False}),
|
|
},
|
|
}
|
|
return ret
|
|
|
|
RETURN_TYPES = ("IMAGE",)
|
|
FUNCTION = "pan_by_step"
|
|
|
|
def pan_by_step(self, **kwargs):
|
|
import os
|
|
import torch
|
|
from PIL import Image
|
|
import torchvision.transforms as transforms
|
|
import torchvision.transforms.functional as TF
|
|
|
|
x_image = kwargs["ImageStack"]
|
|
x_image = x_image.detach().clone()
|
|
x = kwargs["pan x"]
|
|
y = kwargs["pan y"]
|
|
|
|
wrap = kwargs["wrap"]
|
|
|
|
time_index, H, W, C = x_image.shape # Assuming x_image has shape (time_index, H, W, C)
|
|
out_list = []
|
|
|
|
stop_x = x
|
|
stop_y = y
|
|
dx_step = 0 if x == 0 else stop_x / time_index - 1
|
|
dy_step = stop_y / time_index - 1
|
|
|
|
for t in range(time_index):
|
|
tx = dx_step * t
|
|
ty = dy_step * t
|
|
|
|
current_frame = x_image[t].detach().clone()
|
|
# Change from (H, W, C) to (C, H, W)
|
|
current_frame = current_frame.permute(2, 0, 1)
|
|
|
|
# transformed_frame = TF.affine(current_frame, angle=0, translate=(tx, ty), scale=1,
|
|
# shear=[0, 0]).detach().clone()
|
|
|
|
# If wrap is True, apply the wrapping operation
|
|
if wrap:
|
|
transformed_frame = current_frame.roll(
|
|
(int(tx) % W, int(ty) % H), dims=(2, 1)
|
|
)
|
|
else:
|
|
transformed_frame = TF.affine(current_frame, angle=0, translate=(tx, ty), scale=1,
|
|
shear=[0, 0]).detach().clone()
|
|
|
|
# Convert back to (H, W, C)
|
|
transformed_frame = transformed_frame.permute(1, 2, 0)
|
|
|
|
# Store in the list
|
|
x_image[t] = transformed_frame
|
|
|
|
return (x_image.detach().clone(),)
|
|
|
|
|
|
class VideoFolderProvider(metaclass=ABCWidgetMetaclass):
|
|
"""
|
|
given a folder path, creates the folder or empties it entirely returns the folder path as
|
|
a string but as the node type "VIDEOFOLDER"
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
ret = {
|
|
"required": {
|
|
"folder_path": ("STRING", {"default": ""}),
|
|
"clear_folder": ("BOOLEAN", {"default": True}),
|
|
},
|
|
"optional": {
|
|
"activator:": ("*", {"default": None})
|
|
}
|
|
}
|
|
|
|
return ret
|
|
|
|
RETURN_TYPES = ("VIDEOFOLDER",)
|
|
FUNCTION = "video_folder_provider"
|
|
|
|
def video_folder_provider(self, **kwargs):
|
|
import shutil
|
|
import os
|
|
# check if the folder_path is none
|
|
if kwargs["folder_path"] is None:
|
|
raise ValueError("folder_path must be specified")
|
|
|
|
# set folder exists flag by using the os.path.exists function
|
|
folder_exists = os.path.exists(kwargs["folder_path"])
|
|
|
|
if not folder_exists:
|
|
os.makedirs(kwargs["folder_path"])
|
|
folder_exists = True
|
|
|
|
# set files in folder flag by using the os.listdir function
|
|
files_in_folder = os.listdir(kwargs["folder_path"])
|
|
|
|
# set accessible flag by using the os.access function
|
|
accessible = os.access(kwargs["folder_path"], os.W_OK)
|
|
if not accessible:
|
|
raise ValueError("Folder for video files is not accessible")
|
|
|
|
if kwargs["clear_folder"]:
|
|
# if the folder exists, delete all files in the folder
|
|
if folder_exists:
|
|
for file in files_in_folder:
|
|
# use shutil to remove the file
|
|
fl = os.path.join(kwargs["folder_path"], file)
|
|
if os.path.isfile(fl):
|
|
os.remove(fl)
|
|
# use shutil to remove the entire path
|
|
check_this_path = file
|
|
if len(check_this_path) > 3:
|
|
shutil.rmtree(check_this_path, ignore_errors=True)
|
|
else:
|
|
raise ValueError("Folder path is too short")
|
|
|
|
# if the folder does not exist, create the folder
|
|
|
|
return (kwargs["folder_path"],)
|
|
|
|
|
|
class AudioFolderProvider(metaclass=ABCWidgetMetaclass):
|
|
"""
|
|
given a folder path, creates the folder or empties it entirely returns the folder path as
|
|
a string but as the node type "AUDIOFOLDER"
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
ret = {
|
|
"required": {
|
|
"folder_path": ("STRING", {"default": ""}),
|
|
"clear_folder": ("BOOLEAN", {"default": True}),
|
|
},
|
|
"optional": {
|
|
"activator:": ("*", {"default": None})
|
|
}
|
|
}
|
|
|
|
return ret
|
|
|
|
RETURN_TYPES = ("AUDIOFOLDER",)
|
|
FUNCTION = "audio_folder_provider"
|
|
|
|
def audio_folder_provider(self, **kwargs):
|
|
import shutil
|
|
import os
|
|
# check if the folder_path is none
|
|
if kwargs["folder_path"] is None:
|
|
raise ValueError("folder_path must be specified")
|
|
|
|
# set folder exists flag by using the os.path.exists function
|
|
folder_exists = os.path.exists(kwargs["folder_path"])
|
|
|
|
if not folder_exists:
|
|
os.makedirs(kwargs["folder_path"])
|
|
folder_exists = True
|
|
|
|
# set files in folder flag by using the os.listdir function
|
|
files_in_folder = os.listdir(kwargs["folder_path"])
|
|
|
|
# set accessible flag by using the os.access function
|
|
accessible = os.access(kwargs["folder_path"], os.W_OK)
|
|
if not accessible:
|
|
raise ValueError("Folder for audio files is not accessible")
|
|
|
|
if kwargs["clear_folder"]:
|
|
# if the folder exists, delete all files in the folder
|
|
if folder_exists:
|
|
for file in files_in_folder:
|
|
# use shutil to remove the file
|
|
fl = os.path.join(kwargs["folder_path"], file)
|
|
if os.path.isfile(fl):
|
|
os.remove(fl)
|
|
|
|
return (kwargs["folder_path"],)
|
|
|
|
|
|
class VideoDefinitionProvider(metaclass=ABCWidgetMetaclass):
|
|
"""
|
|
given a VIDEOFOLDER node, and inputs for height, width, and fps, creates a VIDEOFOLDERDEF variable
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
ret = {"required": {"video_folder": ("VIDEOFOLDER", {"default": None}),
|
|
"height": ("INT", {"default": 1080}),
|
|
"width": ("INT", {"default": 1920}),
|
|
"fps": ("FLOAT", {"default": 30}),
|
|
"frame format": (["JPG", "PNG"], {"default": "JPG"}),
|
|
},
|
|
}
|
|
return ret
|
|
|
|
RETURN_TYPES = ("VIDEO_FOLDER_DEF",)
|
|
FUNCTION = "video_definition_provider"
|
|
|
|
def video_definition_provider(self, **kwargs):
|
|
from addict import Dict as adict
|
|
ret = adict()
|
|
ret["video_folder"] = kwargs["video_folder"]
|
|
ret["height"] = kwargs["height"]
|
|
ret["width"] = kwargs["width"]
|
|
ret["fps"] = kwargs["fps"]
|
|
ret["frame format"] = kwargs["frame format"]
|
|
# a function that gets the frame count from the disk
|
|
ret.get_frame_count = lambda: len([f for f in os.listdir(ret["video_folder"]) if "frame_" in f])
|
|
# a function that gets the next file name and full path dont forget to add 10 zeros to the frame count
|
|
ret.get_next_file_name = lambda x=0: os.path.join(ret['video_folder'],
|
|
f"frame_{str(ret.get_frame_count() + x).zfill(10)}.{ret['frame format'].lower()}")
|
|
ret.get_last_file_name = lambda offset=0: os.path.join(ret['video_folder'],
|
|
f"frame_{str(max(ret.get_frame_count() - offset - 1, 0)).zfill(10)}.{ret['frame format'].lower()}")
|
|
ret.get_current_file_name = lambda: os.path.join(ret['video_folder'],
|
|
f"frame_{str(ret.get_frame_count() - 1).zfill(10)}.{ret['frame format'].lower()}")
|
|
|
|
def reserve_next_frame():
|
|
fn = ret.get_next_file_name()
|
|
open(fn, "w").close()
|
|
return fn
|
|
|
|
ret.reserve_next_frame = reserve_next_frame
|
|
|
|
return (ret,)
|
|
|
|
|
|
class AudioDefinitionProvider(metaclass=ABCWidgetMetaclass):
|
|
"""
|
|
given a AUDIOFOLDER node, and inputs for sample rate, and channels, creates a AUDIOFOLDERDEF variable
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
ret = {"required": {"audio_folder": ("AUDIOFOLDER", {"default": None}),
|
|
"sample rate": ("INT", {"default": 44100}),
|
|
"channels": ("INT", {"default": 2}),
|
|
"audio format": (["WAV", "MP3"], {"default": "WAV"}),
|
|
},
|
|
}
|
|
return ret
|
|
|
|
RETURN_TYPES = ("AUDIO_FOLDER_DEF",)
|
|
FUNCTION = "audio_definition_provider"
|
|
|
|
def get_num_audio_files_that_match(self, audio_folder):
|
|
audio_files = self.get_all_audio_files(audio_folder)
|
|
return len(audio_files)
|
|
|
|
def get_all_audio_files(self, audio_folder):
|
|
import os
|
|
audio_files = os.listdir(audio_folder)
|
|
audio_files = [os.path.join(audio_folder, x) for x in audio_files if "audio_" in x]
|
|
return audio_files
|
|
|
|
def audio_definition_provider(self, **kwargs):
|
|
from addict import Dict as adict
|
|
ret = adict()
|
|
ret["audio_folder"] = kwargs["audio_folder"]
|
|
ret["sample rate"] = kwargs["sample rate"]
|
|
ret["channels"] = kwargs["channels"]
|
|
ret["audio format"] = kwargs["audio format"]
|
|
# a function that gets the frame count from the disk
|
|
ret.get_file_count = lambda: self.get_num_audio_files_that_match(ret["audio_folder"])
|
|
# a function that gets the next file name and full path dont forget to zfill
|
|
ret.get_next_file_name = lambda: f"{ret['audio_folder']}{os.sep}audio_{str(ret.get_file_count()).zfill(10)}.{ret['audio format'].lower()}"
|
|
ret.get_last_file_name = lambda: f"{ret['audio_folder']}{os.sep}audio_{str(ret.get_file_count() - 1).zfill(10)}.{ret['audio format'].lower()}"
|
|
ret.get_all_audio_files = lambda: self.get_all_audio_files(ret["audio_folder"])
|
|
|
|
return (ret,)
|
|
|
|
|
|
class AddAudioToAudioFolder(metaclass=ABCWidgetMetaclass):
|
|
"""
|
|
given a AUDIO_FOLDER_DEF node, and a list of audio files, adds the audio files to the audio folder
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
ret = {"required": {"audio_folder_def": ("AUDIO_FOLDER_DEF", {"default": None}),
|
|
"AUDIO": ("LIST", {"default": None}),
|
|
"delete after": ("BOOLEAN", {"default": False}),
|
|
},
|
|
}
|
|
return ret
|
|
|
|
RETURN_TYPES = ("AUDIO_FOLDER_DEF",)
|
|
FUNCTION = "add_audio_to_audio_folder"
|
|
|
|
def add_audio_to_audio_folder(self, **kwargs):
|
|
# check each file in the list to make sure we can convert it to the correct format
|
|
import shutil
|
|
import os
|
|
from subprocess import run
|
|
|
|
ffmpeg_exe = ffmpeg.get_ffmpeg_exe()
|
|
|
|
audio_folder_def = kwargs["audio_folder_def"]
|
|
audio_files = kwargs["AUDIO"]
|
|
|
|
delete = kwargs["delete after"]
|
|
|
|
for audio_file in audio_files:
|
|
# use ffmpeg_exe to convert the audio file to the correct format which is audio_folder_def["audio format"]
|
|
# use the audio_folder_def["audio format"] to determine the output file name
|
|
# use the audio_folder_def["audio folder"] to determine the output folder
|
|
# use the audio_folder_def["sample rate"] to determine the sample rate
|
|
# use the audio_folder_def["channels"] to determine the number of channels
|
|
|
|
# get the output file name
|
|
output_file_name = audio_folder_def.get_next_file_name()
|
|
input_file_name = audio_file
|
|
|
|
CMD = f"{ffmpeg_exe} -y -i {input_file_name} -ar {audio_folder_def['sample rate']} -ac {audio_folder_def['channels']} '{output_file_name}'"
|
|
CMD_AS_LIST = [ffmpeg_exe, "-y", "-i", input_file_name, "-ar", str(audio_folder_def['sample rate']), "-ac",
|
|
str(audio_folder_def['channels']), output_file_name]
|
|
|
|
run(CMD_AS_LIST)
|
|
if delete:
|
|
os.remove(input_file_name)
|
|
|
|
return (audio_folder_def,)
|
|
|
|
|
|
class AddSilenceToAudioFolder(metaclass=ABCWidgetMetaclass):
|
|
"""create and add a new audio file that is silence for the specified duration"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
ret = {"required": {"audio_folder_def": ("AUDIO_FOLDER_DEF", {"default": None}),
|
|
"duration": ("FLOAT", {"default": 1.0}),
|
|
},
|
|
}
|
|
return ret
|
|
|
|
RETURN_TYPES = ("AUDIO_FOLDER_DEF",)
|
|
FUNCTION = "add_silence_to_audio_folder"
|
|
|
|
def add_silence_to_audio_folder(self, **kwargs):
|
|
# this is to be multi-platform
|
|
import shutil
|
|
import os
|
|
|
|
audio_folder_def = kwargs["audio_folder_def"]
|
|
duration = kwargs["duration"]
|
|
|
|
# get the output file name
|
|
output_file_name = audio_folder_def.get_next_file_name()
|
|
sample_rate = audio_folder_def["sample rate"]
|
|
channels = audio_folder_def["channels"]
|
|
fmt = audio_folder_def["audio format"]
|
|
|
|
# create the silence audio file
|
|
|
|
if fmt == "WAV":
|
|
fmt_part = "pcm_s16le"
|
|
elif fmt == "MP3":
|
|
fmt_part = "mp3"
|
|
else:
|
|
fmt_part = "pcm_s16le"
|
|
|
|
ffmpeg_exe = ffmpeg.get_ffmpeg_exe()
|
|
# "-i anullsrc" is the source of the silence this will work on all platforms
|
|
# "-c:a" is the codec for the audio file
|
|
# "-f" is the format of the audio file, "lavfi" is equivalent to "ffmpeg filter"
|
|
# "-i anullsrc=r=44100:cl=2:d=1" is the source of the silence
|
|
|
|
# CMD = f"{ffmpeg_exe} -f lavfi -i aevalsrc=0:d={duration}:cl={channels}:s={sample_rate} -c:a {fmt_part} {output_file_name}"
|
|
# CMD = f"{ffmpeg_exe} -f lavfi -i anullsrc=channel_layout={channels}:sample_rate={sample_rate}:duration={duration} -c:a {fmt_part} {output_file_name}"
|
|
CMD = f"{ffmpeg_exe} -f lavfi -i aevalsrc=0:sample_rate={sample_rate}:channel_layout={channels},atrim=0:{duration} -c:a {fmt_part} {output_file_name}"
|
|
# now we call it in a way to that we will know if there is an error
|
|
|
|
import subprocess
|
|
# the plan is to call the command and wait for it to finish
|
|
process = subprocess.Popen(CMD, shell=True, stderr=subprocess.PIPE)
|
|
process.wait()
|
|
# check if the process was successful
|
|
if process.returncode != 0:
|
|
# if the process was not successful raise an exception
|
|
raise Exception(f"ffmpeg failed with error code {process.returncode}")
|
|
|
|
return (audio_folder_def,)
|
|
|
|
|
|
class GetAudioFilesLengthsFromAudioFolder(metaclass=ABCWidgetMetaclass):
|
|
"""Get the lengths of all audio files in a given audio folder"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {"required": {"audio_folder_def": ("AUDIO_FOLDER_DEF", {"default": None})}}
|
|
|
|
RETURN_TYPES = ("AUDIO_FOLDER_DEF", "LIST",)
|
|
RETURN_NAMES = ("audio_folder_def", "[lens(seconds)]",)
|
|
FUNCTION = "get_audio_files_lengths"
|
|
|
|
def get_audio_files_lengths(self, audio_folder_def):
|
|
import os
|
|
import torchaudio
|
|
|
|
audio_folder = audio_folder_def["audio_folder"]
|
|
audio_files = os.listdir(audio_folder)
|
|
audio_files_lengths = []
|
|
|
|
for audio_file in audio_files:
|
|
if audio_file[-3:] not in ["wav", "mp3"]:
|
|
continue
|
|
audio_path = os.path.join(audio_folder, audio_file)
|
|
waveform, sample_rate = torchaudio.load(audio_path)
|
|
duration = waveform.shape[1] / sample_rate
|
|
audio_files_lengths.append(duration)
|
|
|
|
return (audio_folder_def, audio_files_lengths,)
|
|
|
|
|
|
class AudioFolderRenderAudio(metaclass=ABCWidgetMetaclass):
|
|
""" Joins all the audio files """
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"audio_folder_def": ("AUDIO_FOLDER_DEF", {"default": None}),
|
|
"output_path": ("STRING", {"multiline": False, "default": ""}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("STRING",)
|
|
RETURN_NAMES = ("output_path",)
|
|
FUNCTION = 'join_audio_files'
|
|
CATEGORY = 'ETK/audio'
|
|
|
|
def join_audio_files(self, **kwargs):
|
|
import librosa
|
|
import copy
|
|
import soundfile as sf
|
|
|
|
audio_folder_def = kwargs['audio_folder_def']
|
|
kwargs = copy.deepcopy(kwargs)
|
|
paths = audio_folder_def.get_all_audio_files()
|
|
output_path = kwargs['output_path']
|
|
|
|
# Read the first file to get metadata
|
|
audio_data_list = []
|
|
for path in paths:
|
|
audio_data, sr = librosa.load(path, sr=None)
|
|
audio_data_list.append(audio_data)
|
|
|
|
# Concatenate all audio data
|
|
joined_audio_data = np.concatenate(audio_data_list)
|
|
|
|
# TODO: need to implement writing in the correct format
|
|
# Save the concatenated audio
|
|
sf.write(output_path, joined_audio_data, sr)
|
|
|
|
return (output_path,)
|
|
|
|
|
|
import subprocess
|
|
import platform
|
|
|
|
import subprocess
|
|
import platform
|
|
import os
|
|
|
|
|
|
def duplicate_file(source_file, target_files):
|
|
"""
|
|
Duplicates a given source file into multiple target files using a single CLI command
|
|
in a non-blocking way. The base directory is automatically determined from the
|
|
source file's location.
|
|
|
|
Args:
|
|
- source_file (str): Path to the source file to be duplicated.
|
|
- target_files (list of str): A list of target file paths where the source file should be duplicated.
|
|
|
|
Returns:
|
|
- None
|
|
"""
|
|
# Determine the base directory from the source file's directory
|
|
base_dir = os.path.dirname(os.path.abspath(source_file))
|
|
|
|
# Change the current working directory to the base directory
|
|
os.chdir(base_dir)
|
|
|
|
# Get the source file name (relative to the base directory)
|
|
source_file_name = os.path.basename(source_file)
|
|
|
|
# get the relative paths of the target files
|
|
target_files = [os.path.relpath(target_file, base_dir) for target_file in target_files]
|
|
|
|
# Determine the shell command based on the operating system
|
|
copy_command = 'copy' if os.name == 'nt' else 'cp'
|
|
|
|
# Construct the command string
|
|
command_parts = [f'{copy_command} "{source_file_name}" "{target}"' for target in target_files]
|
|
command = ' && '.join(command_parts)
|
|
|
|
command = command.replace("\"", "")
|
|
command = command.replace("\'", "")
|
|
# print(len(command))
|
|
|
|
try:
|
|
# Execute the command in a non-blocking way
|
|
process = subprocess.Popen(command, shell=True, stderr=subprocess.DEVNULL)
|
|
process.wait()
|
|
|
|
except subprocess.CalledProcessError as e:
|
|
pass
|
|
except ValueError as e:
|
|
pass
|
|
except Exception as e:
|
|
pass
|
|
|
|
if "process" not in locals():
|
|
pass
|
|
if "process" not in locals() or process.returncode != 0:
|
|
# If the command is too long, split it into multiple commands
|
|
# split the command into commands that do 64 at a time
|
|
command_parts = [command_parts[i:i + 64] for i in range(0, len(command_parts), 64)]
|
|
processes = []
|
|
for command_part in command_parts:
|
|
command = ' && '.join(command_part)
|
|
process = subprocess.Popen(command, shell=True, stderr=subprocess.DEVNULL)
|
|
processes.append(process)
|
|
|
|
for process in processes:
|
|
process.wait()
|
|
|
|
return process
|
|
|
|
|
|
class AddFramesToVideoFolder(metaclass=ABCWidgetMetaclass):
|
|
"""
|
|
given a VIDEO_FOLDER_DEF node, and a list of frames, adds the frames to the video folder
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
ret = {"required": {"video_folder_def": ("VIDEO_FOLDER_DEF", {"default": None}),
|
|
"IMAGE": ("IMAGE", {"default": None}),
|
|
"Rescale method": (
|
|
["NEAREST", "BOX", "BILINEAR", "HAMMING", "BICUBIC", "LANCZOS"],
|
|
{"default": "LANCZOS"}),
|
|
"Release vram?": ("BOOLEAN", {"default": True}),
|
|
},
|
|
}
|
|
return ret
|
|
|
|
RETURN_TYPES = ("VIDEO_FOLDER_DEF",)
|
|
FUNCTION = "add_frames_to_video_folder"
|
|
|
|
@staticmethod
|
|
def save_or_copy_frame(current_frame, previous_frame, frame_fp, previous_frame_fp):
|
|
from torchvision.utils import save_image
|
|
import shutil
|
|
from PIL import Image
|
|
if previous_frame is None or not torch.equal(current_frame, previous_frame):
|
|
# If the current frame is different from the previous frame, save it to disk
|
|
current_frame = current_frame
|
|
|
|
if current_frame.dtype != torch.uint8: # handle float type frames
|
|
ndarr = current_frame.permute(2, 0, 1).mul(255).add_(0.5).clamp_(0, 255).permute(1, 2, 0).to("cpu",
|
|
torch.uint8).numpy()
|
|
else: # handle uint8 frames
|
|
ndarr = current_frame.permute(2, 0, 1).permute(1, 2, 0).numpy()
|
|
im = Image.fromarray(ndarr)
|
|
im.save(frame_fp)
|
|
|
|
previous_frame = current_frame
|
|
else:
|
|
# If the current frame is the same as the previous frame, copy the previously saved frame file
|
|
shutil.copy2(previous_frame_fp, frame_fp)
|
|
return previous_frame
|
|
|
|
@staticmethod
|
|
def process_frames(frames, rescale_method, video_folder_def):
|
|
import gc
|
|
num_frames = frames.shape[0]
|
|
previous_frame = None
|
|
frame_fp = None
|
|
frames_area_all_the_same = torch.all(frames[0] == frames)
|
|
if frames_area_all_the_same and frames.shape[0] > 1:
|
|
# get a list of file names using reservenexframe
|
|
frame_f_names = [video_folder_def.reserve_next_frame() for _ in range(num_frames)]
|
|
# save the first frame
|
|
AddFramesToVideoFolder.save_or_copy_frame(frames[0], previous_frame, frame_f_names[0], None)
|
|
# copy the first frame to the rest of the frames using os by calling the cli
|
|
duplicate_file(frame_f_names[0], frame_f_names[1:])
|
|
|
|
else:
|
|
for i in range(num_frames):
|
|
next_fn = video_folder_def.get_next_file_name()
|
|
|
|
previous_frame_fp = frame_fp
|
|
frame_fp = next_fn
|
|
|
|
current_frame = frames[i]
|
|
|
|
previous_frame = AddFramesToVideoFolder.save_or_copy_frame(current_frame, previous_frame, frame_fp,
|
|
previous_frame_fp)
|
|
|
|
del frames
|
|
gc.collect()
|
|
return None
|
|
|
|
def add_frames_to_video_folder(self, **kwargs):
|
|
from glob import glob
|
|
from PIL import Image
|
|
import os
|
|
import torch
|
|
import gc
|
|
from torchvision.transforms.functional import resize
|
|
import psutil
|
|
from custom_nodes.EternalKernelLiteGraphNodes.modules.image_utils import lanczos_resize
|
|
|
|
initial_memory = psutil.virtual_memory().available
|
|
print(f"Initial free memory: {initial_memory / (1024 * 1024):.2f} MB")
|
|
|
|
import shutil
|
|
|
|
# get the video folder path
|
|
video_folder = kwargs["video_folder_def"]["video_folder"]
|
|
|
|
# get the frame format
|
|
frame_format = kwargs["video_folder_def"]["frame format"]
|
|
|
|
# get the height
|
|
height = kwargs["video_folder_def"]["height"]
|
|
|
|
# get the width
|
|
width = kwargs["video_folder_def"]["width"]
|
|
|
|
# get the fps
|
|
fps = kwargs["video_folder_def"]["fps"]
|
|
|
|
# get the frames
|
|
frames = kwargs["IMAGE"]
|
|
|
|
# get the rescale method
|
|
rescale_method = kwargs.get("Rescale method", None)
|
|
if not rescale_method:
|
|
rescale_method = kwargs.get("Rescale_method", None)
|
|
|
|
free_vram = kwargs.get("Release vram?", None)
|
|
if not free_vram:
|
|
free_vram = kwargs.get("Release_vram_", True)
|
|
|
|
orig_frames = frames
|
|
# num_files = len(glob(os.path.join(video_folder, f"frame_*.{frame_format.lower()}")))
|
|
|
|
# rescale if needed to the correct size
|
|
if height != frames.shape[1] or width != frames.shape[2]:
|
|
# first since transforms.Resize requires a tensor of shape B,C, H, W
|
|
# we need to permute the tensor to B, C, H, W
|
|
frames = frames.permute(0, 3, 1, 2)
|
|
frames = frames.to("cuda")
|
|
|
|
# get the right value for interpolation
|
|
interpolation_methods = {
|
|
"NEAREST": Image.Resampling.NEAREST,
|
|
"BOX": Image.Resampling.BOX,
|
|
"BILINEAR": Image.Resampling.BILINEAR,
|
|
"HAMMING": Image.Resampling.HAMMING,
|
|
"BICUBIC": Image.Resampling.BICUBIC,
|
|
"LANCZOS": Image.Resampling.LANCZOS
|
|
}
|
|
|
|
rescale_type = interpolation_methods[rescale_method]
|
|
# rescale_method = transforms.Resize((height, width), interpolation=rescale_type) # this doesnt work so well lets use a different function
|
|
|
|
if rescale_type != Image.Resampling.LANCZOS:
|
|
rescale_method = lambda x: resize(x, (height, width), interpolation=rescale_type)
|
|
frames = rescale_method(frames)
|
|
else:
|
|
rescale_method = lambda x: lanczos_resize(x, (width, height))
|
|
frames = rescale_method(frames)
|
|
|
|
# now we need to permute back to B, H, W, C
|
|
frames = frames.permute(0, 2, 3, 1)
|
|
self.process_frames(frames, rescale_method, kwargs["video_folder_def"])
|
|
else:
|
|
self.process_frames(frames, rescale_method, kwargs["video_folder_def"])
|
|
|
|
gc.collect()
|
|
|
|
if free_vram:
|
|
import gc
|
|
if isinstance(frames, torch.Tensor):
|
|
frames.to("cpu").detach().numpy()
|
|
del frames
|
|
|
|
if isinstance(orig_frames, torch.Tensor):
|
|
orig_frames = orig_frames.to("cpu").detach().numpy()
|
|
del orig_frames
|
|
|
|
torch.cuda.empty_cache()
|
|
gc.collect()
|
|
# torch.cuda.empty_cache()
|
|
# gc.collect()
|
|
|
|
initial_memory = psutil.virtual_memory().available
|
|
print(f"after adding frames free memory: {initial_memory / (1024 * 1024):.2f} MB")
|
|
# return the VIDEO_FOLDER_DEF
|
|
return (kwargs["video_folder_def"],)
|
|
|
|
|
|
class RepeatFrames(metaclass=ABCWidgetMetaclass):
|
|
"""
|
|
does nothing more than repeat the frames a number of times
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
ret = {"required": {"IMAGE": ("IMAGE", {"default": None}),
|
|
"repeat": ("INT", {"default": 1}),
|
|
},
|
|
}
|
|
return ret
|
|
|
|
RETURN_TYPES = ("IMAGE",)
|
|
RETURN_NAMES = ("repeated images",)
|
|
FUNCTION = "repeat_frames"
|
|
|
|
def repeat_frames(self, **kwargs):
|
|
import torch
|
|
frames = kwargs["IMAGE"]
|
|
repeat = kwargs["repeat"]
|
|
return (frames.repeat(repeat, 1, 1, 1),)
|
|
|
|
|
|
class HoldFrameForSecsInVideoFolder(metaclass=ABCWidgetMetaclass):
|
|
"""
|
|
given a VIDEO_FOLDER_DEF node, and a list of frames, first uses RepeatFrames to repeat the frames
|
|
for the correct amount and then adds the frames to the video folder using AddFramesToVideoFolder
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
ret = {"required": {"video_folder_def": ("VIDEO_FOLDER_DEF", {"default": None}),
|
|
"IMAGE": ("IMAGE", {"default": None}),
|
|
"seconds": ("FLOAT", {"default": 1.0}),
|
|
"Rescale method": (
|
|
["NEAREST", "BOX", "BILINEAR", "HAMMING", "BICUBIC", "LANCZOS"],
|
|
{"default": "LANCZOS"}),
|
|
"Release vram?": ("BOOLEAN", {"default": True}),
|
|
},
|
|
}
|
|
return ret
|
|
|
|
RETURN_TYPES = ("VIDEO_FOLDER_DEF",)
|
|
FUNCTION = "hold_frame_for_secs_in_video_folder"
|
|
|
|
def saver(self, x, kwargs):
|
|
# this just saves in an async way
|
|
rescale_method = kwargs.get("Rescale method", None)
|
|
if rescale_method is None:
|
|
rescale_method = kwargs.get("Rescale_method", None)
|
|
|
|
rel_vram = kwargs.get("Release vram?", None)
|
|
if rel_vram is None:
|
|
rel_vram = kwargs.get("Release_vram_", True)
|
|
|
|
AddFramesToVideoFolder().add_frames_to_video_folder(
|
|
video_folder_def=kwargs["video_folder_def"],
|
|
IMAGE=x,
|
|
Rescale_method=rescale_method,
|
|
Release_vram_=rel_vram
|
|
)
|
|
|
|
def hold_frame_for_secs_in_video_folder(self, **kwargs):
|
|
import torch
|
|
import asyncio
|
|
|
|
vide_folder_ref = kwargs["video_folder_def"]
|
|
|
|
video_folder = kwargs["video_folder_def"]["video_folder"]
|
|
|
|
# get the frame format
|
|
frame_format = kwargs["video_folder_def"]["frame format"]
|
|
|
|
# get the height
|
|
height = kwargs["video_folder_def"]["height"]
|
|
|
|
# get the width
|
|
width = kwargs["video_folder_def"]["width"]
|
|
|
|
# get the fps
|
|
fps = kwargs["video_folder_def"]["fps"]
|
|
|
|
# get the frames
|
|
frames = kwargs["IMAGE"]
|
|
|
|
# get the number of frames
|
|
num_frames = frames.shape[0]
|
|
|
|
# get the rescale method
|
|
rescale_method = kwargs.get("Rescale method", None)
|
|
if rescale_method is None:
|
|
rescale_method = kwargs.get("Rescale_method", None)
|
|
|
|
# get the number of seconds
|
|
seconds = kwargs["seconds"]
|
|
|
|
# get the number of frames to hold
|
|
num_frames_to_hold = int(fps * seconds)
|
|
|
|
num_frames_in_this_stack = frames.shape[0]
|
|
|
|
# get the number of repeats needed
|
|
num_repeats_needed = num_frames_to_hold // num_frames_in_this_stack
|
|
|
|
# get the number of frames left over
|
|
num_frames_left_over = num_frames_to_hold % num_frames_in_this_stack
|
|
|
|
# write the first set of frames to disk
|
|
self.saver(frames, kwargs)
|
|
|
|
# get the name of the last frame
|
|
source_last_frame_name = vide_folder_ref.get_last_file_name()
|
|
# from the last frame find the rest of the frames names/paths
|
|
source_last_frame_num = int(source_last_frame_name.split("_")[-1].split(".")[0])
|
|
source_first_frame_num = source_last_frame_num - num_frames_in_this_stack + 1
|
|
|
|
template = source_last_frame_name.replace(str(source_last_frame_num).zfill(10), "{}")
|
|
|
|
range_of_source_frame_numbers = list(range(source_first_frame_num, source_last_frame_num + 1))
|
|
|
|
source_frame_names = [template.format(str(x).zfill(10)) for x in range_of_source_frame_numbers]
|
|
|
|
target_done_frame_num = source_last_frame_num + num_frames_to_hold - num_frames_in_this_stack
|
|
num_frames_remaining = num_frames_to_hold - num_frames_in_this_stack
|
|
target_start_frame_num = source_last_frame_num + 1
|
|
|
|
target_frame_range = list(range(target_start_frame_num, target_done_frame_num + 1))
|
|
|
|
target_frame_names = [template.format(str(x).zfill(10)) for x in target_frame_range]
|
|
|
|
set_todo = {fn: [] for fn in source_frame_names}
|
|
# we can now repeat the frames
|
|
for i, target_frame_name in enumerate(target_frame_names):
|
|
# keep track of what stack frame we are on
|
|
stack_frame_idx = i % num_frames_in_this_stack
|
|
stack_frame_name_to_use = source_frame_names[stack_frame_idx]
|
|
set_todo[stack_frame_name_to_use].append(target_frame_name)
|
|
|
|
# now we can repeat the frames
|
|
for frame_name in set_todo:
|
|
src_frame = frame_name
|
|
target_frames = set_todo[src_frame]
|
|
duplicate_file(src_frame, target_frames)
|
|
|
|
return (kwargs["video_folder_def"],)
|
|
|
|
|
|
class Listed_HoldFramesForSecsInVideoFolder(metaclass=ABCWidgetMetaclass):
|
|
"""This just calls HoldFrameForSecsInVideoFolder multiple times for a list of frames and a list of durations"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
ret = {"required": {"video_folder_def": ("VIDEO_FOLDER_DEF", {"default": None}),
|
|
"IMAGE": ("LIST", {"default": None}),
|
|
"seconds": ("LIST", {"default": None}),
|
|
"Rescale method": (
|
|
["NEAREST", "BOX", "BILINEAR", "HAMMING", "BICUBIC", "LANCZOS"],
|
|
{"default": "LANCZOS"}),
|
|
"Release vram?": ("BOOLEAN", {"default": True}),
|
|
},
|
|
}
|
|
return ret
|
|
|
|
RETURN_TYPES = ("VIDEO_FOLDER_DEF",)
|
|
FUNCTION = "listed_hold_frames_for_secs_in_video_folder"
|
|
|
|
def listed_hold_frames_for_secs_in_video_folder(self, **kwargs):
|
|
# the plan is to call HoldFrameForSecsInVideoFolder for each frame and duration
|
|
# and then return the video_folder_def
|
|
|
|
if "IMAGE" not in kwargs or "seconds" not in kwargs:
|
|
return (kwargs["video_folder_def"],)
|
|
|
|
for frame, duration in zip(kwargs["IMAGE"], kwargs["seconds"]):
|
|
HoldFrameForSecsInVideoFolder().hold_frame_for_secs_in_video_folder(
|
|
video_folder_def=kwargs["video_folder_def"],
|
|
IMAGE=frame,
|
|
seconds=duration,
|
|
Rescale_method=kwargs["Rescale method"],
|
|
Release_vram_=kwargs["Release vram?"])
|
|
|
|
return (kwargs["video_folder_def"],)
|
|
|
|
|
|
class VideoFolderDefInfo(metaclass=ABCWidgetMetaclass):
|
|
"""
|
|
Returns everything in the VIDEO_FOLDER_DEF, and also returns information about the status of the folder
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
ret = {"required": {"video_folder_def": ("VIDEO_FOLDER_DEF", {"default": None}),
|
|
},
|
|
}
|
|
return ret
|
|
|
|
RETURN_TYPES = ("VIDEO_FOLDER_DEF", "STRING", "INT", "INT", "FLOAT", "STRING", "INT", "LIST",)
|
|
RETURN_NAMES = ("VFD", "video_folder", "height", "width", "fps", "frame format", "num_files", "list of files")
|
|
|
|
OUTPUT_NODE = True
|
|
FUNCTION = "video_folder_def_info"
|
|
|
|
def video_folder_def_info(self, **kwargs):
|
|
import os
|
|
|
|
video_folder = kwargs["video_folder_def"]["video_folder"]
|
|
height = kwargs["video_folder_def"]["height"]
|
|
width = kwargs["video_folder_def"]["width"]
|
|
fps = kwargs["video_folder_def"]["fps"]
|
|
frame_format = kwargs["video_folder_def"]["frame format"]
|
|
files = os.listdir(video_folder)
|
|
num_files = len(files)
|
|
|
|
return (kwargs["video_folder_def"], video_folder, height, width, fps, frame_format, num_files, files,)
|
|
|
|
|
|
class FadeToInVideoFolder(metaclass=ABCWidgetMetaclass):
|
|
"""
|
|
given a VIDEO_FOLDER_DEF node, and a stack of frames, fades the frames in and adds them to the video folder
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
ret = {"required": {"video_folder_def": ("VIDEO_FOLDER_DEF", {"default": None}),
|
|
"IMAGE": ("IMAGE", {"default": None}),
|
|
"seconds": ("FLOAT", {"default": 1.0}),
|
|
"transition ms": ("FLOAT", {"default": 0.1}),
|
|
"Rescale method": (
|
|
["NEAREST", "BOX", "BILINEAR", "HAMMING", "BICUBIC", "LANCZOS"],
|
|
{"default": "LANCZOS"}),
|
|
"Release vram?": ("BOOLEAN", {"default": True}),
|
|
},
|
|
}
|
|
return ret
|
|
|
|
RETURN_TYPES = ("VIDEO_FOLDER_DEF",)
|
|
FUNCTION = "fade_to_in_video_folder"
|
|
|
|
def fade_to_in_video_folder(self, **kwargs):
|
|
from PIL import Image
|
|
import torch
|
|
|
|
# usee HoldFrameForSecsInVideoFolder to hold the frame after creating and AddFramesToVideoFolder the transition
|
|
|
|
transition_length_secs = int(kwargs["transition ms"] * kwargs["video_folder_def"]["fps"]) / 1000
|
|
hold_frame_length_secs = int(kwargs["seconds"] * kwargs["video_folder_def"]["fps"]) - transition_length_secs
|
|
|
|
transition_frame_count = int(kwargs["video_folder_def"]["fps"] * transition_length_secs)
|
|
|
|
base_frame_fn = kwargs["video_folder_def"].get_last_file_name()
|
|
# load it into a pytorch tensor (B, H, W, C)
|
|
base_frame = Image.open(base_frame_fn)
|
|
base_frame = torch.tensor(base_frame).permute(2, 0, 1).unsqueeze(0).to("cuda")
|
|
|
|
transition_stack = torch.zeros((transition_frame_count,
|
|
base_frame.shape[1],
|
|
base_frame.shape[2],
|
|
base_frame.shape[3]),
|
|
dtype=torch.float16, device="cuda")
|
|
|
|
# fade from base_frame to the first frame in the stack over the transition length
|
|
new_frame = kwargs["IMAGE"][0].to("cuda")
|
|
for i in range(transition_frame_count):
|
|
alpha = i / transition_frame_count
|
|
new_frame = (1 - alpha) * base_frame + alpha * new_frame
|
|
new_frame = new_frame.to("cpu")
|
|
transition_stack[i] = new_frame
|
|
|
|
# add to the video folder
|
|
AddFramesToVideoFolder().add_frames_to_video_folder(
|
|
video_folder_def=kwargs["video_folder_def"],
|
|
IMAGE=transition_stack,
|
|
Rescale_method=kwargs["Rescale method"],
|
|
Release_vram_=kwargs["Release vram?"])
|
|
|
|
# hold the frame
|
|
HoldFrameForSecsInVideoFolder().hold_frame_for_secs_in_video_folder(
|
|
video_folder_def=kwargs["video_folder_def"],
|
|
IMAGE=kwargs["IMAGE"],
|
|
seconds=hold_frame_length_secs,
|
|
Rescale_method=kwargs["Rescale method"],
|
|
Release_vram_=kwargs["Release vram?"])
|
|
|
|
return (kwargs["video_folder_def"],)
|
|
|
|
|
|
class ListedSVDConditioning(metaclass=ABCWidgetMetaclass):
|
|
"""
|
|
repeats SVD_img2vid_Conditioning for each set of params
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
from comfy_extras.nodes_video_model import SVD_img2vid_Conditioning
|
|
base = SVD_img2vid_Conditioning.INPUT_TYPES()
|
|
base["required"]["init_image"] = ("LIST", {"default": None})
|
|
# base["required"]["clip_vision"] = ("LIST", {"default": None})
|
|
base["required"]["video_frames"] = ("LIST", {"default": None})
|
|
|
|
return base
|
|
|
|
RETURN_TYPES = ("LIST", "LIST", "LIST")
|
|
RETURN_NAMES = ("positive", "negative", "latent")
|
|
|
|
FUNCTION = "listed_SVD_conditioning"
|
|
|
|
def listed_SVD_conditioning(self, **kwargs):
|
|
# just like Listed_HoldFramesForSecsInVideoFolder, we will call SVD_img2vid_Conditioning for each set of params
|
|
|
|
positives = []
|
|
negatives = []
|
|
latents = []
|
|
|
|
init_images = kwargs["init_image"]
|
|
clip_vision = kwargs["clip_vision"]
|
|
# vae
|
|
vae = kwargs["vae"]
|
|
# width and height
|
|
width = kwargs["width"]
|
|
height = kwargs["height"]
|
|
video_frames = kwargs["video_frames"]
|
|
motion_bucket_id = kwargs["motion_bucket_id"]
|
|
fps = kwargs["fps"]
|
|
augmentation_level = kwargs["augmentation_level"]
|
|
|
|
lens = [len(init_images), len(video_frames)]
|
|
|
|
# check if one length is divisible by the other
|
|
if lens[0] % lens[1] != 0 and lens[1] % lens[0] != 0:
|
|
raise ValueError("The length of init_image and video_frames must be divisible by each other")
|
|
|
|
# repeat the shorter
|
|
if lens[0] < lens[1]:
|
|
init_images = init_images * (lens[1] // lens[0])
|
|
else:
|
|
video_frames = video_frames * (lens[0] // lens[1])
|
|
|
|
# at this point the lengths should be the same
|
|
|
|
for init_image, video_frame in zip(init_images, video_frames):
|
|
pos, neg, latent = SVD_img2vid_Conditioning().encode(
|
|
clip_vision=clip_vision,
|
|
init_image=init_image,
|
|
video_frames=video_frame,
|
|
vae=vae,
|
|
width=width,
|
|
height=height,
|
|
motion_bucket_id=motion_bucket_id,
|
|
fps=fps,
|
|
augmentation_level=augmentation_level
|
|
)
|
|
|
|
positives.append(pos)
|
|
negatives.append(neg)
|
|
latents.append(latent)
|
|
|
|
return (positives, negatives, latents,)
|
|
|
|
|
|
class VideoFolderFFMPEGVideoInterpolate(metaclass=ABCWidgetMetaclass):
|
|
"""
|
|
use run_ffmpeg to interpolate the frames in the video folder
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
ret = {"required": {"video_folder_def": ("VIDEO_FOLDER_DEF", {"default": None}),
|
|
"last_n_frames": ("INT", {"default": 2}),
|
|
"target_n_frames": ("INT", {"default": 3}),
|
|
},
|
|
}
|
|
return ret
|
|
|
|
RETURN_TYPES = ("VIDEO_FOLDER_DEF",)
|
|
RETURN_NAMES = ("video_folder_def",)
|
|
FUNCTION = "video_folder_ffmpeg_video_interpolate"
|
|
|
|
def video_folder_ffmpeg_video_interpolate(self, **kwargs):
|
|
import os
|
|
import shutil
|
|
|
|
video_folder_def = kwargs["video_folder_def"]
|
|
last_n_frames = kwargs["last_n_frames"]
|
|
target_n_frames = kwargs["target_n_frames"]
|
|
|
|
# Get the current frame count
|
|
current_frame_count = video_folder_def.get_frame_count()
|
|
|
|
# Calculate the target frame rate for interpolation
|
|
target_fps = video_folder_def["fps"] * (target_n_frames / last_n_frames)
|
|
|
|
# Get the file names for the last n frames
|
|
last_frame_names = [video_folder_def.get_last_file_name(offset=i) for i in range(last_n_frames)]
|
|
last_frame_names = list(set(last_frame_names))
|
|
last_frame_names.sort()
|
|
|
|
# Create a temporary folder off of the video frame folder's base path
|
|
temp_folder = os.path.join(video_folder_def["video_folder"], "temp")
|
|
os.makedirs(temp_folder, exist_ok=True)
|
|
|
|
# put the input frames into the new folder
|
|
for frame_name in last_frame_names:
|
|
# do this in a way that overwrites the files and MOVES them
|
|
shutil.move(frame_name, os.path.join(temp_folder, os.path.basename(frame_name)))
|
|
|
|
# new input frame names list
|
|
last_frame_names = [os.path.join(temp_folder, os.path.basename(frame_name)) for frame_name in last_frame_names]
|
|
last_frame_names.sort()
|
|
|
|
# Create a file with the list of input frame paths in the temporary folder
|
|
input_frame_list_file = os.path.join(temp_folder, "input_frames.txt")
|
|
input_frame_list_file = os.path.abspath(input_frame_list_file)
|
|
with open(input_frame_list_file, 'w') as file:
|
|
# need to do this in a manner that puts the first frame at the top of the file.
|
|
file.write('\n'.join([f"file '{frame_name}'" for frame_name in last_frame_names]))
|
|
|
|
# Get the output frame names for the interpolated frames
|
|
# output_frame_names = [video_folder_def.reserve_next_frame() for _ in range(target_n_frames)]
|
|
output_frame_names = [video_folder_def.get_next_file_name(i) for i in range(target_n_frames)]
|
|
|
|
# delete the frames on the list
|
|
# for frame_name in output_frame_names:
|
|
# os.remove(frame_name)
|
|
|
|
temp_output_frame_pattern = os.path.join(video_folder_def["video_folder"], "temp",
|
|
"tmp_frame_%010d." + video_folder_def["frame format"].lower())
|
|
temp_output_frame_pattern = os.path.abspath(temp_output_frame_pattern)
|
|
|
|
# Create the FFmpeg command for motion interpolation using the frame list file
|
|
ffmpeg_interpolate_cmd = [
|
|
"-f", "concat", "-safe", "0",
|
|
"-i", input_frame_list_file,
|
|
"-vsync", "0", "-filter_complex",
|
|
# f"[0:v]minterpolate=fps={target_fps}:mi_mode=mci:mc_mode=aobmc:me_mode=bidir:me=ds:mb_size=16:search_param=8:vsbmc=1[v]",
|
|
# f"[0:v]framerate=fps={target_fps}:interp_start=0:interp_end=255:scene=0[v]",
|
|
# f"[0:v]minterpolate=fps={target_fps}:vsbmc=1[v]",
|
|
f"[0:v]minterpolate=fps={target_fps}:mi_mode=blend[v]",
|
|
"-map", "[v]", "-q:v", "2", "-r", str(target_fps), "-frames", str(target_n_frames),
|
|
"-threads", "0",
|
|
"-y", temp_output_frame_pattern
|
|
]
|
|
|
|
run_ffmpeg_command(ffmpeg_interpolate_cmd)
|
|
|
|
# Move the interpolated frames to the video folder with the correct names
|
|
for i, frame_name in enumerate(output_frame_names):
|
|
temp_frame_name = os.path.join(temp_folder,
|
|
f"tmp_frame_{i + 1:010d}.{video_folder_def['frame format'].lower()}")
|
|
try:
|
|
shutil.move(temp_frame_name, frame_name)
|
|
except FileNotFoundError:
|
|
pass
|
|
|
|
# Remove the temporary folder and frame list file
|
|
os.remove(input_frame_list_file)
|
|
# remove this without generating an error even if it is not empty
|
|
try:
|
|
os.rmdir(temp_folder)
|
|
except OSError:
|
|
shutil.rmtree(temp_folder)
|
|
|
|
return (video_folder_def,)
|
|
|
|
|
|
class AddMetadataNode(metaclass=ABCWidgetMetaclass):
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"input_file": ("STRING", {"default": "input.mp4"}),
|
|
"output_file": ("STRING", {"default": "output.mp4"}),
|
|
"metadata": ("STRING", {"default": "title:My Video"}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("STRING",) # This node returns a string message indicating success or failure
|
|
RETURN_NAMES = ("status_message",) # Providing a name for the return type
|
|
FUNCTION = "add_metadata"
|
|
CATEGORY = "video processing"
|
|
|
|
def add_metadata(self, input_file, output_file, metadata):
|
|
from ffmpeg import FFmpeg
|
|
import shutil
|
|
|
|
import torch # Importing torch as it seems to be a convention in your framework
|
|
# Parse the metadata from string to dictionary
|
|
# strip input and output file
|
|
input_file = input_file.strip()
|
|
output_file = output_file.strip()
|
|
|
|
# so actually if the input_file and output_file are the same
|
|
# it will by default have an error
|
|
# co if this is the case copy then input file to a tempfile then use that as the input file
|
|
import tempfile
|
|
|
|
if input_file == output_file:
|
|
# copy the input file to a temp file
|
|
with tempfile.NamedTemporaryFile(delete=False) as temp_file:
|
|
temp_file_name = temp_file.name
|
|
shutil.copy(input_file, temp_file_name)
|
|
input_file = temp_file_name
|
|
|
|
metadata_dict = {}
|
|
try:
|
|
for line in metadata.splitlines():
|
|
key, value = line.split(':', 1) # Split on the first colon
|
|
mdkey = "metadata"
|
|
# metadata_dict.append(f'metadata:{key.strip()}={value.strip()}')
|
|
metadata_dict[mdkey] = f'{key.strip()}={value.strip()}'
|
|
|
|
# Run ffmpeg to add metadata
|
|
ffmpeg = FFmpeg().option("y")
|
|
try:
|
|
# make sure to tell it to overwrite outputfile
|
|
|
|
ffmpeg.input(input_file).output(output_file, **metadata_dict).execute()
|
|
|
|
return (f"status_message : Metadata added successfully to {output_file}.",)
|
|
except Exception as e:
|
|
return (f"status_message : Error adding metadata to {output_file}.{str(e)}",)
|
|
|
|
return (f"status_message : Metadata added successfully to {output_file}.",)
|
|
except Exception as e:
|
|
return (f"status_message : Error adding metadata to {output_file}.",)
|
|
|
|
@staticmethod
|
|
def describe():
|
|
return "This node adds metadata to video files using ffmpeg. Metadata should be provided as 'key: value' pairs separated by newlines."
|