Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d84d71541c | ||
|
|
e8895028a2 | ||
|
|
b5c00450de | ||
|
|
59083391c7 | ||
|
|
8f2adacc20 |
@@ -1,110 +0,0 @@
|
|||||||
import os
|
|
||||||
import io
|
|
||||||
import cv2 as cv
|
|
||||||
import numpy as np
|
|
||||||
import torch
|
|
||||||
import requests
|
|
||||||
from folder_paths import get_annotated_filepath
|
|
||||||
|
|
||||||
class ComfyUIDeployExternalEXR:
|
|
||||||
RETURN_TYPES = ("IMAGE", "MASK")
|
|
||||||
RETURN_NAMES = ("image", "mask")
|
|
||||||
FUNCTION = "load_exr"
|
|
||||||
CATEGORY = "🔗ComfyDeploy"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def INPUT_TYPES(cls):
|
|
||||||
return {
|
|
||||||
"required": {
|
|
||||||
"input_id": (
|
|
||||||
"STRING",
|
|
||||||
{"multiline": False, "default": "input_exr"},
|
|
||||||
),
|
|
||||||
"exr_file": ("STRING", {"default": ""}),
|
|
||||||
"tonemap": (["linear", "sRGB", "Reinhard"], {"default": "sRGB"}),
|
|
||||||
},
|
|
||||||
"optional": {
|
|
||||||
"default_image": ("IMAGE",),
|
|
||||||
"default_mask": ("MASK",),
|
|
||||||
"display_name": (
|
|
||||||
"STRING",
|
|
||||||
{"multiline": False, "default": ""},
|
|
||||||
),
|
|
||||||
"description": (
|
|
||||||
"STRING",
|
|
||||||
{"multiline": False, "default": ""},
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def VALIDATE_INPUTS(s, exr_file, **kwargs):
|
|
||||||
return True
|
|
||||||
|
|
||||||
def sRGBtoLinear(self, npArray):
|
|
||||||
less = npArray <= 0.0404482362771082
|
|
||||||
npArray[less] = npArray[less] / 12.92
|
|
||||||
npArray[~less] = np.power((npArray[~less] + 0.055) / 1.055, 2.4)
|
|
||||||
|
|
||||||
def linearToSRGB(self, npArray):
|
|
||||||
less = npArray <= 0.0031308
|
|
||||||
npArray[less] = npArray[less] * 12.92
|
|
||||||
npArray[~less] = np.power(npArray[~less], 1/2.4) * 1.055 - 0.055
|
|
||||||
|
|
||||||
def load_exr(self, input_id, exr_file, tonemap="sRGB",
|
|
||||||
default_image=None, default_mask=None,
|
|
||||||
display_name=None, description=None):
|
|
||||||
try:
|
|
||||||
if exr_file and exr_file != "":
|
|
||||||
if exr_file.startswith(('http://', 'https://')):
|
|
||||||
# Handle URL input
|
|
||||||
response = requests.get(exr_file)
|
|
||||||
# Write to temp buffer
|
|
||||||
buffer = io.BytesIO(response.content)
|
|
||||||
nparr = np.frombuffer(buffer.getvalue(), np.uint8)
|
|
||||||
image = cv.imdecode(nparr, cv.IMREAD_UNCHANGED).astype(np.float32)
|
|
||||||
else:
|
|
||||||
# Handle local file
|
|
||||||
exr_path = get_annotated_filepath(exr_file)
|
|
||||||
image = cv.imread(exr_path, cv.IMREAD_UNCHANGED).astype(np.float32)
|
|
||||||
|
|
||||||
if len(image.shape) == 2:
|
|
||||||
image = np.repeat(image[..., np.newaxis], 3, axis=2)
|
|
||||||
|
|
||||||
# Extract RGB and flip channels
|
|
||||||
rgb = np.flip(image[:,:,:3], 2).copy()
|
|
||||||
|
|
||||||
# Apply tonemapping
|
|
||||||
if tonemap == "sRGB":
|
|
||||||
self.linearToSRGB(rgb)
|
|
||||||
rgb = np.clip(rgb, 0, 1)
|
|
||||||
elif tonemap == "Reinhard":
|
|
||||||
rgb = np.clip(rgb, 0, None)
|
|
||||||
rgb = rgb / (rgb + 1)
|
|
||||||
self.linearToSRGB(rgb)
|
|
||||||
rgb = np.clip(rgb, 0, 1)
|
|
||||||
|
|
||||||
rgb = torch.unsqueeze(torch.from_numpy(rgb), 0)
|
|
||||||
|
|
||||||
# Handle alpha/mask
|
|
||||||
mask = torch.zeros((1, image.shape[0], image.shape[1]), dtype=torch.float32)
|
|
||||||
if image.shape[2] > 3:
|
|
||||||
mask[0] = torch.from_numpy(np.clip(image[:,:,3], 0, 1))
|
|
||||||
|
|
||||||
return (rgb, mask)
|
|
||||||
else:
|
|
||||||
# Return defaults if no file provided
|
|
||||||
return (default_image, default_mask)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error loading EXR: {str(e)}")
|
|
||||||
# Return defaults on error
|
|
||||||
return (default_image, default_mask)
|
|
||||||
|
|
||||||
NODE_CLASS_MAPPINGS = {
|
|
||||||
"ComfyUIDeployExternalEXR": ComfyUIDeployExternalEXR
|
|
||||||
}
|
|
||||||
|
|
||||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
||||||
"ComfyUIDeployExternalEXR": "External EXR (ComfyUI Deploy)"
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import os
|
||||||
|
os.environ["OPENCV_IO_ENABLE_OPENEXR"] = "1"
|
||||||
|
import cv2 as cv
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
from folder_paths import get_annotated_filepath
|
||||||
|
|
||||||
|
def linear_to_srgb(np_array):
|
||||||
|
"""Converts a linear RGB numpy array to sRGB."""
|
||||||
|
less = np_array <= 0.0031308
|
||||||
|
np_array[less] = np_array[less] * 12.92
|
||||||
|
np_array[~less] = np.power(np_array[~less], 1/2.4) * 1.055 - 0.055
|
||||||
|
return np_array
|
||||||
|
|
||||||
|
class ExternalExrInput:
|
||||||
|
"""
|
||||||
|
Node to load a single EXR image from a local file path.
|
||||||
|
"""
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"exr_file": ("STRING", {"default": "path/to/image.exr"}),
|
||||||
|
"tonemap": (["linear", "sRGB", "Reinhard"], {"default": "sRGB"}),
|
||||||
|
},
|
||||||
|
"optional": {
|
||||||
|
"default_image": ("IMAGE",),
|
||||||
|
"default_mask": ("MASK",),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RETURN_TYPES = ("IMAGE", "MASK")
|
||||||
|
RETURN_NAMES = ("image", "mask",)
|
||||||
|
FUNCTION = "run"
|
||||||
|
CATEGORY = "🔗ComfyDeploy/EXR"
|
||||||
|
|
||||||
|
def run(self, exr_file, tonemap, default_image=None, default_mask=None):
|
||||||
|
image = None
|
||||||
|
try:
|
||||||
|
if exr_file and exr_file.strip() != "":
|
||||||
|
exr_path = get_annotated_filepath(exr_file)
|
||||||
|
if os.path.exists(exr_path):
|
||||||
|
image = cv.imread(exr_path, cv.IMREAD_UNCHANGED).astype(np.float32)
|
||||||
|
else:
|
||||||
|
print(f"Warning: File not found at {exr_path}")
|
||||||
|
|
||||||
|
if image is None:
|
||||||
|
raise ValueError("Image could not be loaded.")
|
||||||
|
|
||||||
|
if len(image.shape) == 2: # Grayscale
|
||||||
|
image = np.repeat(image[..., np.newaxis], 3, axis=2)
|
||||||
|
|
||||||
|
rgb = np.flip(image[:, :, :3], 2).copy() # BGR to RGB
|
||||||
|
|
||||||
|
# Apply tonemapping
|
||||||
|
if tonemap == "sRGB":
|
||||||
|
rgb = linear_to_srgb(rgb)
|
||||||
|
rgb = np.clip(rgb, 0, 1)
|
||||||
|
elif tonemap == "Reinhard":
|
||||||
|
rgb = np.clip(rgb, 0, None)
|
||||||
|
rgb = rgb / (rgb + 1)
|
||||||
|
rgb = linear_to_srgb(rgb)
|
||||||
|
rgb = np.clip(rgb, 0, 1)
|
||||||
|
|
||||||
|
rgb_tensor = torch.from_numpy(rgb).unsqueeze(0)
|
||||||
|
|
||||||
|
# Handle alpha/mask
|
||||||
|
if image.shape[2] > 3:
|
||||||
|
mask = np.clip(image[:, :, 3], 0, 1)
|
||||||
|
else:
|
||||||
|
mask = np.ones_like(rgb[:, :, 0])
|
||||||
|
mask_tensor = torch.from_numpy(mask).unsqueeze(0)
|
||||||
|
|
||||||
|
return (rgb_tensor, mask_tensor)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error loading EXR file '{exr_file}': {e}")
|
||||||
|
if default_image is not None and default_mask is not None:
|
||||||
|
print("Returning default image.")
|
||||||
|
return (default_image, default_mask)
|
||||||
|
|
||||||
|
print("Warning: Error loading EXR and no default image. Returning a black image.")
|
||||||
|
blank_image = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
|
||||||
|
blank_mask = torch.zeros((1, 64, 64), dtype=torch.float32)
|
||||||
|
return (blank_image, blank_mask)
|
||||||
|
|
||||||
|
NODE_CLASS_MAPPINGS = {
|
||||||
|
"ExternalExrInput": ExternalExrInput
|
||||||
|
}
|
||||||
|
|
||||||
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||||
|
"ExternalExrInput": "External EXR Input (ComfyDeploy)"
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import os
|
||||||
|
os.environ["OPENCV_IO_ENABLE_OPENEXR"] = "1"
|
||||||
|
import cv2 as cv
|
||||||
|
import torch
|
||||||
|
import numpy as np
|
||||||
|
import folder_paths
|
||||||
|
|
||||||
|
def srgb_to_linear(np_array):
|
||||||
|
"""Converts an sRGB numpy array to linear RGB."""
|
||||||
|
less = np_array <= 0.0404482362771082
|
||||||
|
np_array[less] = np_array[less] / 12.92
|
||||||
|
np_array[~less] = np.power((np_array[~less] + 0.055) / 1.055, 2.4)
|
||||||
|
return np_array
|
||||||
|
|
||||||
|
class ExternalExrOutput:
|
||||||
|
"""
|
||||||
|
Node to save a single image as an EXR file to a local path.
|
||||||
|
"""
|
||||||
|
def __init__(self):
|
||||||
|
self.output_dir = folder_paths.get_output_directory()
|
||||||
|
self.type = "output"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"images": ("IMAGE",),
|
||||||
|
"filepath": ("STRING", {"default": "/tmp/output.exr"}),
|
||||||
|
"tonemap": (["linear", "sRGB"], {"default": "linear"}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
RETURN_TYPES = ()
|
||||||
|
FUNCTION = "run"
|
||||||
|
OUTPUT_NODE = True
|
||||||
|
CATEGORY = "🔗ComfyDeploy/EXR"
|
||||||
|
|
||||||
|
def run(self, images, filepath, tonemap):
|
||||||
|
if not filepath.endswith(".exr"):
|
||||||
|
raise ValueError("Filepath must end with '.exr'")
|
||||||
|
|
||||||
|
output_dir = os.path.dirname(filepath)
|
||||||
|
if not os.path.isabs(output_dir):
|
||||||
|
raise ValueError("Filepath must be an absolute path.")
|
||||||
|
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# We only process the first image in the batch
|
||||||
|
image_tensor = images[0]
|
||||||
|
|
||||||
|
linear = image_tensor.cpu().numpy().astype(np.float32)
|
||||||
|
|
||||||
|
# If the source is sRGB, convert to linear
|
||||||
|
if tonemap == "sRGB":
|
||||||
|
linear[...,:3] = srgb_to_linear(linear[...,:3])
|
||||||
|
|
||||||
|
# Convert RGB to BGR for OpenCV
|
||||||
|
bgr = np.flip(linear, 2).copy()
|
||||||
|
|
||||||
|
# Save the image
|
||||||
|
cv.imwrite(filepath, bgr)
|
||||||
|
|
||||||
|
print(f"Saved EXR file to: {filepath}")
|
||||||
|
|
||||||
|
return {"ui": {"images": [{"filename": os.path.basename(filepath), "subfolder": os.path.dirname(filepath), "type": self.type}]}}
|
||||||
|
|
||||||
|
NODE_CLASS_MAPPINGS = {
|
||||||
|
"ExternalExrOutput": ExternalExrOutput
|
||||||
|
}
|
||||||
|
|
||||||
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||||
|
"ExternalExrOutput": "External EXR Output (ComfyDeploy)"
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import os
|
||||||
|
os.environ["OPENCV_IO_ENABLE_OPENEXR"] = "1"
|
||||||
|
import cv2 as cv
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
import re
|
||||||
|
from folder_paths import get_annotated_filepath
|
||||||
|
|
||||||
|
def linear_to_srgb(np_array):
|
||||||
|
"""Converts a linear RGB numpy array to sRGB."""
|
||||||
|
less = np_array <= 0.0031308
|
||||||
|
np_array[less] = np_array[less] * 12.92
|
||||||
|
np_array[~less] = np.power(np_array[~less], 1/2.4) * 1.055 - 0.055
|
||||||
|
return np_array
|
||||||
|
|
||||||
|
class ExternalExrSequenceInput:
|
||||||
|
"""
|
||||||
|
Node to load a sequence of EXR images from a local filepath pattern, a directory,
|
||||||
|
or a single file within a sequence.
|
||||||
|
"""
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"path_or_pattern": ("STRING", {"default": "path/to/frames_or_pattern"}),
|
||||||
|
"tonemap": (["linear", "sRGB", "Reinhard"], {"default": "sRGB"}),
|
||||||
|
"start_frame": ("INT", {"default": 1, "min": 1}),
|
||||||
|
"end_frame": ("INT", {"default": 50, "min": 1}),
|
||||||
|
},
|
||||||
|
"optional": {
|
||||||
|
"default_image": ("IMAGE",),
|
||||||
|
"default_mask": ("MASK",),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RETURN_TYPES = ("IMAGE", "MASK")
|
||||||
|
RETURN_NAMES = ("image", "mask",)
|
||||||
|
FUNCTION = "run"
|
||||||
|
CATEGORY = "🔗ComfyDeploy/EXR"
|
||||||
|
|
||||||
|
def get_image_paths(self, path_input, start_frame, end_frame):
|
||||||
|
image_paths = []
|
||||||
|
|
||||||
|
# Case 1: Input is a C-style pattern
|
||||||
|
if '%' in path_input:
|
||||||
|
print(f"Pattern detected: {path_input}")
|
||||||
|
for i in range(start_frame, end_frame + 1):
|
||||||
|
fpath = get_annotated_filepath(path_input % i)
|
||||||
|
if os.path.exists(fpath):
|
||||||
|
image_paths.append(fpath)
|
||||||
|
return image_paths
|
||||||
|
|
||||||
|
annotated_path = get_annotated_filepath(path_input)
|
||||||
|
|
||||||
|
# Case 2: Input is a directory
|
||||||
|
if os.path.isdir(annotated_path):
|
||||||
|
print(f"Directory detected: {annotated_path}")
|
||||||
|
files_in_dir = sorted(os.listdir(annotated_path))
|
||||||
|
for filename in files_in_dir:
|
||||||
|
if not filename.lower().endswith('.exr'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
matches = re.findall(r'\d+', filename)
|
||||||
|
if not matches:
|
||||||
|
continue
|
||||||
|
|
||||||
|
frame_number = int(matches[-1])
|
||||||
|
if start_frame <= frame_number <= end_frame:
|
||||||
|
image_paths.append(os.path.join(annotated_path, filename))
|
||||||
|
return image_paths
|
||||||
|
|
||||||
|
# Case 3: Input is a single file from a sequence
|
||||||
|
if os.path.isfile(annotated_path):
|
||||||
|
print(f"Single file detected: {annotated_path}. Attempting to find sequence.")
|
||||||
|
base_dir = os.path.dirname(annotated_path)
|
||||||
|
filename = os.path.basename(annotated_path)
|
||||||
|
|
||||||
|
matches = list(re.finditer(r'(\d+)', filename))
|
||||||
|
if not matches: # It's a single file with no frame number
|
||||||
|
return [annotated_path]
|
||||||
|
|
||||||
|
last_match = matches[-1]
|
||||||
|
num_start_pos, num_end_pos = last_match.span()
|
||||||
|
prefix = filename[:num_start_pos]
|
||||||
|
suffix = filename[num_end_pos:]
|
||||||
|
padding = len(last_match.group(0))
|
||||||
|
|
||||||
|
for i in range(start_frame, end_frame + 1):
|
||||||
|
potential_filename = f"{prefix}{str(i).zfill(padding)}{suffix}"
|
||||||
|
potential_path = os.path.join(base_dir, potential_filename)
|
||||||
|
if os.path.exists(potential_path):
|
||||||
|
image_paths.append(potential_path)
|
||||||
|
return image_paths
|
||||||
|
|
||||||
|
return [] # Return empty if no cases match
|
||||||
|
|
||||||
|
def run(self, path_or_pattern, tonemap, start_frame, end_frame, default_image=None, default_mask=None):
|
||||||
|
try:
|
||||||
|
image_paths = self.get_image_paths(path_or_pattern, start_frame, end_frame)
|
||||||
|
if not image_paths:
|
||||||
|
raise ValueError(f"No EXR files found for '{path_or_pattern}' between frames {start_frame}-{end_frame}.")
|
||||||
|
|
||||||
|
print(f"Found {len(image_paths)} EXR files to load.")
|
||||||
|
rgb_frames = []
|
||||||
|
mask_frames = []
|
||||||
|
|
||||||
|
for path in image_paths:
|
||||||
|
image = cv.imread(path, cv.IMREAD_UNCHANGED)
|
||||||
|
if image is None:
|
||||||
|
print(f"Warning: Could not read file {path}, skipping.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
image = image.astype(np.float32)
|
||||||
|
if len(image.shape) == 2:
|
||||||
|
image = np.repeat(image[..., np.newaxis], 3, axis=2)
|
||||||
|
|
||||||
|
rgb = np.flip(image[:, :, :3], 2).copy()
|
||||||
|
|
||||||
|
if tonemap == "sRGB":
|
||||||
|
rgb = linear_to_srgb(rgb)
|
||||||
|
rgb = np.clip(rgb, 0, 1)
|
||||||
|
elif tonemap == "Reinhard":
|
||||||
|
rgb = np.clip(rgb, 0, None)
|
||||||
|
rgb = rgb / (rgb + 1)
|
||||||
|
rgb = linear_to_srgb(rgb)
|
||||||
|
rgb = np.clip(rgb, 0, 1)
|
||||||
|
|
||||||
|
rgb_frames.append(torch.from_numpy(rgb))
|
||||||
|
|
||||||
|
if image.shape[2] > 3:
|
||||||
|
mask = np.clip(image[:, :, 3], 0, 1)
|
||||||
|
else:
|
||||||
|
mask = np.ones_like(rgb[:, :, 0])
|
||||||
|
mask_frames.append(torch.from_numpy(mask))
|
||||||
|
|
||||||
|
if not rgb_frames:
|
||||||
|
raise ValueError("No frames were loaded successfully.")
|
||||||
|
|
||||||
|
print(f"Successfully loaded {len(rgb_frames)} frames into a batch.")
|
||||||
|
return (torch.stack(rgb_frames, 0), torch.stack(mask_frames, 0))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error loading EXR sequence: {e}")
|
||||||
|
if default_image is not None and default_mask is not None:
|
||||||
|
print("Returning default image.")
|
||||||
|
return (default_image, default_mask)
|
||||||
|
|
||||||
|
print("Warning: Error loading sequence and no default image. Returning a black image.")
|
||||||
|
blank_image = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
|
||||||
|
blank_mask = torch.zeros((1, 64, 64), dtype=torch.float32)
|
||||||
|
return (blank_image, blank_mask)
|
||||||
|
|
||||||
|
NODE_CLASS_MAPPINGS = {
|
||||||
|
"ExternalExrSequenceInput": ExternalExrSequenceInput
|
||||||
|
}
|
||||||
|
|
||||||
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||||
|
"ExternalExrSequenceInput": "External EXR Sequence Input (ComfyDeploy)"
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import os
|
||||||
|
os.environ["OPENCV_IO_ENABLE_OPENEXR"] = "1"
|
||||||
|
import cv2 as cv
|
||||||
|
import torch
|
||||||
|
import numpy as np
|
||||||
|
import re
|
||||||
|
|
||||||
|
def srgb_to_linear(np_array):
|
||||||
|
"""Converts an sRGB numpy array to linear RGB."""
|
||||||
|
less = np_array <= 0.0404482362771082
|
||||||
|
np_array[less] = np_array[less] / 12.92
|
||||||
|
np_array[~less] = np.power((np_array[~less] + 0.055) / 1.055, 2.4)
|
||||||
|
return np_array
|
||||||
|
|
||||||
|
class ExternalExrSequenceOutput:
|
||||||
|
"""
|
||||||
|
Node to save a sequence of images as EXR files to a local directory.
|
||||||
|
It uses a filepath pattern like 'path/to/frame_%04d.exr' to save each frame.
|
||||||
|
"""
|
||||||
|
def __init__(self):
|
||||||
|
self.type = "output"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"images": ("IMAGE",),
|
||||||
|
"filepath_pattern": ("STRING", {"default": "/tmp/exr_sequence/frame_%04d.exr"}),
|
||||||
|
"tonemap": (["linear", "sRGB"], {"default": "linear"}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
RETURN_TYPES = ()
|
||||||
|
FUNCTION = "run"
|
||||||
|
OUTPUT_NODE = True
|
||||||
|
CATEGORY = "🔗ComfyDeploy/EXR"
|
||||||
|
|
||||||
|
def run(self, images, filepath_pattern, tonemap):
|
||||||
|
# Basic validation for the filepath pattern
|
||||||
|
if not re.search(r'%0?\d+d', filepath_pattern):
|
||||||
|
raise ValueError("Filepath pattern must contain a C-style format specifier like '%04d'.")
|
||||||
|
|
||||||
|
if not filepath_pattern.endswith(".exr"):
|
||||||
|
raise ValueError("Filepath pattern must end with '.exr'.")
|
||||||
|
|
||||||
|
output_dir = os.path.dirname(filepath_pattern)
|
||||||
|
if not os.path.isabs(output_dir):
|
||||||
|
raise ValueError("Filepath must be an absolute path.")
|
||||||
|
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# Convert tensor to numpy array
|
||||||
|
linear_images = images.cpu().numpy().astype(np.float32)
|
||||||
|
|
||||||
|
# If the source is sRGB, convert to linear
|
||||||
|
if tonemap == "sRGB":
|
||||||
|
srgb_to_linear(linear_images[...,:3])
|
||||||
|
|
||||||
|
# Convert RGB to BGR for OpenCV
|
||||||
|
bgr_images = np.flip(linear_images, 3).copy()
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for i, bgr_image in enumerate(bgr_images):
|
||||||
|
frame_num = i + 1
|
||||||
|
try:
|
||||||
|
# Use the pattern to format the full file path
|
||||||
|
file_path = filepath_pattern % frame_num
|
||||||
|
except TypeError:
|
||||||
|
raise ValueError("Invalid format specifier in filepath_pattern. Use '%d', '%04d', etc.")
|
||||||
|
|
||||||
|
# Save the image
|
||||||
|
cv.imwrite(file_path, bgr_image)
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
"filename": os.path.basename(file_path),
|
||||||
|
"subfolder": os.path.dirname(file_path),
|
||||||
|
"type": self.type,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"ui": {"images": results}}
|
||||||
|
|
||||||
|
NODE_CLASS_MAPPINGS = {
|
||||||
|
"ExternalExrSequenceOutput": ExternalExrSequenceOutput
|
||||||
|
}
|
||||||
|
|
||||||
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||||
|
"ExternalExrSequenceOutput": "External EXR Sequence Output (ComfyDeploy)"
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import os
|
||||||
|
os.environ["OPENCV_IO_ENABLE_OPENEXR"] = "1"
|
||||||
|
import cv2 as cv
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
import requests
|
||||||
|
|
||||||
|
def linear_to_srgb(np_array):
|
||||||
|
"""Converts a linear RGB numpy array to sRGB."""
|
||||||
|
less = np_array <= 0.0031308
|
||||||
|
np_array[less] = np_array[less] * 12.92
|
||||||
|
np_array[~less] = np.power(np_array[~less], 1/2.4) * 1.055 - 0.055
|
||||||
|
return np_array
|
||||||
|
|
||||||
|
class HttpExrInput:
|
||||||
|
"""
|
||||||
|
Node to load a single EXR image from a URL, with optional tonemapping.
|
||||||
|
This node is designed to be used in a ComfyDeploy environment where input files are provided via signed URLs.
|
||||||
|
"""
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"get_signed_url": ("STRING", {"multiline": True, "default": ""}),
|
||||||
|
"tonemap": (["linear", "sRGB", "Reinhard"], {"default": "sRGB"}),
|
||||||
|
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}),
|
||||||
|
},
|
||||||
|
"optional": {
|
||||||
|
"default_image": ("IMAGE",),
|
||||||
|
"default_mask": ("MASK",),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
RETURN_TYPES = ("IMAGE", "MASK")
|
||||||
|
RETURN_NAMES = ("image", "mask",)
|
||||||
|
FUNCTION = "run"
|
||||||
|
CATEGORY = "🔗ComfyDeploy/EXR"
|
||||||
|
|
||||||
|
def load_exr_from_data(self, exr_data):
|
||||||
|
try:
|
||||||
|
nparr = np.frombuffer(exr_data, np.uint8)
|
||||||
|
# Use cv.IMREAD_UNCHANGED to keep all channels (e.g., alpha)
|
||||||
|
image = cv.imdecode(nparr, cv.IMREAD_UNCHANGED)
|
||||||
|
if image is None:
|
||||||
|
raise ValueError("Failed to decode EXR data.")
|
||||||
|
return image.astype(np.float32)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error decoding EXR data: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def run(self, get_signed_url, tonemap, seed, default_image=None, default_mask=None):
|
||||||
|
if not get_signed_url or get_signed_url.strip() == "":
|
||||||
|
print("Warning: No input URL provided. Returning default image if available.")
|
||||||
|
if default_image is not None and default_mask is not None:
|
||||||
|
return (default_image, default_mask)
|
||||||
|
|
||||||
|
print("Warning: No input URL and no default image. Returning a black image.")
|
||||||
|
blank_image = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
|
||||||
|
blank_mask = torch.zeros((1, 64, 64), dtype=torch.float32)
|
||||||
|
return (blank_image, blank_mask)
|
||||||
|
|
||||||
|
image = None
|
||||||
|
try:
|
||||||
|
print(f"Fetching EXR from URL: {get_signed_url}")
|
||||||
|
response = requests.get(get_signed_url)
|
||||||
|
response.raise_for_status()
|
||||||
|
image = self.load_exr_from_data(response.content)
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
print(f"Error fetching EXR from URL {get_signed_url}: {e}")
|
||||||
|
|
||||||
|
if image is None:
|
||||||
|
print("Warning: Could not load or decode EXR image. Returning default image if available.")
|
||||||
|
if default_image is not None and default_mask is not None:
|
||||||
|
return (default_image, default_mask)
|
||||||
|
|
||||||
|
print("Warning: Failed to load EXR and no default image. Returning a black image.")
|
||||||
|
blank_image = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
|
||||||
|
blank_mask = torch.zeros((1, 64, 64), dtype=torch.float32)
|
||||||
|
return (blank_image, blank_mask)
|
||||||
|
|
||||||
|
# BGR to RGB conversion and channel handling
|
||||||
|
if len(image.shape) == 2: # Grayscale
|
||||||
|
image = np.repeat(image[..., np.newaxis], 3, axis=2)
|
||||||
|
|
||||||
|
rgb = np.flip(image[:, :, :3], 2).copy() # OpenCV loads as BGR, convert to RGB
|
||||||
|
|
||||||
|
# Tonemapping
|
||||||
|
if tonemap == "sRGB":
|
||||||
|
rgb = linear_to_srgb(rgb)
|
||||||
|
rgb = np.clip(rgb, 0, 1)
|
||||||
|
elif tonemap == "Reinhard":
|
||||||
|
rgb = np.clip(rgb, 0, None) # Ensure no negative values
|
||||||
|
rgb = rgb / (rgb + 1)
|
||||||
|
rgb = linear_to_srgb(rgb)
|
||||||
|
rgb = np.clip(rgb, 0, 1)
|
||||||
|
|
||||||
|
# Handle alpha channel if it exists
|
||||||
|
if image.shape[2] > 3:
|
||||||
|
mask = np.clip(image[:, :, 3], 0, 1)
|
||||||
|
else:
|
||||||
|
mask = np.ones_like(rgb[:, :, 0]) # Create a full white mask if no alpha
|
||||||
|
|
||||||
|
return (torch.from_numpy(rgb).unsqueeze(0), torch.from_numpy(mask).unsqueeze(0),)
|
||||||
|
|
||||||
|
NODE_CLASS_MAPPINGS = {
|
||||||
|
"HttpExrInput": HttpExrInput
|
||||||
|
}
|
||||||
|
|
||||||
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||||
|
"HttpExrInput": "HTTP EXR Input (ComfyDeploy)"
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import os
|
||||||
|
os.environ["OPENCV_IO_ENABLE_OPENEXR"] = "1"
|
||||||
|
import cv2 as cv
|
||||||
|
import torch
|
||||||
|
import numpy as np
|
||||||
|
import requests
|
||||||
|
|
||||||
|
def srgb_to_linear(np_array):
|
||||||
|
"""Converts an sRGB numpy array to linear RGB."""
|
||||||
|
less = np_array <= 0.0404482362771082
|
||||||
|
np_array[less] = np_array[less] / 12.92
|
||||||
|
np_array[~less] = np.power((np_array[~less] + 0.055) / 1.055, 2.4)
|
||||||
|
return np_array
|
||||||
|
|
||||||
|
class HttpExrOutput:
|
||||||
|
"""
|
||||||
|
Node to save a single EXR image to a pre-signed URL.
|
||||||
|
This node is designed for ComfyDeploy to upload the generated EXR file.
|
||||||
|
"""
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"images": ("IMAGE",),
|
||||||
|
"put_signed_url": ("STRING", {"multiline": True, "default": ""}),
|
||||||
|
"tonemap": (["linear", "sRGB"], {"default": "linear"}),
|
||||||
|
},
|
||||||
|
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
|
||||||
|
}
|
||||||
|
|
||||||
|
RETURN_TYPES = ()
|
||||||
|
FUNCTION = "run"
|
||||||
|
OUTPUT_NODE = True
|
||||||
|
CATEGORY = "ComfyDeploy/EXR"
|
||||||
|
|
||||||
|
def run(self, images, put_signed_url, tonemap, prompt=None, extra_pnginfo=None):
|
||||||
|
if not put_signed_url or put_signed_url.strip() == "":
|
||||||
|
print("Warning: No put_signed_url provided. Nothing will be uploaded.")
|
||||||
|
return {"ui": {"images": []}}
|
||||||
|
|
||||||
|
# We process only the first image of the batch
|
||||||
|
image_tensor = images[0]
|
||||||
|
|
||||||
|
# Convert tensor to numpy array, assuming it's in range [0, 1]
|
||||||
|
linear = image_tensor.cpu().numpy().astype(np.float32)
|
||||||
|
|
||||||
|
# If the source is sRGB, convert to linear
|
||||||
|
if tonemap == "sRGB":
|
||||||
|
linear[...,:3] = srgb_to_linear(linear[...,:3])
|
||||||
|
|
||||||
|
# Convert RGB to BGR for OpenCV
|
||||||
|
bgr = np.flip(linear, 2).copy()
|
||||||
|
|
||||||
|
results = []
|
||||||
|
try:
|
||||||
|
# Encode the image to the EXR format in memory
|
||||||
|
is_success, buffer = cv.imencode(".exr", bgr)
|
||||||
|
if not is_success:
|
||||||
|
raise Exception("Failed to encode image to EXR format.")
|
||||||
|
|
||||||
|
# Upload the image data to the pre-signed URL
|
||||||
|
response = requests.put(put_signed_url, data=buffer.tobytes(), headers={'Content-Type': 'image/x-exr'})
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
print(f"Successfully uploaded EXR to: {put_signed_url}")
|
||||||
|
# The UI can optionally display a link or confirmation
|
||||||
|
results.append({"url": put_signed_url, "output_id": "output_http_exr"})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error uploading EXR to signed URL: {e}")
|
||||||
|
|
||||||
|
return {"ui": {"images": results}}
|
||||||
|
|
||||||
|
NODE_CLASS_MAPPINGS = {
|
||||||
|
"HttpExrOutput": HttpExrOutput
|
||||||
|
}
|
||||||
|
|
||||||
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||||
|
"HttpExrOutput": "HTTP EXR Output (ComfyDeploy)"
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import os
|
||||||
|
os.environ["OPENCV_IO_ENABLE_OPENEXR"] = "1"
|
||||||
|
import cv2 as cv
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
|
||||||
|
def linear_to_srgb(np_array):
|
||||||
|
"""Converts a linear RGB numpy array to sRGB."""
|
||||||
|
less = np_array <= 0.0031308
|
||||||
|
np_array[less] = np_array[less] * 12.92
|
||||||
|
np_array[~less] = np.power(np_array[~less], 1/2.4) * 1.055 - 0.055
|
||||||
|
return np_array
|
||||||
|
|
||||||
|
class HttpExrSequenceInput:
|
||||||
|
"""
|
||||||
|
Node to load a sequence of EXR images from a list of URLs provided as a JSON string.
|
||||||
|
"""
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"urls_json": ("STRING", {"multiline": True, "default": "[]"}),
|
||||||
|
"tonemap": (["linear", "sRGB", "Reinhard"], {"default": "sRGB"}),
|
||||||
|
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}),
|
||||||
|
},
|
||||||
|
"optional": {
|
||||||
|
"default_image": ("IMAGE",),
|
||||||
|
"default_mask": ("MASK",),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
RETURN_TYPES = ("IMAGE", "MASK")
|
||||||
|
RETURN_NAMES = ("image", "mask",)
|
||||||
|
FUNCTION = "run"
|
||||||
|
CATEGORY = "🔗ComfyDeploy/EXR"
|
||||||
|
|
||||||
|
def load_exr_from_data(self, exr_data):
|
||||||
|
try:
|
||||||
|
nparr = np.frombuffer(exr_data, np.uint8)
|
||||||
|
image = cv.imdecode(nparr, cv.IMREAD_UNCHANGED)
|
||||||
|
if image is None:
|
||||||
|
raise ValueError("Failed to decode EXR data.")
|
||||||
|
return image.astype(np.float32)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error decoding EXR data: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def run(self, urls_json, tonemap, seed, default_image=None, default_mask=None):
|
||||||
|
try:
|
||||||
|
urls = json.loads(urls_json)
|
||||||
|
if not isinstance(urls, list) or not all(isinstance(u, str) for u in urls):
|
||||||
|
raise ValueError("urls_json must be a JSON array of URL strings.")
|
||||||
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
|
print(f"Error parsing urls_json: {e}. Using default image if available.")
|
||||||
|
urls = []
|
||||||
|
|
||||||
|
if not urls:
|
||||||
|
if default_image is not None and default_mask is not None:
|
||||||
|
return (default_image, default_mask)
|
||||||
|
|
||||||
|
print("Warning: No valid URLs and no default image. Returning a black image.")
|
||||||
|
blank_image = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
|
||||||
|
blank_mask = torch.zeros((1, 64, 64), dtype=torch.float32)
|
||||||
|
return (blank_image, blank_mask)
|
||||||
|
|
||||||
|
rgb_frames = []
|
||||||
|
mask_frames = []
|
||||||
|
|
||||||
|
for url in urls:
|
||||||
|
image = None
|
||||||
|
try:
|
||||||
|
print(f"Fetching EXR from URL: {url}")
|
||||||
|
response = requests.get(url)
|
||||||
|
response.raise_for_status()
|
||||||
|
image = self.load_exr_from_data(response.content)
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
print(f"Error fetching EXR from URL {url}: {e}")
|
||||||
|
|
||||||
|
if image is None:
|
||||||
|
print(f"Warning: Could not decode EXR from {url}. Skipping frame.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if len(image.shape) == 2: # Grayscale
|
||||||
|
image = np.repeat(image[..., np.newaxis], 3, axis=2)
|
||||||
|
|
||||||
|
rgb = np.flip(image[:, :, :3], 2).copy() # BGR to RGB
|
||||||
|
|
||||||
|
if tonemap == "sRGB":
|
||||||
|
rgb = linear_to_srgb(rgb)
|
||||||
|
rgb = np.clip(rgb, 0, 1)
|
||||||
|
elif tonemap == "Reinhard":
|
||||||
|
rgb = np.clip(rgb, 0, None)
|
||||||
|
rgb = rgb / (rgb + 1)
|
||||||
|
rgb = linear_to_srgb(rgb)
|
||||||
|
rgb = np.clip(rgb, 0, 1)
|
||||||
|
|
||||||
|
rgb_frames.append(torch.from_numpy(rgb))
|
||||||
|
|
||||||
|
if image.shape[2] > 3:
|
||||||
|
mask = np.clip(image[:, :, 3], 0, 1)
|
||||||
|
else:
|
||||||
|
mask = np.ones_like(rgb[:, :, 0])
|
||||||
|
mask_frames.append(torch.from_numpy(mask))
|
||||||
|
|
||||||
|
if not rgb_frames:
|
||||||
|
print("Could not load any frames. Returning default image if available.")
|
||||||
|
if default_image is not None and default_mask is not None:
|
||||||
|
return (default_image, default_mask)
|
||||||
|
|
||||||
|
print("Warning: Failed to load any frames and no default image. Returning a black image.")
|
||||||
|
blank_image = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
|
||||||
|
blank_mask = torch.zeros((1, 64, 64), dtype=torch.float32)
|
||||||
|
return (blank_image, blank_mask)
|
||||||
|
|
||||||
|
print(f"Loaded {len(rgb_frames)} frames successfully.")
|
||||||
|
return (torch.stack(rgb_frames, 0), torch.stack(mask_frames, 0))
|
||||||
|
|
||||||
|
NODE_CLASS_MAPPINGS = {
|
||||||
|
"HttpExrSequenceInput": HttpExrSequenceInput
|
||||||
|
}
|
||||||
|
|
||||||
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||||
|
"HttpExrSequenceInput": "HTTP EXR Sequence Input (ComfyDeploy)"
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import os
|
||||||
|
os.environ["OPENCV_IO_ENABLE_OPENEXR"] = "1"
|
||||||
|
import cv2 as cv
|
||||||
|
import torch
|
||||||
|
import numpy as np
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
|
||||||
|
def srgb_to_linear(np_array):
|
||||||
|
"""Converts an sRGB numpy array to linear RGB."""
|
||||||
|
less = np_array <= 0.0404482362771082
|
||||||
|
np_array[less] = np_array[less] / 12.92
|
||||||
|
np_array[~less] = np.power((np_array[~less] + 0.055) / 1.055, 2.4)
|
||||||
|
return np_array
|
||||||
|
|
||||||
|
class HttpExrSequenceOutput:
|
||||||
|
"""
|
||||||
|
Node to save a sequence of images as EXR files to a list of pre-signed URLs.
|
||||||
|
"""
|
||||||
|
def __init__(self):
|
||||||
|
self.type = "output"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"images": ("IMAGE",),
|
||||||
|
"upload_urls_json": ("STRING", {"multiline": True, "default": "[]"}),
|
||||||
|
"tonemap": (["linear", "sRGB"], {"default": "linear"}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
RETURN_TYPES = ()
|
||||||
|
FUNCTION = "run"
|
||||||
|
OUTPUT_NODE = True
|
||||||
|
CATEGORY = "🔗ComfyDeploy/EXR"
|
||||||
|
|
||||||
|
def run(self, images, upload_urls_json, tonemap):
|
||||||
|
try:
|
||||||
|
upload_urls = json.loads(upload_urls_json)
|
||||||
|
if not isinstance(upload_urls, list) or not all(isinstance(u, str) for u in upload_urls):
|
||||||
|
raise ValueError("upload_urls_json must be a JSON array of URL strings.")
|
||||||
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
|
print(f"Error parsing upload_urls_json: {e}. Aborting upload.")
|
||||||
|
return {"ui": {"images": []}}
|
||||||
|
|
||||||
|
if not upload_urls:
|
||||||
|
print("Warning: No upload URLs provided. Nothing will be uploaded.")
|
||||||
|
return {"ui": {"images": []}}
|
||||||
|
|
||||||
|
if len(images) != len(upload_urls):
|
||||||
|
print(f"Warning: Mismatch between number of images ({len(images)}) and upload URLs ({len(upload_urls)}). Aborting upload.")
|
||||||
|
return {"ui": {"images": []}}
|
||||||
|
|
||||||
|
# Convert tensor to numpy array
|
||||||
|
linear_images = images.cpu().numpy().astype(np.float32)
|
||||||
|
|
||||||
|
# If the source is sRGB, convert all images to linear
|
||||||
|
if tonemap == "sRGB":
|
||||||
|
srgb_to_linear(linear_images[...,:3])
|
||||||
|
|
||||||
|
# Convert RGB to BGR for OpenCV
|
||||||
|
bgr_images = np.flip(linear_images, 3).copy()
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for i, (bgr_image, url) in enumerate(zip(bgr_images, upload_urls)):
|
||||||
|
try:
|
||||||
|
# Encode the image to the EXR format in memory
|
||||||
|
is_success, buffer = cv.imencode(".exr", bgr_image)
|
||||||
|
if not is_success:
|
||||||
|
raise Exception("Failed to encode image to EXR format.")
|
||||||
|
|
||||||
|
# Upload the image data to the pre-signed URL
|
||||||
|
response = requests.put(url, data=buffer.tobytes(), headers={'Content-Type': 'image/x-exr'})
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
print(f"Successfully uploaded frame {i+1} to: {url}")
|
||||||
|
results.append({"url": url})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error uploading frame {i+1} to {url}: {e}")
|
||||||
|
|
||||||
|
return {"ui": {"images": results}}
|
||||||
|
|
||||||
|
NODE_CLASS_MAPPINGS = {
|
||||||
|
"HttpExrSequenceOutput": HttpExrSequenceOutput
|
||||||
|
}
|
||||||
|
|
||||||
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||||
|
"HttpExrSequenceOutput": "HTTP EXR Sequence Output (ComfyDeploy)"
|
||||||
|
}
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
# In file: comfyui-deploy/comfy-nodes/output_exr.py
|
|
||||||
|
|
||||||
import os
|
|
||||||
import numpy as np
|
|
||||||
import folder_paths
|
|
||||||
|
|
||||||
# Try to set up OpenCV for EXR writing.
|
|
||||||
try:
|
|
||||||
os.environ["OPENCV_IO_ENABLE_OPENEXR"] = "1"
|
|
||||||
import cv2
|
|
||||||
OPENCV_AVAILABLE = True
|
|
||||||
except ImportError:
|
|
||||||
print("Warning: OpenCV not found for ComfyDeployOutputEXR. Please add opencv-python-headless to requirements.txt")
|
|
||||||
OPENCV_AVAILABLE = False
|
|
||||||
|
|
||||||
# ALIGNED: Renamed class to match project conventions
|
|
||||||
class ComfyDeployOutputEXR:
|
|
||||||
def __init__(self):
|
|
||||||
self.output_dir = folder_paths.get_output_directory()
|
|
||||||
self.type = "output"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def INPUT_TYPES(s):
|
|
||||||
return {
|
|
||||||
"required": {
|
|
||||||
"images": ("IMAGE", ),
|
|
||||||
"filename_prefix": ("STRING", {"default": "ComfyDeploy_EXR"})
|
|
||||||
},
|
|
||||||
# ADDED: Optional output_id for consistency with other ComfyDeploy nodes
|
|
||||||
"optional": {
|
|
||||||
"output_id": ("STRING", {"multiline": False, "default": "output_exr"}),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
RETURN_TYPES = ()
|
|
||||||
# ALIGNED: Changed function name to 'run'
|
|
||||||
FUNCTION = "run"
|
|
||||||
OUTPUT_NODE = True
|
|
||||||
# ALIGNED: Matched the category name
|
|
||||||
CATEGORY = "🔗ComfyDeploy"
|
|
||||||
DESCRIPTION = "Saves the input images as EXR (HDR) files."
|
|
||||||
|
|
||||||
def run(self, images, filename_prefix="ComfyDeploy_EXR", output_id="output_exr"):
|
|
||||||
if not OPENCV_AVAILABLE:
|
|
||||||
raise ImportError("OpenCV is required to save EXR files. Please ensure opencv-python-headless is in requirements.txt.")
|
|
||||||
|
|
||||||
full_output_folder, filename, counter, subfolder, filename_prefix = (
|
|
||||||
folder_paths.get_save_image_path(
|
|
||||||
filename_prefix, self.output_dir, images[0].shape[1], images[0].shape[0]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
results = list()
|
|
||||||
|
|
||||||
for image in images:
|
|
||||||
image_np = image.cpu().numpy()
|
|
||||||
|
|
||||||
if image_np.dtype != np.float32:
|
|
||||||
image_np = image_np.astype(np.float32)
|
|
||||||
|
|
||||||
file = f"{filename}_{counter:05}.exr"
|
|
||||||
file_path = os.path.join(full_output_folder, file)
|
|
||||||
|
|
||||||
image_np_bgr = cv2.cvtColor(image_np, cv2.COLOR_RGB2BGR)
|
|
||||||
cv2.imwrite(file_path, image_np_bgr)
|
|
||||||
|
|
||||||
results.append({
|
|
||||||
"filename": file,
|
|
||||||
"subfolder": subfolder,
|
|
||||||
"type": self.type,
|
|
||||||
"output_id": output_id, # ADDED
|
|
||||||
})
|
|
||||||
counter += 1
|
|
||||||
|
|
||||||
return {"ui": {"images": results}}
|
|
||||||
|
|
||||||
# ALIGNED: Mappings are defined at the bottom of the node file in this project
|
|
||||||
NODE_CLASS_MAPPINGS = {"ComfyDeployOutputEXR": ComfyDeployOutputEXR}
|
|
||||||
NODE_DISPLAY_NAME_MAPPINGS = {"ComfyDeployOutputEXR": "EXR Output (ComfyDeploy)"}
|
|
||||||
+80
-477
@@ -31,7 +31,6 @@ import torch
|
|||||||
import psutil
|
import psutil
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
import io
|
import io
|
||||||
from urllib.parse import urlencode
|
|
||||||
|
|
||||||
# Global session
|
# Global session
|
||||||
client_session = None
|
client_session = None
|
||||||
@@ -265,7 +264,7 @@ def clear_current_prompt(sid):
|
|||||||
streaming_prompt_metadata[sid].running_prompt_ids.clear()
|
streaming_prompt_metadata[sid].running_prompt_ids.clear()
|
||||||
|
|
||||||
|
|
||||||
async def post_prompt(json_data):
|
def post_prompt(json_data):
|
||||||
prompt_server = server.PromptServer.instance
|
prompt_server = server.PromptServer.instance
|
||||||
json_data = prompt_server.trigger_on_prompt(json_data)
|
json_data = prompt_server.trigger_on_prompt(json_data)
|
||||||
|
|
||||||
@@ -281,48 +280,7 @@ async def post_prompt(json_data):
|
|||||||
|
|
||||||
if "prompt" in json_data:
|
if "prompt" in json_data:
|
||||||
prompt = json_data["prompt"]
|
prompt = json_data["prompt"]
|
||||||
prompt_id = json_data.get("prompt_id") or str(uuid.uuid4())
|
valid = execution.validate_prompt(prompt)
|
||||||
|
|
||||||
partial_execution_targets = None
|
|
||||||
if "partial_execution_targets" in json_data:
|
|
||||||
partial_execution_targets = json_data["partial_execution_targets"]
|
|
||||||
|
|
||||||
# Handle different validate_prompt signatures (newest to oldest)
|
|
||||||
valid = None
|
|
||||||
last_error = None
|
|
||||||
|
|
||||||
# v0.3.48 (3 args)
|
|
||||||
try:
|
|
||||||
valid = await execution.validate_prompt(
|
|
||||||
prompt_id, prompt, partial_execution_targets
|
|
||||||
)
|
|
||||||
except TypeError as e:
|
|
||||||
last_error = e
|
|
||||||
logger.debug(
|
|
||||||
f"validate_prompt with 3 params not supported, trying with 2. Debug: {last_error}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# v0.3.45 - 0.3.47 (2 args)
|
|
||||||
if valid is None:
|
|
||||||
try:
|
|
||||||
valid = await execution.validate_prompt(prompt_id, prompt)
|
|
||||||
except TypeError as e:
|
|
||||||
last_error = e
|
|
||||||
logger.debug(
|
|
||||||
f"validate_prompt with 2 params not supported, trying legacy signature. Debug: {last_error}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# v0.3.44 or older (1 arg)
|
|
||||||
if valid is None:
|
|
||||||
try:
|
|
||||||
valid = execution.validate_prompt(prompt)
|
|
||||||
except TypeError as e:
|
|
||||||
last_error = e
|
|
||||||
logger.error(
|
|
||||||
f"validate_prompt failed with all signatures. Last error: {last_error}"
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
|
|
||||||
extra_data = {}
|
extra_data = {}
|
||||||
if "extra_data" in json_data:
|
if "extra_data" in json_data:
|
||||||
extra_data = json_data["extra_data"]
|
extra_data = json_data["extra_data"]
|
||||||
@@ -333,6 +291,8 @@ async def post_prompt(json_data):
|
|||||||
if "client_id" in json_data:
|
if "client_id" in json_data:
|
||||||
extra_data["client_id"] = json_data["client_id"]
|
extra_data["client_id"] = json_data["client_id"]
|
||||||
if valid[0]:
|
if valid[0]:
|
||||||
|
# if the prompt id is provided
|
||||||
|
prompt_id = json_data.get("prompt_id") or str(uuid.uuid4())
|
||||||
outputs_to_execute = valid[2]
|
outputs_to_execute = valid[2]
|
||||||
prompt_server.prompt_queue.put(
|
prompt_server.prompt_queue.put(
|
||||||
(number, prompt_id, prompt, extra_data, outputs_to_execute)
|
(number, prompt_id, prompt, extra_data, outputs_to_execute)
|
||||||
@@ -539,15 +499,15 @@ def send_prompt(sid: str, inputs: StreamingPrompt):
|
|||||||
|
|
||||||
prompt_id = str(uuid.uuid4())
|
prompt_id = str(uuid.uuid4())
|
||||||
|
|
||||||
# prompt = {
|
prompt = {
|
||||||
# "prompt": workflow_api,
|
"prompt": workflow_api,
|
||||||
# "client_id": sid, # "comfy_deploy_instance", #api.client_id
|
"client_id": sid, # "comfy_deploy_instance", #api.client_id
|
||||||
# "prompt_id": prompt_id,
|
"prompt_id": prompt_id,
|
||||||
# "extra_data": {"extra_pnginfo": {"workflow": workflow}},
|
"extra_data": {"extra_pnginfo": {"workflow": workflow}},
|
||||||
# }
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# res = post_prompt(prompt)
|
res = post_prompt(prompt)
|
||||||
inputs.running_prompt_ids.add(prompt_id)
|
inputs.running_prompt_ids.add(prompt_id)
|
||||||
prompt_metadata[prompt_id] = SimplePrompt(
|
prompt_metadata[prompt_id] = SimplePrompt(
|
||||||
status_endpoint=inputs.status_endpoint,
|
status_endpoint=inputs.status_endpoint,
|
||||||
@@ -558,7 +518,7 @@ def send_prompt(sid: str, inputs: StreamingPrompt):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_type = type(e).__name__
|
error_type = type(e).__name__
|
||||||
stack_trace_short = traceback.format_exc().strip().split("\n")[-2]
|
stack_trace_short = traceback.format_exc().strip().split("\n")[-2]
|
||||||
# stack_trace = traceback.format_exc().strip()
|
stack_trace = traceback.format_exc().strip()
|
||||||
logger.info(f"error: {error_type}, {e}")
|
logger.info(f"error: {error_type}, {e}")
|
||||||
logger.info(f"stack trace: {stack_trace_short}")
|
logger.info(f"stack trace: {stack_trace_short}")
|
||||||
|
|
||||||
@@ -634,7 +594,7 @@ async def comfy_deploy_run(request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
res = await post_prompt(prompt)
|
res = post_prompt(prompt)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_type = type(e).__name__
|
error_type = type(e).__name__
|
||||||
stack_trace_short = traceback.format_exc().strip().split("\n")[-2]
|
stack_trace_short = traceback.format_exc().strip().split("\n")[-2]
|
||||||
@@ -703,7 +663,7 @@ async def stream_prompt(data, token):
|
|||||||
# log('info', "Begin prompt", prompt=prompt)
|
# log('info', "Begin prompt", prompt=prompt)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
res = await post_prompt(prompt)
|
res = post_prompt(prompt)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_type = type(e).__name__
|
error_type = type(e).__name__
|
||||||
stack_trace_short = traceback.format_exc().strip().split("\n")[-2]
|
stack_trace_short = traceback.format_exc().strip().split("\n")[-2]
|
||||||
@@ -1305,11 +1265,22 @@ def handle_execute(class_type, last_node_id, prompt_id, server, unique_id):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
origin_execute = execution.execute
|
origin_execute = execution.execute
|
||||||
is_async = asyncio.iscoroutinefunction(origin_execute)
|
|
||||||
|
|
||||||
if is_async:
|
def swizzle_execute(
|
||||||
|
server,
|
||||||
async def swizzle_execute(
|
dynprompt,
|
||||||
|
caches,
|
||||||
|
current_item,
|
||||||
|
extra_data,
|
||||||
|
executed,
|
||||||
|
prompt_id,
|
||||||
|
execution_list,
|
||||||
|
pending_subgraph_results,
|
||||||
|
):
|
||||||
|
unique_id = current_item
|
||||||
|
class_type = dynprompt.get_node(unique_id)["class_type"]
|
||||||
|
last_node_id = server.last_node_id
|
||||||
|
result = origin_execute(
|
||||||
server,
|
server,
|
||||||
dynprompt,
|
dynprompt,
|
||||||
caches,
|
caches,
|
||||||
@@ -1319,61 +1290,12 @@ try:
|
|||||||
prompt_id,
|
prompt_id,
|
||||||
execution_list,
|
execution_list,
|
||||||
pending_subgraph_results,
|
pending_subgraph_results,
|
||||||
pending_async_nodes,
|
)
|
||||||
):
|
handle_execute(class_type, last_node_id, prompt_id, server, unique_id)
|
||||||
unique_id = current_item
|
return result
|
||||||
class_type = dynprompt.get_node(unique_id)["class_type"]
|
|
||||||
last_node_id = server.last_node_id
|
|
||||||
|
|
||||||
result = await origin_execute(
|
|
||||||
server,
|
|
||||||
dynprompt,
|
|
||||||
caches,
|
|
||||||
current_item,
|
|
||||||
extra_data,
|
|
||||||
executed,
|
|
||||||
prompt_id,
|
|
||||||
execution_list,
|
|
||||||
pending_subgraph_results,
|
|
||||||
pending_async_nodes,
|
|
||||||
)
|
|
||||||
|
|
||||||
handle_execute(class_type, last_node_id, prompt_id, server, unique_id)
|
|
||||||
return result
|
|
||||||
else:
|
|
||||||
|
|
||||||
def swizzle_execute(
|
|
||||||
server,
|
|
||||||
dynprompt,
|
|
||||||
caches,
|
|
||||||
current_item,
|
|
||||||
extra_data,
|
|
||||||
executed,
|
|
||||||
prompt_id,
|
|
||||||
execution_list,
|
|
||||||
pending_subgraph_results,
|
|
||||||
):
|
|
||||||
unique_id = current_item
|
|
||||||
class_type = dynprompt.get_node(unique_id)["class_type"]
|
|
||||||
last_node_id = server.last_node_id
|
|
||||||
|
|
||||||
result = origin_execute(
|
|
||||||
server,
|
|
||||||
dynprompt,
|
|
||||||
caches,
|
|
||||||
current_item,
|
|
||||||
extra_data,
|
|
||||||
executed,
|
|
||||||
prompt_id,
|
|
||||||
execution_list,
|
|
||||||
pending_subgraph_results,
|
|
||||||
)
|
|
||||||
|
|
||||||
handle_execute(class_type, last_node_id, prompt_id, server, unique_id)
|
|
||||||
return result
|
|
||||||
|
|
||||||
execution.execute = swizzle_execute
|
execution.execute = swizzle_execute
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@@ -1517,6 +1439,10 @@ async def send_json_override(self, event, data, sid=None):
|
|||||||
logger.info(format_table(headers, table_data))
|
logger.info(format_table(headers, table_data))
|
||||||
# print("========================\n")
|
# print("========================\n")
|
||||||
|
|
||||||
|
timeline = format_execution_timeline(NODE_EXECUTION_TIMES)
|
||||||
|
logger.info(f"\nNode Execution Timeline:\n{timeline}")
|
||||||
|
# Clear the execution times for the next run
|
||||||
|
|
||||||
# the last executing event is none, then the workflow is finished
|
# the last executing event is none, then the workflow is finished
|
||||||
if event == "executing" and data.get("node") is None:
|
if event == "executing" and data.get("node") is None:
|
||||||
mark_prompt_done(prompt_id=prompt_id)
|
mark_prompt_done(prompt_id=prompt_id)
|
||||||
@@ -2132,7 +2058,6 @@ async def upload_in_background(
|
|||||||
("model_file", "format", "application/octet-stream"),
|
("model_file", "format", "application/octet-stream"),
|
||||||
("result", "format", "application/octet-stream"),
|
("result", "format", "application/octet-stream"),
|
||||||
("text_file", "format", "text/plain"),
|
("text_file", "format", "text/plain"),
|
||||||
("audio", "format", "audio/mpeg"),
|
|
||||||
]:
|
]:
|
||||||
items = data.get(file_type, [])
|
items = data.get(file_type, [])
|
||||||
|
|
||||||
@@ -2220,7 +2145,6 @@ async def update_run_with_output(
|
|||||||
or "model_file" in data
|
or "model_file" in data
|
||||||
or "result" in data
|
or "result" in data
|
||||||
or "text_file" in data
|
or "text_file" in data
|
||||||
or "audio" in data
|
|
||||||
)
|
)
|
||||||
if bypass_upload and have_upload_media:
|
if bypass_upload and have_upload_media:
|
||||||
print(
|
print(
|
||||||
@@ -2829,57 +2753,53 @@ class UploadQueue:
|
|||||||
logger.error(f"Upload failed: {str(e)}")
|
logger.error(f"Upload failed: {str(e)}")
|
||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
finally:
|
finally:
|
||||||
async with self.lock: # Acquire lock to protect shared dict access
|
# Remove this upload from tracking
|
||||||
if prompt_id in self.pending_uploads:
|
if prompt_id in self.pending_uploads:
|
||||||
self.pending_uploads[prompt_id].discard(upload_id)
|
self.pending_uploads[prompt_id].discard(upload_id)
|
||||||
|
# Remove from node tracking if applicable
|
||||||
|
if (
|
||||||
|
node_id
|
||||||
|
and prompt_id in self.node_uploads
|
||||||
|
and node_id in self.node_uploads[prompt_id]
|
||||||
|
):
|
||||||
|
self.node_uploads[prompt_id][node_id].discard(upload_id)
|
||||||
|
|
||||||
if (
|
# If this was the last upload for this node, clean up node data
|
||||||
node_id
|
if not self.node_uploads[prompt_id][node_id]:
|
||||||
and prompt_id in self.node_uploads
|
del self.node_uploads[prompt_id][node_id]
|
||||||
and node_id in self.node_uploads[prompt_id]
|
if prompt_id in self.node_output_data:
|
||||||
):
|
if node_id in self.node_output_data[prompt_id]:
|
||||||
self.node_uploads[prompt_id][node_id].discard(upload_id)
|
if self.node_output_data[prompt_id][node_id]["data"]:
|
||||||
|
# Send final node data to API before cleanup
|
||||||
if not self.node_uploads[prompt_id][node_id]:
|
if prompt_metadata[prompt_id].status_endpoint:
|
||||||
del self.node_uploads[prompt_id][node_id]
|
body = {
|
||||||
|
"run_id": prompt_id,
|
||||||
if (
|
"output_data": self.node_output_data[
|
||||||
prompt_id in self.node_output_data
|
|
||||||
and node_id in self.node_output_data[prompt_id]
|
|
||||||
):
|
|
||||||
node_data = self.node_output_data[prompt_id][
|
|
||||||
node_id
|
|
||||||
]
|
|
||||||
if node_data["data"]:
|
|
||||||
body = {
|
|
||||||
"run_id": prompt_id,
|
|
||||||
"output_data": node_data["data"],
|
|
||||||
"node_meta": {"node_id": node_id},
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
await async_request_with_retry(
|
|
||||||
"POST",
|
|
||||||
prompt_metadata[
|
|
||||||
prompt_id
|
prompt_id
|
||||||
].status_endpoint,
|
][node_id]["data"],
|
||||||
token=prompt_metadata[
|
"node_meta": {"node_id": node_id},
|
||||||
prompt_id
|
}
|
||||||
].token,
|
try:
|
||||||
json=body,
|
await async_request_with_retry(
|
||||||
)
|
"POST",
|
||||||
except Exception as e:
|
prompt_metadata[
|
||||||
logger.error(
|
prompt_id
|
||||||
f"Failed to send final node data: {str(e)}"
|
].status_endpoint,
|
||||||
)
|
token=prompt_metadata[prompt_id].token,
|
||||||
|
json=body,
|
||||||
# Safe to delete now (re-check not strictly needed with lock, but harmless)
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Failed to send final node data: {str(e)}"
|
||||||
|
)
|
||||||
del self.node_output_data[prompt_id][node_id]
|
del self.node_output_data[prompt_id][node_id]
|
||||||
|
|
||||||
|
# Send status update
|
||||||
|
await self.update_queue_status(prompt_id)
|
||||||
|
|
||||||
# If no more pending uploads for this prompt and it's done, update status
|
# If no more pending uploads for this prompt and it's done, update status
|
||||||
if (
|
if not self.pending_uploads[prompt_id] and is_prompt_done(
|
||||||
prompt_id in self.pending_uploads
|
prompt_id
|
||||||
and not self.pending_uploads[prompt_id]
|
|
||||||
and is_prompt_done(prompt_id)
|
|
||||||
):
|
):
|
||||||
# Clean up all data for this prompt
|
# Clean up all data for this prompt
|
||||||
if prompt_id in self.node_uploads:
|
if prompt_id in self.node_uploads:
|
||||||
@@ -2892,12 +2812,9 @@ class UploadQueue:
|
|||||||
loop.create_task(update_run(prompt_id, Status.SUCCESS))
|
loop.create_task(update_run(prompt_id, Status.SUCCESS))
|
||||||
loop.create_task(send("success", {"prompt_id": prompt_id}))
|
loop.create_task(send("success", {"prompt_id": prompt_id}))
|
||||||
|
|
||||||
# Mark task as done (outside lock to avoid holding it unnecessarily)
|
# Mark task as done
|
||||||
self.queue.task_done()
|
self.queue.task_done()
|
||||||
|
|
||||||
# Send status update (also outside lock)
|
|
||||||
await self.update_queue_status(prompt_id)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in upload worker: {str(e)}")
|
logger.error(f"Error in upload worker: {str(e)}")
|
||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
@@ -2968,317 +2885,3 @@ def format_execution_timeline(execution_times):
|
|||||||
current_time += duration
|
current_time += duration
|
||||||
|
|
||||||
return format_table(headers, rows)
|
return format_table(headers, rows)
|
||||||
|
|
||||||
|
|
||||||
@server.PromptServer.instance.routes.get("/comfyui-deploy/auth-response")
|
|
||||||
async def auth_response_proxy(request):
|
|
||||||
request_id = request.rel_url.query.get("request_id")
|
|
||||||
api_url = request.rel_url.query.get("api_url", "https://api.comfydeploy.com")
|
|
||||||
|
|
||||||
if not request_id:
|
|
||||||
return web.json_response({"error": "request_id is required"}, status=400)
|
|
||||||
|
|
||||||
target_url = f"{api_url}/api/platform/comfyui/auth-response?request_id={request_id}"
|
|
||||||
|
|
||||||
try:
|
|
||||||
await ensure_client_session()
|
|
||||||
async with client_session.get(target_url) as response:
|
|
||||||
json_data = await response.json()
|
|
||||||
return web.json_response(json_data, status=response.status)
|
|
||||||
except Exception as e:
|
|
||||||
return web.json_response({"error": str(e)}, status=500)
|
|
||||||
|
|
||||||
|
|
||||||
@server.PromptServer.instance.routes.post("/comfyui-deploy/workflow")
|
|
||||||
async def create_workflow_proxy(request):
|
|
||||||
data = await request.json()
|
|
||||||
name = data.get("name")
|
|
||||||
workflow_json = data.get("workflow_json")
|
|
||||||
workflow_api = data.get("workflow_api")
|
|
||||||
machine_id = data.get("machine_id")
|
|
||||||
api_url = data.get("api_url", "https://api.comfydeploy.com")
|
|
||||||
|
|
||||||
auth_header = request.headers.get("Authorization")
|
|
||||||
if not auth_header:
|
|
||||||
return web.json_response(
|
|
||||||
{"error": "Authorization header is required"}, status=401
|
|
||||||
)
|
|
||||||
|
|
||||||
if not name or not workflow_json or not workflow_api:
|
|
||||||
return web.json_response(
|
|
||||||
{"error": "name, workflow_json, workflow_api are required"}, status=400
|
|
||||||
)
|
|
||||||
|
|
||||||
target_url = f"{api_url}/api/workflow"
|
|
||||||
|
|
||||||
request_body = {
|
|
||||||
"name": name,
|
|
||||||
"workflow_json": json.dumps(workflow_json),
|
|
||||||
"workflow_api": json.dumps(workflow_api),
|
|
||||||
"machine_id": machine_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
await ensure_client_session()
|
|
||||||
async with client_session.post(
|
|
||||||
target_url,
|
|
||||||
json=request_body,
|
|
||||||
headers={
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Authorization": auth_header,
|
|
||||||
},
|
|
||||||
) as response:
|
|
||||||
json_data = await response.json()
|
|
||||||
return web.json_response(json_data, status=response.status)
|
|
||||||
except Exception as e:
|
|
||||||
return web.json_response({"error": str(e)}, status=500)
|
|
||||||
|
|
||||||
|
|
||||||
@server.PromptServer.instance.routes.post("/comfyui-deploy/workflow/version")
|
|
||||||
async def create_workflow_version_proxy(request):
|
|
||||||
data = await request.json()
|
|
||||||
workflow_id = data.get("workflow_id")
|
|
||||||
workflow = data.get("workflow")
|
|
||||||
workflow_api = data.get("workflow_api")
|
|
||||||
comment = data.get("comment", "")
|
|
||||||
api_url = data.get("api_url", "https://api.comfydeploy.com")
|
|
||||||
|
|
||||||
auth_header = request.headers.get("Authorization")
|
|
||||||
|
|
||||||
if not auth_header:
|
|
||||||
return web.json_response(
|
|
||||||
{"error": "Authorization header is required"}, status=401
|
|
||||||
)
|
|
||||||
|
|
||||||
target_url = f"{api_url}/api/workflow/{workflow_id}/version"
|
|
||||||
|
|
||||||
request_body = {
|
|
||||||
"workflow": workflow,
|
|
||||||
"workflow_api": workflow_api,
|
|
||||||
"comment": comment,
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
await ensure_client_session()
|
|
||||||
async with client_session.post(
|
|
||||||
target_url,
|
|
||||||
json=request_body,
|
|
||||||
headers={
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Authorization": auth_header,
|
|
||||||
},
|
|
||||||
) as response:
|
|
||||||
json_data = await response.json()
|
|
||||||
return web.json_response(json_data, status=response.status)
|
|
||||||
except Exception as e:
|
|
||||||
return web.json_response({"error": str(e)}, status=500)
|
|
||||||
|
|
||||||
|
|
||||||
@server.PromptServer.instance.routes.get("/comfyui-deploy/workflows")
|
|
||||||
async def get_workflows_proxy(request):
|
|
||||||
api_url = request.rel_url.query.get("api_url", "https://api.comfydeploy.com")
|
|
||||||
search = request.rel_url.query.get("search", "")
|
|
||||||
limit = request.rel_url.query.get("limit", 10)
|
|
||||||
offset = request.rel_url.query.get("offset", 0)
|
|
||||||
auth_header = request.headers.get("Authorization")
|
|
||||||
|
|
||||||
if not auth_header:
|
|
||||||
return web.json_response(
|
|
||||||
{"error": "Authorization header is required"}, status=401
|
|
||||||
)
|
|
||||||
|
|
||||||
# Build query parameters properly
|
|
||||||
params = {}
|
|
||||||
if search:
|
|
||||||
params["search"] = search
|
|
||||||
if limit:
|
|
||||||
params["limit"] = limit
|
|
||||||
if offset:
|
|
||||||
params["offset"] = offset
|
|
||||||
|
|
||||||
target_url = f"{api_url}/api/workflows"
|
|
||||||
if params:
|
|
||||||
target_url += f"?{urlencode(params)}"
|
|
||||||
|
|
||||||
try:
|
|
||||||
await ensure_client_session()
|
|
||||||
async with client_session.get(
|
|
||||||
target_url,
|
|
||||||
headers={
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Authorization": auth_header,
|
|
||||||
},
|
|
||||||
) as response:
|
|
||||||
json_data = await response.json()
|
|
||||||
return web.json_response(json_data, status=response.status)
|
|
||||||
except Exception as e:
|
|
||||||
return web.json_response({"error": str(e)}, status=500)
|
|
||||||
|
|
||||||
|
|
||||||
# for getting a workflow by id
|
|
||||||
@server.PromptServer.instance.routes.get("/comfyui-deploy/workflow")
|
|
||||||
async def get_workflow_proxy(request):
|
|
||||||
workflow_id = request.rel_url.query.get("workflow_id")
|
|
||||||
api_url = request.rel_url.query.get("api_url", "https://api.comfydeploy.com")
|
|
||||||
auth_header = request.headers.get("Authorization")
|
|
||||||
|
|
||||||
if not auth_header:
|
|
||||||
return web.json_response(
|
|
||||||
{"error": "Authorization header is required"}, status=401
|
|
||||||
)
|
|
||||||
|
|
||||||
target_url = f"{api_url}/api/workflow/{workflow_id}"
|
|
||||||
|
|
||||||
try:
|
|
||||||
await ensure_client_session()
|
|
||||||
async with client_session.get(
|
|
||||||
target_url, headers={"Authorization": auth_header}
|
|
||||||
) as response:
|
|
||||||
json_data = await response.json()
|
|
||||||
return web.json_response(json_data, status=response.status)
|
|
||||||
except Exception as e:
|
|
||||||
return web.json_response({"error": str(e)}, status=500)
|
|
||||||
|
|
||||||
|
|
||||||
# for getting a machine by id
|
|
||||||
@server.PromptServer.instance.routes.get("/comfyui-deploy/machine")
|
|
||||||
async def get_machine_proxy(request):
|
|
||||||
machine_id = request.rel_url.query.get("machine_id")
|
|
||||||
api_url = request.rel_url.query.get("api_url", "https://api.comfydeploy.com")
|
|
||||||
auth_header = request.headers.get("Authorization")
|
|
||||||
|
|
||||||
if not auth_header:
|
|
||||||
return web.json_response(
|
|
||||||
{"error": "Authorization header is required"}, status=401
|
|
||||||
)
|
|
||||||
|
|
||||||
target_url = f"{api_url}/api/machine/{machine_id}"
|
|
||||||
|
|
||||||
try:
|
|
||||||
await ensure_client_session()
|
|
||||||
async with client_session.get(
|
|
||||||
target_url, headers={"Authorization": auth_header}
|
|
||||||
) as response:
|
|
||||||
json_data = await response.json()
|
|
||||||
return web.json_response(json_data, status=response.status)
|
|
||||||
except Exception as e:
|
|
||||||
return web.json_response({"error": str(e)}, status=500)
|
|
||||||
|
|
||||||
|
|
||||||
# for fetching docker steps from current snapshot
|
|
||||||
@server.PromptServer.instance.routes.post("/comfyui-deploy/snapshot-to-docker")
|
|
||||||
async def snapshot_to_docker_proxy(request):
|
|
||||||
data = await request.json()
|
|
||||||
snapshot = data.get("snapshot")
|
|
||||||
api_url = data.get("api_url", "https://api.comfydeploy.com")
|
|
||||||
auth_header = request.headers.get("Authorization")
|
|
||||||
|
|
||||||
if not auth_header:
|
|
||||||
return web.json_response(
|
|
||||||
{"error": "Authorization header is required"}, status=401
|
|
||||||
)
|
|
||||||
|
|
||||||
target_url = f"{api_url}/api/snapshot-to-docker"
|
|
||||||
|
|
||||||
request_body = snapshot
|
|
||||||
|
|
||||||
try:
|
|
||||||
await ensure_client_session()
|
|
||||||
async with client_session.post(
|
|
||||||
target_url, json=request_body, headers={"Authorization": auth_header}
|
|
||||||
) as response:
|
|
||||||
json_data = await response.json()
|
|
||||||
return web.json_response(json_data, status=response.status)
|
|
||||||
except Exception as e:
|
|
||||||
return web.json_response({"error": str(e)}, status=500)
|
|
||||||
|
|
||||||
|
|
||||||
# update a serverless machine with machine id
|
|
||||||
@server.PromptServer.instance.routes.post("/comfyui-deploy/machine/update")
|
|
||||||
async def update_machine_proxy(request):
|
|
||||||
data = await request.json()
|
|
||||||
machine_id = data.get("machine_id")
|
|
||||||
comfyui_version = data.get("comfyui_version", None)
|
|
||||||
docker_steps = data.get("docker_steps")
|
|
||||||
api_url = data.get("api_url", "https://api.comfydeploy.com")
|
|
||||||
auth_header = request.headers.get("Authorization")
|
|
||||||
|
|
||||||
if not auth_header:
|
|
||||||
return web.json_response(
|
|
||||||
{"error": "Authorization header is required"}, status=401
|
|
||||||
)
|
|
||||||
|
|
||||||
target_url = f"{api_url}/api/machine/serverless/{machine_id}"
|
|
||||||
|
|
||||||
request_body = {"docker_command_steps": docker_steps}
|
|
||||||
|
|
||||||
if comfyui_version:
|
|
||||||
request_body["comfyui_version"] = comfyui_version
|
|
||||||
|
|
||||||
try:
|
|
||||||
await ensure_client_session()
|
|
||||||
async with client_session.patch(
|
|
||||||
target_url, json=request_body, headers={"Authorization": auth_header}
|
|
||||||
) as response:
|
|
||||||
json_data = await response.json()
|
|
||||||
return web.json_response(json_data, status=response.status)
|
|
||||||
except Exception as e:
|
|
||||||
return web.json_response({"error": str(e)}, status=500)
|
|
||||||
|
|
||||||
|
|
||||||
@server.PromptServer.instance.routes.post("/comfyui-deploy/machine/create")
|
|
||||||
async def create_machine_proxy(request):
|
|
||||||
data = await request.json()
|
|
||||||
name = data.get("name")
|
|
||||||
docker_command_steps = data.get("docker_command_steps")
|
|
||||||
comfyui_version = data.get("comfyui_version")
|
|
||||||
api_url = data.get("api_url", "https://api.comfydeploy.com")
|
|
||||||
auth_header = request.headers.get("Authorization")
|
|
||||||
|
|
||||||
if not auth_header:
|
|
||||||
return web.json_response(
|
|
||||||
{"error": "Authorization header is required"}, status=401
|
|
||||||
)
|
|
||||||
|
|
||||||
target_url = f"{api_url}/api/machine/serverless"
|
|
||||||
|
|
||||||
request_body = {
|
|
||||||
"name": name,
|
|
||||||
"docker_command_steps": docker_command_steps,
|
|
||||||
"comfyui_version": comfyui_version,
|
|
||||||
"gpu": "A10G",
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
await ensure_client_session()
|
|
||||||
async with client_session.post(
|
|
||||||
target_url, json=request_body, headers={"Authorization": auth_header}
|
|
||||||
) as response:
|
|
||||||
json_data = await response.json()
|
|
||||||
return web.json_response(json_data, status=response.status)
|
|
||||||
except Exception as e:
|
|
||||||
return web.json_response({"error": str(e)}, status=500)
|
|
||||||
|
|
||||||
|
|
||||||
# get latest comfyui version
|
|
||||||
@server.PromptServer.instance.routes.get("/comfyui-deploy/comfyui-version")
|
|
||||||
async def get_comfyui_version_proxy(request):
|
|
||||||
api_url = request.rel_url.query.get("api_url", "https://api.comfydeploy.com")
|
|
||||||
auth_header = request.headers.get("Authorization")
|
|
||||||
|
|
||||||
if not auth_header:
|
|
||||||
return web.json_response(
|
|
||||||
{"error": "Authorization header is required"}, status=401
|
|
||||||
)
|
|
||||||
|
|
||||||
target_url = f"{api_url}/api/latest-hashes"
|
|
||||||
|
|
||||||
try:
|
|
||||||
await ensure_client_session()
|
|
||||||
async with client_session.get(
|
|
||||||
target_url, headers={"Authorization": auth_header}
|
|
||||||
) as response:
|
|
||||||
json_data = await response.json()
|
|
||||||
return web.json_response(json_data, status=response.status)
|
|
||||||
except Exception as e:
|
|
||||||
return web.json_response({"error": str(e)}, status=500)
|
|
||||||
|
|||||||
+16
@@ -56,11 +56,27 @@ streaming_prompt_metadata: dict[str, StreamingPrompt] = {}
|
|||||||
class BinaryEventTypes:
|
class BinaryEventTypes:
|
||||||
PREVIEW_IMAGE = 1
|
PREVIEW_IMAGE = 1
|
||||||
UNENCODED_PREVIEW_IMAGE = 2
|
UNENCODED_PREVIEW_IMAGE = 2
|
||||||
|
EXR_IMAGE = 4
|
||||||
|
|
||||||
|
|
||||||
max_output_id_length = 24
|
max_output_id_length = 24
|
||||||
|
|
||||||
|
|
||||||
|
async def send_exr(image_data, sid=None, output_id: str = None):
|
||||||
|
max_length = max_output_id_length
|
||||||
|
output_id = output_id[:max_length]
|
||||||
|
padded_output_id = output_id.ljust(max_length, "\x00")
|
||||||
|
encoded_output_id = padded_output_id.encode("ascii", "replace")
|
||||||
|
|
||||||
|
bytesIO = BytesIO()
|
||||||
|
# 10 bytes for the output_id
|
||||||
|
bytesIO.write(encoded_output_id)
|
||||||
|
bytesIO.write(image_data)
|
||||||
|
|
||||||
|
preview_bytes = bytesIO.getvalue()
|
||||||
|
await send_bytes(BinaryEventTypes.EXR_IMAGE, preview_bytes, sid=sid)
|
||||||
|
|
||||||
|
|
||||||
async def send_image(image_data, sid=None, output_id: str = None):
|
async def send_image(image_data, sid=None, output_id: str = None):
|
||||||
max_length = max_output_id_length
|
max_length = max_output_id_length
|
||||||
output_id = output_id[:max_length]
|
output_id = output_id[:max_length]
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "comfyui-deploy"
|
name = "comfyui-deploy"
|
||||||
description = "Open source comfyui deployment platform, a vercel for generative workflow infra."
|
description = "Open source comfyui deployment platform, a vercel for generative workflow infra."
|
||||||
version = "2.3.2"
|
version = "2.1.0"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
dependencies = ["aiofiles", "pydantic", "opencv-python", "imageio-ffmpeg", "tabulate", "brotli"]
|
dependencies = ["aiofiles", "pydantic", "opencv-python", "imageio-ffmpeg", "tabulate", "brotli"]
|
||||||
|
|
||||||
|
|||||||
+397
-1137
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,82 +0,0 @@
|
|||||||
// Snapshot Utilities
|
|
||||||
// Centralized snapshot fetching with ComfyUI version fallback
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches the current snapshot with ComfyUI version fallback
|
|
||||||
* If the snapshot response has null comfyui field, it will fetch the latest ComfyUI version
|
|
||||||
* and update the snapshot with the comfyui_hash
|
|
||||||
*
|
|
||||||
* @param {Function} getDataFn - Function that returns { apiKey, apiUrl } for ComfyUI version API calls
|
|
||||||
* @returns {Promise<Object>} - The snapshot data with comfyui field populated
|
|
||||||
*/
|
|
||||||
export async function fetchSnapshot(getDataFn = null) {
|
|
||||||
try {
|
|
||||||
// Fetch the current snapshot
|
|
||||||
const response = await fetch("/snapshot/get_current");
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Snapshot fetch failed: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const snapshot = await response.json();
|
|
||||||
|
|
||||||
// Check if comfyui field is null and we have getDataFn for fallback
|
|
||||||
if (snapshot.comfyui === null && getDataFn) {
|
|
||||||
console.log(
|
|
||||||
"ComfyUI version is null in snapshot, fetching latest version..."
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = getDataFn();
|
|
||||||
if (data && data.apiKey) {
|
|
||||||
const comfyuiVersionResponse = await fetch(
|
|
||||||
`/comfyui-deploy/comfyui-version?api_url=${encodeURIComponent(
|
|
||||||
data.apiUrl || "https://api.comfydeploy.com"
|
|
||||||
)}`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${data.apiKey}`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (comfyuiVersionResponse.ok) {
|
|
||||||
const versionData = await comfyuiVersionResponse.json();
|
|
||||||
if (versionData.comfyui_hash) {
|
|
||||||
console.log(
|
|
||||||
`Using ComfyUI hash from API: ${versionData.comfyui_hash}`
|
|
||||||
);
|
|
||||||
snapshot.comfyui = versionData.comfyui_hash;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn(
|
|
||||||
"Failed to fetch ComfyUI version from API:",
|
|
||||||
comfyuiVersionResponse.status
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("Error fetching ComfyUI version fallback:", error);
|
|
||||||
// Continue with original snapshot even if fallback fails
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return snapshot;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching snapshot:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simple snapshot fetch without ComfyUI version fallback
|
|
||||||
* Use this when you don't need the ComfyUI version fallback logic
|
|
||||||
*
|
|
||||||
* @returns {Promise<Object>} - The snapshot data as-is
|
|
||||||
*/
|
|
||||||
export async function fetchSnapshotSimple() {
|
|
||||||
const response = await fetch("/snapshot/get_current");
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Snapshot fetch failed: ${response.status}`);
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
@@ -1,417 +0,0 @@
|
|||||||
// Workflow list management
|
|
||||||
let workflowsState = {
|
|
||||||
workflows: [],
|
|
||||||
offset: 0,
|
|
||||||
limit: 20,
|
|
||||||
loading: false,
|
|
||||||
hasMore: true,
|
|
||||||
initialized: false,
|
|
||||||
currentSearch: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Make workflowsState accessible globally
|
|
||||||
window.workflowsState = workflowsState;
|
|
||||||
|
|
||||||
async function fetchWorkflows(getData, offset = 0, limit = 20, search = "") {
|
|
||||||
try {
|
|
||||||
const data = getData();
|
|
||||||
if (!data.apiKey) {
|
|
||||||
throw new Error("API key not configured");
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
offset: offset.toString(),
|
|
||||||
limit: limit.toString(),
|
|
||||||
api_url: data.apiUrl || "https://api.comfydeploy.com",
|
|
||||||
...(search && { search }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await fetch(`/comfyui-deploy/workflows?${params}`, {
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${data.apiKey}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to fetch workflows: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
console.log("result", result);
|
|
||||||
return Array.isArray(result) ? result : [];
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching workflows:", error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createWorkflowItem(workflow, getTimeAgo, getData) {
|
|
||||||
const li = document.createElement("li");
|
|
||||||
let loadingToast = null;
|
|
||||||
li.style.cssText = `
|
|
||||||
border-bottom: 1px solid #444;
|
|
||||||
background: transparent;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
cursor: pointer;
|
|
||||||
`;
|
|
||||||
|
|
||||||
li.addEventListener("mouseenter", () => {
|
|
||||||
li.style.background = "#333";
|
|
||||||
});
|
|
||||||
|
|
||||||
li.addEventListener("mouseleave", () => {
|
|
||||||
li.style.background = "transparent";
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add click handler to fetch and load workflow data
|
|
||||||
li.addEventListener("click", async () => {
|
|
||||||
try {
|
|
||||||
const data = getData();
|
|
||||||
if (!data.apiKey) {
|
|
||||||
console.error("No API key configured");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show loading toast
|
|
||||||
loadingToast = window.app.extensionManager.toast.add({
|
|
||||||
severity: "info",
|
|
||||||
summary: "Loading workflow...",
|
|
||||||
detail: `Loading "${workflow.name}"`,
|
|
||||||
life: 3000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
workflow_id: workflow.id,
|
|
||||||
api_url: data.apiUrl || "https://api.comfydeploy.com",
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await fetch(`/comfyui-deploy/workflow?${params}`, {
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${data.apiKey}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to fetch workflow: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const workflowData = await response.json();
|
|
||||||
console.log("Workflow data:", workflowData);
|
|
||||||
|
|
||||||
// Load the workflow into the graph
|
|
||||||
if (workflowData.versions && workflowData.versions.length > 0) {
|
|
||||||
const latestVersion = workflowData.versions[0];
|
|
||||||
if (latestVersion.workflow && window.app) {
|
|
||||||
// Load the workflow
|
|
||||||
window.app.loadGraphData(latestVersion.workflow);
|
|
||||||
|
|
||||||
// Wait a bit for the graph to fully load before checking for ComfyDeploy node
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
||||||
|
|
||||||
// Check if ComfyDeploy node exists, if not add it back
|
|
||||||
const graph = window.app.graph;
|
|
||||||
let deployMeta = graph.findNodesByType("ComfyDeploy");
|
|
||||||
|
|
||||||
if (deployMeta.length === 0) {
|
|
||||||
// Add ComfyDeploy node with workflow metadata
|
|
||||||
graph.beforeChange();
|
|
||||||
const node = LiteGraph.createNode("ComfyDeploy");
|
|
||||||
node.configure({
|
|
||||||
widgets_values: [
|
|
||||||
workflow.name, // workflow_name
|
|
||||||
workflow.id, // workflow_id
|
|
||||||
latestVersion.version, // version
|
|
||||||
],
|
|
||||||
});
|
|
||||||
node.pos = [0, 0];
|
|
||||||
graph.add(node);
|
|
||||||
graph.afterChange();
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`Added ComfyDeploy node with: name="${workflow.name}", id="${workflow.id}", version="${latestVersion.version}"`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show success toast
|
|
||||||
window.app.extensionManager.toast.add({
|
|
||||||
severity: "success",
|
|
||||||
summary: "Workflow loaded successfully",
|
|
||||||
detail: `Loaded "${workflow.name}" v${latestVersion.version}`,
|
|
||||||
life: 3000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error loading workflow:", error);
|
|
||||||
// Show error toast
|
|
||||||
window.app.extensionManager.toast.add({
|
|
||||||
severity: "error",
|
|
||||||
summary: "Failed to load workflow",
|
|
||||||
detail: error.message,
|
|
||||||
life: 5000,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
if (loadingToast) {
|
|
||||||
loadingToast.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const updatedDate = new Date(workflow.updated_at);
|
|
||||||
const timeAgo = getTimeAgo(updatedDate);
|
|
||||||
|
|
||||||
li.innerHTML = `
|
|
||||||
<div style="padding: 12px 16px;">
|
|
||||||
<div style="display: flex; align-items: flex-start; gap: 12px;">
|
|
||||||
${
|
|
||||||
workflow.cover_image
|
|
||||||
? `<img src="${workflow.cover_image}"
|
|
||||||
style="width: 40px; height: 40px; border-radius: 4px; object-fit: cover; flex-shrink: 0;"
|
|
||||||
onerror="this.style.display='none'">`
|
|
||||||
: `<div style="width: 40px; height: 40px; border-radius: 4px; background: #444; flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-size: 14px; color: #888;">
|
|
||||||
${workflow.name.charAt(0).toUpperCase()}
|
|
||||||
</div>`
|
|
||||||
}
|
|
||||||
|
|
||||||
<div style="flex: 1; min-width: 0;">
|
|
||||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 4px;">
|
|
||||||
<h4 style="margin: 0; font-size: 14px; font-weight: 400; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
|
|
||||||
${workflow.name}
|
|
||||||
</h4>
|
|
||||||
${
|
|
||||||
workflow.pinned
|
|
||||||
? `<span style="color: #ffd700; font-size: 12px;">📌</span>`
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${
|
|
||||||
workflow.description
|
|
||||||
? `<p style="margin: 0 0 8px 0; font-size: 12px; color: #bbb; line-height: 1.3; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;">
|
|
||||||
${workflow.description}
|
|
||||||
</p>`
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
|
|
||||||
<div style="display: flex; align-items: center; gap: 8px; margin-top: 8px;">
|
|
||||||
<img src="${workflow.user_icon}"
|
|
||||||
style="width: 16px; height: 16px; border-radius: 50%;"
|
|
||||||
onerror="this.style.display='none'">
|
|
||||||
<span style="font-size: 11px; color: #888;">
|
|
||||||
${workflow.user_name} • Updated ${timeAgo}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
return li;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadMoreWorkflows(element, getData, getTimeAgo) {
|
|
||||||
if (workflowsState.loading || !workflowsState.hasMore) return;
|
|
||||||
|
|
||||||
workflowsState.loading = true;
|
|
||||||
|
|
||||||
const workflowsList = element.querySelector("#workflows-list");
|
|
||||||
const workflowsLoading = element.querySelector("#workflows-loading");
|
|
||||||
|
|
||||||
// Show loading indicator
|
|
||||||
workflowsLoading.style.display = "flex";
|
|
||||||
|
|
||||||
try {
|
|
||||||
const newWorkflows = await fetchWorkflows(
|
|
||||||
getData,
|
|
||||||
workflowsState.offset,
|
|
||||||
workflowsState.limit,
|
|
||||||
workflowsState.currentSearch
|
|
||||||
);
|
|
||||||
|
|
||||||
if (newWorkflows.length === 0) {
|
|
||||||
workflowsState.hasMore = false;
|
|
||||||
} else {
|
|
||||||
workflowsState.workflows.push(...newWorkflows);
|
|
||||||
workflowsState.offset += newWorkflows.length;
|
|
||||||
|
|
||||||
// Render new workflow items
|
|
||||||
newWorkflows.forEach((workflow) => {
|
|
||||||
const workflowItem = createWorkflowItem(workflow, getTimeAgo, getData);
|
|
||||||
workflowsList.appendChild(workflowItem);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error loading more workflows:", error);
|
|
||||||
} finally {
|
|
||||||
workflowsState.loading = false;
|
|
||||||
workflowsLoading.style.display = "none";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupInfiniteScroll(container, element, getData, getTimeAgo) {
|
|
||||||
let isScrolling = false;
|
|
||||||
|
|
||||||
container.addEventListener("scroll", () => {
|
|
||||||
if (isScrolling) return;
|
|
||||||
|
|
||||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
|
||||||
|
|
||||||
// Load more when scrolled to bottom (with 100px threshold)
|
|
||||||
if (scrollTop + clientHeight >= scrollHeight - 100) {
|
|
||||||
isScrolling = true;
|
|
||||||
loadMoreWorkflows(element, getData, getTimeAgo).finally(() => {
|
|
||||||
isScrolling = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function initializeWorkflowsList(element, getData, getTimeAgo) {
|
|
||||||
const workflowsContainer = element.querySelector("#workflows-container");
|
|
||||||
const workflowsList = element.querySelector("#workflows-list");
|
|
||||||
const workflowsLoading = element.querySelector("#workflows-loading");
|
|
||||||
|
|
||||||
// Check if already initialized AND the DOM elements still exist
|
|
||||||
if (
|
|
||||||
workflowsState.initialized &&
|
|
||||||
workflowsList &&
|
|
||||||
workflowsList.children.length > 0
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Reset state (always reset when reinitializing)
|
|
||||||
workflowsState = {
|
|
||||||
workflows: [],
|
|
||||||
offset: 0,
|
|
||||||
limit: 20,
|
|
||||||
loading: false,
|
|
||||||
hasMore: true,
|
|
||||||
initialized: true,
|
|
||||||
currentSearch: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Clear existing content in case of reinitialization
|
|
||||||
if (workflowsList) {
|
|
||||||
workflowsList.innerHTML = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show container and loading
|
|
||||||
workflowsContainer.style.display = "block";
|
|
||||||
workflowsLoading.style.display = "flex";
|
|
||||||
|
|
||||||
// Style the workflows list for full height scrolling
|
|
||||||
workflowsList.style.cssText = `
|
|
||||||
list-style-type: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
height: calc(100vh - 550px);
|
|
||||||
overflow-y: auto;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: #666 transparent;
|
|
||||||
border-top: 1px solid #444;
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Add webkit scrollbar styles
|
|
||||||
const style = document.createElement("style");
|
|
||||||
style.textContent = `
|
|
||||||
#workflows-list::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
}
|
|
||||||
#workflows-list::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
#workflows-list::-webkit-scrollbar-thumb {
|
|
||||||
background: #666;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
#workflows-list::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: #777;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
document.head.appendChild(style);
|
|
||||||
|
|
||||||
// Setup infinite scroll
|
|
||||||
setupInfiniteScroll(workflowsList, element, getData, getTimeAgo);
|
|
||||||
|
|
||||||
// Load initial workflows
|
|
||||||
await loadMoreWorkflows(element, getData, getTimeAgo);
|
|
||||||
|
|
||||||
// Show the list
|
|
||||||
workflowsList.style.display = "block";
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error initializing workflows list:", error);
|
|
||||||
workflowsLoading.innerHTML = `
|
|
||||||
<div style="text-align: center; color: #e74c3c; font-size: 12px; padding: 20px;">
|
|
||||||
<div>Failed to load workflows</div>
|
|
||||||
<button onclick="initializeWorkflowsList(this.closest('.comfy-menu'), getData, getTimeAgo)"
|
|
||||||
style="margin-top: 8px; padding: 4px 8px; font-size: 11px; background: #f0f0f0; border: 1px solid #ccc; border-radius: 4px; cursor: pointer;">
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search functionality
|
|
||||||
function addWorkflowSearch(element, getData, getTimeAgo) {
|
|
||||||
const workflowsContainer = element.querySelector("#workflows-container");
|
|
||||||
const h4 = workflowsContainer.querySelector("h4");
|
|
||||||
|
|
||||||
const searchContainer = document.createElement("div");
|
|
||||||
searchContainer.style.cssText = "margin-bottom: 12px;";
|
|
||||||
|
|
||||||
const searchInput = document.createElement("input");
|
|
||||||
searchInput.type = "text";
|
|
||||||
searchInput.placeholder = "Search workflows...";
|
|
||||||
searchInput.style.cssText = `
|
|
||||||
width: 100%;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border: 1px solid #555;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
background: #333;
|
|
||||||
color: #fff;
|
|
||||||
`;
|
|
||||||
|
|
||||||
let searchTimeout;
|
|
||||||
searchInput.addEventListener("input", (e) => {
|
|
||||||
clearTimeout(searchTimeout);
|
|
||||||
searchTimeout = setTimeout(async () => {
|
|
||||||
const searchTerm = e.target.value.trim();
|
|
||||||
|
|
||||||
// Update the tracked search term
|
|
||||||
workflowsState.currentSearch = searchTerm;
|
|
||||||
|
|
||||||
// Reset state for new search
|
|
||||||
workflowsState.workflows = [];
|
|
||||||
workflowsState.offset = 0;
|
|
||||||
workflowsState.hasMore = true;
|
|
||||||
|
|
||||||
// Clear current list
|
|
||||||
const workflowsList = element.querySelector("#workflows-list");
|
|
||||||
workflowsList.innerHTML = "";
|
|
||||||
|
|
||||||
// Load with search term
|
|
||||||
workflowsState.loading = false;
|
|
||||||
await loadMoreWorkflows(element, getData, getTimeAgo);
|
|
||||||
}, 300);
|
|
||||||
});
|
|
||||||
|
|
||||||
searchContainer.appendChild(searchInput);
|
|
||||||
h4.after(searchContainer);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export the functions
|
|
||||||
export {
|
|
||||||
initializeWorkflowsList,
|
|
||||||
addWorkflowSearch,
|
|
||||||
workflowsState,
|
|
||||||
fetchWorkflows,
|
|
||||||
loadMoreWorkflows,
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user