From 9f2fcd4c9703a012413ac2344615908f77829a58 Mon Sep 17 00:00:00 2001 From: HighDoping Date: Thu, 19 Jun 2025 19:34:37 +0800 Subject: [PATCH] Working node. --- src/ComfyUI_ASSSSA/nodes.py | 882 ++++++++++++++++++++++++++++++++---- 1 file changed, 792 insertions(+), 90 deletions(-) diff --git a/src/ComfyUI_ASSSSA/nodes.py b/src/ComfyUI_ASSSSA/nodes.py index fab5cff..3722bbd 100644 --- a/src/ComfyUI_ASSSSA/nodes.py +++ b/src/ComfyUI_ASSSSA/nodes.py @@ -1,118 +1,820 @@ -from inspect import cleandoc -class Example: - """ - A example node +import datetime +import logging +import os +import re +import subprocess - Class methods - ------------- - INPUT_TYPES (dict): - Tell the main program input parameters of nodes. - IS_CHANGED: - optional method to control when the node is re executed. +import folder_paths - Attributes - ---------- - RETURN_TYPES (`tuple`): - The type of each element in the output tulple. - RETURN_NAMES (`tuple`): - Optional: The name of each output in the output tulple. - FUNCTION (`str`): - The name of the entry-point method. For example, if `FUNCTION = "execute"` then it will run Example().execute() - OUTPUT_NODE ([`bool`]): - If this node is an output node that outputs a result/image from the graph. The SaveImage node is an example. - The backend iterates on these output nodes and tries to execute all their parents if their parent graph is properly connected. - Assumed to be False if not present. - CATEGORY (`str`): - The category the node should appear in the UI. - execute(s) -> tuple || None: - The entry point method. The name of this method must be the same as the value of property `FUNCTION`. - For example, if `FUNCTION = "execute"` then this method's name must be `execute`, if `FUNCTION = "foo"` then it must be `foo`. - """ + +class FFMpegSettingsNode: + def __init__(self): + self.ffmpeg_options = { + "format": "mp4", + "codec": "copy", + "resolution": "source", + "framerate": "source", + "audio_codec": "copy", + "audio_bitrate": "192k", + "preset": "medium", + "crf": "23", + "additional_params": "", + } + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "format": ( + [ + "mp4", + "mkv", + "mov", + "avi", + "webm", + "gif", + "flv", + "wmv", + "ts", + "mxf", + ], + {"default": "mkv", "tooltip": "Output container format"}, + ), + "codec": ( + [ + "copy", # Copy codec from source + "libx264", + "libx265", + "libvpx-vp9", + "libvpx", + "prores_ks", + "dnxhd", + "av1", + "h264_nvenc", + "hevc_nvenc", + "h264_videotoolbox", + "hevc_videotoolbox", + "prores_videotoolbox", + "libaom-av1", + "libsvtav1", + "mpeg2video", + "mpeg4", + ], + { + "default": "copy", + "tooltip": "Video codec to use for encoding, use 'copy' to keep source codec.", + }, + ), + "resolution": ( + "STRING", + { + "default": "source", + "multiline": False, + "tooltip": "Output resolution in format WIDTHxHEIGHT or 'source' to keep original resolution", + }, + ), + "framerate": ( + "STRING", + { + "default": "source", + "multiline": False, + "tooltip": "Target frame rate or 'source' to keep original", + }, + ), + "preset": ( + [ + "ultrafast", + "superfast", + "veryfast", + "faster", + "fast", + "medium", + "slow", + "slower", + "veryslow", + ], + { + "default": "medium", + "tooltip": "Encoding preset (speed vs quality tradeoff)", + }, + ), + "crf": ( + "INT", + { + "default": 23, + "min": 0, + "max": 51, + "step": 1, + "tooltip": "Constant Rate Factor: lower values = higher quality", + }, + ), + }, + "optional": { + "audio_codec": ( + [ + "copy", + "aac", + "mp3", + "opus", + "flac", + "none", + "vorbis", + "ac3", + "eac3", + "libfdk_aac", + "pcm_s16le", + "pcm_s24le", + "pcm_f32le", + ], + { + "default": "copy", + "tooltip": "Audio codec to use for encoding, use 'copy' to keep source codec", + }, + ), + "audio_bitrate": ( + [ + "source", + "64k", + "96k", + "128k", + "160k", + "192k", + "224k", + "256k", + "320k", + "384k", + "448k", + "512k", + ], + { + "default": "source", + "tooltip": "Audio bitrate for encoding or 'source' to maintain original", + }, + ), + "additional_params": ( + "STRING", + { + "default": "", + "multiline": True, + "tooltip": "Additional FFmpeg parameters (advanced)", + }, + ), + }, + } + + RETURN_TYPES = ("FFMPEG_SETTINGS",) + RETURN_NAMES = ("settings",) + FUNCTION = "get_settings" + CATEGORY = "ComfyUI_ASSSSA" + DESCRIPTION = "Configure FFmpeg settings for video processing with advanced options" + + def get_settings( + self, + format, + codec, + resolution, + framerate, + preset, + crf, + audio_codec="copy", + audio_bitrate="192k", + additional_params="", + ): + settings = { + "format": format, + "codec": codec, + "resolution": resolution, + "framerate": framerate, + "preset": preset, + "crf": crf, + "audio_codec": audio_codec, + "audio_bitrate": audio_bitrate, + "additional_params": additional_params.strip() if additional_params else "", + } + return (settings,) + + +class VideoTranscodingNode: + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "video_path": ("STRING", {"multiline": False, "default": "input.mp4"}), + "filename_prefix": ( + "STRING", + { + "multiline": False, + "default": "Transcoded", + "tooltip": "Prefix for the output video file", + }, + ), + "settings": ("FFMPEG_SETTINGS",), + } + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("output_path",) + FUNCTION = "transcode_video" + OUTPUT_NODE = True + CATEGORY = "ComfyUI_ASSSSA" + DESCRIPTION = "Transcode video using FFmpeg settings" + + def transcode_video(self, video_path, filename_prefix, settings): + video_basename = os.path.splitext(os.path.basename(video_path))[0] + if filename_prefix.strip() == "": + output_path = os.path.join( + folder_paths.get_output_directory(), + f"{video_basename}.{settings['format']}", + ) + else: + output_path = os.path.join( + folder_paths.get_output_directory(), + f"{filename_prefix}_{video_basename}{settings['format']}", + ) + if not os.path.exists(os.path.dirname(output_path)): + os.makedirs(os.path.dirname(output_path), exist_ok=True) + + if not os.path.exists(video_path): + logging.error("Error: Input file '{video_path}' not found") + return (output_path,) + + try: + # Build the ffmpeg command based on settings + cmd = [ + "ffmpeg", + "-y", # Overwrite output file if it exists + "-i", + video_path, + ] + + # Add video codec and parameters + if settings["codec"] != "copy": + cmd += ["-c:v", settings["codec"]] + # Only apply these settings when not copying + if settings["resolution"] != "source": + cmd += ["-vf", f"scale={settings['resolution']}"] + if settings["framerate"] != "source": + cmd += ["-r", settings["framerate"]] + if settings["preset"]: + cmd += ["-preset", settings["preset"]] + if settings["crf"]: + cmd += ["-crf", str(settings["crf"])] + else: + # When copying, just set the codec + cmd += ["-c:v", "copy"] + + # Add audio codec and bitrate + if settings["audio_codec"] != "copy": + cmd += ["-c:a", settings["audio_codec"]] + if settings["audio_bitrate"] != "source": + cmd += ["-b:a", settings["audio_bitrate"]] + else: + cmd += ["-c:a", "copy"] + + # Add additional parameters + if settings["additional_params"]: + cmd += settings["additional_params"].split() + + # Set output file path + cmd.append(output_path) + + # Run the command + logging.info(f"Running command: {" ".join(cmd)}") + process = subprocess.run( + cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True + ) + + if process.returncode != 0: + logging.error(f"FFmpeg error: {process.stderr}") + return (output_path,) + else: + logging.info(f"Video transcoded to: {output_path}") + return (output_path,) + except Exception as e: + logging.error(f"Error transcoding video: {str(e)}") + return (output_path,) + + +class SubtitleExtractionNode: + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "video_path": ("STRING", {"multiline": False, "default": "video.mp4"}), + "stream": ( + "INT", + { + "default": 0, + "min": 0, + "max": 100, + "step": 1, + "tooltip": "Subtitle stream index to extract (0 for first stream)", + }, + ), + } + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("output_path",) + FUNCTION = "extract_subtitles" + OUTPUT_NODE = True + CATEGORY = "ComfyUI_ASSSSA" + DESCRIPTION = "Extract subtitles from video file" + + def extract_subtitles(self, video_path, stream=0): + video_basename = os.path.splitext(os.path.basename(video_path))[0] + # Create output path with video name included + output_path = os.path.join( + folder_paths.get_temp_directory(), f"{video_basename}" + ) + # Check if the video file exists + if not os.path.exists(video_path): + logging.error(f"Error: Video file '{video_path}' not found") + return (output_path,) + check_cmd = [ + "ffprobe", + "-v", + "error", + "-select_streams", + "s", + "-show_entries", + "stream=index,codec_name,codec_type,codec_tag_string", + "-of", + "default=noprint_wrappers=1", + video_path, + ] + try: + logging.info(f"Running command: {check_cmd}") + process = subprocess.run( + check_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True + ) + if process.returncode != 0: + logging.error(f"FFprobe error: {process.stderr}") + return (output_path,) + else: + # get indet and codec_name from output + subtitle_streams = [] + for line in process.stdout.splitlines(): + if line.startswith("index="): + index = line.split("=")[1].strip() + elif line.startswith("codec_name="): + codec_name = line.split("=")[1].strip() + subtitle_streams.append((index, codec_name)) + if not subtitle_streams: + logging.error("No subtitle streams found in the video file.") + return (output_path,) + else: + output_path += f".{subtitle_streams[stream][1]}" + except subprocess.CalledProcessError: + logging.error(f"Error checking subtitle streams: {process.stderr}") + return (output_path,) + # "ffmpeg -i input.mkv -map 0:s:0 subs.ass" + # Build the ffmpeg command to extract subtitles + cmd = [ + "ffmpeg", + "-y", # Overwrite output file if it exists + "-i", + video_path, + "-map", + f"0:s:{stream}", # Map the first subtitle stream + f"{output_path}", # Output file path + ] + try: + # Run the command + logging.info(f"Running command: {" ".join(cmd)}") + process = subprocess.run( + cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True + ) + if process.returncode != 0: + logging.error(f"FFmpeg error: {process.stderr}") + return (output_path,) + else: + logging.info(f"Subtitles extracted to: {output_path}") + except Exception as e: + logging.error(f"Error extracting subtitles: {str(e)}") + return (output_path,) + return (output_path,) + + +class SubtitleEmbeddingNode: + def __init__(self): + self.default_settings = { + "format": "mkv", + "codec": "copy", + "resolution": "source", + "framerate": "source", + "audio_codec": "copy", + "audio_bitrate": "192k", + "preset": "medium", + "crf": 20, + "additional_params": "", + } + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "video_path": ("STRING", {"multiline": False, "default": "video.mp4"}), + "subtitle_path": ( + "STRING", + {"multiline": False, "default": "subtitles.srt"}, + ), + "filename_prefix": ( + "STRING", + { + "multiline": False, + "default": "Embedded", + "tooltip": "Prefix for the output video file", + }, + ), + "embed_method": (["soft", "hard"], {"default": "soft"}), + }, + "optional": { + "settings": ("FFMPEG_SETTINGS",), + }, + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("output_path",) + FUNCTION = "embed_subtitles" + OUTPUT_NODE = True + CATEGORY = "ComfyUI_ASSSSA" + DESCRIPTION = "Embed subtitles into video" + + def embed_subtitles( + self, video_path, subtitle_path, filename_prefix, embed_method, settings={} + ): + if settings is None or settings == {}: + settings = self.default_settings + video_basename = os.path.splitext(os.path.basename(video_path))[0] + if filename_prefix.strip() == "": + output_path = os.path.join( + folder_paths.get_output_directory(), + f"{video_basename}.{settings['format']}", + ) + else: + output_path = os.path.join( + folder_paths.get_output_directory(), + f"{filename_prefix}_{video_basename}.{settings['format']}", + ) + if not os.path.exists(os.path.dirname(output_path)): + os.makedirs(os.path.dirname(output_path), exist_ok=True) + # Implementation would go here + # ffmpeg -i input.mp4 -vf "subtitles=subtitle.ass" -c:a copy output.mp4 + if not os.path.exists(video_path): + logging.error(f"Error: Video file '{video_path}' not found") + return (output_path,) + if not os.path.exists(subtitle_path): + logging.error(f"Error: Subtitle file '{subtitle_path}' not found") + return (output_path,) + if embed_method not in ["soft", "hard"]: + logging.error(f"Error: Invalid embed method '{embed_method}'") + return (output_path,) + # Determine subtitle format based on file extension + # subtitle_format = os.path.splitext(subtitle_path)[1][1:].lower() + if embed_method == "soft": + # Build the ffmpeg command to embed subtitles + if settings["format"] != "mkv": + logging.error("Error: Soft embedding is only supported for MKV format.") + return (output_path,) + cmd = [ + "ffmpeg", + "-y", # Overwrite output file if it exists + "-i", + video_path, + "-i", + subtitle_path, + # f"-c:s {subtitle_format}", + ] + + # Add video codec and parameters + if settings["codec"] != "copy": + cmd += ["-c:v", settings["codec"]] + # Only apply these settings when not copying + if settings["resolution"] != "source": + cmd += ["-vf", f"scale={settings['resolution']}"] + if settings["framerate"] != "source": + cmd += ["-r", settings["framerate"]] + if settings["preset"]: + cmd += ["-preset", settings["preset"]] + if settings["crf"]: + cmd += ["-crf", str(settings["crf"])] + else: + # When copying, just set the codec + cmd += ["-c:v", "copy"] + + # Add audio codec and bitrate + if settings["audio_codec"] != "copy": + cmd += ["-c:a", settings["audio_codec"]] + if settings["audio_bitrate"] != "source": + cmd += ["-b:a", settings["audio_bitrate"]] + else: + cmd += ["-c:a", "copy"] + + if settings["additional_params"]: + cmd += settings["additional_params"].split() + + cmd.append(output_path) + + elif embed_method == "hard": + # Build the ffmpeg command to embed subtitles + cmd = [ + "ffmpeg", + "-y", # Overwrite output file if it exists + "-i", + video_path, + "-vf", + f"subtitles={subtitle_path}", + ] + # Add video codec and parameters + if settings["codec"] != "copy": + cmd += ["-c:v", settings["codec"]] + if settings["resolution"] != "source": + cmd += ["-vf", f"scale={settings['resolution']}"] + if settings["framerate"] != "source": + cmd += ["-r", settings["framerate"]] + if settings["preset"]: + cmd += ["-preset", settings["preset"]] + if settings["crf"]: + cmd += ["-crf", str(settings["crf"])] + + # Add audio codec and bitrate + if settings["audio_codec"] != "copy": + cmd += ["-c:a", settings["audio_codec"]] + if settings["audio_bitrate"] != "source": + cmd += ["-b:a", settings["audio_bitrate"]] + + # Set output file path + cmd.append(output_path) + + try: + # Run the command + logging.info(f"Running command: {" ".join(cmd)}") + process = subprocess.run( + cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True + ) + + if process.returncode != 0: + logging.error(f"FFmpeg error: {process.stderr}") + return (output_path,) + else: + logging.info(f"Subtitles embedded to: {output_path}") + return (output_path,) + except Exception as e: + logging.error(f"Error embedding subtitles: {str(e)}") + return (output_path,) + + +class ASSSubtitleReaderNode: def __init__(self): pass @classmethod def INPUT_TYPES(s): - """ - Return a dictionary which contains config for all input fields. - Some types (string): "MODEL", "VAE", "CLIP", "CONDITIONING", "LATENT", "IMAGE", "INT", "STRING", "FLOAT". - Input types "INT", "STRING" or "FLOAT" are special values for fields on the node. - The type can be a list for selection. - - Returns: `dict`: - - Key input_fields_group (`string`): Can be either required, hidden or optional. A node class must have property `required` - - Value input_fields (`dict`): Contains input fields config: - * Key field_name (`string`): Name of a entry-point method's argument - * Value field_config (`tuple`): - + First value is a string indicate the type of field or a list for selection. - + Secound value is a config for type "INT", "STRING" or "FLOAT". - """ return { "required": { - "image": ("Image", { "tooltip": "This is an image"}), - "int_field": ("INT", { - "default": 0, - "min": 0, #Minimum value - "max": 4096, #Maximum value - "step": 64, #Slider's step - "display": "number" # Cosmetic only: display as "number" or "slider" - }), - "float_field": ("FLOAT", { - "default": 1.0, - "min": 0.0, - "max": 10.0, - "step": 0.01, - "round": 0.001, #The value represeting the precision to round to, will be set to the step value by default. Can be set to False to disable rounding. - "display": "number"}), - "print_to_screen": (["enable", "disable"],), - "string_field": ("STRING", { - "multiline": False, #True if you want the field to look like the one on the ClipTextEncode node - "default": "Hello World!" - }), + "ass_file_path": ( + "STRING", + {"multiline": False, "default": "subtitles.ass"}, + ), + }, + "optional": { + "encoding": ( + [ + "utf-8", + "utf-8-sig", + "latin-1", + "cp1252", + "shift-jis", + "euc-jp", + "gbk", + "big5", + ], + { + "default": "utf-8", + "tooltip": "Character encoding of the ASS file", + }, + ), + "max_lines": ( + "INT", + { + "default": 0, + "min": 0, + "max": 10000, + "step": 1, + "tooltip": "Maximum number of lines to read (0 = all lines)", + }, + ), + "filter_style": ( + "STRING", + { + "multiline": False, + "default": "", + "tooltip": "Only include lines with this style name (leave empty for all styles)", + }, + ), + "strip_formatting": ( + ["True", "False"], + { + "default": "False", + "tooltip": "Remove ASS formatting codes from text", + }, + ), }, } - RETURN_TYPES = ("IMAGE",) - #RETURN_NAMES = ("image_output_name",) - DESCRIPTION = cleandoc(__doc__) - FUNCTION = "test" + RETURN_TYPES = ("STRING", "STRING", "STRING") + RETURN_NAMES = ("all_text", "dialog_only", "styles_info") + FUNCTION = "read_ass" + CATEGORY = "ComfyUI_ASSSSA" + DESCRIPTION = "Read ASS subtitle file contents with formatting options" - #OUTPUT_NODE = False - #OUTPUT_TOOLTIPS = ("",) # Tooltips for the output node + def read_ass( + self, + ass_file_path, + encoding="utf-8", + max_lines=0, + filter_style="", + strip_formatting="False", + ): + if not os.path.exists(ass_file_path): + logging.error(f"Error: ASS file '{ass_file_path}' not found") + return ("", "", "") + try: + with open(ass_file_path, "r", encoding=encoding) as file: + content = file.read() - CATEGORY = "Example" + # Parse sections - def test(self, image, string_field, int_field, float_field, print_to_screen): - if print_to_screen == "enable": - print(f"""Your input contains: - string_field aka input text: {string_field} - int_field: {int_field} - float_field: {float_field} - """) - #do some processing on the image, in this example I just invert it - image = 1.0 - image - return (image,) + current_section = None + dialog_lines = [] + styles_info = [] + for line in content.splitlines(): + line = line.strip() + # Track sections + if line.startswith("[") and line.endswith("]"): + current_section = line[1:-1] + continue + + # Process dialog lines + if current_section == "Events" and line.startswith("Dialogue:"): + parts = line.split(",", 9) + if len(parts) >= 10: + style = parts[3].strip() + text = parts[9].strip() + + # Apply filters + if filter_style and style != filter_style: + continue + + # Strip formatting if requested + if strip_formatting == "True": + # Remove ASS formatting codes like {\\...} + text = re.sub(r"{\\[^}]*}", "", text) + + dialog_lines.append(text) + + # Collect styles information + if current_section == "V4+ Styles" and line.startswith("Style:"): + styles_info.append(line) + + # Apply max_lines limit if set + if max_lines > 0: + dialog_lines = dialog_lines[:max_lines] + + dialog_text = "\n".join(dialog_lines) + styles_text = "\n".join(styles_info) + + return (content, dialog_text, styles_text) + + except Exception as e: + logging.error(f"Error reading ASS file: {str(e)}") + return (f"Error: {str(e)}", "", "") + + +class ASSSubtitleCacheNode: """ - The node will always be re executed if any of the inputs change but - this method can be used to force the node to execute again even when the inputs don't change. - You can make this node return a number or a string. This value will be compared to the one returned the last time the node was - executed, if it is different the node will be executed again. - This method is used in the core repo for the LoadImage node where they return the image hash as a string, if the image hash - changes between executions the LoadImage node is executed again. + Save received string to subtitle file (.ass or .srt) in cache. """ - #@classmethod - #def IS_CHANGED(s, image, string_field, int_field, float_field, print_to_screen): - # return "" + + def __init__(self): + self.cache_dir = folder_paths.get_temp_directory() + self.prefix_append = "" + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "subtitle_text": ("STRING", {"multiline": True, "default": ""}), + "filename_prefix": ( + "STRING", + {"multiline": False, "default": "subs/ComfyUI"}, + ), + "format": ( + ["ass", "srt"], + {"default": "ass", "tooltip": "Subtitle format"}, + ), + }, + "optional": { + "encoding": ( + [ + "utf-8", + "utf-8-sig", + "latin-1", + "cp1252", + "shift-jis", + "euc-jp", + "gbk", + "big5", + ], + { + "default": "utf-8", + "tooltip": "Character encoding for the subtitle file", + }, + ), + }, + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("filepath",) + FUNCTION = "save_subtitle" + OUTPUT_NODE = True + CATEGORY = "ComfyUI_ASSSSA" + DESCRIPTION = ( + "Save string content to a subtitle file (.ass or .srt) in cache directory" + ) + + def save_subtitle( + self, subtitle_text, filename_prefix, format="ass", encoding="utf-8" + ): + filename_prefix += self.prefix_append + timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"{filename_prefix}_{timestamp}.{format}" + + # Create full filepath in cache directory + filepath = os.path.join(self.cache_dir, filename) + if not os.path.exists(os.path.dirname(filepath)): + os.makedirs(os.path.dirname(filepath), exist_ok=True) + try: + with open(filepath, mode="w", encoding=encoding) as f: + f.write(subtitle_text) + + logging.info(f"Subtitle saved to: {filepath}") + return (filepath,) + + except Exception as e: + logging.error(f"Error saving {format} file: {str(e)}") + return (filepath,) + + +class MultilineTextInputNode: + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "text": ( + "STRING", + { + "multiline": True, + "default": "", + "placeholder": "Enter your text here...", + }, + ), + }, + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("text",) + FUNCTION = "process_text" + CATEGORY = "ComfyUI_ASSSSA" + DESCRIPTION = ( + "A simple multiline text input node for entering and storing text content" + ) + + def process_text(self, text): + return (text,) # A dictionary that contains all nodes you want to export with their names # NOTE: names should be globally unique NODE_CLASS_MAPPINGS = { - "Example": Example + "FFMpegSettings": FFMpegSettingsNode, + "VideoTranscoding": VideoTranscodingNode, + "SubtitleExtraction": SubtitleExtractionNode, + "SubtitleEmbedding": SubtitleEmbeddingNode, + "ASSSubtitleReader": ASSSubtitleReaderNode, + "ASSSubtitleCache": ASSSubtitleCacheNode, + "MultilineTextInput": MultilineTextInputNode, } # A dictionary that contains the friendly/humanly readable titles for the nodes NODE_DISPLAY_NAME_MAPPINGS = { - "Example": "Example Node" + "FFMpegSettings": "FFmpeg Settings", + "VideoTranscoding": "Video Transcoding", + "SubtitleExtraction": "Subtitle Extraction", + "SubtitleEmbedding": "Subtitle Embedding", + "ASSSubtitleReader": "Load ASS Subtitles", + "ASSSubtitleCache": "Temporary save subtitle file", + "MultilineTextInput": "Multi line Input", }