Add nodes for flexible typing and conversion to Any

This commit adds several new nodes to enable flexible typing and conversion between types:

- ValueToAny - Converts primitive values like ints, floats, strings, bools to Any type
- IterableToAny - Converts iterable values like lists, dicts to Any
- CallableToAny - Converts callables like functions to Any
- ObjectToAny - Converts tensor objects like images, latents etc to Any

The motivation is to allow passing different types flexibly into functions using Any typing. This enables writing functions in a more generic way by accepting Any instead of specific types.

The nodes convert the input values to Any and return debug info on what was converted. This allows tracing what types entered a function even if the function uses generic Any types.

No breaking changes, new nodes added:
- ValueToAny
- IterableToAny
- CallableToAny
- ObjectToAny

These provide building blocks for more flexible typing, opening up possibilities like:

- Writing utility and processing functions accepting Any instead of specific types
- Calling functions dynamically with values of different types
- Tracing and logging what types passed through a function

Next steps could include AnyToValue nodes, more specialized Any converters, and exploring uses for generic functions.
This commit is contained in:
Tasha Upchurch
2023-09-18 17:18:35 -04:00
parent bd888f31b3
commit 35b3ec4f66
6 changed files with 794 additions and 165 deletions
+53 -1
View File
@@ -121,4 +121,56 @@ class JoinAudioFiles:
with open(output_path, "rb") as f:
file_bytes = f.read()
return (output_path, file_bytes, metadata,)
return (output_path, file_bytes, metadata,)
@ETK_audio_base
class ExtractAudioFromVideo:
""" Extracts audio from a video file and saves it as an audio file with a specified format and bitrate """
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"video_path": ("STRING", {"multiline": False, "default": ""}),
"format":(["wav", "mp3"], {"default": "wav"}),
"output_path": ("STRING", {"multiline": False, "default": ""}),
},
"optional": {
"bitrate (MP3)": ("INT", {"default": 192}) # Default bitrate value (change as needed)
}
}
RETURN_TYPES = ("STRING", "BYTES")
RETURN_NAMES = ("output_wave_file_path", "audio_bytes")
FUNCTION = 'extract_audio_from_video'
CATEGORY = 'ETK/audio'
def extract_audio_from_video(self, **kwargs):
import moviepy.editor as mp
import io
kwargs = copy.deepcopy(kwargs)
video_path = kwargs.get('video_path')
output_path = kwargs.get('output_path',"tmp.wav")
audio_format = kwargs.get('format', 'wav')
bitrate = kwargs.get('bitrate', None) # Default bitrate is None
# Load the video clip
video_clip = mp.VideoFileClip(video_path)
# Determine the appropriate codec based on the audio format
if audio_format == 'wav':
codec = "pcm_s16le" # For WAV format
elif audio_format == 'mp3':
codec = "mp3" # For MP3 format
# Extract audio and save it with the specified format and bitrate
audio_clip = video_clip.audio
audio_clip.write_audiofile(output_path, codec=codec, bitrate=bitrate)
# Read the generated audio file as bytes
audio_bytes = None
with open(output_path, 'rb') as audio_file:
audio_bytes = audio_file.read()
return (output_path, audio_bytes,)
+129
View File
@@ -97,6 +97,7 @@ class StrBreakout:
pass
return ("\n".join(notes), out_bool, out_int, out_float, out_list, out_lines, out_comma,)
class IntBreakout:
""" node that breaks out an int into a:
- Boolean
@@ -172,8 +173,136 @@ class IntBreakout:
return ("\n".join(notes), out_bool, out_str, out_float, out_list, out_range, out_none,)
class ValueToAny:
"""takes INT,STRING,FLOAT,BOOL, and returns the value coded as *"""
@classmethod
def INPUT_TYPES(cls):
return {
"optional": {
"INT": ("INT", {"default": None, }),
"FLOAT": ("FLOAT", {"default": None, }),
"STRING": ("STRING", {"default": None, }),
"BOOL": ("BOOL", {"default": None, }),
}
}
RETURN_TYPES = ("*", "STRING",)
RETURN_NAMES = ("*", "debug info",)
FUNCTION = "get_value"
CATEGORY = "conversion"
def get_value(self, INT=None, FLOAT=None, STRING=None, BOOL=None):
if INT:
dbg = f"found INT: {INT}"
return (INT, dbg,)
if FLOAT:
dbg = f"found FLOAT: {FLOAT}"
return (FLOAT, dbg,)
if STRING:
dbg = f"found STRING: {STRING}"
return (STRING, dbg,)
if BOOL:
dbg = f"found BOOL: {BOOL}"
return (BOOL, dbg,)
dbg = f"found nothing, will return the first value that is not None; from INT,FLOAT,STRING,BOOL"
return (None, dbg)
class IterableToAny:
"""takes LIST,DICT, and returns the value coded as *"""
@classmethod
def INPUT_TYPES(cls):
return {
"optional": {
"LIST": ("LIST", {"default": None, }),
"DICT": ("DICT", {"default": None, }),
}
}
RETURN_TYPES = ("*", "STRING",)
RETURN_NAMES = ("*", "debug info",)
FUNCTION = "get_value"
CATEGORY = "conversion"
def get_value(self, LIST=None, DICT=None):
if LIST:
dbg = f"found LIST: {LIST}"
return (LIST, dbg,)
if DICT:
dbg = f"found DICT: {DICT}"
return (DICT, dbg,)
dbg = f"found nothing, will return the first value that is not None; from LIST,DICT"
return (None, dbg)
class CallableToAny:
"""takes FUNC, (others soon) and returns the value coded as *"""
@classmethod
def INPUT_TYPES(cls):
return {
"optional": {
"FUNC": ("FUNC", {"default": None, }),
}
}
RETURN_TYPES = ("*", "STRING",)
RETURN_NAMES = ("*", "debug info",)
FUNCTION = "get_value"
CATEGORY = "conversion"
def get_value(self, FUNC=None):
if FUNC:
dbg = f"found FUNC: {FUNC}"
return (FUNC, dbg,)
dbg = f"found nothing, will return the first value that is not None; from FUNC"
return (None, dbg)
class ObjectToAny:
""" takes non-primitive objects like TENSORS and returns the value coded as *"""
@classmethod
def INPUT_TYPES(cls):
return {
"optional": {
"LATENT": ("LATENT", {"default": None, }),
"CONDITIONING": ("CONDITIONING", {"default": None, }),
"IMAGE": ("IMAGE", {"default": None, }),
"MASK": ("MASK", {"default": None, }),
}
}
RETURN_TYPES = ("*", "STRING",)
RETURN_NAMES = ("*", "debug info",)
FUNCTION = "get_value"
CATEGORY = "conversion"
def get_value(self, LATENT=None, CONDITIONING=None, IMAGE=None, MASK=None):
if LATENT:
dbg = f"found LATENT: {LATENT}"
return (LATENT, dbg,)
if CONDITIONING:
dbg = f"found CONDITIONING: {CONDITIONING}"
return (CONDITIONING, dbg,)
if IMAGE:
dbg = f"found IMAGE: {IMAGE}"
return (IMAGE, dbg,)
if MASK:
dbg = f"found MASK: {MASK}"
return (MASK, dbg,)
dbg = f"found nothing, will return the first value that is not None; from LATENT,CONDITIONING,IMAGE,MASK"
return (None, dbg)
NODE_CLASS_MAPPINGS = {}
NODE_CLASS_MAPPINGS["MultilineStringNode"] = MultilineStringNode
NODE_CLASS_MAPPINGS["StrBreakout"] = StrBreakout
NODE_CLASS_MAPPINGS["IntBreakout"] = IntBreakout
NODE_CLASS_MAPPINGS["ValueToAny"] = ValueToAny
NODE_CLASS_MAPPINGS["IterableToAny"] = IterableToAny
NODE_CLASS_MAPPINGS["CallableToAny"] = CallableToAny
NODE_CLASS_MAPPINGS["ObjectToAny"] = ObjectToAny
+46 -1
View File
@@ -5,9 +5,10 @@ NODE_DISPLAY_NAME_MAPPINGS = {}
_list = {"list": ("LIST",)}
_func = {"func": ("FUNC", {"default": None})}
_func_extra = {"func_extra": ("FUNC",)}
_text = {"code": ("STRING", {"multiline": True, "default": "y=x()"})}
_text = {"code": ("STRING", {"multiline": True, "default": "y mapped to output"})}
_image = {"image": ("IMAGE",)}
_string = {"string": ("STRING", {"default": None})}
_any = {"any": ("*", {"default": None})}
optional = lambda x: {"optional": {k: v for k, v in x.items()}}
required = lambda x: {"required": {k: v for k, v in x.items()}}
@@ -440,6 +441,50 @@ class GetFirstCodeBlock():
return (text,)
@ETK_functional_base
class FuncAnysToImage:
"""runs eval on the given text, with 5 anys as input and outputs an image"""
def __init__(self):
pass
@classmethod
def INPUT_TYPES(cls):
req = optional(
both(
both({"any1": ("*", {"default": None})}, {"any2": ("*", {"default": None})}),
both({"any3": ("*", {"default": None})}, {"any4": ("*", {"default": None})})
),
)
req["required"] = {}
req["required"].update(_text)
return req
CATEGORY = "ETK/func"
RETURN_TYPES = ("IMAGE",)
FUNCTION = "func"
def func(self, **kwargs):
code = kwargs.get("code", None)
any1 = kwargs.get("any1", None)
any2 = kwargs.get("any2", None)
any3 = kwargs.get("any3", None)
any4 = kwargs.get("any4", None)
my_globals = globals()
my_locals = locals()
my_locals["any1"] = any1
my_locals["any2"] = any2
my_locals["any3"] = any3
my_locals["any4"] = any4
exec(code, my_globals, my_locals)
return (my_locals["y"],)
if __name__ == "__main__":
test = {}
+151 -90
View File
@@ -337,7 +337,7 @@ class TinyTxtToImg:
if self.latent_image == None:
self.latent_image = EmptyLatentImage.generate(None, self.width, self.height, self.batch_size)[0]
if "latent" in render_what or "all" in render_what:
if "latent" in render_what or "all" in render_what or "image" in render_what:
self.KSampler = KSampler()
self.samples = self.KSampler.sample(
model=self.mdl,
@@ -384,6 +384,9 @@ class TinyTxtToImg:
self.FUNC = self.tinytxt2img
self.ARGS = kwargs
self.samples["samples"] = self.samples["samples"].to("cpu", non_blocking=True)
ret = (image,
self,
self.samples,
@@ -1653,33 +1656,159 @@ class TextRender:
}),
"color": ("STRING", {
"default": "#000000"
})
}),
"func_only": ([True, False], {"default": False}),
"stroke fill": ("STRING", {"default": "#000000"}),
"stroke width": ("INT", {"default": 1, "min": 1, "max": 100, "step": 1}),
}
}
CATEGORY = "ETK/text"
RETURN_TYPES = ("IMAGE", "IMAGE",)
RETURN_NAMES = ("image", "image rgba",)
RETURN_TYPES = ("IMAGE", "IMAGE", "FUNC",)
RETURN_NAMES = ("image", "image rgba", "FUNC(**kwargs)",)
FUNCTION = "render_text"
def render_text(self, text, x, y, width, height, font='Arial', size=16, color='#000000'):
@profile
def render_text(self, text, x, y, width, height, font='Arial', size=16, color='#888888', func_only=False, **kwargs):
"""
This function renders the provided text at specified location, with the given width and height.
The text is rendered in the provided font, size, and color.
>>> tr = TextRender()
>>> result = tr.render_text('Hello, world!', 128, 128, 512, 512,"Arial",16,"#FF11000")
>>> torch_image_show(result[0][0])
"""
from PIL import ImageFont, ImageDraw
import gc
import psutil
process = psutil.Process();
memory_info = process.memory_info();
start_memory = memory_info.rss / (1024 * 1024)
font_name = font
sw = kwargs.get("stroke width", None)
sf = kwargs.get("stroke fill", None)
@profile
def _wrap_text(font, line, max_width, d):
words = line.split(' ')
new_line = ''
lines = []
for word in words:
temp_line = new_line + word + ' '
#w, _ = d.textsize(temp_line, font=font)
# w, h = d.textsize(line, font=font)
(left, top, right, bottom) = d.textbbox((0, 0), text=temp_line, font=font)
w = right - left
h = bottom - top
previous_height = h # Update the previous height
if w > max_width:
lines.append(new_line.strip())
new_line = word + ' '
else:
new_line = temp_line
lines.append(new_line.strip())
wrapped_line = '\n'.join(lines)
return wrapped_line
@profile
def _render_text(font_name, image, size, text, x, y, allow_wrap=True, allow_shrink=True, sw=None, sf=None):
width = image.size[0]
d = ImageDraw.Draw(image)
font = ImageFont.truetype(font_name, size)
lines = text.split('\n')
shrink_threshold = 24 # The minimum font size before we start wrapping text
line_spacing = 5 # The constant space between lines
y_offset = y
previous_height = 0 # Store the height of the previous line
for i, line in enumerate(lines):
if line.strip() == '' or line.strip() == '\n':
# For blank lines, add a blank line of the previous line's height
y_offset += previous_height + line_spacing
else:
# w, h = d.textsize(line, font=font)
(left, top, right, bottom) = d.textbbox((0, 0), text=line, font=font)
w = right - left
h = bottom - top
previous_height = h # Update the previous height
if w > width:
if allow_shrink:
new_font_size = int(size * width / w)
if new_font_size >= shrink_threshold:
font = ImageFont.truetype(font_name, new_font_size)
else:
# The font size is too small, start wrapping text
wrapped_line = _wrap_text(font, line, width, d)
y_offset = _render_text(font_name,
image,
size,
wrapped_line, x, y_offset + line_spacing,
allow_wrap=False,
allow_shrink=False)
continue # Skip to the next line
if allow_wrap:
# Wrap the text
wrapped_line = _wrap_text(font, line, width, d)
y_offset = _render_text(font_name,
image,
new_font_size,
wrapped_line,
x,
y_offset + line_spacing,
allow_wrap=False,
allow_shrink=False)
continue # Skip to the next line
# Draw the line
x_offset = x + (width - w) / 2
if sw or sw:
d.text((x_offset, y_offset), line, font=font, fill="#FFFFFF", stroke_width=sw, stroke_fill=sf)
else:
d.text((x_offset, y_offset), line, font=font, fill="#FFFFFF")
y_offset += h + line_spacing # Move y_offset to the bottom of the last line drawn plus the line spacing
# debug print the amount of free memory in torch
return y_offset
if func_only:
def lll(new_kwargs):
if "font" not in new_kwargs:
new_kwargs["font"] = kwargs.get("font_name", font)
if "color" not in new_kwargs:
new_kwargs["color"] = color
if "size" not in new_kwargs:
new_kwargs["size"] = size
if "text" not in new_kwargs:
new_kwargs["text"] = text
if "x" not in new_kwargs:
new_kwargs["x"] = x
if "y" not in new_kwargs:
new_kwargs["y"] = y
if "width" not in new_kwargs:
new_kwargs["width"] = width
if "height" not in new_kwargs:
new_kwargs["height"] = height
return TextRender.render_text(None, **new_kwargs)
return (None, None, lll,)
font_name = kwargs.get("font_name", font)
# Create an empty image with RGBA channels
image = Image.new('RGBA', (width, height))
self._render_text(font_name, image, size, text, x, y, allow_shrink=True)
_render_text(font_name, image, size, text, x, y, allow_shrink=True, sf=sf, sw=sw)
# Convert the image to numpy array
image_array = np.array(image)
@@ -1700,87 +1829,19 @@ class TextRender:
# make a copy for the RGB image
image_rgb = image_tensor[..., :3]
print(torch.cuda.memory_stats())
return (image_rgb, image_tensor,)
# print(torch.cuda.memory_stats())
del image
del image_array
del color_arr
from PIL import ImageFont, ImageDraw
from PIL import ImageFont, ImageDraw
process = psutil.Process();
memory_info = process.memory_info();
end_memory = memory_info.rss / (1024 * 1024)
print("Memory used: {} MB".format(end_memory - start_memory))
def _wrap_text(self, font, line, max_width, d):
words = line.split(' ')
new_line = ''
lines = []
for word in words:
temp_line = new_line + word + ' '
w, _ = d.textsize(temp_line, font=font)
if w > max_width:
lines.append(new_line.strip())
new_line = word + ' '
else:
new_line = temp_line
lines.append(new_line.strip())
wrapped_line = '\n'.join(lines)
return wrapped_line
def _render_text(self, font_name, image, size, text, x, y, allow_wrap=True, allow_shrink=True):
width = image.size[0]
d = ImageDraw.Draw(image)
font = ImageFont.truetype(font_name, size)
lines = text.split('\n')
shrink_threshold = 24 # The minimum font size before we start wrapping text
line_spacing = 5 # The constant space between lines
y_offset = y
previous_height = 0 # Store the height of the previous line
for i, line in enumerate(lines):
if line.strip() == '' or line.strip() == '\n':
# For blank lines, add a blank line of the previous line's height
y_offset += previous_height + line_spacing
else:
#w, h = d.textsize(line, font=font)
(left, top, right, bottom) = d.textbbox((0, 0), text=line, font=font)
w = right - left
h = bottom - top
previous_height = h # Update the previous height
if w > width:
if allow_shrink:
new_font_size = int(size * width / w)
if new_font_size >= shrink_threshold:
font = ImageFont.truetype(font_name, new_font_size)
else:
# The font size is too small, start wrapping text
wrapped_line = self._wrap_text(font, line, width, d)
y_offset = self._render_text(font_name,
image,
size,
wrapped_line, x, y_offset + line_spacing,
allow_wrap=False,
allow_shrink=False)
continue # Skip to the next line
if allow_wrap:
# Wrap the text
wrapped_line = self._wrap_text(font, line, width, d)
y_offset = self._render_text(font_name,
image,
new_font_size,
wrapped_line,
x,
y_offset + line_spacing,
allow_wrap=False,
allow_shrink=False)
continue # Skip to the next line
# Draw the line
x_offset = x + (width - w) / 2
d.text((x_offset, y_offset), line, font=font, fill="#FFFFFF")
y_offset += h + line_spacing # Move y_offset to the bottom of the last line drawn plus the line spacing
# debug print the amount of free memory in torch
return y_offset
image_rgb = image_rgb.to("cpu")
image_tensor = image_tensor.to("cpu")
return (image_rgb, image_tensor, None,)
@ETK_image_base
+1 -1
View File
@@ -72,7 +72,7 @@ app.registerExtension({
// Set widget value to filename
// filter to the widget that has the name "vide_in"
const widget = node.widgets.find(w => w.name === "video_in");
widget.value = filename.filename;
widget.value = file.name;
+414 -72
View File
@@ -11,7 +11,9 @@ from abc import ABC, ABCMeta, abstractmethod
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
@@ -163,12 +165,9 @@ class ABCABCVideoFileToImage(ABCVideoWidget, metaclass=ABCWidgetMetaclass):
"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": ([True, False], {"default": False}),
},
"optional":
{"text":
("STRING", {"multiline": False}),
}
}
@abstractmethod
@@ -189,6 +188,8 @@ class VideoToFramesFolder(ABCVideoFileToABCVideoFolder, metaclass=ABCWidgetMetac
# 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)
@@ -200,6 +201,64 @@ class VideoToFramesFolder(ABCVideoFileToABCVideoFolder, metaclass=ABCWidgetMetac
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,)
@@ -532,17 +591,30 @@ class GetImageStackStatistics(ABCABCVideoFolderToImage, metaclass=ABCWidgetMetac
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, text, idx_slice_start, idx_slice_stop, slice_idx_step, idx):
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 = os.path.join(self.input_dir, video_in)
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
@@ -551,6 +623,9 @@ class VideoFileToImageStack(ABCABCVideoFileToImage, metaclass=ABCWidgetMetaclass
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)
@@ -563,12 +638,24 @@ class VideoFileToImageStack(ABCABCVideoFileToImage, metaclass=ABCWidgetMetaclass
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)
tensor = torch.stack([torch.tensor(img, dtype=torch.float32) for img in image_list])
tensor = tensor / 255.0
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
return (tensor,)
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):
@@ -1177,6 +1264,9 @@ class ImageStackToVideoFile(metaclass=ABCWidgetMetaclass):
import imageio
import audioread
import cv2
import torchvision
use_torchvision = True
# video_dir = kwargs["video_dir"]
@@ -1222,52 +1312,54 @@ class ImageStackToVideoFile(metaclass=ABCWidgetMetaclass):
else:
video_fps = frames_length / fps_length_value
folder_out = "temp_frames_folder"
if use_torchvision ==False:
writer = imageio.get_writer(video_out_path, fps=float(video_fps))
# Now, read the frames from disk as numpy data
import tempfile
# use tempfile to create a temporary folder
with tempfile.TemporaryDirectory() as temp_dir:
frames_out_path = temp_dir
# Convert the image stack to frames on disk
frame_folder_handler = ImageStackToVideoFramesFolder()
frame_folder_handler.handler(image_stack=image_stack, folder_out=frames_out_path)
frames = []
for fl in sorted(os.listdir(frames_out_path)):
# if the fl is an actual image file
if not fl.lower().endswith(('.jpg', '.jpeg', '.png')):
continue
# create the numpy array
frame = cv2.imread(os.path.join(frames_out_path, fl))
# convert to RGB
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)
# append to the list of frames
frames.append(frame)
writer.append_data(frame)
writer = imageio.get_writer(video_out_path, fps=video_fps)
for frame in frames:
writer.append_data(frame)
writer.close()
if audio_file is not None:
difference = video_length - audio_length
if audio_file is not None:
if audio_length > video_length:
while video_length < audio_length:
image_stack = np.concatenate((image_stack, image_stack[-1:]), axis=0)
video_length += 1 / video_fps
frames_length += 1
needed_extra_frames = int(difference * video_fps)
args = [
"-i", video_out_path,
"-i", audio_file,
"-c:v", codec,
"-b:v", str(bitrate),
"-c:a", "aac",
"-strict", "experimental",
video_out_path
]
run_ffmpeg_command(args)
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:
aud_sr = torchaudio.io.StreamReader(audio_file)
torchvision.io.write_video(filename=video_out_path,
video_array=image_stack,
fps=video_fps,
audio_array=audio_tensor,
)
return (self, video_out_path,)
@@ -1459,43 +1551,293 @@ def combine_videos(video_paths, base_input_directory, output_path):
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": {"file_path": ("STRING", {"default": "fullpath"})}}
return {"required": {"input_filename": ("STRING", {"default": "fullpath"})}}
RETURN_TYPES = ("STRING",)
RETURN_TYPES = ("STRING", "STRING",)
RETURN_NAMES = ("lines_out", "words_out",)
FUNCTION = "transcribe_movie"
CATEGORY = "transcription"
def transcribe_movie(self, file_path):
import os
import time
import numpy as np
import whisper as wh
import moviepy as mp
def transcribe_movie(self, input_filename):
import folder_paths as fp
input_dir = fp.input_directory
full_path = os.path.join(input_dir, input_filename)
try:
model = wh.load_model("medium.en")
filename = os.path.basename(file_path)
audio_file_path = f"audio_{filename}.wav"
segs = transcribe_to_segs(full_path)
video = mp.VideoFileClip(file_path)
video.audio.write_audiofile(audio_file_path)
res = wh.transcribe(model=model, audio=audio_file_path)
segs = res["segments"]
out = []
lines_out = []
for seg in segs:
out.append(f"{seg['start']:3.3f} - {seg['end']:3.3f}: {seg['text']}")
ret = '\n'.join(out)
lines_out.append(f"{seg['start']:3.3f} - {seg['end']:3.3f}: {seg['text']}")
os.remove(audio_file_path)
lines_out = '\n'.join(lines_out)
return (ret,)
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",)
return ("Error",)
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,)