344 lines
13 KiB
Python
344 lines
13 KiB
Python
"""
|
|
Copyright (C) 2024 Dayuppy
|
|
|
|
This library is free software; you can redistribute it and/or
|
|
modify it under the terms of the GNU Lesser General Public
|
|
License as published by the Free Software Foundation; either
|
|
version 2.1 of the License, or (at your option) any later version.
|
|
|
|
This library is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
|
Lesser General Public License for more details.
|
|
|
|
You should have received a copy of the GNU Lesser General Public
|
|
License along with this library; if not, write to the Free Software
|
|
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301
|
|
USA
|
|
|
|
@author: Dayuppy
|
|
@title: Discord Webhook
|
|
@nickname: DiscordWebhook
|
|
@description: A very simple Discord webhook integration node for ComfyUI that lets you post images and text.
|
|
"""
|
|
|
|
import os
|
|
import tempfile
|
|
import shutil
|
|
import numpy as np
|
|
import asyncio
|
|
import soundfile as sf
|
|
import ffmpeg
|
|
import torch
|
|
import torchvision.io as tvio
|
|
import hashlib
|
|
import folder_paths # type: ignore
|
|
import torchvision.transforms as transforms
|
|
from PIL import Image, ImageDraw, ImageOps
|
|
from discord_webhook import AsyncDiscordWebhook
|
|
|
|
def create_default_image():
|
|
"""Create a simple TV test pattern image."""
|
|
colors = ["white", "yellow", "cyan", "green", "magenta", "red", "blue", "black"]
|
|
bar_width = 128 // len(colors)
|
|
image = Image.new("RGB", (128, 128), "black")
|
|
draw = ImageDraw.Draw(image)
|
|
|
|
for i, color in enumerate(colors):
|
|
draw.rectangle([i * bar_width, 0, (i + 1) * bar_width, 128], fill=color)
|
|
|
|
return image
|
|
|
|
def process_audio(audio):
|
|
"""Process audio input (dict with waveform and sample_rate) and convert it to FLAC format."""
|
|
|
|
# Debugging: Print the entire input to see what we are dealing with
|
|
print("DEBUG: Received audio input:", audio)
|
|
|
|
# Extract waveform and sample rate from the dictionary
|
|
if isinstance(audio, dict):
|
|
if 'waveform' in audio and 'sample_rate' in audio:
|
|
audio_array = audio['waveform']
|
|
sample_rate = audio['sample_rate']
|
|
else:
|
|
raise ValueError(f"Audio input is missing 'waveform' or 'sample_rate'. Available keys: {audio.keys()}")
|
|
else:
|
|
raise ValueError("Expected audio input to be a dictionary.")
|
|
|
|
# Convert to NumPy array if it's a PyTorch tensor
|
|
if hasattr(audio_array, "numpy"):
|
|
audio_array = audio_array.numpy()
|
|
|
|
# Ensure the array is 2D: (samples, channels)
|
|
audio_array = np.squeeze(audio_array) # Remove dimensions of size 1
|
|
|
|
if audio_array.ndim == 1:
|
|
# If mono (1D), reshape to (samples, 1)
|
|
audio_array = audio_array[:, np.newaxis]
|
|
|
|
# Debugging: Print the shape of the audio array
|
|
print(f"DEBUG: Processed audio shape: {audio_array.shape}")
|
|
|
|
# Save the audio input in FLAC format using soundfile
|
|
temp_dir = tempfile.mkdtemp()
|
|
|
|
# Preserve the original filename and change the extension to .flac
|
|
file_path = os.path.join(temp_dir, "audio.flac")
|
|
|
|
sf.write(file_path, audio_array, sample_rate, format='FLAC')
|
|
|
|
with open(file_path, 'rb') as f:
|
|
data = f.read()
|
|
|
|
shutil.rmtree(temp_dir) # Clean up temporary directory
|
|
return {"data": data, "name": "audio.flac"}
|
|
|
|
def process_video_to_WEBM(video_frames, fps=15, max_width=1280, max_height=720):
|
|
"""Process video tensor frames and convert them to a WebM video file suitable for Discord."""
|
|
|
|
# Debugging: Print the shape of the video tensor
|
|
print(f"DEBUG: Video frames shape: {video_frames.shape}")
|
|
|
|
# Transpose to (num_frames, height, width, channels) to match write_video's expected input
|
|
video_frames = video_frames.permute(0, 2, 3, 1) # Move channels to the last dimension
|
|
|
|
# Resize video frames to ensure they fit within the desired width/height limits
|
|
resized_video_frames = []
|
|
for frame in video_frames:
|
|
# Debugging: Check the shape of the individual frame
|
|
print(f"DEBUG: Frame shape before conversion to PIL: {frame.shape}")
|
|
|
|
# Convert the tensor frame to a NumPy array
|
|
frame_np = frame.numpy()
|
|
|
|
# Ensure the frame has 3 channels (RGB)
|
|
if frame_np.shape[-1] > 4:
|
|
raise ValueError(f"Expected the video frame to have 3 or 4 channels, but got {frame_np.shape[-1]} channels.")
|
|
|
|
# Convert the NumPy array to a PIL image
|
|
pil_image = Image.fromarray((frame_np * 255).astype(np.uint8)) # Scale the values to 0-255
|
|
|
|
aspect_ratio = pil_image.width / pil_image.height
|
|
|
|
# Resize the frame if it exceeds the max dimensions
|
|
if pil_image.width > max_width or pil_image.height > max_height:
|
|
if aspect_ratio > 1:
|
|
new_width = max_width
|
|
new_height = int(max_width / aspect_ratio)
|
|
else:
|
|
new_height = max_height
|
|
new_width = int(max_height * aspect_ratio)
|
|
pil_image = pil_image.resize((new_width, new_height))
|
|
|
|
# Convert back to tensor and ensure the shape is (height, width, channels)
|
|
resized_frame = transforms.ToTensor()(pil_image).permute(1, 2, 0) # Ensure it's (height, width, channels)
|
|
resized_frame = resized_frame * 255 # Scale back to 0-255 for video encoding
|
|
resized_video_frames.append(resized_frame)
|
|
|
|
# Stack the frames back into a single tensor with the correct shape
|
|
resized_video_tensor = torch.stack(resized_video_frames).byte() # Ensure the tensor is of byte type (0-255)
|
|
|
|
# Create a temporary directory to store the video file
|
|
temp_dir = tempfile.mkdtemp()
|
|
temp_video_file = os.path.join(temp_dir, "temp_video.mp4") # Save as mp4 initially
|
|
|
|
# Save the tensor video frames to a temporary mp4 file (expected shape: num_frames, height, width, channels)
|
|
tvio.write_video(temp_video_file, resized_video_tensor, fps=fps)
|
|
|
|
# Now use ffmpeg to convert the saved video to WebM format (VP8 or VP9 codec)
|
|
output_file = os.path.join(temp_dir, "video.webm")
|
|
|
|
# Use ffmpeg to convert the mp4 video to WebM with VP8 codec
|
|
ffmpeg.input(temp_video_file).output(output_file, vcodec='libvpx', crf=10, **{'b:v': '1M'}).run(overwrite_output=True)
|
|
|
|
# Read the final WebM video file as binary data
|
|
with open(output_file, 'rb') as f:
|
|
video_data = f.read()
|
|
|
|
# Clean up temporary files and directory
|
|
shutil.rmtree(temp_dir)
|
|
|
|
return {"data": video_data, "name": "video.webm"}
|
|
|
|
|
|
class DiscordLoadVideo:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
# Allow users to either specify a directory or use the input directory
|
|
input_dir = folder_paths.get_input_directory()
|
|
|
|
# List video files with specific extensions
|
|
files = folder_paths.filter_files_content_types(os.listdir(input_dir), ["video"])
|
|
|
|
# Debugging: Print the input directory and list the files
|
|
print("DEBUG: Input Directory:", input_dir)
|
|
print("DEBUG: Files in Input Directory:", files)
|
|
|
|
return {
|
|
"required": {
|
|
"file_path": ("STRING", {"default": input_dir}), # The directory path
|
|
"video": (sorted(files), {"video_upload": True}) # The video file name
|
|
}
|
|
}
|
|
|
|
CATEGORY = "Video"
|
|
RETURN_TYPES = ("VIDEO", "AUDIO")
|
|
FUNCTION = "load_video"
|
|
EXPERIMENTAL = True
|
|
|
|
def load_video(self, video, file_path):
|
|
# Combine file path and video filename to get the full video path
|
|
video_path = os.path.join(file_path, video)
|
|
|
|
# Load video frames and audio using torchvision's read_video
|
|
video_frames, audio_frames, info = tvio.read_video(video_path, pts_unit="sec")
|
|
|
|
# Normalize video frames (assumes RGB)
|
|
video_frames = video_frames.float() / 255.0
|
|
|
|
# Transpose to match the typical format (batch_size, channels, height, width, num_frames)
|
|
video_frames = video_frames.permute(0, 3, 1, 2)
|
|
|
|
# Check if audio is present before normalizing
|
|
if audio_frames is not None and audio_frames.numel() > 0:
|
|
audio_frames = audio_frames.float() / torch.abs(audio_frames).max(dim=0, keepdim=True).values # Normalize audio
|
|
else:
|
|
print("DEBUG: No audio frames found, skipping audio normalization.")
|
|
audio_frames = None
|
|
|
|
return (video_frames, audio_frames)
|
|
|
|
@classmethod
|
|
def IS_CHANGED(cls, video):
|
|
video_path = folder_paths.get_annotated_filepath(video)
|
|
m = hashlib.sha256()
|
|
with open(video_path, 'rb') as f:
|
|
m.update(f.read())
|
|
return m.digest().hex()
|
|
|
|
@classmethod
|
|
def VALIDATE_INPUTS(cls, video):
|
|
if not folder_paths.exists_annotated_filepath(video):
|
|
return "Invalid video file: {}".format(video)
|
|
return True
|
|
|
|
class DiscordSetWebhook:
|
|
RETURN_TYPES = ("IMAGE", "*")
|
|
RETURN_NAMES = ("DUMMY IMAGE", "DUMMY OUTPUT")
|
|
OUTPUT_NODE = True
|
|
FUNCTION = "execute"
|
|
CATEGORY = "Discord"
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {"required": {"URL": ("STRING",)}}
|
|
|
|
def execute(self, URL):
|
|
if not URL.startswith("https://discord.com/api/webhooks/"):
|
|
raise ValueError("Invalid URL format. URL should start with 'https://discord.com/api/webhooks/'. Please reference https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks")
|
|
|
|
with open("discord_webhook_url.txt", "w") as f:
|
|
f.write(URL)
|
|
|
|
return (create_default_image(), None)
|
|
|
|
class DiscordPostViaWebhook:
|
|
RETURN_TYPES = ("IMAGE",)
|
|
OUTPUT_NODE = True
|
|
FUNCTION = "execute"
|
|
CATEGORY = "Discord"
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"message": ("STRING", {"default": "", "multiline": True}),
|
|
},
|
|
"optional": {
|
|
"image": ("IMAGE",),
|
|
"audio": ("AUDIO",),
|
|
"video": ("VIDEO",),
|
|
"prepend_message": ("STRING", {"default": "", "multiline": True}),
|
|
},
|
|
}
|
|
|
|
async def send_webhook(self, url, message, files=None):
|
|
webhook = AsyncDiscordWebhook(url=url, content=message[:2000], timeout=30.0)
|
|
if files:
|
|
for file in files:
|
|
webhook.add_file(file=file["data"], filename=file["name"])
|
|
await webhook.execute()
|
|
|
|
def process_image(self, image):
|
|
"""Process the image (or batch of images) and return them in a format suitable for Discord."""
|
|
images_to_send = []
|
|
|
|
if image is None:
|
|
images_to_send.append(create_default_image())
|
|
else:
|
|
image_array = image.cpu().numpy() if hasattr(image, "cpu") else image
|
|
image_array = np.clip(image_array * 255, 0, 255).astype(np.uint8)
|
|
|
|
if image_array.ndim == 4: # Batch of images
|
|
images_to_send = [Image.fromarray(image_array[i]) for i in range(image_array.shape[0])]
|
|
elif image_array.ndim == 3: # Single image
|
|
images_to_send.append(Image.fromarray(image_array))
|
|
else:
|
|
raise ValueError("Input must be a 3D (single image) or 4D (batch) array.")
|
|
|
|
temp_dir = tempfile.mkdtemp()
|
|
files = []
|
|
|
|
for idx, img in enumerate(images_to_send):
|
|
file_path = os.path.join(temp_dir, f"image_{idx}.png")
|
|
img.save(file_path, format="PNG", compress_level=1)
|
|
|
|
if os.path.getsize(file_path) > 20 * 1024 * 1024:
|
|
img = img.resize((img.width // 2, img.height // 2))
|
|
img.save(file_path, format="PNG", compress_level=9)
|
|
|
|
with open(file_path, "rb") as f:
|
|
files.append({"data": f.read(), "name": f"image_{idx}.png"})
|
|
|
|
shutil.rmtree(temp_dir)
|
|
return files
|
|
|
|
def execute(self, image=None, audio=None, video=None, message="", prepend_message=""):
|
|
with open("discord_webhook_url.txt", "r") as f:
|
|
webhook_url = f.read().strip()
|
|
|
|
if not webhook_url:
|
|
raise ValueError("Webhook URL is empty.")
|
|
|
|
full_message = f"{prepend_message}\n{message}"
|
|
|
|
files = []
|
|
if image is not None:
|
|
files.extend(self.process_image(image))
|
|
if audio is not None:
|
|
files.append(process_audio(audio)) # Handle audio as an array/tensor
|
|
if video is not None:
|
|
files.append(process_video_to_WEBM(video)) # Handle video
|
|
|
|
# Ensure files are within size limit
|
|
valid_files = [f for f in files if len(f["data"]) <= 25 * 1024 * 1024]
|
|
|
|
# Split files into batches of 4 (Discord limit)
|
|
batches = [valid_files[i:i + 4] for i in range(0, len(valid_files), 4)]
|
|
|
|
for batch in batches:
|
|
asyncio.run(self.send_webhook(webhook_url, full_message, batch))
|
|
|
|
return (image,)
|
|
|
|
NODE_CLASS_MAPPINGS = {
|
|
"DiscordSetWebhook": DiscordSetWebhook,
|
|
"DiscordPostViaWebhook": DiscordPostViaWebhook,
|
|
"DiscordLoadVideo": DiscordLoadVideo,
|
|
}
|
|
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"DiscordSetWebhook": "Set Discord Webhook",
|
|
"DiscordPostViaWebhook": "Use Discord Webhook",
|
|
"DiscordLoadVideo": "Load Video",
|
|
} |