Files
AEmotionStudio-ComfyUI-Disc…/discord_video_node.py
T
google-labs-jules[bot] c4dcfd586d Improve tooltips for GitHub and Discord settings
- Update `filename_prefix` tooltip to clarify `%batch_num%` usage.
- Update `github_repo` tooltip with an example format.
- Update `github_token` tooltip with navigation instructions to find the token.
- Standardize `webhook_url` tooltip across image and video nodes.
- Add `.jules/palette.md` for UX learnings.
2026-01-20 01:14:51 +00:00

1563 lines
86 KiB
Python

"""ComfyUI node for sending videos to Discord and saving them locally."""
import os
import json
import time
import numpy as np
from PIL import Image
import torch
import folder_paths
from PIL.PngImagePlugin import PngInfo
from comfy.cli_args import args
import re
import cv2
import requests
from io import BytesIO
from uuid import uuid4
from typing import Any, Union, List, Optional
from pathlib import Path
import sys
import datetime
import subprocess
import itertools
import functools
import server
# Import shared utilities
from discordsend_utils import sanitize_json_for_export, update_github_cdn_urls, send_to_discord_with_retry, tensor_to_numpy_uint8
# Define cached decorator for local use
def cached(max_size=None):
"""
Simple cache decorator to avoid re-loading formats
"""
def decorator(func):
cache = {}
@functools.wraps(func)
def wrapper(*args, **kwargs):
key = str(args) + str(kwargs)
if key not in cache:
cache[key] = func(*args, **kwargs)
return cache[key]
return wrapper
# Handle when decorator is used without arguments
if callable(max_size):
func = max_size
max_size = None
return decorator(func)
return decorator
# Try to import dependencies from nodes.py
try:
from discordsend_utils import ffmpeg_path, get_audio, hash_path, validate_path, requeue_workflow, \
gifski_path, calculate_file_hash, strip_path, try_download_video, is_url, \
imageOrLatent, BIGMAX, merge_filter_args, ENCODE_ARGS, floatOrInt
from comfy.utils import ProgressBar
has_vhs_formats = False # Set to False since we're not using VHS formats anymore
except ImportError:
print("Warning: Some dependencies from Video Helper Suite might be missing")
# Define minimal versions of required functions/classes
ffmpeg_path = None
ENCODE_ARGS = ("utf-8", "ignore")
floatOrInt = ("FLOAT", "INT")
imageOrLatent = ("IMAGE", "LATENT")
BIGMAX = 1000000
has_vhs_formats = False
class ProgressBar:
def __init__(self, total):
self.total = total
self.current = 0
def update(self, advance=1):
self.current += advance
print(f"Progress: {self.current}/{self.total}", end="\r")
# Fallback if ffmpeg_path is None, try to detect it
if ffmpeg_path is None:
# Try direct detection methods for ffmpeg
try:
# Try imageio-ffmpeg first (common in Python environments)
try:
import imageio_ffmpeg
ffmpeg_path = imageio_ffmpeg.get_ffmpeg_exe()
print(f"Found ffmpeg via imageio_ffmpeg: {ffmpeg_path}")
except (ImportError, Exception):
# Fall back to checking the system path
from shutil import which
ffmpeg_path = which("ffmpeg")
if ffmpeg_path:
print(f"Found ffmpeg in system path: {ffmpeg_path}")
except Exception as e:
print(f"Error during ffmpeg detection: {str(e)}")
# Function to validate video files for Discord compatibility
def validate_video_for_discord(file_path):
"""
Validate that a video file is compatible with Discord.
Returns a tuple of (is_valid, message)
"""
if not os.path.exists(file_path):
return False, f"File does not exist: {file_path}"
# Check if file is empty or too small
file_size = os.path.getsize(file_path)
if file_size == 0:
return False, "File is empty"
if file_size < 1024: # Less than 1KB
return False, f"File is suspiciously small: {file_size} bytes"
# Check if file is too large for Discord (Discord limit is 25MB for regular users, 50MB for Nitro)
max_size = 25 * 1024 * 1024 # 25MB in bytes
if file_size > max_size:
return False, f"File exceeds Discord's size limit: {file_size} bytes (max {max_size} bytes)"
# Get file extension
ext = os.path.splitext(file_path)[1].lower().lstrip('.')
# Return validation result based on file type
if ext in ['mp4', 'webm', 'gif']:
# These formats are well supported by Discord
return True, "Valid video format for Discord"
elif ext in ['mov']:
# MOV files (ProRes) may need conversion for Discord
return False, "MOV files may need conversion for Discord compatibility"
elif ext in ['png']:
# PNG sequences are not directly supported by Discord
return False, "PNG sequences are not directly supported by Discord and require compilation into a video format"
else:
# For any other extension, warn but allow sending
return False, f"Unknown format {ext}, may not be compatible with Discord"
class DiscordSendSaveVideo:
"""
A ComfyUI node that can send videos to Discord and save them with advanced options.
Videos can be sent to Discord via webhook integration, while providing flexible
saving options with customizable format options.
"""
def __init__(self):
self.type = "output"
self.prefix_append = ""
self.compress_level = 4
self.output_dir = None # Will be set during saving to store the actual path used
@classmethod
def INPUT_TYPES(s):
"""
Define the input types for the DiscordSendSaveVideo node.
"""
# Determine format options and defaults based on ffmpeg availability
if ffmpeg_path is None:
print("ffmpeg not found. Video output will be limited or unavailable.")
# Always make GIF available even without ffmpeg (we'll use PIL as fallback)
video_formats = ["video/gif"]
format_default = "video/gif"
format_tooltip = "⚠️ FFmpeg not found! Only GIF format is available (using PIL fallback).\n\nPlease install FFmpeg to unlock MP4, WebM, and other video formats."
else:
# Enhanced video formats for high-quality output
video_formats = [
# Image/Animation formats
"image/gif", # Standard GIF format
"image/webp", # WebP format (better quality than GIF)
"video/gif", # Video format GIF (higher frame rate support)
# Standard video formats
"video/mp4", # Standard MP4 format with good compatibility
"video/h264-mp4", # High-quality MP4 with H264 codec
"video/h265-mp4", # MP4 with H265/HEVC codec (better compression)
"video/webm", # Standard WebM format
"video/vp9-webm", # WebM with VP9 codec (higher quality than VP8)
# Professional formats
"video/prores", # Apple ProRes (high quality, low compression)
]
format_default = "video/h264-mp4"
format_tooltip = ("The video format to save in. Options include:\n"
"# Image formats:\n"
"- image/gif: Standard GIF format (NO AUDIO support)\n"
"- image/webp: WebP format (better quality than GIF, NO AUDIO support)\n"
"- video/gif: Video format GIF (higher frame rate support, NO AUDIO support)\n\n"
"# Standard video formats:\n"
"- video/mp4: Standard MP4 format with good compatibility (supports audio)\n"
"- video/h264-mp4: High-quality MP4 with H264 codec (supports audio)\n"
"- video/h265-mp4: MP4 with H265/HEVC codec (better compression, supports audio)\n"
"- video/webm: Standard WebM format (supports audio)\n"
"- video/vp9-webm: WebM with VP9 codec (higher quality, supports audio)\n\n"
"# Professional formats:\n"
"- video/prores: Apple ProRes (professional quality, supports audio when saving locally but NO AUDIO when sending to Discord, requires QuickTime or specialized player to view on Windows)")
return {
"required": {
"images": ("IMAGE", {"tooltip": "The image sequence to save as video and/or send to Discord."}),
"filename_prefix": ("STRING", {"default": "ComfyUI-Video", "tooltip": "The prefix for the saved files."}),
"overwrite_last": ("BOOLEAN", {"default": False, "tooltip": "If enabled, will overwrite the last video instead of creating incrementing filenames."})
},
"optional": {
# Video format settings
"format": (video_formats, {
'default': format_default,
"tooltip": format_tooltip
}),
"frame_rate": (
"FLOAT",
{"default": 8.0, "min": 0.1, "max": 120.0, "step": 0.1, "tooltip": "Frames per second for the output video. Values below 1 will make each image stay on screen longer (e.g., 0.5 = 2 seconds per frame)."}
),
"quality": ("INT", {
"default": 85,
"min": 1,
"max": 100,
"step": 1,
"tooltip": "Quality setting for video compression (1-100). Higher is better quality but larger file size."
}),
"loop_count": ("INT", {
"default": 0,
"min": 0,
"max": 100,
"step": 1,
"display": "loop (0=infinite)",
"tooltip": "Number of times to loop the video (0 = infinite loop, only applies to GIF format)."
}),
"lossless": ("BOOLEAN", {
"default": False,
"tooltip": "If enabled, will use lossless compression for supported formats. This option may be ignored for formats that don't support lossless encoding."
}),
# Video effect options
"pingpong": ("BOOLEAN", {"default": False, "tooltip": "If enabled, video will play forward then backward (ping-pong effect)."}),
"save_output": ("BOOLEAN", {
"default": True,
"tooltip": "Whether to save videos to disk. When disabled, videos will only be previewed in the UI."
}),
"audio": ("AUDIO", {
"tooltip": "Optional audio to embed in the video."
}),
# Filename options
"add_date": ("BOOLEAN", {
"default": True,
"tooltip": "Add the current date (YYYY-MM-DD) to filenames."
}),
"add_time": ("BOOLEAN", {
"default": True,
"tooltip": "Add time to filenames. ⚠️ KEEP ENABLED for Discord videos! Disabling causes playback failure (single frame bug). To hide info in chat, disable 'include_video_info' instead."
}),
"add_dimensions": ("BOOLEAN", {
"default": True,
"tooltip": "Add width and height dimensions to the filename (WxH format)."
}),
# Discord options
"send_to_discord": ("BOOLEAN", {
"default": False,
"tooltip": "Whether to send the videos to Discord via webhook."
}),
"webhook_url": ("STRING", {
"default": "",
"multiline": False,
"tooltip": "Secure Discord webhook URL (found in Server Settings > Integrations > Webhooks). Treated as sensitive data. Leave empty to disable."
}),
"discord_message": ("STRING", {
"default": "",
"multiline": True,
"tooltip": "Optional text to display with the video. Supports Discord Markdown (e.g. **bold**, *italic*, > quote)."
}),
"include_prompts_in_message": ("BOOLEAN", {
"default": False,
"tooltip": "Whether to include the positive and negative prompts in the Discord message."
}),
"include_video_info": ("BOOLEAN", {
"default": True,
"tooltip": "Whether to include the video information (frame rate, format) in the Discord message."
}),
"send_workflow_json": ("BOOLEAN", {
"default": False,
"tooltip": "Whether to send the workflow JSON alongside the video to Discord, allowing dragging the JSON into ComfyUI to restore the workflow."
}),
"save_cdn_urls": ("BOOLEAN", {
"default": False,
"tooltip": "Whether to save the Discord CDN URLs of the uploaded videos as a text file and attach it to the Discord message."
}),
"github_cdn_update": ("BOOLEAN", {
"default": False,
"tooltip": "Whether to update a GitHub repository with the Discord CDN URLs."
}),
"github_repo": ("STRING", {
"default": "",
"multiline": False,
"tooltip": "GitHub repository to update with CDN URLs (format: username/repo, e.g. 'AEmotionStudio/ComfyUI-DiscordSend')."
}),
"github_token": ("STRING", {
"default": "",
"multiline": False,
"tooltip": "GitHub personal access token with 'repo' permissions (Settings -> Developer settings -> Personal access tokens). Keep this private!"
}),
"github_file_path": ("STRING", {
"default": "cdn_urls.md",
"multiline": False,
"tooltip": "Path to the file within the GitHub repository to update with CDN URLs."
})
},
"hidden": {
"prompt": "PROMPT",
"extra_pnginfo": "EXTRA_PNGINFO",
"unique_id": "UNIQUE_ID"
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("video_path",)
RETURN_TYPE_TOOLTIPS = ("Path to the saved video file, can be used in other nodes to reference the generated video.",)
FUNCTION = "save_video"
OUTPUT_NODE = True
CATEGORY = "video/output"
DESCRIPTION = "Saves image sequences as videos with advanced options and can send them to Discord via webhook integration."
OUTPUT_IS_LIST = (False,)
@classmethod
def CONTEXT_MENUS(s):
return {
}
def save_video(self, images, filename_prefix="ComfyUI-Video", overwrite_last=False,
format="video/h264-mp4", frame_rate=8.0, quality=85, loop_count=0, lossless=False,
pingpong=False, save_output=True, audio=None,
add_date=True, add_time=True, add_dimensions=True,
send_to_discord=False, webhook_url="", discord_message="",
include_prompts_in_message=False, include_video_info=True, send_workflow_json=False,
save_cdn_urls=False, github_cdn_update=False, github_repo="", github_token="",
github_file_path="cdn_urls.md", prompt=None, extra_pnginfo=None, unique_id=None, **format_properties):
"""
Save image sequences as videos and optionally send to Discord.
"""
results = []
output_files = []
discord_sent_files = []
discord_send_success = True
# For tracking Discord CDN URLs
discord_cdn_urls = []
# Store original prompt for later processing but sanitize it for security
original_prompt = prompt
original_extra_pnginfo = extra_pnginfo
# Sanitize the workflow and extra_pnginfo data to remove webhook URLs
# This protects user security when sharing workflows
if prompt is not None:
prompt = sanitize_json_for_export(prompt)
if extra_pnginfo is not None:
extra_pnginfo = sanitize_json_for_export(extra_pnginfo)
# Ensure workflow JSON is sanitized if sending to Discord
if send_to_discord and send_workflow_json and original_prompt is not None:
# Double sanitization for Discord export to ensure all webhook URLs are removed
workflow_export = sanitize_json_for_export(original_prompt)
# Get number of frames and set up progress bar
num_frames = len(images)
if num_frames == 0:
print("DiscordSendSaveVideo: No frames to process")
raise ValueError("DiscordSendSaveVideo: No frames to process. Please check your input images.")
pbar = ProgressBar(num_frames)
# Get first image for metadata
first_image = images[0]
# Add date and/or time if enabled
date_time_parts = []
# Video info for Discord message
video_info = {}
if add_date:
# Get ONLY the date in YYYY-MM-DD format
current_date = time.strftime("%Y-%m-%d")
date_time_parts.append(current_date)
print(f"Adding date to filename: {current_date}")
video_info["date"] = current_date
if add_time:
# Get ONLY the time in HH-MM-SS format
current_time = time.strftime("%H-%M-%S")
date_time_parts.append(current_time)
print(f"Adding time to filename: {current_time}")
video_info["time"] = current_time
# Add dimensions if enabled
if add_dimensions:
# Get dimensions from first frame
height, width = images[0].shape[0], images[0].shape[1]
dim_text = f"{width}x{height}"
date_time_parts.append(dim_text)
print(f"Adding dimensions to filename: {dim_text}")
video_info["dimensions"] = dim_text
# Add date/time/dimensions components to filename prefix if any were enabled
if date_time_parts:
date_time_suffix = "_" + "_".join(date_time_parts)
filename_prefix += date_time_suffix
print(f"Final metadata suffix: {date_time_suffix}")
# Add prefix append
filename_prefix += self.prefix_append
# Get ComfyUI output directory for safe path handling
comfy_output_dir = folder_paths.get_output_directory()
# Choose destination directory based on save_output flag
if save_output:
# Create a video output subfolder in the ComfyUI output directory
dest_folder = os.path.join(comfy_output_dir, "discord_output")
os.makedirs(dest_folder, exist_ok=True)
else:
# Use ComfyUI's temporary directory for preview-only files
dest_folder = folder_paths.get_temp_directory()
os.makedirs(dest_folder, exist_ok=True)
print(f"Using temporary directory for preview: {dest_folder}")
# Setup paths using ComfyUI's path validation
full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(
filename_prefix, dest_folder, images[0].shape[1], images[0].shape[0])
# For overwrite functionality
if overwrite_last:
counter = 1 # Always use the same counter value for overwriting
print("Overwrite mode enabled: will overwrite last video with same name")
# Set metadata
metadata = PngInfo()
video_metadata = {}
if prompt is not None:
metadata.add_text("prompt", json.dumps(prompt))
video_metadata["prompt"] = json.dumps(prompt)
if extra_pnginfo is not None:
for x in extra_pnginfo:
metadata.add_text(x, json.dumps(extra_pnginfo[x]))
video_metadata[x] = extra_pnginfo[x]
metadata.add_text("CreationTime", datetime.datetime.now().isoformat(" ")[:19])
# Save first frame as PNG to keep metadata
first_image_file = f"{filename}_{counter:05}.png"
first_image_path = os.path.join(full_output_folder, first_image_file)
Image.fromarray(tensor_to_numpy_uint8(first_image)).save(
first_image_path,
pnginfo=metadata,
compress_level=4,
)
output_files.append(first_image_path)
# Process format string
format_type, format_ext = format.split("/")
# Handle special format extensions
if format == "video/h264-mp4" or format == "video/h265-mp4":
format_ext = "mp4"
elif format == "video/vp9-webm":
format_ext = "webm"
elif format == "video/prores":
format_ext = "mov"
# Prepare images for ffmpeg or PIL
if pingpong:
# Create ping-pong effect
def to_pingpong(inp):
if not hasattr(inp, "__getitem__"):
inp = list(inp)
yield from inp
for i in range(len(inp)-2, 0, -1):
yield inp[i]
image_sequence = list(to_pingpong(images))
else:
image_sequence = images
# Set up file naming and path
file = f"{filename}_{counter:05}.{format_ext}"
file_path = os.path.join(full_output_folder, file)
# Check if we need ffmpeg (for video formats) or can use PIL (for image formats)
use_pil = format_type == "image" or ffmpeg_path is None
# For PIL-based formats (image/xxx) or when ffmpeg is not available
if use_pil:
try:
print(f"Using PIL for {format} creation")
# Convert tensor images to PIL images
pil_images = []
for img in image_sequence:
img_np = tensor_to_numpy_uint8(img)
pil_images.append(Image.fromarray(img_np))
if len(pil_images) > 0:
if format_ext == "gif":
# Calculate durations
duration = int(1000 / frame_rate) # Convert fps to ms per frame
durations = [duration] * len(pil_images)
# Save GIF using PIL
pil_images[0].save(
file_path,
format="GIF",
append_images=pil_images[1:],
save_all=True,
duration=durations,
loop=0 if loop_count == 0 else loop_count,
optimize=False
)
output_files.append(file_path)
elif format_ext == "webp":
# WebP supports animation too
duration = int(1000 / frame_rate)
# WebP supports lossless mode
if lossless:
pil_images[0].save(
file_path,
format="WEBP",
append_images=pil_images[1:],
save_all=True,
duration=duration,
loop=0 if loop_count == 0 else loop_count,
lossless=True
)
else:
pil_images[0].save(
file_path,
format="WEBP",
append_images=pil_images[1:],
save_all=True,
duration=duration,
loop=0 if loop_count == 0 else loop_count,
quality=quality,
)
output_files.append(file_path)
else:
# For non-animated formats, just save the first frame
print(f"Format {format} doesn't support animation with PIL. Saving first frame only.")
pil_images[0].save(file_path, format=format_ext.upper())
output_files.append(file_path)
else:
print("No images to save")
except Exception as e:
error_msg = f"Error creating {format} with PIL: {str(e)}"
print(error_msg)
raise RuntimeError(error_msg)
if ffmpeg_path is None and format_type == "video" and format_ext not in ["gif", "webp"]:
print(f"Warning: ffmpeg is required for video format {format} and could not be found.")
print(f"The file was saved as an image sequence instead.")
else:
# Handle VHS formats if this is an advanced format
is_vhs_format = has_vhs_formats and format not in ["video/mp4", "video/webm", "video/gif"]
# If using VHS format, apply format widgets and get custom parameters
if is_vhs_format:
try:
# Extract the format name without the prefix
vhs_format_name = format.split("/")[-1]
# Define basic formats directly without loading from JSON files
basic_formats = {
"mp4": {
"extension": "mp4",
"main_pass": ["-c:v", "libx264", "-crf", str(100-quality), "-pix_fmt", "yuv420p"],
},
"webm": {
"extension": "webm",
"main_pass": ["-c:v", "libvpx-vp9", "-crf", str(100-quality), "-b:v", "0"],
},
"gif": {
"extension": "gif",
"main_pass": ["-vf", f"fps={frame_rate}", "-loop", "0" if loop_count == 0 else str(loop_count)],
}
}
# Use basic format if defined, otherwise default
video_format = basic_formats.get(vhs_format_name, {"extension": vhs_format_name, "main_pass": []})
# Debug output to help diagnose format issues
print(f"Using format: {vhs_format_name}")
print(f"Format options: {str(video_format)}")
# Handle special format settings like dimension alignment
has_alpha = first_image.shape[-1] == 4
dim_alignment = video_format.get("dim_alignment", 2)
# Check if dimensions need to be adjusted
if (first_image.shape[1] % dim_alignment) or (first_image.shape[0] % dim_alignment):
# Output frames must be padded
print(f"Adjusting dimensions to match format requirements (alignment: {dim_alignment})")
to_pad = (-first_image.shape[1] % dim_alignment,
-first_image.shape[0] % dim_alignment)
# Use simple resizing since we don't have the padding function
new_dims = (
-first_image.shape[1] % dim_alignment + first_image.shape[1],
-first_image.shape[0] % dim_alignment + first_image.shape[0]
)
dimensions = f"{new_dims[0]}x{new_dims[1]}"
else:
dimensions = f"{first_image.shape[1]}x{first_image.shape[0]}"
# Set color depth
if video_format.get('input_color_depth', '8bit') == '16bit':
# Handle 16-bit if needed - for now we just fall back to 8-bit
print("16-bit color depth requested but not supported in this node, using 8-bit")
if has_alpha:
i_pix_fmt = 'rgba'
else:
i_pix_fmt = 'rgb24'
else:
if has_alpha:
i_pix_fmt = 'rgba'
else:
i_pix_fmt = 'rgb24'
# Get bitrate settings
bitrate_arg = []
bitrate = video_format.get('bitrate')
if bitrate is not None:
if video_format.get('megabit') == 'True':
bitrate_arg = ["-b:v", f"{bitrate}M"]
else:
bitrate_arg = ["-b:v", f"{bitrate}K"]
# Handle loop arguments
if loop_count > 0:
loop_args = ["-vf", f"loop={loop_count}:size={num_frames}"]
else:
loop_args = []
# Set up file path with correct extension
file = f"{filename}_{counter:05}.{video_format['extension']}"
file_path = os.path.join(full_output_folder, file)
# Set up environment
env = os.environ.copy()
if "environment" in video_format:
env.update(video_format["environment"])
# Convert tensor images to bytes
# Optimization: Use tensor_to_numpy_uint8 for faster conversion
# Ensure contiguity to avoid ValueError in subprocess.stdin.write
image_chunks = map(lambda x: np.ascontiguousarray(tensor_to_numpy_uint8(x)), image_sequence)
# Base ffmpeg arguments
args = [
ffmpeg_path, "-v", "error",
"-f", "rawvideo",
"-pix_fmt", i_pix_fmt,
"-s", dimensions,
"-r", str(frame_rate),
"-i", "-"
] + loop_args
# Apply main pass arguments from format
args += video_format['main_pass'] + bitrate_arg + [file_path]
# Execute ffmpeg process
try:
process = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env)
# Feed frames to ffmpeg
for chunk in image_chunks:
pbar.update(1)
process.stdin.write(chunk)
# Close stdin and get output
process.stdin.close()
process.wait()
if process.returncode != 0:
stderr = process.stderr.read().decode(*ENCODE_ARGS)
raise Exception(f"ffmpeg encoding error: {stderr}")
output_files.append(file_path)
except Exception as e:
print(f"Error with VHS format encoding: {str(e)}")
# Fall back to basic encoding if VHS format fails
is_vhs_format = False
except Exception as e:
print(f"Error applying VHS format {format}: {str(e)}")
# Fall back to basic encoding if VHS format fails
is_vhs_format = False
# Use standard ffmpeg for basic formats or if VHS format failed
if not is_vhs_format:
# Use ffmpeg for video processing
# Set video parameters
dimensions = f"{first_image.shape[1]}x{first_image.shape[0]}"
has_alpha = first_image.shape[-1] == 4
# Convert tensor images to bytes
if has_alpha:
i_pix_fmt = 'rgba'
else:
i_pix_fmt = 'rgb24'
# Optimization: Use tensor_to_numpy_uint8 for faster conversion
# Ensure contiguity to avoid ValueError in subprocess.stdin.write
image_chunks = map(lambda x: np.ascontiguousarray(tensor_to_numpy_uint8(x)), image_sequence)
# Set up ffmpeg arguments based on format
loop_args = []
if format_ext == "gif" and loop_count > 0:
loop_args = ["-vf", f"loop={loop_count}:size={num_frames}"]
bitrate_arg = []
# Higher quality = higher bitrate (up to 20M for 100 quality)
bitrate = int((quality / 100.0) * 20)
if bitrate > 0 and not lossless: # Only use bitrate for non-lossless
bitrate_arg = ["-b:v", f"{bitrate}M"]
# Base ffmpeg arguments
args = [
ffmpeg_path, "-v", "error",
"-f", "rawvideo",
"-pix_fmt", i_pix_fmt,
"-s", dimensions,
"-r", str(frame_rate),
"-i", "-"
] + loop_args
# Format-specific arguments
main_pass = []
if format_ext == "gif":
# Different GIF encoders
if format == "video/gif":
# Standard GIF settings
if lossless:
# For "lossless" GIF, use highest quality settings possible
main_pass = ["-vf", "split[s0][s1];[s0]palettegen=max_colors=256:stats_mode=full[p];[s1][p]paletteuse=dither=sierra2_4a", "-loop", "0"]
else:
main_pass = ["-vf", "split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse", "-loop", "0"]
# Add flags to improve Discord compatibility
if loop_count <= 0:
loop_count = 0 # Ensure infinite loop for Discord
elif format_ext == "mp4":
# Determine MP4 codec based on the format
if format == "video/h264-mp4":
# High-quality H.264 settings
if lossless:
main_pass = ["-c:v", "libx264", "-preset", "slow", "-qp", "0"]
else:
main_pass = ["-c:v", "libx264", "-preset", "slow", "-crf", str(int(18 - (quality/10)))]
elif format == "video/h265-mp4":
# H.265/HEVC settings (better compression)
if lossless:
main_pass = ["-c:v", "libx265", "-preset", "medium", "-x265-params", "lossless=1"]
else:
main_pass = ["-c:v", "libx265", "-preset", "medium", "-crf", str(int(23 - (quality/5)))]
else:
# Standard MP4 settings
if lossless:
main_pass = ["-c:v", "libx264", "-preset", "medium", "-qp", "0"]
else:
main_pass = ["-c:v", "libx264", "-preset", "medium", "-crf", str(int(28 - (quality/5)))]
# Ensure Discord compatibility by always using yuv420p
main_pass.extend(["-pix_fmt", "yuv420p"])
# Add extra parameters for Discord compatibility
main_pass.extend(["-movflags", "faststart"])
elif format_ext == "webm":
# Determine WebM codec based on the format
if format == "video/vp9-webm":
# VP9 settings (higher quality than VP8)
if lossless:
main_pass = ["-c:v", "libvpx-vp9", "-lossless", "1", "-row-mt", "1"]
else:
main_pass = ["-c:v", "libvpx-vp9", "-crf", str(int(30 - (quality/3))), "-b:v", "0", "-row-mt", "1"]
else:
# Standard WebM settings
if lossless:
main_pass = ["-c:v", "libvpx-vp9", "-lossless", "1"]
else:
main_pass = ["-c:v", "libvpx-vp9", "-crf", str(int(63 - (quality * 0.63)))]
# Set pixel format based on alpha
if has_alpha:
main_pass.extend(["-pix_fmt", "yuva420p"])
else:
main_pass.extend(["-pix_fmt", "yuv420p"])
# Additional flags for Discord compatibility
main_pass.extend(["-auto-alt-ref", "0"])
args += main_pass + bitrate_arg + [file_path]
# Execute ffmpeg process
env = os.environ.copy()
try:
process = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env)
# Feed frames to ffmpeg
for chunk in image_chunks:
pbar.update(1)
process.stdin.write(chunk)
# Close stdin and get output
process.stdin.close()
process.wait()
if process.returncode != 0:
stderr = process.stderr.read().decode(*ENCODE_ARGS)
raise Exception(f"ffmpeg encoding error: {stderr}")
output_files.append(file_path)
except Exception as e:
print(f"DiscordSendSaveVideo error: {str(e)}")
discord_send_success = False
# Add audio if provided (applies to both VHS and standard formats)
if audio is not None and discord_send_success:
try:
a_waveform = audio.get('waveform', None)
if a_waveform is not None:
# Create audio file if input was provided
if is_vhs_format:
extension = video_format['extension']
else:
extension = format_ext
output_file_with_audio = f"{filename}_{counter:05}-audio.{extension}"
output_file_with_audio_path = os.path.join(full_output_folder, output_file_with_audio)
# Set up audio encoding parameters
channels = a_waveform.size(1)
sample_rate = audio.get('sample_rate', 44100)
# Determine audio codec based on format
if is_vhs_format and "audio_pass" in video_format:
# Use format-specific audio settings if available
audio_pass = video_format["audio_pass"]
else:
# Default audio codecs based on format
if format_ext == "mp4":
audio_pass = ["-c:a", "aac", "-b:a", "192k"]
elif format_ext == "webm":
audio_pass = ["-c:a", "libopus", "-b:a", "128k"]
else:
audio_pass = ["-c:a", "libopus", "-b:a", "128k"]
# FFmpeg command with audio re-encoding
mux_args = [
ffmpeg_path, "-v", "error", "-n",
"-i", file_path,
"-ar", str(sample_rate),
"-ac", str(channels),
"-f", "f32le",
"-i", "-",
"-c:v", "copy"
] + audio_pass + [
"-shortest",
output_file_with_audio_path
]
# Use ascontiguousarray to ensure the transposed array is C-contiguous.
# This avoids BufferError in subprocess.run when using memoryview on non-contiguous arrays.
audio_data = np.ascontiguousarray(a_waveform.squeeze(0).transpose(0,1).numpy())
try:
# Use memoryview to avoid copy while satisfying subprocess.run input check
res = subprocess.run(mux_args, input=memoryview(audio_data), env=env, capture_output=True, check=True)
if res.stderr:
print(res.stderr.decode(*ENCODE_ARGS), end="", file=sys.stderr)
output_files.append(output_file_with_audio_path)
file = output_file_with_audio # Use this file for preview
except subprocess.CalledProcessError as e:
print(f"Error adding audio to video: {e.stderr.decode(*ENCODE_ARGS)}")
except Exception as e:
print(f"Error processing audio: {str(e)}")
# Send to Discord if requested
if send_to_discord and webhook_url:
try:
# For Discord compatibility, create a special Discord-optimized copy of the video
# This is particularly important when add_time is disabled
discord_optimized_file = None
try:
# Get input file (the last output file)
input_file = output_files[-1]
# Create optimized output file in a temp location
temp_dir = folder_paths.get_temp_directory()
discord_optimized_file = os.path.join(temp_dir, f"discord_optimized_{uuid4()}{os.path.splitext(input_file)[1]}")
# Set up ffmpeg arguments for optimized Discord conversion
# This creates a new file specifically optimized for Discord playback
format_ext = os.path.splitext(input_file)[1].lstrip('.').lower()
optimize_args = []
if format_ext == "mp4":
# MP4 optimization for Discord
optimize_args = [
ffmpeg_path, "-i", input_file,
"-c:v", "libx264", "-pix_fmt", "yuv420p",
"-movflags", "faststart", "-preset", "fast",
"-profile:v", "baseline", "-level", "3.0",
"-crf", "23"
]
# Add audio if present in original file
optimize_args.extend(["-c:a", "aac", "-b:a", "128k"])
# Add output file
optimize_args.append(discord_optimized_file)
elif format_ext == "webm":
# WebM optimization for Discord
optimize_args = [
ffmpeg_path, "-i", input_file,
"-c:v", "libvpx-vp9",
"-pix_fmt", "yuv420p",
"-crf", "30", "-b:v", "0",
"-deadline", "good"
]
# Add audio if present in original file
optimize_args.extend(["-c:a", "libopus", "-b:a", "96k"])
# Add output file
optimize_args.append(discord_optimized_file)
elif format_ext == "gif":
# GIF optimization for Discord
optimize_args = [
ffmpeg_path, "-i", input_file,
"-vf", "fps=15,scale=trunc(iw/2)*2:trunc(ih/2)*2",
]
# Add output file
optimize_args.append(discord_optimized_file)
if optimize_args:
print(f"Creating Discord-optimized version of {format_ext.upper()} file...")
subprocess.run(optimize_args, check=True, capture_output=True)
print(f"Discord-optimized file created: {discord_optimized_file}")
else:
# If no optimization needed, just use the original file
discord_optimized_file = input_file
print(f"Using original file for Discord: {discord_optimized_file}")
except Exception as e:
print(f"Warning: Failed to create Discord-optimized file: {str(e)}")
print("Falling back to original file")
discord_optimized_file = output_files[-1]
# Prepare Discord files and message
discord_files = []
# Include the workflow JSON if requested
workflow_file = None
if send_workflow_json and (original_prompt is not None or original_extra_pnginfo is not None):
try:
# Extract workflow data
workflow_json = None
# First check if extra_pnginfo contains the workflow data
if original_extra_pnginfo is not None and "workflow" in original_extra_pnginfo:
workflow_json = original_extra_pnginfo["workflow"]
# Ensure the workflow is sanitized
workflow_json = sanitize_json_for_export(workflow_json)
# If no workflow in extra_pnginfo, check if prompt is actually a workflow
if workflow_json is None and original_prompt is not None:
# Check if prompt is already a workflow
if isinstance(original_prompt, dict) and "nodes" in original_prompt and "links" in original_prompt:
workflow_json = original_prompt
# Ensure the workflow is sanitized
workflow_json = sanitize_json_for_export(workflow_json)
# Only proceed if we have workflow data
if workflow_json:
# Generate a JSON filename with UUID
json_filename = f"{uuid4()}.json"
# Convert workflow data to JSON string in the proper format
workflow_json_str = json.dumps(workflow_json, indent=2)
workflow_file = BytesIO(workflow_json_str.encode('utf-8'))
print(f"ComfyUI workflow JSON file will be sent alongside the video")
else:
print("No workflow data found in the provided metadata")
except Exception as e:
print(f"Error preparing workflow for Discord: {str(e)}")
# Prepare message content
message_content = discord_message
# Add video metadata information to Discord message when enabled
video_metadata_text = ""
# Only build video metadata text if the option is enabled
if include_video_info:
# Add date if enabled
if add_date and "date" in video_info:
video_metadata_text += f"**Date**: {video_info['date']}\n"
# Add time if enabled
if add_time and "time" in video_info:
video_metadata_text += f"**Time**: {video_info['time']}\n"
# Add dimensions if enabled
if add_dimensions and "dimensions" in video_info:
video_metadata_text += f"**Dimensions**: {video_info['dimensions']}\n"
# Add frame rate information
video_metadata_text += f"**Frame Rate**: {frame_rate} fps\n"
# Add format information
video_metadata_text += f"**Format**: {format}\n"
# Add video metadata to message if any was collected
if video_metadata_text and message_content:
message_content += "\n\n**Video Info**:\n" + video_metadata_text
print(f"Added video metadata to Discord message: {len(video_metadata_text)} chars")
elif video_metadata_text:
message_content = "**Video Info**:\n" + video_metadata_text
print(f"Added video metadata to Discord message: {len(video_metadata_text)} chars")
else:
print("Video info was not included in Discord message (disabled by user)")
# Include the generation prompts if requested
if include_prompts_in_message:
print(f"Prompt inclusion requested! Starting prompt extraction process...")
print(f"original_prompt type: {type(original_prompt) if original_prompt is not None else 'None'}")
print(f"original_extra_pnginfo type: {type(original_extra_pnginfo) if original_extra_pnginfo is not None else 'None'}")
if original_prompt is not None:
print(f"original_prompt keys: {original_prompt.keys() if isinstance(original_prompt, dict) else 'Not a dict'}")
if original_extra_pnginfo is not None:
print(f"original_extra_pnginfo keys: {original_extra_pnginfo.keys() if isinstance(original_extra_pnginfo, dict) else 'Not a dict'}")
if isinstance(original_extra_pnginfo, dict) and "workflow" in original_extra_pnginfo:
print(f"workflow keys: {original_extra_pnginfo['workflow'].keys() if isinstance(original_extra_pnginfo['workflow'], dict) else 'Workflow not a dict'}")
# First identify the workflow data to extract prompts from
workflow_data = None
# First try to get workflow from extra_pnginfo
if original_extra_pnginfo is not None and isinstance(original_extra_pnginfo, dict) and "workflow" in original_extra_pnginfo:
workflow_data = original_extra_pnginfo["workflow"]
print("Found workflow data in extra_pnginfo")
# If no workflow in extra_pnginfo, check if prompt is actually a workflow
if workflow_data is None and original_prompt is not None:
# Check if prompt is already a workflow
if isinstance(original_prompt, dict) and "nodes" in original_prompt:
workflow_data = original_prompt
print("Found workflow data in original_prompt")
# Extract prompts if we found workflow data
if workflow_data is not None:
print("Workflow data found, attempting to extract prompts...")
# Extract prompts and add to message
try:
# Try to use discord_image_node's function first
try:
from discord_image_node import extract_prompts_from_workflow
positive_prompt, negative_prompt = extract_prompts_from_workflow(workflow_data)
print(f"extract_prompts_from_workflow returned: positive_type={type(positive_prompt)}, negative_type={type(negative_prompt)}")
# Ensure the prompts are strings or None
if positive_prompt is not False and positive_prompt is not None and not isinstance(positive_prompt, str):
positive_prompt = str(positive_prompt)
print(f"Converted positive prompt to string: {positive_prompt[:50]}...")
if negative_prompt is not False and negative_prompt is not None and not isinstance(negative_prompt, str):
negative_prompt = str(negative_prompt)
print(f"Converted negative prompt to string: {negative_prompt[:50]}...")
# Check if we have valid prompt data
has_valid_prompt = (
(isinstance(positive_prompt, str) and positive_prompt) or
(isinstance(negative_prompt, str) and negative_prompt)
)
if has_valid_prompt:
print(f"Successfully extracted prompts via discord_image_node")
# Add to message
prompt_text = "\n\n**Generation Prompts:**\n"
if isinstance(positive_prompt, str) and positive_prompt:
prompt_text += f"**Positive:**\n```\n{positive_prompt}\n```\n"
if isinstance(negative_prompt, str) and negative_prompt:
prompt_text += f"**Negative:**\n```\n{negative_prompt}\n```\n"
# Add to message content
message_content += prompt_text
print("Successfully added prompts to Discord message")
else:
print("No valid prompts extracted from discord_image_node function")
# Raise an exception to trigger the fallback extraction method
raise ValueError("No valid prompts extracted")
except Exception as e:
print(f"Could not use discord_image_node extraction: {str(e)}")
print("Falling back to built-in prompt extraction")
# Local extraction logic
positive_prompt = None
negative_prompt = None
# Find CLIP text nodes
clip_nodes = []
# Define node types that can contain prompts
prompt_node_types = [
"CLIPTextEncode", # Standard SD 1.5 prompt node
"SDXLPromptEncoder", # SDXL prompt encoder
"SDXLTextEncode", # Another SDXL text node
]
if "nodes" in workflow_data:
nodes = workflow_data["nodes"]
node_list = []
# Debug print full node data for the first few nodes
print("\nExamining raw node data:")
if isinstance(nodes, dict):
for i, (node_id, node) in enumerate(list(nodes.items())[:3]):
print(f"Node {i} (ID: {node_id}): {node.get('type', 'unknown')} - Active status: bypassed={node.get('bypassed', 'Not set')}, muted={node.get('muted', 'Not set')}, disabled={node.get('disabled', 'Not set')}")
elif isinstance(nodes, list) and len(nodes) > 0:
for i, node in enumerate(nodes[:3]):
print(f"Node {i}: {node.get('type', 'unknown')} - Active status: bypassed={node.get('bypassed', 'Not set')}, muted={node.get('muted', 'Not set')}, disabled={node.get('disabled', 'Not set')}")
# Convert to list for processing
if isinstance(nodes, dict):
print("Nodes is a dictionary, converting to list...")
for node_id, node in nodes.items():
if isinstance(node, dict):
# Store the ID in the node dict for reference
node_with_id = node.copy() # Copy to avoid modifying original
node_with_id["id"] = node_id
node_list.append(node_with_id)
elif isinstance(nodes, list):
print("Nodes is already a list")
node_list = nodes
else:
print(f"Unexpected type for nodes: {type(nodes)}")
print(f"Processed {len(node_list)} nodes from workflow data")
# Let's specifically search for all CLIP text nodes to analyze
print("\nSearching for all possible prompt nodes (active or disabled):")
all_prompt_nodes = []
for node in node_list:
if isinstance(node, dict) and "type" in node:
node_type = node.get("type", "")
if node_type in prompt_node_types or ("Text" in node_type and ("Encode" in node_type or "Prompt" in node_type)):
# Found a potential prompt node
title = node.get("title", "Untitled")
is_active = not (node.get("bypassed", False) or node.get("muted", False) or
node.get("disabled", False) or node.get("active", True) == False or
node.get("enabled", True) == False)
# Print detailed info
print(f"Prompt node found: {title} (Type: {node_type})")
print(f" - Status: {'ACTIVE' if is_active else 'DISABLED'}")
print(f" - Properties: bypassed={node.get('bypassed', 'Not set')}, muted={node.get('muted', 'Not set')}, disabled={node.get('disabled', 'Not set')}")
if "widgets_values" in node and node["widgets_values"]:
text_preview = node["widgets_values"][0][:50] + "..." if len(node["widgets_values"][0]) > 50 else node["widgets_values"][0]
print(f" - Content preview: {text_preview}")
all_prompt_nodes.append(node)
print(f"Found {len(all_prompt_nodes)} total potential prompt nodes ({len([n for n in all_prompt_nodes if not (n.get('bypassed', False) or n.get('muted', False) or n.get('disabled', False) or n.get('active', True) == False or n.get('enabled', True) == False)])} active, {len([n for n in all_prompt_nodes if n.get('bypassed', False) or n.get('muted', False) or n.get('disabled', False) or n.get('active', True) == False or n.get('enabled', True) == False])} disabled)")
# Check for active prompt nodes (not muted/bypassed)
for node in node_list:
if isinstance(node, dict) and "type" in node:
# Check for various ways a node might be disabled
is_disabled = (
node.get("bypassed", False) or
node.get("muted", False) or
node.get("disabled", False) or
node.get("active", True) == False or
node.get("enabled", True) == False
)
# If no clear status property, let's check outputs to see if they're connected
if not is_disabled and ("outputs" in node or "Output" in node):
# No outputs might mean disconnected
has_outputs = False
if "outputs" in node and node["outputs"]:
has_outputs = True
elif "Output" in node and node["Output"]:
has_outputs = True
# Check if we should include nodes with no connections (sometimes these are valid sources)
# For prompt nodes, we'll consider them even if disconnected, but log it
if not has_outputs and node["type"] in prompt_node_types:
print(f"Note: Including prompt node that appears disconnected: {node.get('title', node['type'])}")
if is_disabled:
print(f"Skipping disabled node: {node.get('title', node.get('type', 'unknown'))}")
continue
# If we reach here, the node is active
# Check standard prompt node types
if node["type"] in prompt_node_types:
if "widgets_values" in node and node["widgets_values"]:
clip_nodes.append(node)
print(f"Found active prompt node: {node.get('title', node['type'])}")
# Also look for other common prompt nodes
elif "Text" in node["type"] and ("Encode" in node["type"] or "Prompt" in node["type"]):
print(f"Found potential prompt node of type: {node['type']}")
if "widgets_values" in node and node["widgets_values"]:
print(f"Values: {node['widgets_values']}")
clip_nodes.append(node)
print(f"Found {len(clip_nodes)} active prompt nodes")
# Dump the first 5 nodes to debug
print("Sampling first few nodes for debugging:")
if isinstance(workflow_data["nodes"], dict):
sample_nodes = list(workflow_data["nodes"].values())[:5]
for i, node in enumerate(sample_nodes):
if isinstance(node, dict):
print(f"Node {i}: type={node.get('type', 'unknown')}, title={node.get('title', 'untitled')}")
elif isinstance(workflow_data["nodes"], list):
for i, node in enumerate(workflow_data["nodes"][:5]):
if isinstance(node, dict):
print(f"Node {i}: type={node.get('type', 'unknown')}, title={node.get('title', 'untitled')}")
# Process CLIP nodes
if len(clip_nodes) == 2:
# Identify which is positive/negative
indicators = ["worst quality", "low quality", "bad quality", "nude", "nsfw"]
scores = [0, 0]
for i, node in enumerate(clip_nodes):
text = node["widgets_values"][0].lower()
for indicator in indicators:
if indicator in text:
scores[i] += 1
# The one with more negative indicators is the negative prompt
if scores[0] > scores[1]:
negative_prompt = clip_nodes[0]["widgets_values"][0]
positive_prompt = clip_nodes[1]["widgets_values"][0]
else:
negative_prompt = clip_nodes[1]["widgets_values"][0]
positive_prompt = clip_nodes[0]["widgets_values"][0]
print(f"Identified prompts with scores: {scores}")
# Add to message
prompt_text = "\n\n**Generation Prompts:**\n"
prompt_text += f"**Positive:**\n```\n{positive_prompt}\n```\n"
prompt_text += f"**Negative:**\n```\n{negative_prompt}\n```\n"
# Add to message content
message_content += prompt_text
print("Added prompts to Discord message via built-in extraction")
elif len(clip_nodes) == 1:
# Just one prompt, assume it's positive
positive_prompt = clip_nodes[0]["widgets_values"][0]
# Add to message
prompt_text = "\n\n**Generation Prompts:**\n"
prompt_text += f"**Positive:**\n```\n{positive_prompt}\n```\n"
# Add to message content
message_content += prompt_text
print("Added single prompt to Discord message")
else:
# Handle multiple CLIP nodes (more than 2)
print(f"Found {len(clip_nodes)} CLIP nodes, will analyze each to determine positive/negative")
# Collect potential positive and negative prompts
positive_candidates = []
negative_candidates = []
# Common negative prompt indicators
negative_indicators = ["worst quality", "low quality", "bad quality", "nude", "nsfw",
"negative", "bad", "worse", "poor", "deformed"]
for node in clip_nodes:
# Get the node's text content
if "widgets_values" in node and node["widgets_values"]:
text = node["widgets_values"][0]
title = node.get("title", "").lower()
node_type = node.get("type", "").lower()
# Check if title explicitly indicates a negative prompt
is_negative = False
if "negative" in title:
is_negative = True
print(f"Found explicit negative prompt node by title: {title}")
else:
# Check content for negative indicators
text_lower = text.lower()
negative_score = 0
for indicator in negative_indicators:
if indicator in text_lower:
negative_score += 1
if negative_score >= 2: # Threshold for considering as negative
is_negative = True
print(f"Identified negative prompt by content with score {negative_score}")
# Categorize the prompt
if is_negative:
negative_candidates.append(text)
else:
positive_candidates.append(text)
# Get the final prompts
positive_prompt = None
negative_prompt = None
if positive_candidates:
# Use the longest positive prompt as it likely has more information
positive_prompt = max(positive_candidates, key=len)
print(f"Selected positive prompt ({len(positive_prompt)} chars)")
if negative_candidates:
# Use the longest negative prompt
negative_prompt = max(negative_candidates, key=len)
print(f"Selected negative prompt ({len(negative_prompt)} chars)")
# Add to message if any prompts were found
if positive_prompt or negative_prompt:
prompt_text = "\n\n**Generation Prompts:**\n"
if positive_prompt:
prompt_text += f"**Positive:**\n```\n{positive_prompt}\n```\n"
if negative_prompt:
prompt_text += f"**Negative:**\n```\n{negative_prompt}\n```\n"
# Add to message content
message_content += prompt_text
print(f"Added prompts to Discord message from {len(positive_candidates)} positive and {len(negative_candidates)} negative candidates")
else:
print("Could not identify any positive or negative prompts from the CLIP nodes")
except Exception as e:
print(f"Error extracting prompts: {str(e)}")
print(f"Error type: {type(e).__name__}")
import traceback
traceback.print_exc()
else:
print("No workflow data found for prompt extraction")
# Send videos to Discord
# Use requests to send files to Discord webhook
discord_data = {"content": message_content} if message_content else {}
# Debug the message content to make sure prompts are included
print(f"Final Discord message content length: {len(message_content) if message_content else 0} characters")
if message_content:
lines = message_content.split('\n')
print(f"Message has {len(lines)} lines")
if "Generation Prompts" in message_content:
print("Message contains 'Generation Prompts' section")
else:
print("WARNING: Message does NOT contain 'Generation Prompts' section")
# Prepare files for upload
files = []
# Use our optimized file for Discord instead of the original
file_path = discord_optimized_file
try:
# Validate the video file before sending to Discord
is_valid, validation_message = validate_video_for_discord(file_path)
if not is_valid:
print(f"WARNING: {validation_message}")
print("Will attempt to send file to Discord anyway.")
else:
print(f"Video validation for Discord: {validation_message}")
# Add main video file
with open(file_path, 'rb') as f:
file_content = f.read()
# Use UUID to generate a unique filename for Discord
# This is better than using the original filename to prevent issues with duplicate filenames
original_extension = os.path.splitext(file_path)[1].lstrip('.')
discord_filename = f"{uuid4()}.{original_extension}"
# Determine MIME type for Discord
mime_type = ""
if original_extension == "mp4":
mime_type = "video/mp4"
elif original_extension == "webm":
mime_type = "video/webm"
elif original_extension == "gif":
mime_type = "image/gif"
elif original_extension == "webp":
mime_type = "image/webp"
elif original_extension == "mov":
mime_type = "video/quicktime"
else:
# Default to video/extension
mime_type = f"video/{original_extension}"
# Log the file details for debugging
print(f"Sending file to Discord: {file_path}")
print(f"File size: {len(file_content)} bytes")
print(f"Using MIME type: {mime_type}")
print(f"Discord filename: {discord_filename}")
files.append(('file', (discord_filename, file_content, mime_type)))
except Exception as e:
print(f"Error preparing video file for Discord: {str(e)}")
discord_send_success = False
# Add workflow JSON if requested
if workflow_file:
# Also use UUID for the JSON file to ensure uniqueness
json_filename = f"{uuid4()}.json"
files.append(('workflow', (json_filename, workflow_file.getvalue(), 'application/json')))
# Send to Discord with retry logic
response = send_to_discord_with_retry(
webhook_url,
data=discord_data,
files=files
)
# Discord can return either 204 (no content) or 200 (success with content) for successful requests
if response.status_code in [200, 204]:
discord_sent_files.append(discord_filename) # Store the Discord UUID filename instead of local path
print(f"Successfully sent video to Discord with filename: {discord_filename}")
# Try to extract CDN URL if available
if save_cdn_urls and response.status_code == 200:
try:
response_data = response.json()
# Discord webhook responses include attachments with URLs
if "attachments" in response_data and isinstance(response_data["attachments"], list):
for attachment in response_data["attachments"]:
if "url" in attachment and "filename" in attachment:
# Filter out workflow JSON files
if not attachment["filename"].endswith(".json"):
discord_cdn_urls.append((attachment["filename"], attachment["url"]))
print(f"Extracted CDN URL for video: {attachment['url']}")
except Exception as e:
print(f"Error extracting CDN URL from response: {e}")
else:
print(f"Discord API error: {response.status_code} - {response.text}")
discord_send_success = False
except Exception as e:
print(f"Error sending to Discord: {str(e)}")
discord_send_success = False
# If we have CDN URLs and the option is enabled, send them as a text file
if save_cdn_urls and discord_cdn_urls:
try:
# Create the text file content
url_text_content = "# Discord CDN URLs\n\n"
for idx, (filename, url) in enumerate(discord_cdn_urls):
url_text_content += f"{idx+1}. {filename}: {url}\n"
# Create a unique filename for the text file
urls_filename = f"cdn_urls-{uuid4()}.txt"
# Prepare the request with just the URL file
url_files = {"file": (urls_filename, url_text_content.encode('utf-8'))}
url_data = {"content": "Discord CDN URLs for the uploaded videos:"}
# Send a follow-up message with just the URLs text file
url_response = send_to_discord_with_retry(
webhook_url,
files=url_files,
data=url_data
)
if url_response.status_code in [200, 204]:
print(f"Successfully sent CDN URLs text file to Discord")
else:
print(f"Error sending CDN URLs text file: Status code {url_response.status_code}")
except Exception as e:
print(f"Error creating or sending CDN URLs text file: {e}")
# Update GitHub repository with CDN URLs if enabled
if github_cdn_update and send_to_discord and discord_cdn_urls:
# Use the collected CDN URLs for GitHub update
print(f"GitHub update is enabled with: repo={github_repo}, token_provided={'Yes' if github_token else 'No'}, file_path={github_file_path}")
print(f"Number of available CDN URLs to update GitHub: {len(discord_cdn_urls)}")
if discord_cdn_urls:
# Call the GitHub update function
print(f"Updating GitHub repository {github_repo} with {len(discord_cdn_urls)} Discord CDN URLs...")
success, message = update_github_cdn_urls(
github_repo=github_repo,
github_token=github_token,
file_path=github_file_path,
cdn_urls=discord_cdn_urls
)
if success:
print(f"GitHub update successful: {message}")
else:
print(f"GitHub update failed: {message}")
else:
print("No CDN URLs available to update GitHub repository")
elif github_cdn_update:
# If GitHub update is enabled but not triggered, explain why
reasons = []
if not send_to_discord:
reasons.append("send_to_discord is disabled")
if not discord_cdn_urls:
reasons.append("no CDN URLs were collected (did Discord upload succeed?)")
if not github_repo:
reasons.append("github_repo is empty")
if not github_token:
reasons.append("github_token is empty")
if not github_file_path:
reasons.append("github_file_path is empty")
print(f"GitHub update was enabled but not triggered because: {', '.join(reasons)}")
# Check if any output files were created
if len(output_files) == 0:
print("DiscordSendSaveVideo: No output files were created")
raise RuntimeError("DiscordSendSaveVideo: No output files were created. Check console for details.")
# Status messages
if save_output:
print(f"DiscordSendSaveVideo: Saved video to {full_output_folder}")
else:
print("DiscordSendSaveVideo: Preview only mode - no video saved to disk")
# Discord status
if send_to_discord and discord_sent_files:
print("DiscordSendSaveVideo: Successfully sent video to Discord")
elif send_to_discord and not discord_send_success:
print("DiscordSendSaveVideo: There were errors sending video to Discord")
# Get the final output file path - the last one in the list
final_output_path = output_files[-1]
# Return UI info with basic video preview for ComfyUI
preview = {
"filename": os.path.basename(final_output_path),
"subfolder": subfolder,
"type": "output" if save_output else "temp",
"format": format,
"frame_rate": frame_rate,
"fullpath": final_output_path,
}
return {"ui": {"videos": [preview]}, "result": (final_output_path,)}
@classmethod
def IS_CHANGED(s, images, filename_prefix="ComfyUI-Video", overwrite_last=False,
format="video/h264-mp4", frame_rate=8.0, quality=85, loop_count=0, lossless=False,
pingpong=False, save_output=True, audio=None,
add_date=True, add_time=True, add_dimensions=True,
send_to_discord=False, webhook_url="", discord_message="",
include_prompts_in_message=False, include_video_info=True, send_workflow_json=False,
save_cdn_urls=False, github_cdn_update=False, github_repo="", github_token="",
github_file_path="cdn_urls.md", prompt=None, extra_pnginfo=None, unique_id=None, **format_properties):
# Always return True to ensure execution and proper handling of dynamic format properties
return True