From 4a1e6dfb49b8f0ab9a4624292a2334a2cb7ddcda Mon Sep 17 00:00:00 2001 From: Tasha Upchurch Date: Sun, 22 Sep 2024 20:05:04 -0600 Subject: [PATCH] utils.py: Added FileBackedDict, which provides a dictionary-like interface with file-backed persistence. Supports nested dictionaries and ensures data is stored and retrieved from disk. video.py: Implemented audio resampling in CombineAudioVideoFiles, including video and audio resampling logic. Refined create_video_from_images to use ffmpeg for combining image sequences into video. Updated CombineFoldersWithFramesToVideoFiles to handle both duration and fps-based input. Added additional parameters for video quality and pixel format. torchvision_nodes.py: Centralized the imports for NODE_CLASS_MAPPINGS and NODE_DISPLAY_NAME_MAPPINGS across the module. Adjusted default values for certain fields to improve user experience. youtube_nodes.py & modules/youtube.py: Extended the YouTube upload functionality to support optional thumbnail uploads. Improved initialize_upload function to return video_id after a successful upload. Introduced the ability to upload a thumbnail alongside the video. web/ui_modifications.js: Reworked the login form handling to prevent page reload and improved element management. Added dynamic graph creation in the handleGraphCreateNew function. web/showMarkdown.js: Fixed the import of marked.js, using Marked as the module name to avoid potential conflicts. modules/xttscli.py: Increased the batch_size for text-to-speech processing from 2 to 4, enabling faster concurrent execution. --- modules/xttscli.py | 2 +- modules/youtube.py | 74 ++++++------- torchvision_nodes.py | 7 +- utils.py | 98 +++++++++++++++++ video.py | 233 ++++++++++++++++++++++++++-------------- web/showMarkdown.js | 5 +- web/ui_modifications.js | 53 ++++++--- youtube_nodes.py | 14 +-- 8 files changed, 333 insertions(+), 153 deletions(-) diff --git a/modules/xttscli.py b/modules/xttscli.py index dae6525..e18679d 100644 --- a/modules/xttscli.py +++ b/modules/xttscli.py @@ -43,7 +43,7 @@ def generate_xtts(**kwargs): texts = text o_files = [] - batch_size = 2 + batch_size = 4 with ProcessPoolExecutor(max_workers=batch_size) as executor: futures = [] for i, text in enumerate(texts): diff --git a/modules/youtube.py b/modules/youtube.py index 9762711..5a467fd 100644 --- a/modules/youtube.py +++ b/modules/youtube.py @@ -10,6 +10,7 @@ from apiclient.http import MediaFileUpload from oauth2client.client import flow_from_clientsecrets from oauth2client.file import Storage from oauth2client.tools import argparser, run_flow +from googleapiclient.http import MediaFileUpload # Explicitly tell the underlying HTTP transport library not to retry, since # we are handling retry logic ourselves. @@ -25,16 +26,6 @@ RETRIABLE_EXCEPTIONS = (httplib2.HttpLib2Error, IOError) # codes is raised. RETRIABLE_STATUS_CODES = [500, 502, 503, 504] -# The CLIENT_SECRETS_FILE variable specifies the name of a file that contains -# the OAuth 2.0 information for this application, including its client_id and -# client_secret. You can acquire an OAuth 2.0 client ID and client secret from -# the Google API Console at -# https://console.cloud.google.com/. -# Please ensure that you have enabled the YouTube Data API for your project. -# For more information about using OAuth2 to access the YouTube Data API, see: -# https://developers.google.com/youtube/v3/guides/authentication -# For more information about the client_secrets.json file format, see: -# https://developers.google.com/api-client-library/python/guide/aaa_client_secrets try: import comfy except ModuleNotFoundError: @@ -44,11 +35,9 @@ except ModuleNotFoundError: comfy.__path__._path = ["."] comfy_dir = comfy.__path__._path[0] -# the base dir we need is one level down use os.path.dirname to get it base_dir = os.path.dirname(comfy_dir) -# now we can use os.path.join to get the full path to the file CLIENT_SECRETS_FILE = os.path.join(base_dir, "google-credentials.json") - +CLIENT_SECRETS_FILE = r"Q:\GDE\custom_nodes\EternalKernelLiteGraphNodes\modules\youtube.py-oauth2-secrets.json" # This OAuth 2.0 access scope allows an application to upload files to the # authenticated user's YouTube channel, but doesn't allow other types of access. YOUTUBE_UPLOAD_SCOPE = "https://www.googleapis.com/auth/youtube.upload" @@ -91,7 +80,7 @@ def get_authenticated_service(args): http=credentials.authorize(httplib2.Http())) -def initialize_upload(youtube, options): +def initialize_upload(youtube_api, options): tags = None if options.keywords: tags = options.keywords.split(",") @@ -109,28 +98,14 @@ def initialize_upload(youtube, options): ) # Call the API's videos.insert method to create and upload the video. - insert_request = youtube.videos().insert( + insert_request = youtube_api.videos().insert( part=",".join(body.keys()), body=body, - # The chunksize parameter specifies the size of each chunk of data, in - # bytes, that will be uploaded at a time. Set a higher value for - # reliable connections as fewer chunks lead to faster uploads. Set a lower - # value for better recovery on less reliable connections. - # - # Setting "chunksize" equal to -1 in the code below means that the entire - # file will be uploaded in a single HTTP request. (If the upload fails, - # it will still be retried where it left off.) This is usually a best - # practice, but if you're using Python older than 2.6 or if you're - # running on App Engine, you should set the chunksize to something like - # 1024 * 1024 (1 megabyte). media_body=MediaFileUpload(options.file, chunksize=-1, resumable=True) ) - resumable_upload(insert_request) - - -# This method implements an exponential backoff strategy to resume a -# failed upload. + video_id = resumable_upload(insert_request) + return video_id def resumable_upload(insert_request): @@ -143,14 +118,13 @@ def resumable_upload(insert_request): status, response = insert_request.next_chunk() if response is not None: if 'id' in response: - print("Video id '%s' was successfully uploaded." % - response['id']) + print("Video id '%s' was successfully uploaded." % response['id']) + return response['id'] else: exit("The upload failed with an unexpected response: %s" % response) except HttpError as e: if e.resp.status in RETRIABLE_STATUS_CODES: - error = "A retriable HTTP error %d occurred:\n%s" % (e.resp.status, - e.content) + error = "A retriable HTTP error %d occurred:\n%s" % (e.resp.status, e.content) else: raise except RETRIABLE_EXCEPTIONS as e: @@ -168,19 +142,26 @@ def resumable_upload(insert_request): time.sleep(sleep_seconds) +def upload_thumbnail(youtube, video_id, thumbnail_path): + print("Uploading thumbnail...") + request = youtube.thumbnails().set( + videoId=video_id, + media_body=MediaFileUpload(thumbnail_path) + ) + response = request.execute() + print("Thumbnail uploaded successfully.") + + if __name__ == '__main__': - argparser.add_argument("--file", required=True, - help="Video file to upload") + argparser.add_argument("--file", required=True, help="Video file to upload") argparser.add_argument("--title", help="Video title", default="Test Title") - argparser.add_argument("--description", help="Video description", - default="Test Description") + argparser.add_argument("--description", help="Video description", default="Test Description") argparser.add_argument("--category", default="22", - help="Numeric video category. " + - "See https://developers.google.com/youtube/v3/docs/videoCategories/list") - argparser.add_argument("--keywords", help="Video keywords, comma separated", - default="") + help="Numeric video category. See https://developers.google.com/youtube/v3/docs/videoCategories/list") + argparser.add_argument("--keywords", help="Video keywords, comma separated", default="") argparser.add_argument("--privacyStatus", choices=VALID_PRIVACY_STATUSES, default=VALID_PRIVACY_STATUSES[0], help="Video privacy status.") + argparser.add_argument("--thumbnail", help="Path to thumbnail image file") args = argparser.parse_args() if not os.path.exists(args.file): @@ -188,6 +169,11 @@ if __name__ == '__main__': youtube = get_authenticated_service(args) try: - initialize_upload(youtube, args) + video_id = initialize_upload(youtube, args) + if args.thumbnail: + if not os.path.exists(args.thumbnail): + print("Thumbnail file does not exist. Skipping thumbnail upload.") + else: + upload_thumbnail(youtube, video_id, args.thumbnail) except HttpError as e: print("An HTTP error %d occurred:\n%s" % (e.resp.status, e.content)) diff --git a/torchvision_nodes.py b/torchvision_nodes.py index c683990..71c4323 100644 --- a/torchvision_nodes.py +++ b/torchvision_nodes.py @@ -7,8 +7,9 @@ import functools import torch import torchvision -NODE_CLASS_MAPPINGS = {} -NODE_DISPLAY_NAME_MAPPINGS = {} +#NODE_CLASS_MAPPINGS = {} +#NODE_DISPLAY_NAME_MAPPINGS = {} +from . import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS def ETK_torchvision_base(cls): @@ -80,7 +81,7 @@ class TorchVisionTransformNode: "composition": ("TV_COMPOSITION_LIST", {"default": None}), "transform": (c_list,), }, "optional": { - "args": ("STRING", {"default": None}), + "args": ("STRING", {"default": ""}), }, } diff --git a/utils.py b/utils.py index 81e5881..dc05bd8 100644 --- a/utils.py +++ b/utils.py @@ -23,3 +23,101 @@ def etk_deep_copy(x, verbose=False): print(f"copying {x}, of type {type(x)}") return copy.deepcopy(x) + + +import json +import os +from collections.abc import MutableMapping + + +class FileBackedDict(MutableMapping): + FBD_MARKER = "__is_fbd__" # Marker to indicate nested FileBackedDicts + + def __init__(self, file_path): + # Ensure the provided path is absolute, raise an error if not + if not os.path.isabs(file_path): + raise ValueError(f"The path '{file_path}' is not an absolute path.") + + # Convert file_path to an absolute path (if needed) + self.file_path = os.path.abspath(file_path) + self._data = {} + self._load() + + def _load(self): + """Loads the data from the file into memory.""" + if os.path.exists(self.file_path): + with open(self.file_path, 'r', encoding='utf-8') as file: + try: + raw_data = json.load(file) + # Convert any sub-dictionaries marked as FileBackedDicts + for key, value in raw_data.items(): + if isinstance(value, dict) and value.get(self.FBD_MARKER, False): + # Create a nested FileBackedDict for each sub-dictionary + sub_dict_file = f"{self.file_path}_{key}.json" + self._data[key] = FileBackedDict(sub_dict_file) + else: + self._data[key] = value + except json.JSONDecodeError: + self._data = {} + else: + self._data = {} + + def _save(self): + """Saves the current data to the file.""" + # Save only the top-level keys that are not FileBackedDicts + data_to_save = {} + for key, value in self._data.items(): + if isinstance(value, FileBackedDict): + # Mark this key as being backed by a FileBackedDict + data_to_save[key] = {self.FBD_MARKER: True} + value._save() # Trigger save for the sub-dictionaries + else: + data_to_save[key] = value + + # Save the metadata about the keys + with open(self.file_path, 'w', encoding='utf-8') as file: + json.dump(data_to_save, file, ensure_ascii=False, indent=4) + + def __getitem__(self, key): + self._load() # Ensure the data is up-to-date + return self._data[key] + + def __setitem__(self, key, value): + self._load() # Ensure the data is up-to-date + if isinstance(value, dict): + # Create a nested FileBackedDict for each sub-dictionary + sub_dict_file = f"{self.file_path}_{key}.json" + self._data[key] = FileBackedDict(sub_dict_file) + self._data[key].update(value) + else: + self._data[key] = value + self._save() + + def __delitem__(self, key): + self._load() # Ensure the data is up-to-date + del self._data[key] + self._save() + + def __iter__(self): + self._load() # Ensure the data is up-to-date + return iter(self._data) + + def __len__(self): + self._load() # Ensure the data is up-to-date + return len(self._data) + + def __repr__(self): + self._load() # Ensure the data is up-to-date + return repr(self._data) + + def clear(self): + """Clear the dictionary and save the changes to the file.""" + self._data.clear() + self._save() + + def update(self, *args, **kwargs): + self._load() # Ensure the data is up-to-date + """Update the dictionary with new data and save the changes to the file.""" + for key, value in dict(*args, **kwargs).items(): + self.__setitem__(key, value) + self._save() \ No newline at end of file diff --git a/video.py b/video.py index 3aa1fc8..44c99e4 100644 --- a/video.py +++ b/video.py @@ -11,6 +11,7 @@ import shutil import fractions import os from abc import ABC, ABCMeta, abstractmethod +from os.path import splitext, join from typing import List, Tuple, Union import cv2 import torch @@ -20,7 +21,7 @@ import torchaudio from torch import dtype # from custom_nodes.EternalKernelLiteGraphNodes.image import torch_image_show from torchvision.models.optical_flow import raft_large, Raft_Large_Weights, raft_small, Raft_Small_Weights -from custom_nodes.EternalKernelLiteGraphNodes.modules.image_utils import lanczos_resize + import os import torch from torchvision.utils import draw_keypoints @@ -30,9 +31,11 @@ import numpy as np import cv2 import subprocess import imageio_ffmpeg as ffmpeg -from comfy_extras.nodes_video_model import SVD_img2vid_Conditioning +# 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 +from . import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS + +# NODE_CLASS_MAPPINGS = {} # this is the dictionary that will be used to register the nodes # setup directories root_p = os.path.dirname(os.path.dirname(os.path.dirname(os.path.realpath(__file__)))) @@ -1137,11 +1140,12 @@ class CombineAudioAndVideoFiles(metaclass=ABCWidgetMetaclass): """Combines lists of audio files and video files using imageio-ffmpeg""" @classmethod - def INPUT_TYPES(self): + def INPUT_TYPES(cls): return { "required": { "audio_files": ("LIST", {"default": []}), "video_files": ("LIST", {"default": []}), + "resample": (["None", "video", "audio", ],), "fps": ("FLOAT", {"default": 30, "min": 1, "max": 120}), }, "optional": { @@ -1166,41 +1170,81 @@ class CombineAudioAndVideoFiles(metaclass=ABCWidgetMetaclass): except subprocess.CalledProcessError: return False + import subprocess + import os + from os.path import splitext, join + import shutil + + def get_duration(self, file): + """Get the duration of a file in seconds.""" + command = ['ffprobe', '-v', 'error', '-show_entries', 'format=duration', '-of', + 'default=nw=1:nk=1', file] + try: + output = subprocess.check_output(command, stderr=subprocess.STDOUT) + return float(output.strip()) + except subprocess.CalledProcessError: + return None + def handler(self, **kwargs): - from os.path import splitext, join - import shutil audio_files = kwargs["audio_files"] video_files = kwargs["video_files"] move_to_folder = kwargs.get("move_to_folder", "") + resample = kwargs.get("resample", "None") + fps = kwargs.get("fps", 30.0) combined_video_paths = [] - video_audio_pairs = zip(video_files, audio_files) + video_audio_pairs = list(zip(video_files, audio_files)) ffmpeg_exe = ffmpeg.get_ffmpeg_exe() if ffmpeg_exe is None: raise RuntimeError("FFmpeg could not be found.") for video_file, audio_file in video_audio_pairs: + video_file = f'{video_file}' + audio_file = f'{audio_file}' - video_out_file_fp = splitext(video_file)[0] + "_v_and_a.mp4" + video_out_file_fp = f'{splitext(video_file)[0]}_v_and_a.mp4' input_args = ['-i', video_file, '-i', audio_file] if self.has_audio_stream(video_file): # The video has an audio stream, proceed with mixing - filter_complex_arg = ['-filter_complex', '[0:a][1:a]amix=inputs=2:duration=longest[a]'] + filter_complex_arg = '[0:a][1:a]amix=inputs=2:duration=longest[a]' output_args = ['-c:v', 'copy', '-map', '0:v:0', '-map', '[a]', '-c:a', 'aac', '-strict', 'experimental', '-y', video_out_file_fp] - - else: - filter_complex_arg = [] + 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 resample == "video": + video_duration = self.get_duration(video_file) + audio_duration = self.get_duration(audio_file) + + if video_duration and audio_duration: + speed = audio_duration / video_duration + filter_complex_arg = f'[0:v]setpts={speed}*PTS[v];[v][1:a]concat=n=1:v=1:a=1[v][a]' + output_args = ['-map', '[v]', '-map', '[a]', '-c:v', 'libx264', '-c:a', 'aac', '-strict', + 'experimental', '-y', video_out_file_fp] + else: + raise RuntimeError("Could not determine duration of video or audio file.") + elif resample == "None": + output_args.extend(['-r', str(fps)]) + elif resample == "audio": + # Not implemented: Finding silence in audio and removing it + raise NotImplementedError( + "Resampling audio is not implemented. Consider finding silence in the audio and removing it.") + + try: + # try to run it with the list of parts, using the list + if filter_complex_arg: + commands_to_run = [ffmpeg_exe, *input_args, '-filter_complex', filter_complex_arg, *output_args] + else: + commands_to_run = [ffmpeg_exe, *input_args, *output_args] + + commands_to_run = [str(c).replace("\"", "") for c in commands_to_run] + subprocess.run(commands_to_run, check=True) + except subprocess.CalledProcessError as e: + print(f"An error occurred: {e}") if move_to_folder: destination_path = join(move_to_folder, os.path.basename(video_out_file_fp)) @@ -1212,6 +1256,10 @@ class CombineAudioAndVideoFiles(metaclass=ABCWidgetMetaclass): else: combined_video_paths.append(video_out_file_fp) + os.chdir("..") + os.chdir("..") + os.chdir("..") + return (combined_video_paths,) @@ -1226,7 +1274,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", "h264_nvenc", "hevc_nvenc"], + ["libx264", "libx265", "mpeg4", "vp9", "huffyuv", "flv1", "h264_nvenc", "hevc_nvenc", "rawvideo"], {"default": "libx264"} ), "file_extension": ( @@ -1237,6 +1285,8 @@ class CombineFoldersWithFramesToVideoFiles(metaclass=ABCWidgetMetaclass): }, "optional": { "[duration or fps]": ("LIST", {"default": []}), + "q": ("INT", {"default": 20, "min": 0, "max": 31}), + "pix_fmt": ("STRING", {"default": "yuv420p"}), }, } @@ -1247,63 +1297,76 @@ class CombineFoldersWithFramesToVideoFiles(metaclass=ABCWidgetMetaclass): FUNCTION = "handler" def handler(self, **kwargs): - import imageio - import os - import time - - start_time = time.time() - - folders_with_frames = kwargs["folders_with_frames"] - fps_length_value = kwargs["fps/length"] - bitrate = kwargs["bitrate"] - codec = kwargs["codec"] - file_extension = kwargs["file_extension"] - fps_or_length = kwargs["fps or length"] + folders_with_frames = kwargs.get("folders_with_frames", []) + fps_length_value = kwargs.get("fps/length", 30) + file_extension = kwargs.get("file_extension", ".mkv") + fps_or_length = kwargs.get("fps or length", "fps") duration_or_fps_list = kwargs.get("[duration or fps]", []) + codec = kwargs.get("codec", "libx264") + q = kwargs.get("q", 20) + pix_fmt = kwargs.get("pix_fmt", "yuv420p") combined_video_paths = [] messages = [] + ffmpeg_exe = 'ffmpeg' # Ensure ffmpeg is in your PATH or provide the full path to the ffmpeg executable + if not shutil.which(ffmpeg_exe): + raise RuntimeError("FFmpeg could not be found.") + for i, folder_path in enumerate(folders_with_frames): try: - out_file = os.path.join(os.path.dirname(folder_path), - 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'))]) - - if not frame_files: - messages.append(f"No frame files found in folder: {folder_path}") - continue - - if fps_or_length == "fps": + output_file = os.path.join(os.path.dirname(folder_path), + f"{os.path.basename(folder_path)}tmp_{i}{file_extension}") + if fps_or_length == 'fps': fps = fps_length_value if duration_or_fps_list: fps = duration_or_fps_list[i] - else: + frame_files = sorted( + [f for f in os.listdir(folder_path) if f.lower().endswith(('.jpg', '.jpeg', '.png'))] + ) total_frames = len(frame_files) fps = total_frames / fps_length_value if fps_length_value != 0 else 30 if duration_or_fps_list: - # 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) - - for frame_file in frame_files: - frame_path = os.path.join(folder_path, frame_file) - frame = imageio.imread(frame_path) - writer.append_data(frame) - - writer.close() - combined_video_paths.append(out_file) + create_video_from_images(folder_path, output_file, fps, codec, q, pix_fmt) + combined_video_paths.append(output_file) except Exception as e: messages.append(f"Error processing folder {folder_path}: {str(e)}") - end_time = time.time() - print(f"Time taken for CombineFoldersWithFramesToVideoFiles: {end_time - start_time} seconds") return (combined_video_paths, "\n".join(messages),) +def create_file_list(folder_path): + frame_files = sorted( + [f for f in os.listdir(folder_path) if f.lower().endswith(('.jpg', '.jpeg', '.png'))] + ) + list_file_path = os.path.join(folder_path, 'filelist.txt') + with open(list_file_path, 'w') as f: + for frame_file in frame_files: + f.write(f"file '{os.path.join(folder_path, frame_file)}'\n") + return list_file_path + + +import subprocess + + +def create_video_from_images(folder_path, output_file, fps, codec, q, pix_fmt): + list_file_path = create_file_list(folder_path) + ffmpeg_exe = 'ffmpeg' # Ensure ffmpeg is in your PATH or provide the full path to the ffmpeg executable + + command = [ + ffmpeg_exe, '-y', '-f', 'concat', '-safe', '0', '-i', list_file_path, + '-vf', f"fps={fps}", '-c:v', codec, '-pix_fmt', pix_fmt, "-qp", str(q), output_file + ] + + try: + subprocess.run(command, check=True) + except subprocess.CalledProcessError as e: + raise RuntimeError(f"ffmpeg failed with error: {e.stderr.decode()}") + + class ImageStackToVideoFile(metaclass=ABCWidgetMetaclass): """Really just saves the image stack to a video file""" @@ -2065,7 +2128,8 @@ class VideoFolderProvider(metaclass=ABCWidgetMetaclass): def INPUT_TYPES(cls): ret = { "required": { - "folder_path": ("STRING", {"default": None}), + "folder_path": ("STRING", {"default": ""}), + "clear_folder": ("BOOLEAN", {"default": True}), }, "optional": { "activator:": ("*", {"default": None}) @@ -2099,19 +2163,20 @@ class VideoFolderProvider(metaclass=ABCWidgetMetaclass): 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 kwargs["clear_folder"]: + # if the folder exists, delete all files in the folder + if folder_exists: + for file in files_in_folder: + # use shutil to remove the file + fl = os.path.join(kwargs["folder_path"], file) + if os.path.isfile(fl): + os.remove(fl) + # use shutil to remove the entire path + check_this_path = file + if len(check_this_path) > 3: + shutil.rmtree(check_this_path, ignore_errors=True) + else: + raise ValueError("Folder path is too short") # if the folder does not exist, create the folder @@ -2128,7 +2193,8 @@ class AudioFolderProvider(metaclass=ABCWidgetMetaclass): def INPUT_TYPES(cls): ret = { "required": { - "folder_path": ("STRING", {"default": None}), + "folder_path": ("STRING", {"default": ""}), + "clear_folder": ("BOOLEAN", {"default": True}), }, "optional": { "activator:": ("*", {"default": None}) @@ -2162,15 +2228,14 @@ class AudioFolderProvider(metaclass=ABCWidgetMetaclass): 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 + if kwargs["clear_folder"]: + # if the folder exists, delete all files in the folder + if folder_exists: + for file in files_in_folder: + # use shutil to remove the file + fl = os.path.join(kwargs["folder_path"], file) + if os.path.isfile(fl): + os.remove(fl) return (kwargs["folder_path"],) @@ -2288,6 +2353,7 @@ class AddAudioToAudioFolder(metaclass=ABCWidgetMetaclass): # check each file in the list to make sure we can convert it to the correct format import shutil import os + from subprocess import run ffmpeg_exe = ffmpeg.get_ffmpeg_exe() @@ -2307,9 +2373,11 @@ class AddAudioToAudioFolder(metaclass=ABCWidgetMetaclass): output_file_name = audio_folder_def.get_next_file_name() input_file_name = audio_file - CMD = f"{ffmpeg_exe} -y -i {input_file_name} -ar {audio_folder_def['sample rate']} -ac {audio_folder_def['channels']} {output_file_name}" + CMD = f"{ffmpeg_exe} -y -i {input_file_name} -ar {audio_folder_def['sample rate']} -ac {audio_folder_def['channels']} '{output_file_name}'" + CMD_AS_LIST = [ffmpeg_exe, "-y", "-i", input_file_name, "-ar", str(audio_folder_def['sample rate']), "-ac", + str(audio_folder_def['channels']), output_file_name] - os.system(CMD) + run(CMD_AS_LIST) if delete: os.remove(input_file_name) @@ -2605,6 +2673,7 @@ class AddFramesToVideoFolder(metaclass=ABCWidgetMetaclass): import gc from torchvision.transforms.functional import resize import psutil + from custom_nodes.EternalKernelLiteGraphNodes.modules.image_utils import lanczos_resize initial_memory = psutil.virtual_memory().available print(f"Initial free memory: {initial_memory / (1024 * 1024):.2f} MB") @@ -2688,8 +2757,8 @@ class AddFramesToVideoFolder(metaclass=ABCWidgetMetaclass): torch.cuda.empty_cache() gc.collect() - 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") @@ -2868,6 +2937,9 @@ class Listed_HoldFramesForSecsInVideoFolder(metaclass=ABCWidgetMetaclass): # the plan is to call HoldFrameForSecsInVideoFolder for each frame and duration # and then return the video_folder_def + if "IMAGE" not in kwargs or "seconds" not in kwargs: + return (kwargs["video_folder_def"],) + for frame, duration in zip(kwargs["IMAGE"], kwargs["seconds"]): HoldFrameForSecsInVideoFolder().hold_frame_for_secs_in_video_folder( video_folder_def=kwargs["video_folder_def"], @@ -2988,6 +3060,7 @@ class ListedSVDConditioning(metaclass=ABCWidgetMetaclass): @classmethod def INPUT_TYPES(cls): + from comfy_extras.nodes_video_model import SVD_img2vid_Conditioning base = SVD_img2vid_Conditioning.INPUT_TYPES() base["required"]["init_image"] = ("LIST", {"default": None}) # base["required"]["clip_vision"] = ("LIST", {"default": None}) diff --git a/web/showMarkdown.js b/web/showMarkdown.js index 5b32173..ce2bea7 100644 --- a/web/showMarkdown.js +++ b/web/showMarkdown.js @@ -1,7 +1,6 @@ import { app } from "../../scripts/app.js"; import { ComfyWidgets } from "../../scripts/widgets.js"; -import marked from "./marked.js"; - +import Marked from "./marked.js"; // Displays input markdown on a node @@ -29,7 +28,7 @@ app.registerExtension({ const w = ComfyWidgets["STRING"](this, "markdown", ["STRING", { multiline: true }], app).widget; // Convert markdown to HTML - const html = marked(list); + const html = Marked(list); // Create a new div element to hold the rendered markdown const div = document.createElement("div"); diff --git a/web/ui_modifications.js b/web/ui_modifications.js index c762596..e07650e 100644 --- a/web/ui_modifications.js +++ b/web/ui_modifications.js @@ -18,7 +18,12 @@ class GDELogin { } initElements() { - this.login_div = this.createElement("div", {id: "gde-login"}); + this.login_form = this.createElement("form", {id: "gde-login"}); + // make sure the form doesnt cause the page to reload + this.login_form.addEventListener("submit", (event) => { + event.preventDefault(); + }); + //this.login_div = this.createElement("div", {id: "gde-login"}); this.usernameField = this.createInputField("username-field", "Username"); this.passwordField = this.createInputField("password-field", "Password", "password"); this.loginButton = this.createButton("login-button", "Login", this.handleLogin.bind(this)); @@ -95,7 +100,7 @@ class GDELogin { id: "welcome-message", innerText: `Welcome ${this.usernameField.value}` }); - this.login_div.append(welcomeMessage); + this.login_form.append(welcomeMessage); } @@ -121,12 +126,12 @@ class GDELogin { this.usernameField.remove(); this.passwordField.remove(); this.loginButton.remove(); - this.login_div.append(this.logoutButton); + this.login_form.append(this.logoutButton); } switchToLoginView() { this.logoutButton.remove(); - this.login_div.append(this.usernameField, this.passwordField, this.loginButton); + this.login_form.append(this.usernameField, this.passwordField, this.loginButton); } clearDropdowns() { @@ -174,15 +179,15 @@ class GDELogin { separator.style.margin = "20px 0"; separator.style.width = "100%"; menu.append(separator); - menu.append(this.login_div); + menu.append(this.login_form); if (logged_in) { - this.login_div.append(this.logoutButton); + this.login_form.append(this.logoutButton); } else { - this.login_div.append(this.usernameField); - this.login_div.append(this.passwordField); - this.login_div.append(this.loginButton); + this.login_form.append(this.usernameField); + this.login_form.append(this.passwordField); + this.login_form.append(this.loginButton); } }); @@ -338,20 +343,36 @@ app.registerExtension({ const handleGraphCreateNew = (event, dropdown) => { // Custom logic for when "Create New" is selected in the branch dropdown - - showModal('graph-name-field') + showModal('graph-name-field'); dropdown.disabled = true; const modal = document.getElementById("graph-name-modal"); - // hijack its onsubmit function + // Hijack its onsubmit function const prev_submit = modal.submit; - //submitButton.onclick + modal.submit = () => { let graphName = prev_submit(); - gdeState.graph = graphName; - dropdown.disabled = false; + if (graphName) { + // Add the new graph to the dropdown + const optionElement = document.createElement("option"); + optionElement.value = graphName; + optionElement.textContent = graphName; + dropdown.append(optionElement); - } + // Set the new graph as the selected value + dropdown.value = graphName; + gdeState.graph = graphName; + + // Dispatch a custom event to notify other parts of the application + const graphChangeEvent = new CustomEvent('graphChangeEvent', { + detail: { + graph: graphName + } + }); + document.dispatchEvent(graphChangeEvent); + } + dropdown.disabled = false; + }; }; const handleGraphSelect = (event, dropdown) => { // change gdeState.graph when it is changed diff --git a/youtube_nodes.py b/youtube_nodes.py index 7414488..1b54a49 100644 --- a/youtube_nodes.py +++ b/youtube_nodes.py @@ -3,6 +3,7 @@ import os NODE_CLASS_MAPPINGS = {} NODE_DISPLAY_NAME_MAPPINGS = {} +from . import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS def ETK_youtube_base(cls): @@ -32,6 +33,9 @@ class YoutubeUploadNode: "description": "Description of the video"}), "keywords": ("STRING", {"default": "tag1, tag2, tag3", "description": "Keywords for the video"}), "privacy_status": (cls.VALID_PRIVACY_STATUSES,) + }, + "optional": { + "thumbnail_path": ("STRING", {"default": "", "description": "Path to the thumbnail image file"}) } } @@ -39,7 +43,7 @@ class YoutubeUploadNode: FUNCTION = "upload_video" CATEGORY = "ETK/Youtube" - def upload_video(self, file_path, category, title, description, keywords, privacy_status): + def upload_video(self, file_path, category, title, description, keywords, privacy_status, thumbnail_path=""): import sys my_python_exe = sys.executable @@ -49,14 +53,12 @@ class YoutubeUploadNode: args = ["--file", file_path, "--category", str(category), "--title", title, "--description", description, "--keywords", keywords, "--privacyStatus", privacy_status] + if thumbnail_path: + args.extend(["--thumbnail", thumbnail_path]) + current_dir = os.getcwd() os.chdir(os.path.dirname(os.path.realpath(__file__))) - # result = subprocess.run([my_python_exe, "./modules/youtube.py"] + args, - # capture_output=True, - # text=True) - - # the above does not capture the output, so we use the below instead result = subprocess.run([my_python_exe, "./modules/youtube.py"] + args, stdout=subprocess.PIPE, stderr=subprocess.PIPE,