Working node.

This commit is contained in:
HighDoping
2025-06-19 19:42:51 +08:00
parent 04a10352be
commit 9f2fcd4c97
+792 -90
View File
@@ -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",
}