Compare commits

..
Author SHA1 Message Date
KarrixLee ea45930102 tweak 2025-05-21 18:43:09 +08:00
14 changed files with 296 additions and 1022 deletions
+110
View File
@@ -0,0 +1,110 @@
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)"
}
-93
View File
@@ -1,93 +0,0 @@
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)"
}
-73
View File
@@ -1,73 +0,0 @@
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)"
}
-159
View File
@@ -1,159 +0,0 @@
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)"
}
@@ -1,88 +0,0 @@
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)"
}
-54
View File
@@ -1,54 +0,0 @@
class ComfyUIDeployExternalNumberSliderInt:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"input_id": (
"STRING",
{"multiline": False, "default": "input_number_slider_int"},
),
},
"optional": {
"default_value": (
"INT",
{"multiline": True, "display": "number", "min": -2147483647, "max": 2147483647, "default": 1, "step": 1},
),
"min_value": (
"INT",
{"multiline": True, "display": "number", "min": -2147483647, "max": 2147483647, "default": 0, "step": 1},
),
"max_value": (
"INT",
{"multiline": True, "display": "number", "min": -2147483647, "max": 2147483647, "default": 10, "step": 1},
),
"display_name": (
"STRING",
{"multiline": False, "default": ""},
),
"description": (
"STRING",
{"multiline": True, "default": ""},
),
}
}
RETURN_TYPES = ("INT",)
RETURN_NAMES = ("value",)
FUNCTION = "run"
CATEGORY = "🔗ComfyDeploy"
def run(self, input_id, default_value=None, min_value=0, max_value=10, display_name=None, description=None):
try:
int_value = int(round(float(input_id)))
if min_value <= int_value <= max_value:
print("my integer", int_value)
return [int_value]
else:
print("Integer out of range. Returning default value:", default_value)
return [default_value]
except (ValueError, TypeError):
print("Invalid input. Returning default value:", default_value)
return [default_value]
NODE_CLASS_MAPPINGS = {"ComfyUIDeployExternalNumberSliderInt": ComfyUIDeployExternalNumberSliderInt}
NODE_DISPLAY_NAME_MAPPINGS = {"ComfyUIDeployExternalNumberSliderInt": "External Number Slider Int (ComfyUI Deploy)"}
+35 -68
View File
@@ -748,64 +748,36 @@ class ComfyUIDeployExternalVideo:
file_parts = f.split(".")
if len(file_parts) > 1 and (file_parts[-1] in video_extensions):
files.append(f)
return {
"required": {
"input_id": (
"STRING",
{"multiline": False, "default": "input_video"},
),
"force_rate": ("INT", {"default": 0, "min": 0, "max": 60, "step": 1}),
"force_size": (
[
"Disabled",
"Custom Height",
"Custom Width",
"Custom",
"256x?",
"?x256",
"256x256",
"512x?",
"?x512",
"512x512",
],
),
"custom_width": (
"INT",
{"default": 512, "min": 0, "max": DIMMAX, "step": 8},
),
"custom_height": (
"INT",
{"default": 512, "min": 0, "max": DIMMAX, "step": 8},
),
"frame_load_cap": (
"INT",
{"default": 0, "min": 0, "max": BIGMAX, "step": 1},
),
"skip_first_frames": (
"INT",
{"default": 0, "min": 0, "max": BIGMAX, "step": 1},
),
"select_every_nth": (
"INT",
{"default": 1, "min": 1, "max": BIGMAX, "step": 1},
),
},
"optional": {
"meta_batch": ("VHS_BatchManager",),
"vae": ("VAE",),
"default_video": (sorted(files),),
"display_name": (
"STRING",
{"multiline": False, "default": ""},
),
"description": (
"STRING",
{"multiline": True, "default": ""},
),
"default_value_url": ("STRING", {"image_preview": True, "default": ""}),
},
"hidden": {"unique_id": "UNIQUE_ID"},
}
return {"required": {
"input_id": (
"STRING",
{"multiline": False, "default": "input_video"},
),
"force_rate": ("INT", {"default": 0, "min": 0, "max": 60, "step": 1}),
"force_size": (["Disabled", "Custom Height", "Custom Width", "Custom", "256x?", "?x256", "256x256", "512x?", "?x512", "512x512"],),
"custom_width": ("INT", {"default": 512, "min": 0, "max": DIMMAX, "step": 8}),
"custom_height": ("INT", {"default": 512, "min": 0, "max": DIMMAX, "step": 8}),
"frame_load_cap": ("INT", {"default": 0, "min": 0, "max": BIGMAX, "step": 1}),
"skip_first_frames": ("INT", {"default": 0, "min": 0, "max": BIGMAX, "step": 1}),
"select_every_nth": ("INT", {"default": 1, "min": 1, "max": BIGMAX, "step": 1}),
},
"optional": {
"meta_batch": ("VHS_BatchManager",),
"vae": ("VAE",),
"default_video": (sorted(files),),
"display_name": (
"STRING",
{"multiline": False, "default": ""},
),
"description": (
"STRING",
{"multiline": True, "default": ""},
),
},
"hidden": {
"unique_id": "UNIQUE_ID"
},
}
CATEGORY = "Video Helper Suite 🎥🅥🅗🅢"
@@ -832,21 +804,16 @@ class ComfyUIDeployExternalVideo:
select_every_nth = kwargs.get("select_every_nth")
meta_batch = kwargs.get("meta_batch")
unique_id = kwargs.get("unique_id")
default_value_url = kwargs.get("default_value_url")
input_dir = folder_paths.get_input_directory()
if input_id.startswith("http") or (
default_value_url and default_value_url.startswith("http")
):
if input_id.startswith("http"):
import requests
# Use input_id if it's a URL, otherwise use default_value_url
url = input_id if input_id.startswith("http") else default_value_url
print("Fetching video from URL: ", url)
response = requests.get(url, stream=True)
print("Fetching video from URL: ", input_id)
response = requests.get(input_id, stream=True)
file_size = int(response.headers.get("Content-Length", 0))
file_extension = url.split(".")[-1].split("?")[
file_extension = input_id.split(".")[-1].split("?")[
0
] # Extract extension and handle URLs with parameters
if file_extension not in video_extensions:
-111
View File
@@ -1,111 +0,0 @@
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)"
}
-80
View File
@@ -1,80 +0,0 @@
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)"
}
-126
View File
@@ -1,126 +0,0 @@
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)"
}
-91
View File
@@ -1,91 +0,0 @@
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)"
}
+24 -26
View File
@@ -2767,32 +2767,30 @@ class UploadQueue:
# If this was the last upload for this node, clean up node data
if not self.node_uploads[prompt_id][node_id]:
del self.node_uploads[prompt_id][node_id]
if prompt_id in self.node_output_data:
if node_id in self.node_output_data[prompt_id]:
if self.node_output_data[prompt_id][node_id]["data"]:
# Send final node data to API before cleanup
if prompt_metadata[prompt_id].status_endpoint:
body = {
"run_id": prompt_id,
"output_data": self.node_output_data[
prompt_id
][node_id]["data"],
"node_meta": {"node_id": node_id},
}
try:
await async_request_with_retry(
"POST",
prompt_metadata[
prompt_id
].status_endpoint,
token=prompt_metadata[prompt_id].token,
json=body,
)
except Exception as e:
logger.error(
f"Failed to send final node data: {str(e)}"
)
del self.node_output_data[prompt_id][node_id]
if self.node_output_data[prompt_id][node_id]["data"]:
# Send final node data to API before cleanup
if prompt_metadata[prompt_id].status_endpoint:
body = {
"run_id": prompt_id,
"output_data": self.node_output_data[
prompt_id
][node_id]["data"],
"node_meta": {"node_id": node_id},
}
try:
await async_request_with_retry(
"POST",
prompt_metadata[
prompt_id
].status_endpoint,
token=prompt_metadata[prompt_id].token,
json=body,
)
except Exception as e:
logger.error(
f"Failed to send final node data: {str(e)}"
)
del self.node_output_data[prompt_id][node_id]
# Send status update
await self.update_queue_status(prompt_id)
-16
View File
@@ -56,27 +56,11 @@ streaming_prompt_metadata: dict[str, StreamingPrompt] = {}
class BinaryEventTypes:
PREVIEW_IMAGE = 1
UNENCODED_PREVIEW_IMAGE = 2
EXR_IMAGE = 4
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):
max_length = max_output_id_length
output_id = output_id[:max_length]
+127 -37
View File
@@ -5,7 +5,7 @@ LGraphNode = LiteGraph.LGraphNode;
import { ComfyDialog, $el } from "../../scripts/ui.js";
import { generateDependencyGraph } from "https://esm.sh/comfyui-json@0.1.25";
// import { ComfyDeploy } from "https://esm.sh/comfydeploy@2.0.0-beta.69";
import { ComfyDeploy } from "https://esm.sh/comfydeploy@2.0.0-beta.69";
const styles = `
.comfydeploy-menu-item {
@@ -330,7 +330,8 @@ async function convertToInput(node, widget, config) {
inputNode.configure({
widgets_values: [inputId, widget.value, JSON.stringify(options)],
});
} else {
} else
{
inputNode.configure({
widgets_values: [inputId, widget.value],
});
@@ -344,7 +345,7 @@ async function convertToInput(node, widget, config) {
console.log(options);
inputNode.widgets.find((x) => x.name == "default_value").options.values = options;
}
app.graph.add(inputNode);
inputNode.connect(0, node, index);
@@ -614,9 +615,9 @@ const ext = {
// return r
// };
},
// async nodeCreated(node) {
// },
registerCustomNodes() {
@@ -782,15 +783,25 @@ const ext = {
},
DYNAMIC_ENUM(node, inputName, inputData) {
// console.log("DYNAMIC_ENUM", JSON.parse(JSON.stringify(node)), inputName, inputData);
const enumWidget = node.addWidget(
"combo",
inputName,
"",
{ serialize: true, values: [] },
);
// Add a check to see if nativeEnum exists and provide fallback
if (node.nativeEnum && typeof node.nativeEnum === "function") {
// Original code using nativeEnum
return node.nativeEnum(inputName, inputData);
} else {
// Fallback implementation
console.log(
"DYNAMIC_ENUM",
JSON.parse(JSON.stringify(node)),
inputName,
inputData
);
const enumWidget = node.addWidget("combo", inputName, "", {
serialize: true,
values: [],
});
return { widget: enumWidget };
return { widget: enumWidget };
}
},
};
},
@@ -1395,6 +1406,85 @@ async function deployWorkflow() {
}
}
// Add this function to refresh the workflows list
function refreshWorkflowsList(el) {
const workflowsList = el.querySelector("#workflows-list");
const workflowsLoading = el.querySelector("#workflows-loading");
workflowsLoading.style.display = "flex";
workflowsList.style.display = "none";
workflowsList.innerHTML = "";
client.workflows
.getAll({
page: "1",
pageSize: "10",
})
.then((result) => {
workflowsLoading.style.display = "none";
workflowsList.style.display = "block";
if (result.length === 0) {
workflowsList.innerHTML =
"<li style='color: #bdbdbd;'>No workflows found</li>";
return;
}
result.forEach((workflow) => {
const li = document.createElement("li");
li.style.marginBottom = "15px";
li.style.padding = "15px";
li.style.backgroundColor = "#2a2a2a";
li.style.borderRadius = "8px";
li.style.boxShadow = "0 2px 4px rgba(0,0,0,0.1)";
const lastRun = workflow.runs[0];
const lastRunStatus = lastRun ? lastRun.status : "No runs";
const statusColor =
lastRunStatus === "success"
? "#4CAF50"
: lastRunStatus === "error"
? "#F44336"
: "#FFC107";
const timeAgo = getTimeAgo(new Date(workflow.updatedAt));
li.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<div style="flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
<strong style="font-size: 18px; color: #e0e0e0;">${workflow.name}</strong>
</div>
<span style="font-size: 12px; color: ${statusColor}; margin-left: 10px;">Last run: ${lastRunStatus}</span>
</div>
<div style="font-size: 14px; color: #bdbdbd; margin-bottom: 10px;">Last updated ${timeAgo}</div>
<div style="display: flex; gap: 10px;">
<button class="open-cloud-btn" style="padding: 5px 10px; background-color: #4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer;">Open in Cloud</button>
<button class="load-api-btn" style="padding: 5px 10px; background-color: #2196F3; color: white; border: none; border-radius: 4px; cursor: pointer;">Load Workflow</button>
</div>
`;
const openCloudBtn = li.querySelector(".open-cloud-btn");
openCloudBtn.onclick = () =>
window.open(
`${getData().endpoint}/workflows/${workflow.id}?workspace=true`,
"_blank",
);
const loadApiBtn = li.querySelector(".load-api-btn");
loadApiBtn.onclick = () => loadWorkflowApi(workflow.versions[0].id);
workflowsList.appendChild(li);
});
})
.catch((error) => {
console.error("Error fetching workflows:", error);
workflowsLoading.style.display = "none";
workflowsList.style.display = "block";
workflowsList.innerHTML =
"<li style='color: #F44336;'>Error fetching workflows</li>";
});
}
function addButton() {
const menu = document.querySelector(".comfy-menu");
@@ -1881,10 +1971,10 @@ export class ConfigDialog extends ComfyDialog {
export const configDialog = new ConfigDialog();
const currentOrigin = window.location.origin;
// const client = new ComfyDeploy({
// bearerAuth: getData().apiKey,
// serverURL: `${currentOrigin}/comfydeploy/api/`,
// });
const client = new ComfyDeploy({
bearerAuth: getData().apiKey,
serverURL: `${currentOrigin}/comfydeploy/api/`,
});
// Check if the current URL hostname starts with localhost or 127.0.0.1
const isLocalhost =
@@ -1936,7 +2026,7 @@ if (isLocalhost) {
deployButton.onclick = async () => {
await deployWorkflow();
// Refresh the workflows list after deployment
// refreshWorkflowsList(el);
refreshWorkflowsList(el);
};
deployContainer.appendChild(deployButton);
@@ -1964,7 +2054,7 @@ if (isLocalhost) {
const workflowsList = el.querySelector("#workflows-list");
const workflowsLoading = el.querySelector("#workflows-loading");
// refreshWorkflowsList(el);
refreshWorkflowsList(el);
},
});
}
@@ -1984,24 +2074,24 @@ function getTimeAgo(date) {
return Math.floor(seconds) + " seconds ago";
}
// async function loadWorkflowApi(versionId) {
// try {
// const response = await client.comfyui.getWorkflowVersionVersionId({
// versionId: versionId,
// });
// // Implement the logic to load the workflow API into the ComfyUI interface
// console.log("Workflow API loaded:", response);
// await window["app"].ui.settings.setSettingValueAsync(
// "Comfy.Validation.Workflows",
// true,
// );
// app.loadGraphData(response.workflow);
// // You might want to update the UI or trigger some action in ComfyUI here
// } catch (error) {
// console.error("Error loading workflow API:", error);
// // Show an error message to the user
// }
// }
async function loadWorkflowApi(versionId) {
try {
const response = await client.comfyui.getWorkflowVersionVersionId({
versionId: versionId,
});
// Implement the logic to load the workflow API into the ComfyUI interface
console.log("Workflow API loaded:", response);
await window["app"].ui.settings.setSettingValueAsync(
"Comfy.Validation.Workflows",
true,
);
app.loadGraphData(response.workflow);
// You might want to update the UI or trigger some action in ComfyUI here
} catch (error) {
console.error("Error loading workflow API:", error);
// Show an error message to the user
}
}
const orginal_fetch_api = api.fetchApi;
api.fetchApi = async (route, options) => {