diff --git a/3d.py b/3d.py
new file mode 100644
index 0000000..804d3b2
--- /dev/null
+++ b/3d.py
@@ -0,0 +1,159 @@
+NODE_CLASS_MAPPINGS = {}
+NODE_DISPLAY_NAME_MAPPINGS = {}
+
+
+def ETK_3d_base(cls):
+ import functools
+ cls.CATEGORY = "ETK/3d"
+ # Add spaces to the camel case class name
+ pretty_name = cls.__name__
+ for i in range(1, len(pretty_name)):
+ if pretty_name[i].isupper():
+ pretty_name = pretty_name[:i] + " " + pretty_name[i:]
+ cls.DISPLAY_NAME = pretty_name
+ NODE_CLASS_MAPPINGS[cls.__name__] = cls
+ NODE_DISPLAY_NAME_MAPPINGS[cls.DISPLAY_NAME] = pretty_name
+ return cls
+
+
+@ETK_3d_base
+class TripoSRNode:
+ def __init__(self):
+ import os
+ from folder_paths import output_directory
+ self.output_dir = output_directory
+ self.repo_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "repos", "img23d")
+ self.repo_url = "https://github.com/VAST-AI-Research/TripoSR.git"
+ self.venv_dir = os.path.join(self.repo_dir, "venv")
+ self.python_executable = os.path.join(self.venv_dir, "Scripts", "python")
+
+ @classmethod
+ def INPUT_TYPES(cls):
+ return {
+ "required": {
+ "image": ("IMAGE",),
+ "output_dir": ("STRING", {"default": "outputs/TripoSR"}),
+ "mc_resolution": ("INT", {"default": 256}),
+ }
+ }
+
+ RETURN_TYPES = ("STRING",)
+ FUNCTION = "generate_3d_models"
+
+ CATEGORY = "3D"
+
+ def generate_3d_models(self, image, output_dir=None, mc_resolution=256):
+ import asyncio
+
+ if output_dir is not None:
+ #self.output_dir = f"{self.output_dir}/{output_dir}"
+ self.final_out_dir = f"{self.output_dir}/{output_dir}"
+
+ # Create a new asyncio loop
+ loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+
+ # Run the async function within the loop
+ output_paths = loop.run_until_complete(self.async_generate_3d_models(image, mc_resolution))
+
+ return (output_paths,)
+
+ async def async_generate_3d_models(self, image, mc_resolution):
+ import os
+ import numpy as np
+ from PIL import Image
+ import asyncio
+
+ # Check if the TripoSR repository is installed
+ if not os.path.exists(self.repo_dir):
+ # Clone the repository if it doesn't exist
+ await self.clone_repository()
+ # Create a virtual environment and install requirements
+ await self.create_venv_and_install_requirements()
+
+ # Create a temporary directory for saving input images
+ temp_dir = "temp_images"
+ os.makedirs(temp_dir, exist_ok=True)
+
+ # Save each image in the batch as a separate file
+ # each image is a torch tensor with shape (b,h,w,c)
+ image_paths = []
+ for i in range(image.shape[0]):
+ image_np = image[i].cpu().numpy()
+ image_pil = Image.fromarray((image_np * 255).astype(np.uint8))
+ image_path = os.path.join(temp_dir, f"image_{i}.png")
+ image_pil.save(image_path)
+ image_paths.append(image_path)
+
+ # Run inference using the TripoSR CLI for each image simultaneously
+ output_paths = await asyncio.gather(*[self.run_inference(image_path,mc_resolution) for image_path in image_paths])
+
+ # Clean up the temporary image files
+ for image_path in image_paths:
+ os.remove(image_path)
+
+ return output_paths
+
+ async def run_inference(self, image_path, mc_resolution):
+ import asyncio
+ import os
+ # Create a virtual environment and install requirements if they don't exist
+ command = [
+ self.python_executable,
+ os.path.join(self.repo_dir, "run.py"),
+ image_path,
+ "--mc-resolution",
+ f"{mc_resolution}",
+ "--output-dir",
+ self.final_out_dir,
+ ]
+ process = await asyncio.create_subprocess_exec(*command)
+ await process.wait()
+ output_path = self.get_output_path(image_path)
+ return output_path
+
+ async def clone_repository(self):
+ import asyncio
+ # Clone the TripoSR repository
+ command = ["git", "clone", self.repo_url, self.repo_dir]
+ process = await asyncio.create_subprocess_exec(*command)
+ await process.wait()
+
+ async def create_venv_and_install_requirements(self):
+ import asyncio
+ import os
+ # Create a virtual environment
+ command = ["python", "-m", "venv", self.venv_dir]
+ # use os to execute the command
+ os.system(" ".join(command))
+
+ # install pip "python -m ensurepip"
+ command = [self.python_executable, "-m", "ensurepip"]
+ os.system(" ".join(command))
+
+ # install wheel
+ command = [self.python_executable, "-m", "pip", "install", "wheel"]
+ os.system(" ".join(command))
+
+ # install torch
+ # TODO: detect what version of torch to install based on what is installed on system
+ command = [self.python_executable, "-m", "pip", "install", "torch",
+ "--index-url https://download.pytorch.org/whl/cu121"]
+
+ #command = [self.python_executable, "-m", "pip", "install", "torch",
+ # "--index-url https://download.pytorch.org/whl/cu118"]
+
+ os.system(" ".join(command))
+
+ # Install requirements
+ command = [self.python_executable, "-m", "pip", "install", "-r",
+ os.path.join(self.repo_dir, "requirements.txt")]
+ os.system(" ".join(command))
+
+ def get_output_path(self, image_path):
+ import os
+ # Extract the base name of the input image file
+ base_name = os.path.splitext(os.path.basename(image_path))[0]
+ # Construct the output path based on the base name and output directory
+ output_path = os.path.join(self.output_dir, base_name, "obj", "mesh_norm.obj")
+ return output_path
diff --git a/__init__.py b/__init__.py
index 1936540..6df4939 100644
--- a/__init__.py
+++ b/__init__.py
@@ -1,5 +1,6 @@
import os
import importlib
+
WEB_DIRECTORY = "./web"
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
OLD_NODE_CLASS_MAPPINGS = {
@@ -33,12 +34,14 @@ NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
# Try importing optional dependencies
+import sys
-if os.getenv("UNIT_TEST", False):
+if os.getenv("UNIT_TEST", False) or 'pytest' in sys.modules:
pass
else: # if not a unit test
try:
import comfy.samplers
+
use_generic_nodes = False
except ImportError:
use_generic_nodes = True
@@ -53,7 +56,7 @@ else: # if not a unit test
if file.endswith(".py") and file[0] != "_":
# import the NODE_CLASS_MAPPINGS from the file
try:
- module = importlib.import_module(f".{file[:-3]}", __name__)
+ module = importlib.import_module(f".{file[:-3]}", __name__)
except ImportError:
try:
module = importlib.import_module(f"{file[:-3]}") # needed for testing?
diff --git a/audio.py b/audio.py
index 57cdb8e..c5cd55a 100644
--- a/audio.py
+++ b/audio.py
@@ -1,9 +1,18 @@
-import copy
+import shutil
+import copy
+import imageio_ffmpeg as ffmpeg
+import joblib
+from config import config_settings
+
+temp_dir = config_settings["tmp_dir"]
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
+# Create a memory object for caching
+# memory = joblib.Memory(temp_dir, verbose=0)
+
def ETK_audio_base(cls):
cls.CATEGORY = "ETK/audio"
NODE_CLASS_MAPPINGS[cls.__name__] = cls
@@ -73,10 +82,9 @@ class AudioFileListFromPathPattern:
return (l_fpaths, l_bytes, l_metadata,)
-import librosa
-import soundfile as sf
import numpy as np
+
@ETK_audio_base
class JoinAudioFiles:
""" Joins a list of audio files into a single audio file """
@@ -95,6 +103,8 @@ class JoinAudioFiles:
CATEGORY = 'ETK/audio'
def join_audio_files(self, **kwargs):
+ import librosa
+ import soundfile as sf
kwargs = copy.deepcopy(kwargs)
paths = kwargs['paths']
output_path = kwargs['output_path']
@@ -123,6 +133,7 @@ class JoinAudioFiles:
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 """
@@ -132,7 +143,7 @@ class ExtractAudioFromVideo:
return {
"required": {
"video_path": ("STRING", {"multiline": False, "default": ""}),
- "format":(["wav", "mp3"], {"default": "wav"}),
+ "format": (["wav", "mp3"], {"default": "wav"}),
"output_path": ("STRING", {"multiline": False, "default": ""}),
},
"optional": {
@@ -151,7 +162,7 @@ class ExtractAudioFromVideo:
kwargs = copy.deepcopy(kwargs)
video_path = kwargs.get('video_path')
- output_path = kwargs.get('output_path',"tmp.wav")
+ output_path = kwargs.get('output_path', "tmp.wav")
audio_format = kwargs.get('format', 'wav')
bitrate = kwargs.get('bitrate', None) # Default bitrate is None
@@ -174,3 +185,395 @@ class ExtractAudioFromVideo:
audio_bytes = audio_file.read()
return (output_path, audio_bytes,)
+
+
+# import torchaudio
+# from audiocraft.models import MusicGen
+# from audiocraft.data.audio import audio_write
+#
+# model = MusicGen.get_pretrained('melody')
+# model.set_generation_params(duration=8) # generate 8 seconds.
+#
+# descriptions = ['happy rock', 'energetic EDM', 'sad jazz']
+#
+# melody, sr = torchaudio.load('./assets/bach.mp3')
+# # generates using the melody from the given audio and the provided descriptions.
+# wav = model.generate_with_chroma(descriptions, melody[None].expand(3, -1, -1), sr)
+#
+# for idx, one_wav in enumerate(wav):
+# # Will save under {idx}.wav, with loudness normalization at -14 db LUFS.
+# audio_write(f'{idx}', one_wav.cpu(), model.sample_rate, strategy="loudness")
+
+@ETK_audio_base
+class GenerateMusic:
+ """ Generates music from a given melody and descriptions """
+
+ @classmethod
+ def INPUT_TYPES(cls):
+ return {
+ "optional": {
+ "description": ("STRING", {"default": ""}),
+ "[description]": ("LIST", {"default": None}),
+ },
+ "required": {
+ "model_name": (["facebook/musicgen-melody-large",
+ "facebook/musicgen-melody-small",
+ "facebook/musicgen-melody-medium",
+
+ "facebook/musicgen-stereo-melody-large",
+ "facebook/musicgen-stereo-melody-small",
+ "facebook/musicgen-stereo-melody-medium",
+
+ "facebook/musicgen-large",
+ "facebook/musicgen-small",
+ "facebook/musicgen-medium",
+
+ "facebook/musicgen-stereo-large",
+ "facebook/musicgen-stereo-small",
+ "facebook/musicgen-stereo-medium",
+
+ ],),
+ "duration": ("INT", {"default": None}),
+ "[duration]": ("LIST", {"default": None}),
+ "file": ("STRING", {"default": ""}),
+ "seed": ("INT", {"default": -1}),
+ }
+ }
+
+ RETURN_TYPES = ("STRING", "LIST",)
+ RETURN_NAMES = ("file", "files_list",)
+ FUNCTION = 'generate_music'
+ CATEGORY = 'ETK/audio'
+
+ def create_command(self, kwargs):
+ import os
+ musicgencli_path = os.path.join(os.path.dirname(__file__), "modules", "musicgencli.py")
+ ukwargs = copy.deepcopy(kwargs)
+ nl = "\n"
+ ukwargs["description"] = kwargs.get("[description]", [])
+ ukwargs.pop("[description]")
+ ukwargs["duration"] = kwargs.get("[duration]", [])
+ ukwargs.pop("[duration]")
+ ukwargs["description"] = str([i.replace("'", "") for i in ukwargs["description"]])
+ command = " ".join([f"--{k} \"{str(v).replace(nl, ' ')}\"" for k, v in ukwargs.items()])
+ command = f"python {musicgencli_path} {command}"
+ return command
+
+ def generate_music(self, **kwargs):
+ # use the cli to call the cli music generator at ./modules/musicgencli.py
+ import os
+ import subprocess
+
+ command = self.create_command(kwargs)
+
+ # Execute the command caputring the output
+ try:
+ output = subprocess.check_output(command, shell=True)
+ except subprocess.CalledProcessError as e:
+ print(f"Error: {e}")
+
+ # use the os to find the new files
+ new_files = []
+ for file in os.listdir(os.path.dirname(kwargs['file'])):
+ if file.endswith(".wav"):
+ new_files.append(file)
+
+ return (kwargs['file'], new_files,)
+
+ def generate_music_old(self, **kwargs):
+ from audiocraft.models import MusicGen
+ from audiocraft.data.audio import audio_write
+ import torch
+ torch.cuda.empty_cache()
+
+ import random
+
+ # Set seed, -1 means random seed
+ seed = kwargs.get('seed', -1)
+ if kwargs.get('seed') == -1:
+ seed = random.randint(0, 100000)
+ old_seed_values = set_all_seeds(seed)
+
+ kwargs = copy.deepcopy(kwargs)
+ description = kwargs.get('description')
+ descriptions = kwargs.get('[description]')
+
+ if description and not descriptions:
+ descriptions = [description]
+
+ file = kwargs.get('file')
+ # add .wave if not there
+ if not file.endswith('.wav'):
+ file = file + ".wav"
+
+ model_name = kwargs.get('model_name', 'melody')
+ duration = kwargs.get('duration', 10)
+ durations = kwargs.get('[duration]', None)
+
+ if duration:
+ durations = [duration]
+
+ model = MusicGen.get_pretrained(model_name)
+ o_files = []
+ for i, description in enumerate(descriptions):
+ ii = str(i).zfill(1 + len(str(1 + len(descriptions))))
+ file_to_use = file.replace(".wav", f"_{ii}.wav")
+ duration_to_use = durations[i]
+
+ model.set_generation_params(duration=duration_to_use)
+ wav = model.generate([description])
+ # q = MusicGen.set_generation_params()
+ audio_write(file_to_use.replace(".wav", ""), wav[0].cpu(), model.sample_rate, strategy="loudness")
+ o_files.append(file_to_use)
+
+ # Reset seed
+ reset_seeds(old_seed_values)
+ torch.cuda.empty_cache()
+ del model
+ torch.cuda.empty_cache()
+ return (o_files[0], o_files,)
+
+
+def set_all_seeds(seed):
+ import random
+ import os
+ import numpy as np
+ import torch
+ old_values = {}
+ old_values['random'] = random.getstate()
+ old_values['numpy'] = np.random.get_state()
+ old_values['torch'] = torch.get_rng_state()
+ old_values['torch_cuda'] = torch.cuda.get_rng_state()
+ old_values['torch_cuda_all'] = torch.cuda.get_rng_state_all()
+ old_values['torch_deterministic'] = torch.backends.cudnn.deterministic
+
+ # From https://gist.github.com/gatheluck/c57e2a40e3122028ceaecc3cb0d152ac
+ random.seed(seed)
+ os.environ["PYTHONHASHSEED"] = str(seed)
+ np.random.seed(seed)
+ torch.manual_seed(seed)
+ torch.cuda.manual_seed(seed)
+ torch.backends.cudnn.deterministic = True
+
+ return old_values
+
+
+def reset_seeds(old_values):
+ import random
+ import numpy as np
+ import torch
+
+ random.setstate(old_values['random'])
+ np.random.set_state(old_values['numpy'])
+ torch.set_rng_state(old_values['torch'])
+ torch.cuda.set_rng_state(old_values['torch_cuda'])
+ torch.cuda.set_rng_state_all(old_values['torch_cuda_all'])
+ torch.backends.cudnn.deterministic = old_values['torch_deterministic']
+
+
+@ETK_audio_base
+class XttsNode:
+ """ Converts text to speech """
+
+ @classmethod
+ def INPUT_TYPES(cls):
+ return {
+ "required": {
+ "device": ("STRING", {"default": "cuda"}),
+ },
+ "optional": {
+ "text": ("STRING", {"multiline": False, "default": ""}),
+ "[text]": ("LIST", {"multiline": False, "default": ""}),
+ "speaker_wav": ("STRING", {"multiline": False, "default": ""}),
+ "language_code": (
+ ['en', 'es', 'fr', 'de', 'it', 'pt', 'pl', 'tr', 'ru', 'nl', 'cs', 'ar', 'zh-cn', 'hu', 'ko', 'ja',
+ 'hi'], {"default": "en"}),
+ "output_path": ("STRING", {"multiline": False, "default": ""}),
+ "AUDIO_FOLDER_DEF": ("AUDIO_FOLDER_DEF", {"default": None}),
+ }
+ }
+
+ RETURN_TYPES = ("STRING", "LIST", "STRING", "AUDIO_FOLDER_DEF",)
+ RETURN_NAMES = ("output file", "output files list", "output folder", "AUDIO_FOLDER_DEF",)
+ FUNCTION = 'xtts'
+ CATEGORY = 'ETK/audio'
+
+ def xtts(self=None, **kwargs):
+ import os
+ import subprocess
+ import sys
+ import shutil
+ from .config import this_file_path
+
+ audio_folder_def = kwargs.get("AUDIO_FOLDER_DEF", None)
+
+ python_exe = sys.executable
+
+ # Path to the xttscli.py script
+ xttscli_path = os.path.join(this_file_path, "modules", "xttscli.py")
+
+ # Check if '[text]' is in kwargs and if it is a list
+ if '[text]' in kwargs and isinstance(kwargs['[text]'], list):
+ texts = str(kwargs['[text]'])
+ else:
+ texts = str([kwargs.get('text', "")])
+
+ # Create the command
+ command = [
+ python_exe, xttscli_path,
+ "--text", texts,
+ "--folder", kwargs.get('output_path', "."),
+ "--lang", kwargs.get('lang', 'en'),
+ "--speaker", kwargs.get('speaker_wav', ''),
+ ]
+
+ # execute the command in its own full porcess and recieve the output
+ output = subprocess.check_output(command, shell=True)
+ generated = output.decode("utf-8")
+ generated_files = generated.split("Generated files: (")[1].split(")")[0]
+ generated_files = eval(generated_files)
+ generated_files = generated_files[1]
+ # get the output folder
+ output_folder = os.path.dirname(kwargs.get('file', "output.wav"))
+
+ # move the files to the correct files
+ out_files = []
+ for src_file in generated_files:
+ target_file = audio_folder_def.get_next_file_name()
+ shutil.copy(src_file, target_file)
+ out_files.append(target_file)
+
+ return (out_files[0], out_files, output_folder, audio_folder_def,)
+
+
+import moviepy.editor as mpy
+
+
+class MovieCrossfadeMultiAudio:
+ @classmethod
+ def INPUT_TYPES(cls):
+ return {
+ "required": {
+ "video_path1": ("STRING", {}),
+ "video_path2": ("STRING", {}),
+ "crossfade_duration": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 10.0, "step": 0.1})
+ }
+ }
+
+ RETURN_TYPES = ("STRING",)
+ FUNCTION = "crossfade_videos"
+ CATEGORY = "video_processing"
+
+ def crossfade_videos(self, video_path1, video_path2, crossfade_duration):
+ # Load video clips
+ clip1 = mpy.VideoFileClip(video_path1)
+ clip2 = mpy.VideoFileClip(video_path2)
+
+ # Extract primary audio tracks (assuming the first track is primary)
+ audio1 = clip1.audio
+ audio2 = clip2.audio
+
+ # Crossfade audio tracks
+ audio_crossfade = mpy.CompositeAudioClip([audio1, audio2.set_start(clip1.duration - crossfade_duration)])
+
+ # Crossfade video clips
+ video_crossfade = mpy.concatenate_videoclips([clip1, clip2.set_start(clip1.duration - crossfade_duration)],
+ method="compose", padding=-crossfade_duration)
+
+ # Set the crossfaded audio to the crossfaded video
+ final_clip = video_crossfade.set_audio(audio_crossfade)
+
+ # Output attributes
+ self.video = final_clip
+
+ return {
+ "video": self.video
+ }
+
+
+@ETK_audio_base
+class AdjustAudioVolumeForFiles():
+ """Adjusts the volume of a list of audio files."""
+
+ @classmethod
+ def INPUT_TYPES(cls):
+ return {
+ "required": {
+ "audio_files": ("LIST", {"default": []}),
+ "volume": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 10.0, "step": 0.1}),
+ },
+ "optional": {
+ "output_folder": ("STRING", {"default": ""}),
+ }
+ }
+
+ RETURN_TYPES = ("LIST",)
+ RETURN_NAMES = ("modified_audio_paths",)
+ CATEGORY = "audio_processing"
+ OUTPUT_NODE = True
+ FUNCTION = "handler"
+
+ def handler(self, **kwargs):
+ import os
+ from os.path import splitext, join
+ audio_files = kwargs["audio_files"]
+ volume = kwargs["volume"]
+ output_folder = kwargs.get("output_folder", "")
+
+ modified_audio_paths = []
+
+ ffmpeg_exe = ffmpeg.get_ffmpeg_exe()
+ if ffmpeg_exe is None:
+ raise RuntimeError("FFmpeg could not be found.")
+
+ for audio_file in audio_files:
+ output_file = splitext(audio_file)[0] + f"_adjusted_volume.mp3"
+ if output_folder:
+ output_file = join(output_folder, os.path.basename(output_file))
+
+ command = [
+ ffmpeg_exe, '-i', audio_file,
+ '-filter:a', f'volume={volume}',
+ '-y', output_file
+ ]
+
+ cmd = ' '.join(command)
+ print(cmd)
+ os.system(cmd)
+
+ modified_audio_paths.append(output_file)
+
+ return (modified_audio_paths,)
+
+
+if __name__ == '__main__':
+ # do some testing
+ wave_file = """C:\\Users\\Tasha\\Nextcloud\\ETK\\YT\\Monsters\\The Haunter of the Ring\\The Haunter of the Ring real.wav"""
+ xtts_node = XttsNode()
+ xtts_node.xtts(speaker_wav=wave_file,
+ text="Hello world! I'm alive, you had better believe it! muahahaha ---- I'm alive! HAHAHAHA! hahahahahahahaha",
+ language_code="en",
+ output_path="output.wav")
+ # use windows to play the file
+ import os
+
+ os.system("start output.wav")
+
+ # Paths to the video files to crossfade
+ video_path1 = 'path/to/your/first/video.mp4'
+ video_path2 = 'path/to/your/second/video.mp4'
+
+ # Duration of the crossfade in seconds
+ crossfade_duration = 2.0 # Example duration
+
+ # Create an instance of the MovieCrossfadeMultiAudio node
+ crossfade_node = MovieCrossfadeMultiAudio()
+
+ # Execute the node's crossfade function
+ output_video = crossfade_node.crossfade_videos(video_path1, video_path2, crossfade_duration)
+
+ # Save the output video to a file
+ output_video_path = 'path/to/your/output/crossfaded_video.mp4'
+ output_video['video'].write_videofile(output_video_path, codec='libx264', audio_codec='aac')
+
+ print(f'Crossfaded video saved to {output_video_path}')
diff --git a/config.yaml b/config.yaml
index 94cc943..6314cee 100644
--- a/config.yaml
+++ b/config.yaml
@@ -3,4 +3,6 @@ tmp_dir: comfy_path/tmp
gitio:
COTRF: https://github.com/Hopping-Mad-Games/COTRF.git
StSq: https://github.com/story-squad/GDE_Graph_IO.git
+ HMG: https://github.com/TashaSkyUp/HMG_GDE_GITIO
base: ./data/gitio
+tesseract_location: /usr/bin/tesseract
\ No newline at end of file
diff --git a/functional.py b/functional.py
index 13d5717..4c1c62b 100644
--- a/functional.py
+++ b/functional.py
@@ -22,6 +22,7 @@ def ETK_functional_base(cls):
NODE_CLASS_MAPPINGS[cls.__name__] = cls
return cls
+
def new_exec(c, g, l):
global old_exec
import os
@@ -36,6 +37,7 @@ def new_exec(c, g, l):
return locals
+
old_exec = exec
exec = new_exec
@@ -336,7 +338,7 @@ class FuncListToList:
exec(code, my_globals, my_locals)
- return (my_locals["y_list"],)
+ return (my_locals["y"],)
@ETK_functional_base
@@ -390,7 +392,7 @@ class FuncStrToList:
exec(code, my_globals, my_locals)
- return (my_locals["y_list"],)
+ return (my_locals["y"],)
@ETK_functional_base
@@ -483,14 +485,16 @@ class FuncAnysToImage:
FUNCTION = "func"
def func(self, **kwargs):
+ import gc
+ import torch
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_globals = globals().copy()
+ my_locals = {}
my_locals["any1"] = any1
my_locals["any2"] = any2
@@ -499,11 +503,21 @@ class FuncAnysToImage:
exec(code, my_globals, my_locals)
if "y" in my_locals.keys():
- return (my_locals["y"],)
+ y = my_locals["y"]
elif "y" in my_globals.keys():
- return (my_globals["y"],)
+ y = my_globals["y"]
else:
- return (None,)
+ y = None
+
+ del my_locals
+ del my_globals
+ del code
+ del any1, any2, any3, any4
+ gc.collect()
+ torch.cuda.empty_cache()
+
+ return (y,)
+
@ETK_functional_base
class FuncAnysToLatent:
@@ -552,6 +566,7 @@ class FuncAnysToLatent:
else:
return (None,)
+
@ETK_functional_base
class FuncAnysToString:
"""runs eval on the given text, with 5 anys as input and outputs a string"""
@@ -599,6 +614,7 @@ class FuncAnysToString:
else:
return (None,)
+
@ETK_functional_base
class FuncAnysToList:
"""runs eval on the given text, with 5 anys as input and outputs a list"""
@@ -631,7 +647,7 @@ class FuncAnysToList:
any4 = kwargs.get("any4", None)
my_globals = globals()
- my_locals = locals()
+ my_locals = {}
my_locals["any1"] = any1
my_locals["any2"] = any2
@@ -645,6 +661,61 @@ class FuncAnysToList:
return (my_globals["y"],)
else:
return (None,)
+
+
+@ETK_functional_base
+class FuncAnysToCond:
+ """runs eval on the given text, with 5 anys as input and outputs a cond"""
+
+ 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 = ("CONDITIONING",)
+ FUNCTION = "func"
+
+ def func(self, **kwargs):
+ import gc
+ 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 = {}
+
+ my_locals["any1"] = any1
+ my_locals["any2"] = any2
+ my_locals["any3"] = any3
+ my_locals["any4"] = any4
+
+ exec(code, my_globals, my_locals)
+ if "y" in my_locals.keys():
+ y = (my_locals["y"],)
+ elif "y" in my_globals.keys():
+ y = my_globals["y"],
+ else:
+ y = (None,)
+ del my_locals
+ gc.collect()
+
+ return (y,)
+
+
if __name__ == "__main__":
test = {}
diff --git a/hf_diffusers_nodes.py b/hf_diffusers_nodes.py
index e00a205..8777c91 100644
--- a/hf_diffusers_nodes.py
+++ b/hf_diffusers_nodes.py
@@ -251,3 +251,27 @@ class TrainUnet2dModel:
return (model,)
+# @ETK_HF_Diffusers_base
+# class LoadModelFromHF:
+# """A node that loads the specified model from huggingface"""
+#
+# @classmethod
+# def INPUT_TYPES(cls):
+# return {
+# "required": {
+# "model_name": ("STRING", {"default": "johnowhitaker/ddpm-butterflies-32px"}),
+# },
+# }
+#
+# RETURN_TYPES = ("TORCH_MODEL",)
+# FUNCTION = "load_model_from_hf"
+#
+# def load_model_from_hf(self, model_name):
+# from huggingface_hub import hf_hub_url
+# from diffusers import DDPMPipeline
+# ret = DDPMPipeline.from_pretrained(model_name, device="cuda")
+# return (ret,)
+
+
+
+
diff --git a/image.py b/image.py
index 399bf37..2ad9eec 100644
--- a/image.py
+++ b/image.py
@@ -1,4 +1,5 @@
import os
+
import comfy
try:
@@ -29,7 +30,8 @@ if testing:
pass
else:
try:
- from nodes import VAEDecode, KSampler, CheckpointLoaderSimple, EmptyLatentImage
+ from nodes import VAEDecode, KSampler, CheckpointLoaderSimple, EmptyLatentImage, CLIPTextEncode, VAEDecode, \
+ VAEEncode, PreviewImage
from nodes import CLIPTextEncode, VAEEncode, SaveImage
from nodes import common_ksampler
@@ -54,9 +56,7 @@ except ImportError as e:
import folder_paths
import torch
import torchvision
-import PIL.Image as Image
import os
-import numpy as np
import hashlib
### info for code completion AI ###
@@ -71,10 +71,22 @@ NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
+# def ETK_image_base(cls):
+# # cls.FUNCTION = "func"
+# cls.CATEGORY = "ETK/Image"
+# NODE_CLASS_MAPPINGS[cls.__name__] = cls
+# return cls
+
def ETK_image_base(cls):
- # cls.FUNCTION = "func"
cls.CATEGORY = "ETK/Image"
+ # Add spaces to the camel case class name
+ pretty_name = cls.__name__
+ for i in range(1, len(pretty_name)):
+ if pretty_name[i].isupper():
+ pretty_name = pretty_name[:i] + " " + pretty_name[i:]
+ cls.DISPLAY_NAME = pretty_name
NODE_CLASS_MAPPINGS[cls.__name__] = cls
+ NODE_DISPLAY_NAME_MAPPINGS[cls.DISPLAY_NAME] = pretty_name
return cls
@@ -173,10 +185,10 @@ class TinyTxtToImg:
def __init__(self):
print("ETK> TinyTxtToImg init")
- import random
- self.mdl = TinyTxtToImg.share_mdl
- self.clp = TinyTxtToImg.share_clip
- self.vae = TinyTxtToImg.share_vae
+ self.mdl = None
+ self.clp = None
+ self.vae = None
+
self.vision = None
self.steps = 10
self.cfg = 8
@@ -210,8 +222,11 @@ class TinyTxtToImg:
"default": "tinytxt2img"}
),
"overrides": ("STRING", {"multiline": True,
- "default": ""}),
- "FUNC": ("FUNC",)
+ "default": '{"width":768,"height":768,"steps":20}'}),
+ "latent": ("LATENT",),
+ "pos_cond": ("CONDITIONING",),
+ "neg_cond": ("CONDITIONING",),
+ "image": ("IMAGE",),
}
}
@@ -221,19 +236,13 @@ class TinyTxtToImg:
RETURN_NAMES = ("image", "FUNC", "latent", "positive", "negative",)
FUNCTION = "tinytxt2img"
-
def tinytxt2img(self, **kwargs):
""" use the imports from nodes to generate an image from text """
-
+ import comfy.model_management as mm
import random
import json
import gc
-
- # Create a stream for computation
- # gpu_stream = torch.cuda.Stream(device="cuda")
- # cpu_stream = torch.Stream()
-
- # kwargs = deepcopy(kwargs)
+ torch.cuda.empty_cache()
unload_model = kwargs.get("unload_model", True)
prompt = kwargs.get("prompt", "")
@@ -255,45 +264,37 @@ class TinyTxtToImg:
self.denoise = kwargs.get("denoise", 1.0)
self.steps = kwargs.get("steps", 10)
- if not hasattr(TinyTxtToImg, "share_mdl"):
- TinyTxtToImg.share_mdl = None
- TinyTxtToImg.share_mdl_ckpt_name = None
+ self.mdl, self.clp, self.vae = \
+ CheckpointLoaderSimple.load_checkpoint(None,
+ ckpt_name=ckpt_name
+ )
+ # Check if an input image is provided
+ input_image = kwargs.get("image", None)
+ if input_image is not None:
+ # Encode the input image using the model's VAE
+ with torch.no_grad():
+ self.vae.device = torch.device("cuda:0")
+ self.vae.first_stage_model = self.vae.first_stage_model.to("cuda:0")
+ # self.vae.first_stage_model.decoder = self.vae.first_stage_model.decoder.to("cuda:0")
+ # self.vae.first_stage_model = self.vae.first_stage_model.to("cuda:0")
+ input_image = input_image.to("cuda")
+ q = self.vae.encode(input_image)
+ self.latent_image = {"samples": q}
- if TinyTxtToImg.share_mdl is None:
- self.mdl, self.clp, self.vae = \
- CheckpointLoaderSimple.load_checkpoint(None,
- ckpt_name=ckpt_name
- )
- TinyTxtToImg.share_mdl_ckpt_name = ckpt_name
- CSL = TinyTxtToImg
- CSL.share_mdl = self.mdl
- CSL.share_clip = self.clp
- CSL.share_vae = self.vae
- CSL.share_vision = self.vision
+ self.cfg = kwargs.get("cfg", 8)
- else:
- # check if the checkpoint is the same
- if TinyTxtToImg.share_mdl_ckpt_name != ckpt_name:
- self.mdl, self.clp, self.vae = \
- CheckpointLoaderSimple.load_checkpoint(None,
- ckpt_name=ckpt_name
- )
- TinyTxtToImg.share_mdl_ckpt_name = ckpt_name
- CSL = TinyTxtToImg
- CSL.share_mdl = self.mdl
- CSL.share_clip = self.clp
- CSL.share_vae = self.vae
- CSL.share_vision = self.vision
+ if not "sampler_name" in kwargs:
+ if "lcm" in ckpt_name.lower():
+ self.sampler_name = comfy.samplers.KSampler.SAMPLERS[18]
+ self.scheduler = comfy.samplers.KSampler.SCHEDULERS[0]
else:
+ self.sampler_name = comfy.samplers.KSampler.SAMPLERS[0]
+ self.scheduler = comfy.samplers.KSampler.SCHEDULERS[0]
+ self.scheduler = comfy.samplers.KSampler.SCHEDULERS[0]
+ else:
+ self.sampler_name = kwargs.get("sampler_name", comfy.samplers.KSampler.SAMPLERS[0])
+ self.scheduler = kwargs.get("scheduler", comfy.samplers.KSampler.SCHEDULERS[0])
- self.mdl, self.clp, self.vae = (TinyTxtToImg.share_mdl,
- TinyTxtToImg.share_clip,
- TinyTxtToImg.share_vae
- )
-
- self.cfg = 8
- self.sampler_name = comfy.samplers.KSampler.SAMPLERS[0]
- self.scheduler = comfy.samplers.KSampler.SCHEDULERS[0]
self.positive = prompt
self.negative = neg_prompt
self.width = 512
@@ -338,15 +339,14 @@ class TinyTxtToImg:
print(e)
raise ValueError("advanced clip encoder failed")
self.clp.cond_stage_model = self.clp.cond_stage_model.to("cpu")
- import comfy.model_management as mm
torch.cuda.empty_cache()
gc.collect()
if self.latent_image == None:
- self.latent_image = EmptyLatentImage.generate(None, self.width, self.height, self.batch_size)[0]
+ self.latent_image = EmptyLatentImage().generate(self.width, self.height, self.batch_size)[0]
if "latent" in render_what or "all" in render_what or "image" in render_what:
- self.KSampler = KSampler()
+ self.KSampler = ETKKSampler()
# self.mdl.offload_device = torch.device("cuda:0")
# self.mdl.load_device = torch.device("cuda:0")
self.samples = self.KSampler.sample(
@@ -403,7 +403,18 @@ class TinyTxtToImg:
self.pos_cond,
self.neg_cond
,)
- # ret = deepcopy(ret)
+ if unload_model:
+ del self.mdl
+ del self.clp
+ del self.vae
+ if "vision" in self.__dict__:
+ del self.vision
+ del self.samples
+ del self.FUNC
+ del self.ARGS
+ mm.free_memory(mm.get_total_memory(mm.get_torch_device()), mm.get_torch_device())
+ torch.cuda.empty_cache()
+ gc.collect()
return ret
@@ -1334,7 +1345,6 @@ class StackImages:
def stackme(self, image1, image2=None, number_of_images=2, mode="all", number_of_repeats=1):
# import modules to help clear memory
import gc
- import torch
import torch.cuda
"""
@@ -1502,7 +1512,7 @@ class KSamplerCreateDataset:
@classmethod
def INPUT_TYPES(cls):
- inps = KSampler.INPUT_TYPES()
+ inps = ETKKSampler.INPUT_TYPES()
# make sure to set a default
inps["required"]["dataset_name"] = ("STRING", {"default": "dataset"})
return inps
@@ -1532,7 +1542,7 @@ class KSamplerCreateDataset:
:return:
"""
# create the sampler
- sampler = KSampler()
+ sampler = ETKKSampler()
x = {"model": model, "seed": seed, "steps": steps, "cfg": cfg, "sampler_name": sampler_name,
"scheduler": scheduler, "positive": positive, "negative": negative, "latent_image": latent_image,
@@ -1563,22 +1573,13 @@ class KSamplerCreateDataset:
@ETK_image_base
-class KSampler:
+class ETKKSampler:
@classmethod
def INPUT_TYPES(s):
- return {"required":
- {"model": ("MODEL",),
- "seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}),
- "steps": ("INT", {"default": 20, "min": 1, "max": 10000}),
- "cfg": ("FLOAT", {"default": 8.0, "min": 0.0, "max": 100.0}),
- "sampler_name": (comfy.samplers.KSampler.SAMPLERS,),
- "scheduler": (comfy.samplers.KSampler.SCHEDULERS,),
- "positive": ("CONDITIONING",),
- "negative": ("CONDITIONING",),
- "latent_image": ("LATENT",),
- "denoise": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}),
- "one_seed_per_batch": ([True, False], {"default": False}),
- }}
+ from nodes import KSampler
+ ret = KSampler.INPUT_TYPES()
+ ret["required"]["one_seed_per_batch"] = ([True, False], {"default": False})
+ return ret
RETURN_TYPES = ("LATENT", "FUNC",)
FUNCTION = "sample"
@@ -1587,7 +1588,7 @@ class KSampler:
def sample(self, **kwargs):
- kwargs = deepcopy(kwargs)
+ # kwargs = deepcopy(kwargs)
model = kwargs.get("model", None)
seed = kwargs.get("seed", None)
@@ -1601,8 +1602,8 @@ class KSampler:
denoise = kwargs.get("denoise", None)
one_seed_per_batch = kwargs.get("one_seed_per_batch", None)
- self.FUNC = lambda x: KSampler.sample(self, **x)
- self.ARGS = kwargs
+ # self.FUNC = lambda x: ETKKSampler.sample(self, **x)
+ # self.ARGS = kwargs
try:
ret = common_ksampler(model, seed, steps, cfg, sampler_name, scheduler, positive, negative, latent_image,
@@ -1610,11 +1611,319 @@ class KSampler:
except TypeError as e:
ret = common_ksampler(model, seed, steps, cfg, sampler_name, scheduler, positive, negative, latent_image,
denoise=denoise)
+ except RuntimeError as e:
+ raise e
ret = (ret[0], self,)
return ret
-from PIL import Image, ImageDraw, ImageFont
+@ETK_image_base
+class ListKSampler:
+ """
+ A KSampler that takes a list for every input, if the list has one element it will be repeated for every sample
+ """
+
+ @classmethod
+ def INPUT_TYPES(cls):
+ s1 = ETKKSampler.INPUT_TYPES()["required"]["sampler_name"]
+ s2 = ETKKSampler.INPUT_TYPES()["required"]["scheduler"]
+ return {
+ "required": {
+ "model": ("MODEL",),
+ "seed": ("LIST",),
+ "steps": ("LIST",),
+ "cfg": ("LIST",),
+ "sampler_name": s1,
+ "scheduler": s2,
+ "positive": ("LIST",),
+ "negative": ("LIST",),
+ "latent_image": ("LIST",),
+ "denoise": ("LIST",),
+ "one_seed_per_batch": ("LIST",),
+ }
+ }
+
+ RETURN_TYPES = ("LIST",)
+ FUNCTION = "sample"
+
+ CATEGORY = "sampling"
+
+ def sample(self, **kwargs):
+ import torch
+ # figure out how many samples we need to generate
+ num_samples = 1
+ for key in kwargs:
+ if key == "model":
+ continue
+ elif key == "sampler_name" or key == "scheduler":
+ continue
+ if kwargs[key] is not None:
+ num_samples = max(num_samples, len(kwargs[key]))
+
+ # create a list of dictionaries
+ samples = []
+ for i in range(num_samples):
+ sample = {}
+ for key in kwargs:
+ if not isinstance(kwargs[key], list) and key != "model":
+ kwargs[key] = [kwargs[key]]
+ if key == "model":
+ sample[key] = kwargs[key]
+ elif kwargs[key] is None:
+ sample[key] = None
+ elif len(kwargs[key]) == 1:
+ sample[key] = kwargs[key][0]
+ else:
+ sample[key] = kwargs[key][i]
+ samples.append(sample)
+
+ # create a list of results
+ results = []
+ local_ksampler = ETKKSampler()
+ for sample in samples:
+ results.append(local_ksampler.sample(**sample)[0])
+ torch.cuda.empty_cache()
+
+ return (results,)
+
+
+@ETK_image_base
+class ListCLIPTextEncode:
+ """
+ A CLIPTextEncode that takes a list for every input, if the list has one element it will be repeated for every sample
+ """
+
+ @classmethod
+ def INPUT_TYPES(cls):
+ return {
+ "required": {
+ "text": ("LIST",),
+ "clip": ("LIST",),
+
+ }
+ }
+
+ RETURN_TYPES = ("LIST",)
+ FUNCTION = "encode"
+
+ CATEGORY = "CLIP"
+
+ def encode(self, **kwargs):
+ # figure out how many samples we need to generate
+ num_samples = 1
+ for key in kwargs:
+ num_samples = max(num_samples, len(kwargs[key]))
+
+ # create a list of dictionaries
+ samples = []
+ for i in range(num_samples):
+ sample = {}
+ for key in kwargs:
+ if len(kwargs[key]) == 1:
+ sample[key] = kwargs[key][0]
+ else:
+ sample[key] = kwargs[key][i]
+ samples.append(sample)
+
+ # create a list of results
+ results = []
+ for sample in samples:
+ results.append(CLIPTextEncode().encode(**sample)[0])
+
+ return (results,)
+
+
+@ETK_image_base
+class ListVAEDecode:
+ """
+ A VAEDecode that takes a list for every input, if the list has one element it will be repeated for every sample
+ """
+
+ @classmethod
+ def INPUT_TYPES(cls):
+ return {
+ "required": {
+ "latent": ("LIST",),
+
+ },
+ "optional": {
+ "vae_list": ("LIST",),
+ "vae": ("VAE",),
+
+ }
+ }
+
+ RETURN_TYPES = ("LIST",)
+ FUNCTION = "decode"
+
+ CATEGORY = "VAE"
+
+ def decode(self, **kwargs):
+ # figure out if we are using the list of vae or just one
+ if kwargs.get("vae_list") is None:
+ kwargs["vae"] = [kwargs.get("vae")]
+ else:
+ kwargs["vae"] = kwargs.pop("vae_list")
+
+ if "vae_list" in kwargs:
+ kwargs.pop("vae_list")
+
+ # figure out how many samples we need to generate
+ num_samples = 1
+ for key in kwargs:
+ num_samples = max(num_samples, len(kwargs[key]))
+
+ # create a list of dictionaries
+ samples = []
+ for i in range(num_samples):
+ sample = {}
+ for key in kwargs:
+ if len(kwargs[key]) == 1:
+ sample[key] = kwargs[key][0]
+ else:
+ sample[key] = kwargs[key][i]
+ samples.append(sample)
+
+ # create a list of results
+ results = []
+ for sample in samples:
+ sample["samples"] = sample.pop("latent")
+ results.append(VAEDecode().decode(**sample)[0])
+
+ return (results,)
+
+
+@ETK_image_base
+class ListVAEEncode:
+ """
+ A VAEEncode that takes a list for every input, if the list has one element it will be repeated for every sample
+ """
+
+ @classmethod
+ def INPUT_TYPES(cls):
+ return {
+ "required": {
+ "image": ("LIST",),
+
+ },
+ "optional": {
+ "vae_list": ("LIST",),
+ "vae": ("VAE",),
+
+ }
+ }
+
+ RETURN_TYPES = ("LIST",)
+ FUNCTION = "encode"
+
+ CATEGORY = "VAE"
+
+ def encode(self, **kwargs):
+ if kwargs.get("vae_list", None) is None:
+ kwargs["vae"] = [kwargs.get("vae")]
+ else:
+ kwargs["vae"] = kwargs.pop("vae_list")
+
+ if "vae_list" in kwargs:
+ kwargs.pop("vae_list")
+
+ # figure out how many samples we need to generate
+ num_samples = 1
+ for key in kwargs:
+ num_samples = max(num_samples, len(kwargs[key]))
+
+ # create a list of dictionaries
+ samples = []
+ for i in range(num_samples):
+ sample = {}
+ for key in kwargs:
+ if len(kwargs[key]) == 1:
+ sample[key] = kwargs[key][0]
+ else:
+ sample[key] = kwargs[key][i]
+ samples.append(sample)
+
+ # create a list of results
+ results = []
+ for sample in samples:
+ results.append(VAEEncode().encode(**sample)[0])
+
+ return (results,)
+
+
+@ETK_image_base
+class ImageFromListIdx:
+ """
+ A node that takes a list of images and an index and returns the image at that index
+ """
+
+ @classmethod
+ def INPUT_TYPES(cls):
+ return {
+ "required": {
+ "images": ("LIST", {"default": []}),
+ "index": ("INT", {"default": 0, "min": 0, "max": 10e10}),
+ }
+ }
+
+ RETURN_TYPES = ("IMAGE",)
+ FUNCTION = "get_image"
+
+ CATEGORY = "ETK/image"
+
+ def get_image(self, images, index):
+ return (images[index],)
+
+
+@ETK_image_base
+class LatentFromListIdx:
+ """
+ A node that takes a list of latents and an index and returns the latent at that index
+ """
+
+ @classmethod
+ def INPUT_TYPES(cls):
+ return {
+ "required": {
+ "latents": ("LIST", {"default": []}),
+ "index": ("INT", {"default": 0, "min": -10e10, "max": 10e10}),
+ }
+ }
+
+ RETURN_TYPES = ("LATENT",)
+ FUNCTION = "get_latent"
+
+ CATEGORY = "ETK/latent"
+
+ def get_latent(self, latents, index):
+ return (latents[index],)
+
+
+@ETK_image_base
+class ListifyAnything:
+ """
+ A node that takes anything and returns it in a list
+ """
+
+ @classmethod
+ def INPUT_TYPES(cls):
+ return {
+ "required": {
+ "anything": ("*",),
+ "repeat": ("INT", {"default": 1, "min": 1, "max": 100}),
+ }
+ }
+
+ RETURN_TYPES = ("LIST",)
+ FUNCTION = "listify"
+
+ CATEGORY = "ETK/other"
+
+ def listify(self, anything, repeat):
+ return ([anything] * repeat,)
+
+
+from PIL import Image
import torch
import numpy as np
@@ -1670,13 +1979,16 @@ class TextRender:
"func_only": ([True, False], {"default": False}),
"stroke fill": ("STRING", {"default": "#000000"}),
"stroke width": ("INT", {"default": 1, "min": 1, "max": 100, "step": 1}),
+ },
+ "optional": {
+ "font_override": ("STRING", {"default": None}),
}
}
CATEGORY = "ETK/text"
- RETURN_TYPES = ("IMAGE", "IMAGE", "FUNC",)
- RETURN_NAMES = ("image", "image rgba", "FUNC(**kwargs)",)
+ RETURN_TYPES = ("IMAGE", "IMAGE", "STRING",)
+ RETURN_NAMES = ("image", "image rgba", "possible_fonts",)
FUNCTION = "render_text"
@@ -1687,11 +1999,14 @@ class TextRender:
The text is rendered in the provided font, size, and color.
"""
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_override = kwargs.get("font_override", None)
+
+ if font_override:
+ font = font_override
sw = kwargs.get("stroke width", None)
sf = kwargs.get("stroke fill", None)
@@ -1758,7 +2073,9 @@ class TextRender:
size,
wrapped_line, x, y_offset + line_spacing,
allow_wrap=False,
- allow_shrink=False)
+ allow_shrink=False,
+ sf=sf,
+ sw=sw)
continue # Skip to the next line
if allow_wrap:
@@ -1771,7 +2088,9 @@ class TextRender:
x,
y_offset + line_spacing,
allow_wrap=False,
- allow_shrink=False)
+ allow_shrink=False,
+ sf=sf,
+ sw=sw)
continue # Skip to the next line
# Draw the line
@@ -1823,10 +2142,10 @@ class TextRender:
image_tensor = torch.from_numpy(image_array).unsqueeze(0).float() / 255.0
# set the alpha to the first channel to make a mask
- image_tensor[..., 3] = image_tensor[..., 0]
+ image_tensor[..., 3] = image_tensor[..., 0].to("cpu")
# now set the text to the correct color
- color_arr = torch.zeros(1, 1, 1, 3)
+ color_arr = torch.zeros(1, 1, 1, 3).to("cpu")
color_arr[0, 0, 0, 0] = int(color[1:3], 16) / 255.0
color_arr[0, 0, 0, 1] = int(color[3:5], 16) / 255.0
color_arr[0, 0, 0, 2] = int(color[5:7], 16) / 255.0
@@ -1847,7 +2166,8 @@ class TextRender:
image_rgb = image_rgb.to("cpu")
image_tensor = image_tensor.to("cpu")
- return (image_rgb, image_tensor, None,)
+ list_of_possible_fonts_str = "\n".join(get_fonts())
+ return (image_rgb, image_tensor, list_of_possible_fonts_str,)
@ETK_image_base
@@ -1904,7 +2224,7 @@ class FuncImageStackToImageStack:
my_locals = locals()
image_x = kwargs.get("image", None)
- #gs = image_x.mean(dim=3).repeat(1, 1, 1, 3)
+ # gs = image_x.mean(dim=3).repeat(1, 1, 1, 3)
my_locals["x"] = func
my_locals["x_image"] = kwargs.get("image", None)
@@ -2048,7 +2368,6 @@ class ImageStackAndMatch:
MAX_RESOLUTION = 4096
-# Define the ComfyUI node class for text detection using CRNN model from Torch Hub
@ETK_image_base
class TextInImage:
@@ -2058,38 +2377,51 @@ class TextInImage:
"required": {
"image": ("IMAGE",),
"scale_factor": ("FLOAT", {"default": 1.0, "min": 0.5, "max": 2.0, "step": 0.1}),
- }
+ },
}
RETURN_TYPES = ("STRING",)
- FUNCTION = "detect_text"
+ FUNCTION = "detect_text_batch"
CATEGORY = "OCR"
+ def detect_text_batch(self, image, scale_factor):
+ # calls the detect_text function for each image in the batch
+ results = []
+ for i in range(image.shape[0]):
+ results.append(self.detect_text(image[i].unsqueeze(0), scale_factor)[0])
+
+ results = '\n\n'.join(results)
+ return (results,)
+
def detect_text(self, image, scale_factor):
- import torch
- from torchvision import transforms
- # the incoming tensor in image is in the format (batch, height, width, channels)
- # we need to convert it to (batch, channels, height, width)
+ from .config import config_settings
+ tesse = config_settings.get("tesseract_location", None)
+ if tesse is None:
+ raise Exception("Tesseract location not set in config")
+ import pytesseract
+ import cv2
+ import numpy as np
+ pytesseract.pytesseract.tesseract_cmd = tesse
+
+ # incomming tensor is (B, H, W, C) so convert it
image = image.permute(0, 3, 1, 2)
- new_size = list(image.shape)
- new_size[2] = int(new_size[2] * scale_factor)
- new_size[3] = int(new_size[3] * scale_factor)
- new_size = tuple(new_size[2:])
- rescaled_image = torch.nn.functional.interpolate(image, size=new_size, mode='bilinear')
- # convert to PIL image
+ # Convert the image to a numpy array
+ image = image[0].cpu().numpy()
+ image = np.transpose(image, (1, 2, 0))
+ image = (image * 255).astype(np.uint8)
- pil_image = transforms.ToPILImage()(rescaled_image.squeeze(0))
- # Load CRNN model from Torch Hub
- # Use a pipeline as a high-level helper
+ # Resize the image
+ height, width, _ = image.shape
+ image = cv2.resize(image, (int(width * scale_factor), int(height * scale_factor)))
- from transformers import pipeline
+ # Convert the image to grayscale
+ gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
- pipe = pipeline("image-to-text", model="microsoft/trocr-large-printed")
-
- detected_text = pipe.predict(pil_image)[0]["generated_text"]
+ # Perform OCR on the image
+ detected_text = pytesseract.image_to_string(gray)
return (detected_text,)
@@ -2166,7 +2498,6 @@ class TileImage:
def tile_image(self, image):
import torch
- import torch.nn.functional as F
# image is already B,H,W,C
@@ -2186,8 +2517,6 @@ class TileImage:
return (image,)
-
-
@ETK_image_base
class FloodFillNode:
node_id = "comfyui.flood_fill"
@@ -2208,7 +2537,6 @@ class FloodFillNode:
RETURN_TYPES = ("IMAGE",)
FUNCTION = "execute"
-
def __init__(self):
self.num_workers = None # Can be set to the number of processes to create
@@ -2264,6 +2592,7 @@ class FloodFillNode:
return images
+
@ETK_image_base
class DecorateHeightMap:
"""
@@ -2284,7 +2613,6 @@ class DecorateHeightMap:
RETURN_TYPES = ("IMAGE",)
FUNCTION = "execute"
-
def execute(self, height_map, images, heights, feather=0.05):
"""
Args:
@@ -2311,10 +2639,9 @@ class DecorateHeightMap:
# if there are two heights then it should be length 2
# if there are three heights then it should be length 3
# etc
- if len(feather) != len(heights)-2:
+ if len(feather) != len(heights) - 2:
raise ValueError("feather must be a single float or a list of floats of the same length as heights")
-
# Check for dimension match between height_map and images
if height_map.shape[1:3] != images.shape[1:3]:
raise ValueError("height_map and images must have the same height and width")
@@ -2338,6 +2665,7 @@ class DecorateHeightMap:
output = torch.clamp(output, 0, 1).unsqueeze(0)
return (output,)
+
@ETK_image_base
class RandomImageFromFolder:
"""
@@ -2348,14 +2676,13 @@ class RandomImageFromFolder:
def INPUT_TYPES(cls):
return {
"required": {
- "folder": ("STRING",{"default": "./output"}),
+ "folder": ("STRING", {"default": "./output"}),
}
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "execute"
-
def execute(self, folder):
"""
Args:
@@ -2382,8 +2709,347 @@ class RandomImageFromFolder:
return (image,)
-import torch
+@ETK_image_base
+class ZoomInImage:
+ """
+ Zoom in on an image by a specified factor and center point
+ """
+
+ @classmethod
+ def INPUT_TYPES(cls):
+ return {
+ "required": {
+ "image": ("IMAGE",),
+ "zoom_factor": ("FLOAT", {"default": 2.0, "min": .01, "max": 1000.0, "step": 0.1}),
+ "center_x": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}),
+ "center_y": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}),
+ "interpolation": (["nearest", "bilinear", "bicubic", "area"], {"default": "bilinear"}),
+ "keep_size": (["True", "False"], {"default": "True"}),
+ "animate": ("BOOLEAN", {"default": False}),
+ "exponential_zoom": ("BOOLEAN", {"default": False}),
+ }
+ }
+
+ RETURN_TYPES = ("IMAGE",)
+ FUNCTION = "zoom_in"
+
+ CATEGORY = "ETK/image"
+
+ def zoom_in(self, image, zoom_factor, center_x, center_y, interpolation, keep_size, animate, exponential_zoom):
+ batch_size, height, width, channels = image.shape
+ frames = []
+
+ for frame in range(batch_size):
+ percent_done = frame / batch_size
+ if exponential_zoom:
+ percent_done = percent_done ** 2
+
+ # Calculate the zoom factor for this image
+ frame_zoom_factor = 1 + (zoom_factor * percent_done)
+
+ # Calculate the new dimensions after zooming
+ new_height = int(height / frame_zoom_factor)
+ new_width = int(width / frame_zoom_factor)
+
+ # Calculate the coordinates of the top-left corner of the zoomed region
+ top = int((height - new_height) * center_y)
+ left = int((width - new_width) * center_x)
+
+ # Extract the zoomed region from the image
+ zoomed_image = image[frame, top:top + new_height, left:left + new_width, :].unsqueeze(0)
+
+ if keep_size == "True":
+ # change order of dims for interpolation
+ zoomed_image = zoomed_image.permute(0, 3, 1, 2)
+ # Resize the zoomed region back to the original dimensions
+ zoomed_image = torch.nn.functional.interpolate(zoomed_image, size=(height, width), mode=interpolation,
+ align_corners=False)
+ # change order of dims back
+ zoomed_image = zoomed_image.permute(0, 2, 3, 1)
+
+ frames.append(zoomed_image)
+
+ # Stack all frames into a 4D tensor
+ frames = torch.cat(frames, dim=0)
+
+ return (frames,)
+
+
+@ETK_image_base
+class ListUpscaleLatentBy:
+ """uses from nodes import LatentUpscaleBy"""
+
+ @classmethod
+ def INPUT_TYPES(cls):
+ from nodes import LatentUpscaleBy
+ tmp = LatentUpscaleBy.INPUT_TYPES()["required"]["upscale_method"]
+ return {
+ "required": {
+ "latents": ("LIST", {"default": None}),
+ "upscale_method": tmp,
+
+ },
+ "optional": {
+ "factors": ("LIST", {"default": None}),
+ "factor": ("FLOAT", {"default": 1.5, "min": 0.0, }),
+ }
+ }
+
+ RETURN_TYPES = ("LIST",)
+ FUNCTION = "upscale"
+
+ CATEGORY = "latent"
+
+ def upscale(self, latents, upscale_method, factors=None, factor=1.5):
+ from nodes import LatentUpscaleBy
+
+ if not factors:
+ factors = [factor] * len(latents)
+
+ # Check if latents and factors have the same length
+ if len(latents) != len(factors):
+ raise ValueError("Latents and factors must have the same length")
+
+ # Apply LatentUpscaleBy to each pair of latent and factor
+ upscaled_latents = [LatentUpscaleBy().upscale(latent, upscale_method, factor)[0] for latent, factor in
+ zip(latents, factors)]
+
+ return (upscaled_latents,)
+
+
+@ETK_image_base
+class SBSImage:
+ """
+ Combine two images side by side
+ """
+
+ @classmethod
+ def INPUT_TYPES(cls):
+ return {
+ "required": {
+ "image1": ("IMAGE",),
+ "image2": ("IMAGE",),
+ }
+ }
+
+ RETURN_TYPES = ("IMAGE",)
+ FUNCTION = "execute"
+
+ CATEGORY = "ETK/image"
+
+ def execute(self, image1, image2):
+ """
+ Args:
+ image1: torch image b,h,w,3
+ image2: torch image b,h,w,3
+ Returns:
+ torch image 1,h,w,3
+ """
+
+ # Check if the images have the same height
+ if image1.shape[1] != image2.shape[1]:
+ raise ValueError("Images must have the same height")
+
+ # Check if the images have the same number of channels
+ if image1.shape[3] != image2.shape[3]:
+ raise ValueError("Images must have the same number of channels")
+
+ # Stack the images horizontally
+ output = torch.cat([image1, image2], dim=2)
+
+ return (output,)
+
+
+@ETK_image_base
+class TTBImage:
+ """
+ Combine two images top to bottom
+ """
+
+ @classmethod
+ def INPUT_TYPES(cls):
+ return {
+ "required": {
+ "image1": ("IMAGE",),
+ "image2": ("IMAGE",),
+ }
+ }
+
+ RETURN_TYPES = ("IMAGE",)
+ FUNCTION = "execute"
+
+ CATEGORY = "ETK/image"
+
+ def execute(self, image1, image2):
+ """
+ Args:
+ image1: torch image b,h,w,3
+ image2: torch image b,h,w,3
+ Returns:
+ torch image 1,h,w,3
+ """
+
+ # Check if the images have the same width
+ if image1.shape[2] != image2.shape[2]:
+ raise ValueError("Images must have the same width")
+
+ # Check if the images have the same number of channels
+ if image1.shape[3] != image2.shape[3]:
+ raise ValueError("Images must have the same number of channels")
+
+ # Stack the images vertically
+ output = torch.cat([image1, image2], dim=1)
+
+ return (output,)
+
+
+@ETK_image_base
+class ImageGrid:
+ """
+ Combine multiple images into a grid
+ """
+
+ @classmethod
+ def INPUT_TYPES(cls):
+ return {
+ "required": {
+ "image": ("IMAGE",),
+ "rows": ("INT", {"default": 2, "min": 1}),
+ "cols": ("INT", {"default": 2, "min": 1}),
+ }
+ }
+
+ RETURN_TYPES = ("IMAGE",)
+ FUNCTION = "execute"
+
+ CATEGORY = "ETK/image"
+
+ def get_img_idx(self, row, col, rows, cols):
+ return row * cols + col
+
+ def get_xy_for_idx(self, idx, rows, cols):
+ row = idx % cols
+ col = idx // cols
+ return row, col
+
+ def execute(self, image, rows, cols):
+ # TODO: update this to handle a time series.
+ """
+ Args:
+ image: image (r*c),h,w,3
+ rows: int
+ cols: int
+ Returns:
+ torch image 1,h*r,w*c,3
+
+ """
+
+ # Check if the number of images is equal to the number of rows times the number of columns
+ if len(image) != rows * cols:
+ raise ValueError("Number of images must be equal to rows * cols")
+
+ # check if each row has the same hiegth for each image
+ for row in range(rows):
+ row_idxs = [self.get_img_idx(row, col, rows, cols) for col in range(cols)]
+ heights = [image[idx].shape[1] for idx in row_idxs]
+ if not all(height == heights[0] for height in heights):
+ raise ValueError("Images in the same row must have the same height")
+
+ # check if all columns of images have the same width
+ for col in range(cols):
+ col_idxs = [self.get_img_idx(row, col, rows, cols) for row in range(rows)]
+ widths = [image[idx].shape[2] for idx in col_idxs]
+ if not all(width == widths[0] for width in widths):
+ raise ValueError("Images in the same column must have the same width")
+
+ col_widths = []
+ for column in range(cols):
+ idx = self.get_img_idx(0, column, rows, cols)
+ col_width = image[idx].shape[1]
+ col_widths.append(col_width)
+
+ col_heights = []
+ for row in range(rows):
+ idx = self.get_img_idx(row, 0, rows, cols)
+ col_height = image[idx].shape[0]
+ col_heights.append(col_height)
+
+ col_y_pix = [int(sum(col_heights[:i])) for i in range(len(col_heights))]
+ col_x_pix = [int(sum(col_widths[:i])) for i in range(len(col_widths))]
+
+ total_height = 0
+ for row in range(rows):
+ total_height += image[self.get_img_idx(row, 0, rows, cols)].shape[0]
+
+ total_width = 0
+ for col in range(cols):
+ total_width += image[self.get_img_idx(0, col, rows, cols)].shape[1]
+
+ out_tensor = torch.zeros((1, total_height, total_width, image.shape[3]), dtype=image.dtype,
+ device=image.device)
+
+ for idx in range(len(image)):
+ x, y = self.get_xy_for_idx(idx, rows, cols)
+ x_pix = col_x_pix[x]
+ y_pix = col_y_pix[y]
+ h, w, _ = image[idx].shape
+ out_tensor[0, y_pix:y_pix + h, x_pix:x_pix + w, :] = image[idx]
+
+ image.to("cpu")
+ del image
+ torch.cuda.empty_cache()
+
+ return (out_tensor,)
+
+
+@ETK_image_base
+class QueryImage:
+ @classmethod
+ def INPUT_TYPES(cls):
+ return {
+ "required": {
+ "prompt": ("STRING", {"multiline": True}),
+ "image": ("IMAGE",)
+ }
+ }
+
+ FUNCTION = "execute"
+
+ CATEGORY = "image_to_text"
+ RETURN_TYPES = ("STRING",)
+
+ def execute(self, prompt, image):
+ from .modules.agent_commands import query_image
+ result = query_image.execute({"prompt": prompt, "image": image})
+
+ return (result["result"],)
+
+
+from typing import Tuple
+
+
+@ETK_image_base
+class PreviewImagePassThrough(PreviewImage):
+ @classmethod
+ def INPUT_TYPES(s):
+ # get the input types from the parent class
+ input_types = super().INPUT_TYPES()
+
+ return input_types
+
+ RETURN_TYPES = ("IMAGE",)
+ FUNCTION = "pass_through"
+
+ def pass_through(self, **kwargs):
+ images = kwargs.pop("images", None)
+ # first call the parent class's save_image method
+ result = super().save_images(images, **kwargs)
+ result["result"] = (images,)
+ return result
+
+
+import torch
if __name__ == "__main__":
# test TextRender
diff --git a/modules/image_utils.py b/modules/image_utils.py
new file mode 100644
index 0000000..a9ef174
--- /dev/null
+++ b/modules/image_utils.py
@@ -0,0 +1,404 @@
+import torch
+import torch.nn as nn
+import cv2
+import time
+from PIL import Image
+import numpy as np
+import scipy.ndimage
+
+
+class MaskExpansionModel(nn.Module):
+ """
+ A PyTorch model that expands or erodes a mask using the provided function.
+ """
+
+ def forward(self, mask, expand, tapered_corners):
+ assert mask.dim() == 4, "Input mask must have 3 dimensions (batch_size, height, width)."
+ bs = mask.size(0)
+ c = 0 if tapered_corners else 1
+ kernel = torch.tensor([[c, 1, c],
+ [1, 1, 1],
+ [c, 1, c]], dtype=mask.dtype, device=mask.device)
+ kernel = kernel.view(1, 1, 3, 3)
+
+ if expand < 0:
+ kernel = -kernel
+ expand = -expand
+ mask = 1 - mask
+
+ mask = mask.unsqueeze(1)
+ # print(mask.shape)
+ for _ in range(expand):
+ for i in range(bs):
+ # print(i)
+ mask[i] = torch.nn.functional.conv2d(mask[i], kernel, padding=1)
+ mask[i] = torch.clamp(mask[i], 0, 1) # Clip the mask values to the range [0, 1]
+
+ mask = mask.squeeze(1)
+ if expand < 0:
+ mask = 1 - mask
+
+ return mask
+
+
+class LanczosInterpolationLayer(nn.Module):
+ """
+ A PyTorch module that applies Lanczos interpolation to resize a batch of images.
+
+ Args:
+ target_size (tuple): The target size for resizing the images, in the format (width, height).
+ """
+
+ def __init__(self, target_size):
+ super(LanczosInterpolationLayer, self).__init__()
+ self.target_size = target_size
+
+ def forward(self, x):
+ """
+ Performs the forward pass of the Lanczos interpolation layer.
+
+ Args:
+ x (torch.Tensor): A batch of input images with shape (batch_size, channels, height, width).
+
+ Returns:
+ torch.Tensor: A batch of resized images with shape (batch_size, channels, target_height, target_width).
+ """
+ device = torch.device("cpu")
+ interpolated_images = []
+ for image in x:
+ # Convert the PyTorch tensor to a NumPy array
+ image_np = image.permute(1, 2, 0).to("cpu", torch.float32).numpy()
+
+ # Resize the image using OpenCV's resize function with Lanczos interpolation
+ interpolated_image = cv2.resize(image_np, self.target_size, interpolation=cv2.INTER_LANCZOS4)
+ mn = interpolated_image.min()
+ mx = interpolated_image.max()
+
+ color_range = mx - mn
+
+ under_bottom_target_range = -10/255
+ over_top_target_range = 1/255
+
+ if color_range > 1:
+ # here we are going to compress the over the top values
+ over_top = interpolated_image > 1
+ not_over_top = interpolated_image < 1
+ over_top_range = mx - 1
+ # subtract the normal top value
+ interpolated_image[over_top] = interpolated_image[over_top] - 1
+ # scale the over the top values to the target range
+ interpolated_image[over_top] *= (over_top_target_range / over_top_range)
+ # add the target range to the over the top values
+ interpolated_image[over_top] += (1-over_top_target_range)
+ # scale the non-over-the top to make room
+ interpolated_image[not_over_top] *= (1 - over_top_target_range)
+
+ # here we are going to compress the under the bottom values
+ under_bottom = interpolated_image < 0
+ not_under_bottom = interpolated_image > 0
+ under_bottom_range = mn
+ # subtract the normal bottom value
+ interpolated_image[under_bottom] = interpolated_image[under_bottom] - 0
+ # scale the under the bottom values to the target range
+ interpolated_image[under_bottom] *= (under_bottom_target_range / under_bottom_range)
+ # add the target range to the under the bottom values
+ interpolated_image[under_bottom] += under_bottom_target_range
+ # scale the non-under-the-bottom to make room
+ interpolated_image[not_under_bottom] *= (1 - under_bottom_target_range)
+
+
+
+
+ else:
+ pass
+
+ # Convert the resized image back to a PyTorch tensor
+ # interpolated_image = torch.from_numpy(interpolated_image).to(device, dtype=torch.float16).permute(2, 0, 1)
+ interpolated_image = torch.from_numpy(interpolated_image)
+
+ # convert fix range to 0-255
+ interpolated_image = interpolated_image * 255
+ # convert to uint8
+ interpolated_image = interpolated_image.to(device, dtype=torch.uint8).permute(2, 0, 1)
+
+ interpolated_images.append(interpolated_image)
+ del image
+ del image_np
+ del interpolated_image
+
+ # transform this list of tensors into a single tensor of shape (batch_size, channels, height, width)
+
+ # pre allocate a tensor of shape (batch_size, channels, target_height, target_width)
+ y = torch.zeros((x.shape[0], interpolated_images[0].shape[0], self.target_size[1], self.target_size[0]),
+ device=device,
+ dtype=interpolated_images[0].dtype)
+ y = torch.stack(interpolated_images, dim=0, out=y)
+ del interpolated_images
+
+ return y
+
+
+class LanczosInterpolationModel(nn.Module):
+ """
+ A PyTorch model that applies Lanczos interpolation to resize a batch of images.
+
+ Args:
+ target_size (tuple): The target size for resizing the images, in the format (width, height).
+ """
+
+ def __init__(self, target_size):
+ super(LanczosInterpolationModel, self).__init__()
+ self.interpolation_layer = LanczosInterpolationLayer(target_size)
+
+ def forward(self, x):
+ """
+ Performs the forward pass of the Lanczos interpolation model.
+
+ Args:
+ x (torch.Tensor): A batch of input images with shape (batch_size, channels, height, width).
+
+ Returns:
+ torch.Tensor: A batch of resized images with shape (batch_size, channels, target_height, target_width).
+ """
+ return self.interpolation_layer(x)
+
+
+def expand_mask(mask, expand, tapered_corners, device=None, dtype=None):
+ """
+ Convenience function for expanding or eroding a mask using the MaskExpansionModel.
+
+ Args:
+ mask (torch.Tensor): The input mask tensor.
+ expand (int): The number of iterations to expand (positive) or erode (negative) the mask.
+ tapered_corners (bool): Whether to use tapered corners when expanding or eroding the mask.
+ device (torch.device, optional): The device to use for the model and input tensor.
+ If not provided, it will use the default device (CPU).
+ dtype (torch.dtype, optional): The data type to use for the input tensor.
+ If not provided, it will use the default dtype (float32).
+
+ Returns:
+ torch.Tensor: The expanded or eroded mask tensor.
+ """
+ # Create an instance of the MaskExpansionModel
+ model = MaskExpansionModel()
+
+ # Move the model to the specified device (or default to CPU)
+ device = device or torch.device("cpu")
+ model = model.to(device)
+
+ # Move the input tensor to the specified device and convert to the specified dtype
+ dtype = dtype or torch.float32
+ mask = mask.to(device, dtype=dtype)
+
+ # Expand or erode the mask using the MaskExpansionModel
+ with torch.no_grad():
+ expanded_mask = model(mask, expand, tapered_corners)
+
+ # Clear the GPU cache if using a GPU
+ if device != torch.device("cpu"):
+ torch.cuda.empty_cache()
+
+ return expanded_mask
+
+
+def lanczos_resize(image, target_size, device="cpu", dtype=torch.float32, output_type="tensor"):
+ """
+ Convenience function for resizing an image using the Lanczos interpolation model.
+
+ Args:
+ image (PIL.Image or torch.Tensor): The input image to be resized.
+ target_size (tuple): The target size for resizing the image, in the format (width, height).
+ device (torch.device, optional): The device to use for the model and input tensor.
+ If not provided, it will use the default device (CPU).
+ dtype (torch.dtype, optional): The data type to use for the input tensor.
+ If not provided, it will use the default dtype (float32).
+ output_type (str, optional): The type of the output image.
+ If "tensor", the function will return a PyTorch tensor.
+ If "pil", the function will return a PIL Image.
+ If not provided, it will return a tensor by default.
+
+ Returns:
+ torch.Tensor or PIL.Image: The resized image.
+ """
+ import gc
+ import os
+ import sys
+ if sys.platform == "win32":
+ import psutil
+ print(f"Free memory: {psutil.virtual_memory().available / 1024 / 1024} MB")
+ else:
+ # works on linux and mac
+ print(f"Free memory: {os.popen('free -m').readlines()[1].split()[3]} MB")
+
+ # Create an instance of the LanczosInterpolationModel
+ model = LanczosInterpolationModel(target_size)
+
+ # Move the model to the specified device (or default to CPU)
+ device = device or torch.device("cpu")
+ model = model.to(device)
+
+ # Convert the input image to a PyTorch tensor
+ if isinstance(image, list):
+ if isinstance(image[0], Image.Image):
+ try:
+ images = np.array(image)
+ except:
+ images = np.array([np.array(img) for img in image])
+
+ images = torch.from_numpy(images).to(device, dtype).permute(0, 3, 1, 2)
+ elif isinstance(image, torch.Tensor):
+ images = image.to(device, dtype)
+
+ del image
+ torch.cuda.empty_cache()
+
+ # Resize the image using the Lanczos interpolation model
+ with torch.no_grad():
+ resized_images = model(images)
+
+ # Convert the resized tensor back to a PIL Image or keep it as a tensor based on the output_type
+ if output_type.lower() == "pil":
+ resized_images = resized_images.squeeze(0).permute(0, 2, 3, 1).cpu().numpy()
+ resized_images = Image.fromarray(resized_images.astype(np.uint8))
+ elif output_type.lower() == "tensor":
+ pass # keep the resized_image as a tensor
+ else:
+ raise ValueError(f"Invalid output_type: {output_type}. Valid options are 'tensor' or 'pil'.")
+
+ # Clear the GPU cache if using a GPU
+ if device != torch.device("cpu"):
+ torch.cuda.empty_cache()
+
+ del images
+ del model
+ gc.collect()
+ # print current free memory on cpu, in a way that is cross platform 1 way that works on all
+
+ if sys.platform == "win32":
+ import psutil
+ print(f"Free memory: {psutil.virtual_memory().available / 1024 / 1024} MB")
+ else:
+ # works on linux and mac
+ print(f"Free memory: {os.popen('free -m').readlines()[1].split()[3]} MB")
+
+ return resized_images
+
+
+def test_lanczos_interpolation_model():
+ """
+ Tests the Lanczos interpolation model by resizing a batch of random images and benchmarking the performance.
+ """
+ # Set the target sizes for resizing
+ hd_size = (1920, 1080)
+ sd_size = (1280, 720)
+
+ # Generate random PIL images
+ batch_size = 600
+ hd_images = [Image.fromarray(np.random.randint(0, 256, size=(hd_size[1], hd_size[0], 3), dtype=np.uint8))
+ for _ in range(batch_size)]
+ sd_images = [Image.fromarray(np.random.randint(0, 256, size=(sd_size[1], sd_size[0], 3), dtype=np.uint8))
+ for _ in range(batch_size)]
+
+ # Perform the benchmarks
+ num_iterations = 3
+
+ # HD to SD benchmark
+ start_time = time.time()
+ for _ in range(num_iterations):
+ sd_output_images = lanczos_resize(hd_images, sd_size)
+ end_time = time.time()
+ hd_to_sd_elapsed_time = end_time - start_time
+ hd_to_sd_avg_time_per_iteration = hd_to_sd_elapsed_time / num_iterations
+
+ # SD to HD benchmark
+ start_time = time.time()
+ for _ in range(num_iterations):
+ hd_output_images = lanczos_resize(sd_images, hd_size)
+ end_time = time.time()
+ sd_to_hd_elapsed_time = end_time - start_time
+ sd_to_hd_avg_time_per_iteration = sd_to_hd_elapsed_time / num_iterations
+
+ # Check the sizes of the output images
+
+ assert sd_output_images.shape[0] == batch_size
+
+ assert hd_output_images.shape[0] == batch_size
+
+ print(f"Lanczos interpolation test passed!")
+ print(f"Benchmark results:")
+ print(f"Number of iterations: {num_iterations}")
+ print(f"HD to SD:")
+ print(f" Total elapsed time: {hd_to_sd_elapsed_time:.4f} seconds")
+ print(f" Average time per iteration: {hd_to_sd_avg_time_per_iteration:.4f} seconds")
+ print(f" Average time per HD image -> SD image: {hd_to_sd_avg_time_per_iteration / batch_size:.4f} seconds")
+ print(f"SD to HD:")
+ print(f" Total elapsed time: {sd_to_hd_elapsed_time:.4f} seconds")
+ print(f" Average time per iteration: {sd_to_hd_avg_time_per_iteration:.4f} seconds")
+ print(f" Average time per SD image -> HD image: {sd_to_hd_avg_time_per_iteration / batch_size:.4f} seconds")
+
+
+def test_mask_expansion_model():
+ """
+ Tests the MaskExpansionModel by expanding and eroding random masks and benchmarking the performance.
+ """
+ # Generate random masks
+ batch_size = 1000
+ mask_size = (512, 512)
+ # masks are 0-1 tensors float
+ masks = torch.rand((batch_size, mask_size[0], mask_size[1])).to("cuda")
+ print(masks.min(), masks.max())
+
+ # Perform the benchmarks
+ num_iterations = 1
+ expand_steps = 5
+ erode_steps = -5
+
+ # Mask expansion benchmark
+ start_time = time.time()
+ for _ in range(num_iterations):
+ print(masks.mean())
+ expanded_masks = expand_mask(masks, expand_steps, tapered_corners=True, device="cuda")
+ print(masks.mean())
+ end_time = time.time()
+ expansion_elapsed_time = end_time - start_time
+ expansion_avg_time_per_iteration = expansion_elapsed_time / num_iterations
+
+ # Mask erosion benchmark
+ start_time = time.time()
+ for _ in range(num_iterations):
+ print(masks.mean())
+ eroded_masks = expand_mask(masks, erode_steps, tapered_corners=True, device="cuda")
+ print(eroded_masks.mean())
+ end_time = time.time()
+ erosion_elapsed_time = end_time - start_time
+ erosion_avg_time_per_iteration = erosion_elapsed_time / num_iterations
+
+ # Check the sizes of the output masks
+ expected_mask_size = (batch_size, mask_size[0], mask_size[1])
+ assert expanded_masks.shape == expected_mask_size, f"Expected expanded mask size: {expected_mask_size}, but got: {expanded_masks.shape}"
+ assert eroded_masks.shape == expected_mask_size, f"Expected eroded mask size: {expected_mask_size}, but got: {eroded_masks.shape}"
+
+ print(f"Mask expansion/erosion test passed!")
+ print(f"Benchmark results:")
+ print(f"Number of iterations: {num_iterations}")
+ print(f"Mask expansion:")
+ print(f" Total elapsed time: {expansion_elapsed_time:.4f} seconds")
+ print(f" Average time per iteration: {expansion_avg_time_per_iteration:.4f} seconds")
+ print(f" Average time per mask expansion: {expansion_avg_time_per_iteration / batch_size:.4f} seconds")
+ print(f"Mask erosion:")
+ print(f" Total elapsed time: {erosion_elapsed_time:.4f} seconds")
+ print(f" Average time per iteration: {erosion_avg_time_per_iteration:.4f} seconds")
+ print(f" Average time per mask erosion: {erosion_avg_time_per_iteration / batch_size:.4f} seconds")
+
+
+if __name__ == "image_utils":
+ print("image_utils module loaded.")
+ print("Testing Lanczos interpolation model...")
+ test_lanczos_interpolation_model()
+ print("Testing Mask expansion model...")
+ # test_mask_expansion_model()
+ exit(0)
+
+if __name__ == "__main__":
+ test_lanczos_interpolation_model()
+ # test_mask_expansion_model()
diff --git a/modules/musicgencli.py b/modules/musicgencli.py
new file mode 100644
index 0000000..20193f1
--- /dev/null
+++ b/modules/musicgencli.py
@@ -0,0 +1,224 @@
+def set_all_seeds(seed):
+ import random
+ import os
+ import numpy as np
+ import torch
+ old_values = {}
+ old_values['random'] = random.getstate()
+ old_values['numpy'] = np.random.get_state()
+ old_values['torch'] = torch.get_rng_state()
+ old_values['torch_cuda'] = torch.cuda.get_rng_state()
+ old_values['torch_cuda_all'] = torch.cuda.get_rng_state_all()
+ old_values['torch_deterministic'] = torch.backends.cudnn.deterministic
+
+ # From https://gist.github.com/gatheluck/c57e2a40e3122028ceaecc3cb0d152ac
+ random.seed(seed)
+ os.environ["PYTHONHASHSEED"] = str(seed)
+ np.random.seed(seed)
+ torch.manual_seed(seed)
+ torch.cuda.manual_seed(seed)
+ torch.backends.cudnn.deterministic = True
+
+ return old_values
+
+
+def reset_seeds(old_values):
+ import random
+ import numpy as np
+ import torch
+
+ random.setstate(old_values['random'])
+ np.random.set_state(old_values['numpy'])
+ torch.set_rng_state(old_values['torch'])
+ torch.cuda.set_rng_state(old_values['torch_cuda'])
+ torch.cuda.set_rng_state_all(old_values['torch_cuda_all'])
+ torch.backends.cudnn.deterministic = old_values['torch_deterministic']
+
+import time # Add this at the top of your script
+
+def generate_music_for_description(description, duration, file, model_name, position):
+ time.sleep(position * 5) # Wait for position * 5 seconds
+ from audiocraft.models import MusicGen
+ from audiocraft.data.audio import audio_write
+ import torch
+ model = MusicGen.get_pretrained(model_name)
+ print(f"Generating music for description: {description}")
+ print(f"Using model: {model_name}")
+ print(f"Using duration: {duration}")
+ model.set_generation_params(duration=duration)
+ wav = model.generate([description])
+ print(f"Writing to {file}")
+ audio_write(file.replace(".wav", ""), wav[0].cpu(), model.sample_rate, strategy="loudness")
+ del model
+ torch.cuda.empty_cache()
+
+
+def generate_music(**kwargs):
+ from audiocraft.models import MusicGen
+ from audiocraft.data.audio import audio_write
+ import torch
+ import ast
+ import copy
+ import random
+ from concurrent.futures import ProcessPoolExecutor, as_completed
+
+
+
+ torch.cuda.empty_cache()
+
+ # Set seed, -1 means random seed
+ seed = kwargs.get('seed', -1)
+ if seed == -1:
+ seed = random.randint(0, 100000)
+ old_seed_values = set_all_seeds(seed)
+
+ kwargs = copy.deepcopy(kwargs)
+ description = kwargs.get('description')
+
+ file = kwargs.get('file')
+ # Add .wav if not there
+ if not file.endswith('.wav'):
+ file = file + ".wav"
+
+ model_name = kwargs.get('model_name', 'melody')
+ duration = kwargs.get('duration', 10)
+
+ descriptions = ast.literal_eval(description)
+ durations = ast.literal_eval(duration)
+
+
+
+ o_files = []
+ batch_size = 3
+ with ProcessPoolExecutor(max_workers=batch_size) as executor:
+ futures = []
+ for i, (description, duration) in enumerate(zip(descriptions, durations)):
+ ii = str(i).zfill(1 + len(str(1 + len(descriptions))))
+ file_to_use = file.replace(".wav", f"_{ii}.wav")
+ # Pass i (the position in the batch) as an additional argument
+ future = executor.submit(generate_music_for_description, description, duration, file_to_use, model_name, i//batch_size)
+ futures.append((future, file_to_use))
+
+ for future, file_to_use in futures:
+ # Wait for the future to complete, no need to get the result since the function handles file writing
+ future.result()
+ o_files.append(file_to_use)
+
+ # Reset seed
+ reset_seeds(old_seed_values)
+ torch.cuda.empty_cache()
+
+ return (o_files[0], o_files,)
+
+
+import argparse
+import copy
+
+
+def main():
+ print("Running main function...")
+ # Define the model names as a list for the choices parameter
+ model_names = [
+ "facebook/musicgen-melody-large",
+ "facebook/musicgen-melody-small",
+ "facebook/musicgen-melody-medium",
+
+ "facebook/musicgen-stereo-melody-large",
+ "facebook/musicgen-stereo-melody-small",
+ "facebook/musicgen-stereo-melody-medium",
+
+ "facebook/musicgen-large",
+ "facebook/musicgen-small",
+ "facebook/musicgen-medium",
+
+ "facebook/musicgen-stereo-large",
+ "facebook/musicgen-stereo-small",
+ "facebook/musicgen-stereo-medium",
+ ]
+
+ # Parse command line arguments
+ parser = argparse.ArgumentParser(description='Generate music based on a given description.')
+ parser.add_argument('--description', help='Description of the music to generate')
+ parser.add_argument('--file', type=str, default='output.wav', help='Output file name (default: output.wav)')
+ parser.add_argument('--model_name', type=str, default='facebook/musicgen-large', choices=model_names,
+ help='Model name to use for generation')
+ parser.add_argument('--duration', help='Duration of the generated music in seconds (default: 10)')
+
+ parser.add_argument('--seed', type=int, default=-1,
+ help='Seed for random number generation (default: -1 for random seed)')
+ parser.add_argument('--test', action='store_true', help='Run the test suite instead of generating music')
+ args = parser.parse_args()
+
+ # Convert argparse.Namespace to dictionary
+ kwargs = vars(args)
+
+ if args.test:
+ run_tests()
+ else:
+ print(f"Generating music with the following arguments: {kwargs}")
+ # Generate music
+ generated_files = generate_music(**kwargs)
+ print(f"Generated music files: {generated_files}")
+
+
+def run_tests():
+ print("Running test suite...")
+ # Define your test cases here
+ test_model_name_validation()
+ test_file_generation()
+ # Add more tests as needed
+ print("All tests passed!")
+
+
+def test_model_name_validation():
+ print("Testing model name validation...")
+ model_names = [
+ "facebook/musicgen-melody-large",
+ "facebook/musicgen-melody-small",
+ "facebook/musicgen-melody-medium",
+ "facebook/musicgen-stereo-melody-large",
+ "facebook/musicgen-stereo-melody-small",
+ "facebook/musicgen-stereo-melody-medium",
+ "facebook/musicgen-large",
+ "facebook/musicgen-small",
+ "facebook/musicgen-medium",
+ "facebook/musicgen-stereo-large",
+ "facebook/musicgen-stereo-small",
+ "facebook/musicgen-stereo-medium",
+ ]
+
+ description = "A brief test piece" # Example description for testing
+ duration = 5 # 5 seconds long
+ file_template = "test_output_{}.wav" # Template for generated file names
+
+ for i, model_name in enumerate(model_names):
+ print(f"Testing {model_name}...")
+ kwargs = {
+ 'description': description,
+ 'duration': duration,
+ 'model_name': model_name,
+ 'file': file_template.format(i), # Ensure unique file names
+ 'seed': 42 # Fixed seed for consistency, if needed
+ }
+
+ try:
+ # Assuming generate_music is adapted to work with these arguments
+ generate_music(**kwargs)
+ print(f"Successfully tested {model_name}")
+ except Exception as e:
+ print(f"Error testing {model_name}: {e}")
+ # Handle the error appropriately - for example, you might want to fail the test or log the error.
+
+ print("Model name validation test completed.")
+
+
+def test_file_generation():
+ # Example test function
+ print("Testing file generation...")
+ # Implement the logic to test music file generation
+ # This could involve generating a small piece of music and checking if the file exists
+ # ...
+
+
+if __name__ == "__main__":
+ main()
diff --git a/modules/noise.py b/modules/noise.py
index afe885d..04b33cb 100644
--- a/modules/noise.py
+++ b/modules/noise.py
@@ -1,3 +1,4 @@
+# TODO: replace pyfastnoisesimd with pyfastnoiselite (works with rpi)
import pyfastnoisesimd as fns
diff --git a/modules/xttscli.py b/modules/xttscli.py
new file mode 100644
index 0000000..062a974
--- /dev/null
+++ b/modules/xttscli.py
@@ -0,0 +1,94 @@
+import argparse
+import torch.multiprocessing as mp
+
+from concurrent.futures import ProcessPoolExecutor, as_completed
+
+def generate_xtts_for_text(text, file, lang, speaker):
+ from TTS.api import TTS
+ import torch
+ tts = TTS("tts_models/multilingual/multi-dataset/xtts_v2").to("cuda")
+ tts.tts_to_file(
+ text=text,
+ speaker_wav=speaker,
+ language=lang,
+ file_path=file,
+ # emotion="angry", # doesnt do anything as far as i can tell with xtts2
+ )
+
+
+def generate_xtts(**kwargs):
+ import torch
+ import ast
+ import uuid
+ from concurrent.futures import ProcessPoolExecutor, as_completed
+
+ torch.cuda.empty_cache()
+
+ text = kwargs.get('text')
+ folder = kwargs.get('folder', '.')
+ lang = kwargs.get('lang', 'en')
+ speaker = kwargs.get('speaker', '')
+
+ try:
+ texts = ast.literal_eval(text)
+ except SyntaxError:
+ texts = [text]
+
+ o_files = []
+ batch_size = 3
+ with ProcessPoolExecutor(max_workers=batch_size) as executor:
+ futures = []
+ for i, text in enumerate(texts):
+ ii = str(i).zfill(1 + len(str(1 + len(texts))))
+ file_to_use = f"{folder}/tts_{ii}_{str(uuid.uuid4())}.wav"
+ future = executor.submit(generate_xtts_for_text, text, file_to_use, lang, speaker)
+ futures.append((future, file_to_use))
+
+ for future, file_to_use in futures:
+ future.result()
+ o_files.append(file_to_use)
+
+ torch.cuda.empty_cache()
+
+ return (o_files[0], o_files,)
+
+
+
+
+# def generate_xtts_for_text_advanced(text, file, lang, speaker):
+# from TTS.api import TTS
+# import torch
+#
+# # Create a SharedMemoryManager
+# manager = mp.Manager()
+# # Create a shared list to store the result
+# result = manager.list()
+# model = manager.model()
+#
+#
+# # also we want to SHARE the TTS model
+# something = manager.
+
+
+
+
+
+
+
+
+def main():
+ parser = argparse.ArgumentParser(description='Generate from given text.')
+ parser.add_argument('--text', help='Text to generate for')
+ parser.add_argument('--folder', type=str, default='.', help='output folder')
+ parser.add_argument('--lang', type=str, default='en')
+ parser.add_argument('--speaker', type=str, default='', help='Speaker wav use for generation')
+
+ args = parser.parse_args()
+ kwargs = vars(args)
+
+ print(f"Generating with the following arguments: {kwargs}")
+ generated_files = generate_xtts(**kwargs)
+ print(f"Generated files: {generated_files}")
+
+if __name__ == "__main__":
+ main()
\ No newline at end of file
diff --git a/pytorch_nodes.py b/pytorch_nodes.py
index 8da397f..c8d17f8 100644
--- a/pytorch_nodes.py
+++ b/pytorch_nodes.py
@@ -1186,6 +1186,7 @@ class SaveTorchTensor:
RETURN_TYPES = ("STRING",)
FUNCTION = "save_torch_tensor"
+ OUTPUT_NODE = True
def save_torch_tensor(self, tensor, file):
import torch
diff --git a/requirements.txt b/requirements.txt
index 3ab8378..2f8e601 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,3 +1,4 @@
+git+https://github.com/facebookresearch/audiocraft.git
google-api-python-client
google-auth-oauthlib
google-auth-httplib2
@@ -8,4 +9,54 @@ librosa
moviepy
openai-whisper
addict
-diffusers
\ No newline at end of file
+diffusers
+
+# core deps for xtts
+numpy==1.22.0;python_version<="3.10"
+numpy>=1.24.3;python_version>"3.10"
+cython>=0.29.30
+scipy>=1.11.2
+torch>=2.1
+torchaudio
+soundfile>=0.12.0
+librosa>=0.10.0
+scikit-learn>=1.3.0
+numba==0.55.1;python_version<"3.9"
+numba>=0.57.0;python_version>="3.9"
+inflect>=5.6.0
+tqdm>=4.64.1
+anyascii>=0.3.0
+pyyaml>=6.0
+fsspec>=2023.6.0 # <= 2023.9.1 makes aux tests fail
+aiohttp>=3.8.1
+packaging>=23.1
+mutagen==1.47.0
+
+
+# deps for inference
+pysbd>=0.3.4
+
+# deps for training
+matplotlib>=3.7.0
+
+# coqui stack
+trainer>=0.0.36
+
+# config management
+coqpit>=0.0.16
+
+
+#deps for tortoise
+einops>=0.6.0
+transformers>=4.33.0
+#deps for bark
+encodec>=0.1.1
+
+# deps for XTTS
+unidecode>=1.3.2
+num2words
+spacy[ja]>=3
+TTS
+
+#deps for ocr
+pytesseract
\ No newline at end of file
diff --git a/server_endpoints.py b/server_endpoints.py
index ec80fcc..e966941 100644
--- a/server_endpoints.py
+++ b/server_endpoints.py
@@ -197,6 +197,12 @@ user_data = {
"client_id": None,
"git_io": "COTRF"
},
+ "HMG": {
+ "password": "[REDACTED]",
+ "security": None,
+ "client_id": None,
+ "git_io": "HMG"
+ },
}
@@ -409,6 +415,10 @@ def hijack_prompt_server(route_path,
return part1
if isinstance(part1, web.Request):
modified_func_response = await post_handler(part1)
+ if isinstance(part1, dict):
+ a = await post_handler(part1)
+ b = part1
+ return a
return modified_func_response
elif after_post_func:
@PromptServer.instance.routes.post(path)
@@ -545,6 +555,9 @@ async def my_post_hijack_before(request: web.Request):
ret = request
print("client_id:", client_id)
+ if not isinstance(request, web.Request):
+ print("request is not a web.Request")
+ return request
user = await get_user_if_client_id_logged_in(client_id)
skp_lcl = config_settings["skip_login_for_local"]
diff --git a/tests/test_video.py b/tests/test_video.py
index 65f2f9f..eae27db 100644
--- a/tests/test_video.py
+++ b/tests/test_video.py
@@ -1,8 +1,17 @@
from unittest import TestCase
import os
import torch
+import os
+import shutil
+import tempfile
+import pytest
+import torch
+from PIL import Image
+from ..video import AddFramesToVideoFolder, VideoFolderProvider, VideoDefinitionProvider
+
os.environ["ETERNAL_KERNEL_LITEGRAPH_NODES_TEST"] = "True"
+
class TestImageStackToVideoFile(TestCase):
def test_handler(self):
# test with random frames of noise
@@ -62,9 +71,9 @@ class TestTemporalOpticalFlowSmoothing(TestCase):
import time
import numpy as np
# create a random stack of images
- #stack = torch.rand((24*4, 512, 512, 3))
- h=256
- w=512
+ # stack = torch.rand((24*4, 512, 512, 3))
+ h = 256
+ w = 512
fps = 24
seconds = 2
stack = torch.rand((fps * seconds, h, w, 3))
@@ -77,7 +86,7 @@ class TestTemporalOpticalFlowSmoothing(TestCase):
# fill the box with ones
box = box + 1
# the box width
- bw = min(int(w * .2),int(h*.2))
+ bw = min(int(w * .2), int(h * .2))
# the box height
bh = int(h * .2)
# the box x position
@@ -93,12 +102,12 @@ class TestTemporalOpticalFlowSmoothing(TestCase):
# the box y position
by = int(h * .333)
# fill the box with ones
- box[i,:,:,:] = 0
+ box[i, :, :, :] = 0
box[i, by:by + bh, bx:bx + bw, :] = 1
# create a mask for the box region
- #box_mask = torch.zeros_like(stack)
- #box_mask[:, by:by + bh, bx:bx + bw, :] = 1
+ # box_mask = torch.zeros_like(stack)
+ # box_mask[:, by:by + bh, bx:bx + bw, :] = 1
# set the color of the stack to blue where the box mask is 1
stack = torch.where(box == 1, torch.tensor([.24, .5, .7], dtype=torch.float32), stack)
@@ -115,24 +124,154 @@ class TestTemporalOpticalFlowSmoothing(TestCase):
result = node.handler(image_stack=stack, device="cuda")
result = result[0]
-
- #result = torch.cat(result[0], dim=0)
- #print(result.shape)
+ # result = torch.cat(result[0], dim=0)
+ # print(result.shape)
# now we can permute the dimensions
- #result = result.permute(0, 2,3, 1)
-
+ # result = result.permute(0, 2,3, 1)
_, path = ImageStackToVideoFile().handler(image_stack=result, video_out="test_result_optical_flow.mp4", fps=fps)
assert os.path.exists(path), f"Output video file doesn't exist: {path}"
os.system(f"start {path}")
## Check the shape of the output
- #assert len(result) == stack.shape[0] - 1
+ # assert len(result) == stack.shape[0] - 1
# Each element in the result should have the shape of [1, 2, H, W]
- #for flow in result:
+ # for flow in result:
# assert flow.shape == torch.Size([1, 2, 512, 512])
# check the file generation and video play
+from ..video import AddFramesToVideoFolder, VideoFolderProvider, VideoDefinitionProvider
+
+
+@pytest.fixture(scope="function")
+def video_folder_def():
+ this_file_dir = os.path.dirname(os.path.abspath(__file__))
+ temp_dir = os.path.join(this_file_dir, "temp")
+ os.makedirs(temp_dir, exist_ok=True)
+ vfp = VideoFolderProvider().video_folder_provider(folder_path=temp_dir)[0]
+ d = {
+ "video_folder": vfp,
+ "fps": 24,
+ "width": 256,
+ "height": 256,
+ "frame format": "jpg",
+ }
+ VFD = VideoDefinitionProvider().video_definition_provider(**d)[0]
+ yield VFD
+ # shutil.rmtree(temp_dir)
+
+
+def test_add_frames_to_video_folder(video_folder_def):
+ # Generate synthetic inputs
+ num_frames = 10
+ image_size = (256, 256)
+ frames = torch.randint(0, 256, (num_frames, *image_size, 3), dtype=torch.uint8)
+ rescale_method = "LANCZOS"
+ release_vram = True
+
+ # Call the function
+ result = AddFramesToVideoFolder().add_frames_to_video_folder(video_folder_def=video_folder_def,
+ IMAGE=frames,
+ Rescale_method=rescale_method,
+ Release_vram=release_vram
+ )[0]
+
+ # Check the output
+ assert len(os.listdir(result["video_folder"])) == num_frames
+
+ # Check the saved frames
+ for i in range(num_frames):
+ frame_path = video_folder_def["get_last_file_name"](i + 1)
+ saved_frame = Image.open(frame_path)
+ assert saved_frame.size == image_size
+
+
+def test_save_or_copy_frame(video_folder_def):
+ # Test case 1: Different frames
+ # Generate synthetic inputs
+ current_frame = torch.randint(0, 256, (256, 256, 3), dtype=torch.uint8)
+ previous_frame = torch.randint(0, 256, (256, 256, 3), dtype=torch.uint8)
+ frame_fp = video_folder_def.get_next_file_name()
+ previous_frame_fp = video_folder_def.get_next_file_name()
+
+ # Save the previous frame
+ Image.fromarray(previous_frame.permute(2, 0, 1).permute(1, 2, 0).numpy()).save(previous_frame_fp)
+
+ # Call the function
+ result = AddFramesToVideoFolder.save_or_copy_frame(current_frame, previous_frame, frame_fp, previous_frame_fp)
+
+ # Check the output
+ assert torch.equal(result, current_frame)
+ assert os.path.exists(frame_fp)
+
+ # Check the saved frame
+ saved_frame = Image.open(frame_fp)
+ assert saved_frame.size == (256, 256)
+ saved_frame.close()
+ # clean up
+ os.remove(frame_fp)
+
+
+ # Test case 2: Duplicate frames
+ # Generate synthetic inputs
+ duplicate_frames = [torch.randint(0, 256, (256, 256, 3), dtype=torch.uint8)]
+ duplicate_frame = duplicate_frames * 2
+
+
+ previous_duplicate_frame_fp = video_folder_def.get_next_file_name(0)
+ # need to check the file names for 0000000000 and 0000000001
+ assert "0000000000" in previous_duplicate_frame_fp
+ #assert "0000000001" in duplicate_frame_fp
+
+ # Save the previous duplicate frame
+ Image.fromarray(duplicate_frames[0].permute(2, 0, 1).permute(1, 2, 0).numpy()).save(previous_duplicate_frame_fp)
+
+
+ # Call the function 100 times
+ for i in range(100):
+ duplicate_frame_fp = video_folder_def.get_next_file_name(0)
+ result = AddFramesToVideoFolder.save_or_copy_frame(duplicate_frames[0], duplicate_frame[1], duplicate_frame_fp,
+ previous_duplicate_frame_fp)
+
+ # Check the output
+ assert torch.equal(result, duplicate_frame[0])
+ assert os.path.exists(duplicate_frame_fp)
+
+ # check that here are 101 files
+ assert len(os.listdir(video_folder_def["video_folder"])) == 101
+
+ # clean up
+ for i in range(101):
+ os.remove(video_folder_def.get_last_file_name(0))
+
+
+
+
+def test_process_frames(video_folder_def):
+ # Generate synthetic inputs
+ num_frames = 10
+ image_size = (256, 256)
+ frames = torch.randint(0, 256, (num_frames, *image_size, 3), dtype=torch.uint8)
+ rescale_method = "LANCZOS"
+
+ # Call the function
+ result = AddFramesToVideoFolder.process_frames(frames, rescale_method, video_folder_def)
+
+ # Check the output
+ # import pdb;
+ # pdb.set_trace()
+
+ assert result is None
+ test = video_folder_def["video_folder"]
+ print("path: ", test)
+ assert len(os.listdir(test)) == num_frames
+
+ # Check the saved frames
+ assert video_folder_def["get_frame_count"]() == num_frames
+ for i in range(num_frames):
+ frame_path = video_folder_def["get_last_file_name"](i + 1)
+ saved_frame = Image.open(frame_path)
+ assert saved_frame.size == image_size
diff --git a/video.py b/video.py
index db51dab..f78471f 100644
--- a/video.py
+++ b/video.py
@@ -6,6 +6,9 @@ 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 typing import List, Tuple, Union
@@ -15,9 +18,9 @@ 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 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 imageio_ffmpeg as ffmpeg
+from custom_nodes.EternalKernelLiteGraphNodes.modules.image_utils import lanczos_resize
import os
import torch
from torchvision.utils import draw_keypoints
@@ -27,6 +30,7 @@ import numpy as np
import cv2
import subprocess
import imageio_ffmpeg as ffmpeg
+from comfy_extras.nodes_video_model import SVD_img2vid_Conditioning
NODE_CLASS_MAPPINGS = {} # this is the dictionary that will be used to register the nodes
@@ -64,6 +68,8 @@ def run_ffmpeg_command(args):
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)
@@ -551,6 +557,8 @@ class GetImageStackStatistics(ABCABCVideoFolderToImage, metaclass=ABCWidgetMetac
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
+
else:
# not implimented
raise NotImplementedError
@@ -561,13 +569,13 @@ class GetImageStackStatistics(ABCABCVideoFolderToImage, metaclass=ABCWidgetMetac
mean = torch.mean(stack_mse_B)
std = torch.std(stack_mse_B)
median = torch.median(stack_mse_B)
- max = torch.max(stack_mse_B)
- min = torch.min(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}\n min: {min}\n len: {image_stack_diff_len}"
+ 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=100)
+ 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
@@ -579,18 +587,21 @@ class GetImageStackStatistics(ABCABCVideoFolderToImage, metaclass=ABCWidgetMetac
# Fill the image tensor with the histogram
for i, value in enumerate(hist_values):
- height = int(value * scale_height)
+ 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 = int(value * scale_height / max) # Normalize by the max value
+ 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 # Color as white
+ out_hist_image[0, height:, start_x:end_x, :] = (1 * height) / 512 # Color as white
return (out_str, out_hist_image,)
@@ -674,7 +685,7 @@ class ImageStackToVideoFramesFolder(metaclass=ABCWidgetMetaclass):
},
}
- RETURN_TYPES = ()
+ RETURN_TYPES = ("STRING",)
CATEGORY = "video"
OUTPUT_NODE = True
FUNCTION = "handler"
@@ -717,7 +728,7 @@ class ImageStackToVideoFramesFolder(metaclass=ABCWidgetMetaclass):
image_stack = None
del image_stack
- return ()
+ return (folder_out,)
class TemporalSpatialSmoothing(metaclass=ABCWidgetMetaclass):
@@ -1126,6 +1137,17 @@ class CombineAudioAndVideoFiles(metaclass=ABCWidgetMetaclass):
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
+
def handler(self, **kwargs):
from os.path import splitext, join
import shutil
@@ -1141,15 +1163,33 @@ class CombineAudioAndVideoFiles(metaclass=ABCWidgetMetaclass):
raise RuntimeError("FFmpeg could not be found.")
for video_file, audio_file in video_audio_pairs:
+
video_out_file_fp = splitext(video_file)[0] + "_v_and_a.mp4"
input_args = ['-i', video_file, '-i', audio_file]
- output_args = ['-c:v', 'copy', '-c:a', 'aac', '-strict', 'experimental', '-y', video_out_file_fp]
- command = [ffmpeg_exe] + input_args + output_args
- os.system(' '.join(command))
+
+ if self.has_audio_stream(video_file):
+ # The video has an audio stream, proceed with mixing
+ filter_complex_arg = ['-filter_complex', '[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]
+
+ command = [ffmpeg_exe] + input_args + filter_complex_arg + output_args
+ cmd = ' '.join(command)
+ print(cmd)
+ os.system(cmd)
if move_to_folder:
destination_path = join(move_to_folder, os.path.basename(video_out_file_fp))
- shutil.move(video_out_file_fp, destination_path)
+
+ 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)
@@ -1168,7 +1208,7 @@ class CombineFoldersWithFramesToVideoFiles(metaclass=ABCWidgetMetaclass):
"fps/length": ("FLOAT", {"default": 30, "min": 1, "max": 10000000000}),
"bitrate": ("STRING", {"default": "20000k"}),
"codec": (
- ["libx264", "libx265", "mpeg4", "vp9", "huffyuv", "flv1"],
+ ["libx264", "libx265", "mpeg4", "vp9", "huffyuv", "flv1", "h264_nvenc", "hevc_nvenc"],
{"default": "libx264"}
),
"file_extension": (
@@ -1176,7 +1216,10 @@ class CombineFoldersWithFramesToVideoFiles(metaclass=ABCWidgetMetaclass):
{"default": ".mp4"}
),
"fps or length": (["fps", "length"], {"default": "fps"}),
- }
+ },
+ "optional": {
+ "[duration or fps]": ("LIST", {"default": []}),
+ },
}
RETURN_TYPES = ("LIST", "STRING",)
@@ -1195,14 +1238,15 @@ class CombineFoldersWithFramesToVideoFiles(metaclass=ABCWidgetMetaclass):
codec = kwargs["codec"]
file_extension = kwargs["file_extension"]
fps_or_length = kwargs["fps or length"]
+ duration_or_fps_list = kwargs.get("[duration or fps]", [])
combined_video_paths = []
messages = []
- for folder_path in folders_with_frames:
+ for i, folder_path in enumerate(folders_with_frames):
try:
out_file = os.path.join(os.path.dirname(folder_path),
- f"{os.path.basename(folder_path)}{file_extension}")
+ f"{os.path.basename(folder_path)}tmp_{i}{file_extension}")
frame_files = sorted(
[f for f in os.listdir(folder_path) if f.lower().endswith(('.jpg', '.jpeg', '.png'))])
@@ -1212,9 +1256,15 @@ class CombineFoldersWithFramesToVideoFiles(metaclass=ABCWidgetMetaclass):
if fps_or_length == "fps":
fps = fps_length_value
+ if duration_or_fps_list:
+ fps = duration_or_fps_list[i]
+
else:
total_frames = len(frame_files)
fps = total_frames / fps_length_value if fps_length_value != 0 else 30
+ if duration_or_fps_list:
+ # calculate the fps based on the duration
+ fps = total_frames / duration_or_fps_list[i]
writer = imageio.get_writer(out_file, fps=fps, codec=codec, bitrate=bitrate)
@@ -1270,7 +1320,7 @@ class ImageStackToVideoFile(metaclass=ABCWidgetMetaclass):
import cv2
import torchvision
- use_torchvision = True
+ use_torchvision = False
# video_dir = kwargs["video_dir"]
@@ -1319,6 +1369,9 @@ class ImageStackToVideoFile(metaclass=ABCWidgetMetaclass):
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)
@@ -1352,12 +1405,15 @@ class ImageStackToVideoFile(metaclass=ABCWidgetMetaclass):
if use_torchvision:
audio_tensor = None
if audio_file is not None:
- aud_sr = torchaudio.io.StreamReader(audio_file)
-
+ 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,
- fps=video_fps,
+ 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,)
@@ -1919,6 +1975,7 @@ class PanByStep(metaclass=ABCWidgetMetaclass):
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
@@ -1938,12 +1995,14 @@ class PanByStep(metaclass=ABCWidgetMetaclass):
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 = stop_x / time_index - 1
+ 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):
@@ -1954,8 +2013,17 @@ class PanByStep(metaclass=ABCWidgetMetaclass):
# 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()
+ # 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)
@@ -1964,3 +2032,1097 @@ class PanByStep(metaclass=ABCWidgetMetaclass):
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": None}),
+ },
+ "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 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": 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 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)
+
+ # if the folder does not exist, create the folder
+
+ 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
+
+ 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}"
+
+ os.system(CMD)
+ 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:
+ 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
+
+ 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
+
+ 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):
+ 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,)
diff --git a/video_v2.py b/video_v2.py
new file mode 100644
index 0000000..e69de29
diff --git a/web/marked.js b/web/marked.js
new file mode 100644
index 0000000..3128e26
--- /dev/null
+++ b/web/marked.js
@@ -0,0 +1,1191 @@
+/**
+ * marked v12.0.1 - a markdown parser
+ * Copyright (c) 2011-2024, Christopher Jeffrey. (MIT Licensed)
+ * https://github.com/markedjs/marked
+ */
+!function (e, t) {
+ "object" == typeof exports && "undefined" != typeof module ? t(exports) : "function" == typeof define && define.amd ? define(["exports"], t) : t((e = "undefined" != typeof globalThis ? globalThis : e || self).marked = {})
+}(this, (function (e) {
+ "use strict";
+
+ function t() {
+ return {
+ async: !1,
+ breaks: !1,
+ extensions: null,
+ gfm: !0,
+ hooks: null,
+ pedantic: !1,
+ renderer: null,
+ silent: !1,
+ tokenizer: null,
+ walkTokens: null
+ }
+ }
+
+ function n(t) {
+ e.defaults = t
+ }
+
+ e.defaults = {
+ async: !1,
+ breaks: !1,
+ extensions: null,
+ gfm: !0,
+ hooks: null,
+ pedantic: !1,
+ renderer: null,
+ silent: !1,
+ tokenizer: null,
+ walkTokens: null
+ };
+ const s = /[&<>"']/, r = new RegExp(s.source, "g"), i = /[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,
+ l = new RegExp(i.source, "g"), o = {"&": "&", "<": "<", ">": ">", '"': """, "'": "'"},
+ a = e => o[e];
+
+ function c(e, t) {
+ if (t) {
+ if (s.test(e)) return e.replace(r, a)
+ } else if (i.test(e)) return e.replace(l, a);
+ return e
+ }
+
+ const h = /&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/gi;
+
+ function p(e) {
+ return e.replace(h, ((e, t) => "colon" === (t = t.toLowerCase()) ? ":" : "#" === t.charAt(0) ? "x" === t.charAt(1) ? String.fromCharCode(parseInt(t.substring(2), 16)) : String.fromCharCode(+t.substring(1)) : ""))
+ }
+
+ const u = /(^|[^\[])\^/g;
+
+ function k(e, t) {
+ let n = "string" == typeof e ? e : e.source;
+ t = t || "";
+ const s = {
+ replace: (e, t) => {
+ let r = "string" == typeof t ? t : t.source;
+ return r = r.replace(u, "$1"), n = n.replace(e, r), s
+ }, getRegex: () => new RegExp(n, t)
+ };
+ return s
+ }
+
+ function g(e) {
+ try {
+ e = encodeURI(e).replace(/%25/g, "%")
+ } catch (e) {
+ return null
+ }
+ return e
+ }
+
+ const f = {exec: () => null};
+
+ function d(e, t) {
+ const n = e.replace(/\|/g, ((e, t, n) => {
+ let s = !1, r = t;
+ for (; --r >= 0 && "\\" === n[r];) s = !s;
+ return s ? "|" : " |"
+ })).split(/ \|/);
+ let s = 0;
+ if (n[0].trim() || n.shift(), n.length > 0 && !n[n.length - 1].trim() && n.pop(), t) if (n.length > t) n.splice(t); else for (; n.length < t;) n.push("");
+ for (; s < n.length; s++) n[s] = n[s].trim().replace(/\\\|/g, "|");
+ return n
+ }
+
+ function x(e, t, n) {
+ const s = e.length;
+ if (0 === s) return "";
+ let r = 0;
+ for (; r < s;) {
+ const i = e.charAt(s - r - 1);
+ if (i !== t || n) {
+ if (i === t || !n) break;
+ r++
+ } else r++
+ }
+ return e.slice(0, s - r)
+ }
+
+ function b(e, t, n, s) {
+ const r = t.href, i = t.title ? c(t.title) : null, l = e[1].replace(/\\([\[\]])/g, "$1");
+ if ("!" !== e[0].charAt(0)) {
+ s.state.inLink = !0;
+ const e = {type: "link", raw: n, href: r, title: i, text: l, tokens: s.inlineTokens(l)};
+ return s.state.inLink = !1, e
+ }
+ return {type: "image", raw: n, href: r, title: i, text: c(l)}
+ }
+
+ class w {
+ options;
+ rules;
+ lexer;
+
+ constructor(t) {
+ this.options = t || e.defaults
+ }
+
+ space(e) {
+ const t = this.rules.block.newline.exec(e);
+ if (t && t[0].length > 0) return {type: "space", raw: t[0]}
+ }
+
+ code(e) {
+ const t = this.rules.block.code.exec(e);
+ if (t) {
+ const e = t[0].replace(/^ {1,4}/gm, "");
+ return {
+ type: "code",
+ raw: t[0],
+ codeBlockStyle: "indented",
+ text: this.options.pedantic ? e : x(e, "\n")
+ }
+ }
+ }
+
+ fences(e) {
+ const t = this.rules.block.fences.exec(e);
+ if (t) {
+ const e = t[0], n = function (e, t) {
+ const n = e.match(/^(\s+)(?:```)/);
+ if (null === n) return t;
+ const s = n[1];
+ return t.split("\n").map((e => {
+ const t = e.match(/^\s+/);
+ if (null === t) return e;
+ const [n] = t;
+ return n.length >= s.length ? e.slice(s.length) : e
+ })).join("\n")
+ }(e, t[3] || "");
+ return {
+ type: "code",
+ raw: e,
+ lang: t[2] ? t[2].trim().replace(this.rules.inline.anyPunctuation, "$1") : t[2],
+ text: n
+ }
+ }
+ }
+
+ heading(e) {
+ const t = this.rules.block.heading.exec(e);
+ if (t) {
+ let e = t[2].trim();
+ if (/#$/.test(e)) {
+ const t = x(e, "#");
+ this.options.pedantic ? e = t.trim() : t && !/ $/.test(t) || (e = t.trim())
+ }
+ return {type: "heading", raw: t[0], depth: t[1].length, text: e, tokens: this.lexer.inline(e)}
+ }
+ }
+
+ hr(e) {
+ const t = this.rules.block.hr.exec(e);
+ if (t) return {type: "hr", raw: t[0]}
+ }
+
+ blockquote(e) {
+ const t = this.rules.block.blockquote.exec(e);
+ if (t) {
+ const e = x(t[0].replace(/^ *>[ \t]?/gm, ""), "\n"), n = this.lexer.state.top;
+ this.lexer.state.top = !0;
+ const s = this.lexer.blockTokens(e);
+ return this.lexer.state.top = n, {type: "blockquote", raw: t[0], tokens: s, text: e}
+ }
+ }
+
+ list(e) {
+ let t = this.rules.block.list.exec(e);
+ if (t) {
+ let n = t[1].trim();
+ const s = n.length > 1,
+ r = {type: "list", raw: "", ordered: s, start: s ? +n.slice(0, -1) : "", loose: !1, items: []};
+ n = s ? `\\d{1,9}\\${n.slice(-1)}` : `\\${n}`, this.options.pedantic && (n = s ? n : "[*+-]");
+ const i = new RegExp(`^( {0,3}${n})((?:[\t ][^\\n]*)?(?:\\n|$))`);
+ let l = "", o = "", a = !1;
+ for (; e;) {
+ let n = !1;
+ if (!(t = i.exec(e))) break;
+ if (this.rules.block.hr.test(e)) break;
+ l = t[0], e = e.substring(l.length);
+ let s = t[2].split("\n", 1)[0].replace(/^\t+/, (e => " ".repeat(3 * e.length))),
+ c = e.split("\n", 1)[0], h = 0;
+ this.options.pedantic ? (h = 2, o = s.trimStart()) : (h = t[2].search(/[^ ]/), h = h > 4 ? 1 : h, o = s.slice(h), h += t[1].length);
+ let p = !1;
+ if (!s && /^ *$/.test(c) && (l += c + "\n", e = e.substring(c.length + 1), n = !0), !n) {
+ const t = new RegExp(`^ {0,${Math.min(3,h-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ \t][^\\n]*)?(?:\\n|$))`),
+ n = new RegExp(`^ {0,${Math.min(3,h-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),
+ r = new RegExp(`^ {0,${Math.min(3,h-1)}}(?:\`\`\`|~~~)`),
+ i = new RegExp(`^ {0,${Math.min(3,h-1)}}#`);
+ for (; e;) {
+ const a = e.split("\n", 1)[0];
+ if (c = a, this.options.pedantic && (c = c.replace(/^ {1,4}(?=( {4})*[^ ])/g, " ")), r.test(c)) break;
+ if (i.test(c)) break;
+ if (t.test(c)) break;
+ if (n.test(e)) break;
+ if (c.search(/[^ ]/) >= h || !c.trim()) o += "\n" + c.slice(h); else {
+ if (p) break;
+ if (s.search(/[^ ]/) >= 4) break;
+ if (r.test(s)) break;
+ if (i.test(s)) break;
+ if (n.test(s)) break;
+ o += "\n" + c
+ }
+ p || c.trim() || (p = !0), l += a + "\n", e = e.substring(a.length + 1), s = c.slice(h)
+ }
+ }
+ r.loose || (a ? r.loose = !0 : /\n *\n *$/.test(l) && (a = !0));
+ let u, k = null;
+ this.options.gfm && (k = /^\[[ xX]\] /.exec(o), k && (u = "[ ] " !== k[0], o = o.replace(/^\[[ xX]\] +/, ""))), r.items.push({
+ type: "list_item",
+ raw: l,
+ task: !!k,
+ checked: u,
+ loose: !1,
+ text: o,
+ tokens: []
+ }), r.raw += l
+ }
+ r.items[r.items.length - 1].raw = l.trimEnd(), r.items[r.items.length - 1].text = o.trimEnd(), r.raw = r.raw.trimEnd();
+ for (let e = 0; e < r.items.length; e++) if (this.lexer.state.top = !1, r.items[e].tokens = this.lexer.blockTokens(r.items[e].text, []), !r.loose) {
+ const t = r.items[e].tokens.filter((e => "space" === e.type)),
+ n = t.length > 0 && t.some((e => /\n.*\n/.test(e.raw)));
+ r.loose = n
+ }
+ if (r.loose) for (let e = 0; e < r.items.length; e++) r.items[e].loose = !0;
+ return r
+ }
+ }
+
+ html(e) {
+ const t = this.rules.block.html.exec(e);
+ if (t) {
+ return {
+ type: "html",
+ block: !0,
+ raw: t[0],
+ pre: "pre" === t[1] || "script" === t[1] || "style" === t[1],
+ text: t[0]
+ }
+ }
+ }
+
+ def(e) {
+ const t = this.rules.block.def.exec(e);
+ if (t) {
+ const e = t[1].toLowerCase().replace(/\s+/g, " "),
+ n = t[2] ? t[2].replace(/^<(.*)>$/, "$1").replace(this.rules.inline.anyPunctuation, "$1") : "",
+ s = t[3] ? t[3].substring(1, t[3].length - 1).replace(this.rules.inline.anyPunctuation, "$1") : t[3];
+ return {type: "def", tag: e, raw: t[0], href: n, title: s}
+ }
+ }
+
+ table(e) {
+ const t = this.rules.block.table.exec(e);
+ if (!t) return;
+ if (!/[:|]/.test(t[2])) return;
+ const n = d(t[1]), s = t[2].replace(/^\||\| *$/g, "").split("|"),
+ r = t[3] && t[3].trim() ? t[3].replace(/\n[ \t]*$/, "").split("\n") : [],
+ i = {type: "table", raw: t[0], header: [], align: [], rows: []};
+ if (n.length === s.length) {
+ for (const e of s) /^ *-+: *$/.test(e) ? i.align.push("right") : /^ *:-+: *$/.test(e) ? i.align.push("center") : /^ *:-+ *$/.test(e) ? i.align.push("left") : i.align.push(null);
+ for (const e of n) i.header.push({text: e, tokens: this.lexer.inline(e)});
+ for (const e of r) i.rows.push(d(e, i.header.length).map((e => ({
+ text: e,
+ tokens: this.lexer.inline(e)
+ }))));
+ return i
+ }
+ }
+
+ lheading(e) {
+ const t = this.rules.block.lheading.exec(e);
+ if (t) return {
+ type: "heading",
+ raw: t[0],
+ depth: "=" === t[2].charAt(0) ? 1 : 2,
+ text: t[1],
+ tokens: this.lexer.inline(t[1])
+ }
+ }
+
+ paragraph(e) {
+ const t = this.rules.block.paragraph.exec(e);
+ if (t) {
+ const e = "\n" === t[1].charAt(t[1].length - 1) ? t[1].slice(0, -1) : t[1];
+ return {type: "paragraph", raw: t[0], text: e, tokens: this.lexer.inline(e)}
+ }
+ }
+
+ text(e) {
+ const t = this.rules.block.text.exec(e);
+ if (t) return {type: "text", raw: t[0], text: t[0], tokens: this.lexer.inline(t[0])}
+ }
+
+ escape(e) {
+ const t = this.rules.inline.escape.exec(e);
+ if (t) return {type: "escape", raw: t[0], text: c(t[1])}
+ }
+
+ tag(e) {
+ const t = this.rules.inline.tag.exec(e);
+ if (t) return !this.lexer.state.inLink && /^/i.test(t[0]) && (this.lexer.state.inLink = !1), !this.lexer.state.inRawBlock && /^<(pre|code|kbd|script)(\s|>)/i.test(t[0]) ? this.lexer.state.inRawBlock = !0 : this.lexer.state.inRawBlock && /^<\/(pre|code|kbd|script)(\s|>)/i.test(t[0]) && (this.lexer.state.inRawBlock = !1), {
+ type: "html",
+ raw: t[0],
+ inLink: this.lexer.state.inLink,
+ inRawBlock: this.lexer.state.inRawBlock,
+ block: !1,
+ text: t[0]
+ }
+ }
+
+ link(e) {
+ const t = this.rules.inline.link.exec(e);
+ if (t) {
+ const e = t[2].trim();
+ if (!this.options.pedantic && /^$/.test(e)) return;
+ const t = x(e.slice(0, -1), "\\");
+ if ((e.length - t.length) % 2 == 0) return
+ } else {
+ const e = function (e, t) {
+ if (-1 === e.indexOf(t[1])) return -1;
+ let n = 0;
+ for (let s = 0; s < e.length; s++) if ("\\" === e[s]) s++; else if (e[s] === t[0]) n++; else if (e[s] === t[1] && (n--, n < 0)) return s;
+ return -1
+ }(t[2], "()");
+ if (e > -1) {
+ const n = (0 === t[0].indexOf("!") ? 5 : 4) + t[1].length + e;
+ t[2] = t[2].substring(0, e), t[0] = t[0].substring(0, n).trim(), t[3] = ""
+ }
+ }
+ let n = t[2], s = "";
+ if (this.options.pedantic) {
+ const e = /^([^'"]*[^\s])\s+(['"])(.*)\2/.exec(n);
+ e && (n = e[1], s = e[3])
+ } else s = t[3] ? t[3].slice(1, -1) : "";
+ return n = n.trim(), /^$/.test(e) ? n.slice(1) : n.slice(1, -1)), b(t, {
+ href: n ? n.replace(this.rules.inline.anyPunctuation, "$1") : n,
+ title: s ? s.replace(this.rules.inline.anyPunctuation, "$1") : s
+ }, t[0], this.lexer)
+ }
+ }
+
+ reflink(e, t) {
+ let n;
+ if ((n = this.rules.inline.reflink.exec(e)) || (n = this.rules.inline.nolink.exec(e))) {
+ const e = t[(n[2] || n[1]).replace(/\s+/g, " ").toLowerCase()];
+ if (!e) {
+ const e = n[0].charAt(0);
+ return {type: "text", raw: e, text: e}
+ }
+ return b(n, e, n[0], this.lexer)
+ }
+ }
+
+ emStrong(e, t, n = "") {
+ let s = this.rules.inline.emStrongLDelim.exec(e);
+ if (!s) return;
+ if (s[3] && n.match(/[\p{L}\p{N}]/u)) return;
+ if (!(s[1] || s[2] || "") || !n || this.rules.inline.punctuation.exec(n)) {
+ const n = [...s[0]].length - 1;
+ let r, i, l = n, o = 0;
+ const a = "*" === s[0][0] ? this.rules.inline.emStrongRDelimAst : this.rules.inline.emStrongRDelimUnd;
+ for (a.lastIndex = 0, t = t.slice(-1 * e.length + n); null != (s = a.exec(t));) {
+ if (r = s[1] || s[2] || s[3] || s[4] || s[5] || s[6], !r) continue;
+ if (i = [...r].length, s[3] || s[4]) {
+ l += i;
+ continue
+ }
+ if ((s[5] || s[6]) && n % 3 && !((n + i) % 3)) {
+ o += i;
+ continue
+ }
+ if (l -= i, l > 0) continue;
+ i = Math.min(i, i + l + o);
+ const t = [...s[0]][0].length, a = e.slice(0, n + s.index + t + i);
+ if (Math.min(n, i) % 2) {
+ const e = a.slice(1, -1);
+ return {type: "em", raw: a, text: e, tokens: this.lexer.inlineTokens(e)}
+ }
+ const c = a.slice(2, -2);
+ return {type: "strong", raw: a, text: c, tokens: this.lexer.inlineTokens(c)}
+ }
+ }
+ }
+
+ codespan(e) {
+ const t = this.rules.inline.code.exec(e);
+ if (t) {
+ let e = t[2].replace(/\n/g, " ");
+ const n = /[^ ]/.test(e), s = /^ /.test(e) && / $/.test(e);
+ return n && s && (e = e.substring(1, e.length - 1)), e = c(e, !0), {
+ type: "codespan",
+ raw: t[0],
+ text: e
+ }
+ }
+ }
+
+ br(e) {
+ const t = this.rules.inline.br.exec(e);
+ if (t) return {type: "br", raw: t[0]}
+ }
+
+ del(e) {
+ const t = this.rules.inline.del.exec(e);
+ if (t) return {type: "del", raw: t[0], text: t[2], tokens: this.lexer.inlineTokens(t[2])}
+ }
+
+ autolink(e) {
+ const t = this.rules.inline.autolink.exec(e);
+ if (t) {
+ let e, n;
+ return "@" === t[2] ? (e = c(t[1]), n = "mailto:" + e) : (e = c(t[1]), n = e), {
+ type: "link",
+ raw: t[0],
+ text: e,
+ href: n,
+ tokens: [{type: "text", raw: e, text: e}]
+ }
+ }
+ }
+
+ url(e) {
+ let t;
+ if (t = this.rules.inline.url.exec(e)) {
+ let e, n;
+ if ("@" === t[2]) e = c(t[0]), n = "mailto:" + e; else {
+ let s;
+ do {
+ s = t[0], t[0] = this.rules.inline._backpedal.exec(t[0])?.[0] ?? ""
+ } while (s !== t[0]);
+ e = c(t[0]), n = "www." === t[1] ? "http://" + t[0] : t[0]
+ }
+ return {type: "link", raw: t[0], text: e, href: n, tokens: [{type: "text", raw: e, text: e}]}
+ }
+ }
+
+ inlineText(e) {
+ const t = this.rules.inline.text.exec(e);
+ if (t) {
+ let e;
+ return e = this.lexer.state.inRawBlock ? t[0] : c(t[0]), {type: "text", raw: t[0], text: e}
+ }
+ }
+ }
+
+ const m = /^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/, y = /(?:[*+-]|\d{1,9}[.)])/,
+ $ = k(/^(?!bull |blockCode|fences|blockquote|heading|html)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html))+?)\n {0,3}(=+|-+) *(?:\n+|$)/).replace(/bull/g, y).replace(/blockCode/g, / {4}/).replace(/fences/g, / {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g, / {0,3}>/).replace(/heading/g, / {0,3}#{1,6}/).replace(/html/g, / {0,3}<[^\n>]+>\n/).getRegex(),
+ z = /^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,
+ T = /(?!\s*\])(?:\\.|[^\[\]\\])+/,
+ R = k(/^ {0,3}\[(label)\]: *(?:\n *)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n *)?| *\n *)(title))? *(?:\n+|$)/).replace("label", T).replace("title", /(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),
+ _ = k(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g, y).getRegex(),
+ A = "address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",
+ S = /|$))/,
+ I = k("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:\\1>[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|?(tag)(?: +|\\n|/?>)[\\s\\S]*?(?:(?:\\n *)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$)|(?!script|pre|style|textarea)[a-z][\\w-]*\\s*>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$))", "i").replace("comment", S).replace("tag", A).replace("attribute", / +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),
+ E = k(z).replace("hr", m).replace("heading", " {0,3}#{1,6}(?:\\s|$)").replace("|lheading", "").replace("|table", "").replace("blockquote", " {0,3}>").replace("fences", " {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list", " {0,3}(?:[*+-]|1[.)]) ").replace("html", "?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)").replace("tag", A).getRegex(),
+ q = {
+ blockquote: k(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph", E).getRegex(),
+ code: /^( {4}[^\n]+(?:\n(?: *(?:\n|$))*)?)+/,
+ def: R,
+ fences: /^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,
+ heading: /^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,
+ hr: m,
+ html: I,
+ lheading: $,
+ list: _,
+ newline: /^(?: *(?:\n|$))+/,
+ paragraph: E,
+ table: f,
+ text: /^[^\n]+/
+ },
+ Z = k("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr", m).replace("heading", " {0,3}#{1,6}(?:\\s|$)").replace("blockquote", " {0,3}>").replace("code", " {4}[^\\n]").replace("fences", " {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list", " {0,3}(?:[*+-]|1[.)]) ").replace("html", "?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)").replace("tag", A).getRegex(),
+ L = {
+ ...q,
+ table: Z,
+ paragraph: k(z).replace("hr", m).replace("heading", " {0,3}#{1,6}(?:\\s|$)").replace("|lheading", "").replace("table", Z).replace("blockquote", " {0,3}>").replace("fences", " {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list", " {0,3}(?:[*+-]|1[.)]) ").replace("html", "?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)").replace("tag", A).getRegex()
+ }, P = {
+ ...q,
+ html: k("^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+?\\1> *(?:\\n{2,}|\\s*$)| ${e}
\n" : "' + (n ? e : c(e, !0)) + "
\n"
+ }
+
+ blockquote(e) {
+ return `" + (n ? e : c(e, !0)) + "\n${e}
\n`
+ }
+
+ html(e, t) {
+ return e
+ }
+
+ heading(e, t, n) {
+ return `
\n"
+ }
+
+ list(e, t, n) {
+ const s = t ? "ol" : "ul";
+ return "<" + s + (t && 1 !== n ? ' start="' + n + '"' : "") + ">\n" + e + "" + s + ">\n"
+ }
+
+ listitem(e, t, n) {
+ return `
${e}`
+ }
+
+ br() {
+ return "An error occurred:
" + c(n.message + "", !0) + ""; + return t ? Promise.resolve(e) : e + } + if (t) return Promise.reject(n); + throw n + } + } + } + + const ae = new oe; + + function ce(e, t) { + return ae.parse(e, t) + } + + ce.options = ce.setOptions = function (e) { + return ae.setOptions(e), ce.defaults = ae.defaults, n(ce.defaults), ce + }, ce.getDefaults = t, ce.defaults = e.defaults, ce.use = function (...e) { + return ae.use(...e), ce.defaults = ae.defaults, n(ce.defaults), ce + }, ce.walkTokens = function (e, t) { + return ae.walkTokens(e, t) + }, ce.parseInline = ae.parseInline, ce.Parser = ie, ce.parser = ie.parse, ce.Renderer = se, ce.TextRenderer = re, ce.Lexer = ne, ce.lexer = ne.lex, ce.Tokenizer = w, ce.Hooks = le, ce.parse = ce; + const he = ce.options, pe = ce.setOptions, ue = ce.use, ke = ce.walkTokens, ge = ce.parseInline, fe = ce, + de = ie.parse, xe = ne.lex; + e.Hooks = le, e.Lexer = ne, e.Marked = oe, e.Parser = ie, e.Renderer = se, e.TextRenderer = re, e.Tokenizer = w, e.getDefaults = t, e.lexer = xe, e.marked = ce, e.options = he, e.parse = fe, e.parseInline = ge, e.parser = de, e.setOptions = pe, e.use = ue, e.walkTokens = ke +})); diff --git a/web/showMarkdown.js b/web/showMarkdown.js new file mode 100644 index 0000000..5b32173 --- /dev/null +++ b/web/showMarkdown.js @@ -0,0 +1,48 @@ +import { app } from "../../scripts/app.js"; +import { ComfyWidgets } from "../../scripts/widgets.js"; +import marked from "./marked.js"; + + + +// Displays input markdown on a node + +app.registerExtension({ + name: "pysssss.ShowMarkdown", + async beforeRegisterNodeDef(nodeType, nodeData, app) { + if (nodeData.internal_state_display || nodeData.name == "ShowMarkdown") { + // When the node is executed we will be sent the input markdown, display this in the widget + const onExecuted = nodeType.prototype.onExecuted; + nodeType.prototype.onExecuted = function (message) { + onExecuted?.apply(this, arguments); + + if (this.widgets) { + const pos = this.widgets.findIndex((w) => w.name === "markdown"); + if (pos !== -1) { + for (let i = pos; i < this.widgets.length; i++) { + this.widgets[i].onRemove?.(); + } + this.widgets.length = pos; + } + } + + for (const list of message.text) { + const w = ComfyWidgets["STRING"](this, "markdown", ["STRING", { multiline: true }], app).widget; + + // Convert markdown to HTML + const html = marked(list); + + // Create a new div element to hold the rendered markdown + const div = document.createElement("div"); + div.classList.add("markdown-widget"); + div.style.opacity = 0.6; + div.innerHTML = html; + + // Replace the input element with the div containing the rendered markdown + w.inputEl.parentNode.replaceChild(div, w.inputEl); + } + + this.onResize?.(this.size); + }; + } + }, +}); \ No newline at end of file diff --git a/youtube_nodes.py b/youtube_nodes.py index e6f1d9a..7414488 100644 --- a/youtube_nodes.py +++ b/youtube_nodes.py @@ -43,7 +43,8 @@ class YoutubeUploadNode: import sys my_python_exe = sys.executable - category = self.CATEGORY_NAMES.index(category) + category_idx = self.CATEGORY_NAMES.index(category) + category = self.VALID_CATEGORIES[category_idx] args = ["--file", file_path, "--category", str(category), "--title", title, "--description", description, "--keywords", keywords, "--privacyStatus", privacy_status]