Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebc71a7d72 | ||
|
|
9076b4c0ce | ||
|
|
e34d1651f7 | ||
|
|
1c0a4d5950 | ||
|
|
1a8e089c7d | ||
|
|
557799cdb1 | ||
|
|
d5f52131db | ||
|
|
755399a255 | ||
|
|
ee3717b4b3 | ||
|
|
c13828af56 | ||
|
|
ffda951cfa | ||
|
|
ac0d8ba725 | ||
|
|
31c38ddb9b | ||
|
|
2f2c63fa11 | ||
|
|
7675c5ba90 | ||
|
|
7d65d23f84 | ||
|
|
a91edf6371 | ||
|
|
967709dd07 | ||
|
|
07a827d56b | ||
|
|
7f6eea361e | ||
|
|
14829a2b12 | ||
|
|
03c4e2d85d | ||
|
|
bad8b1104f | ||
|
|
ac8779dc54 | ||
|
|
2a5223f2f0 | ||
|
|
f02aef4cb3 | ||
|
|
22458a1cd6 | ||
|
|
0a58eba554 | ||
|
|
def54df9c2 | ||
|
|
32a950afe8 | ||
|
|
8130779d94 | ||
|
|
4cbd2a8225 | ||
|
|
d6fb2daeff | ||
|
|
a91effe3c8 | ||
|
|
c015b710fe | ||
|
|
2738d1913a | ||
|
|
3f4c11e3f1 |
@@ -2,6 +2,10 @@
|
||||
|
||||
Open source comfyui deployment platform, a `vercel` for generative workflow infra. (serverless hosted gpu with vertical intergation with comfyui)
|
||||
|
||||
Check out our latest lcoal demo -> https://github.com/comfy-deploy/comfyui-api-comfydeploy
|
||||
|
||||
Full backend and frontend is here -> https://github.com/comfy-deploy/comfydeploy
|
||||
|
||||
> [!NOTE]
|
||||
> Im looking for creative hacker to join ComfyDeploy's core team! DM me on [twitter](https://x.com/BennyKokMusic)
|
||||
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
@@ -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)"
|
||||
}
|
||||
@@ -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)"
|
||||
}
|
||||
@@ -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)"
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import folder_paths
|
||||
|
||||
|
||||
class AnyType(str):
|
||||
def __ne__(self, __value: object) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
WILDCARD = AnyType("*")
|
||||
|
||||
|
||||
class ComfyUIDeployExternalFile:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"input_id": (
|
||||
"STRING",
|
||||
{"multiline": False, "default": "input_file"},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"display_name": (
|
||||
"STRING",
|
||||
{"multiline": False, "default": ""},
|
||||
),
|
||||
"description": (
|
||||
"STRING",
|
||||
{"multiline": True, "default": ""},
|
||||
),
|
||||
"file_url": (
|
||||
"STRING",
|
||||
{"multiline": False, "default": ""},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = (WILDCARD,)
|
||||
RETURN_NAMES = ("path",)
|
||||
FUNCTION = "run"
|
||||
CATEGORY = "🔗ComfyDeploy"
|
||||
|
||||
def run(
|
||||
self,
|
||||
input_id,
|
||||
display_name=None,
|
||||
description=None,
|
||||
file_url=None,
|
||||
):
|
||||
import requests
|
||||
import os
|
||||
import uuid
|
||||
from urllib.parse import urlparse
|
||||
|
||||
if file_url:
|
||||
if file_url.startswith("http"):
|
||||
# Use cache directory for saving files
|
||||
cache_dir = folder_paths.get_temp_directory()
|
||||
if not os.path.exists(cache_dir):
|
||||
os.makedirs(cache_dir)
|
||||
|
||||
# Always generate random filename to avoid conflicts
|
||||
parsed_url = urlparse(file_url)
|
||||
original_filename = os.path.basename(parsed_url.path)
|
||||
|
||||
# Extract file extension from original filename if available
|
||||
file_extension = ""
|
||||
if original_filename and "." in original_filename:
|
||||
file_extension = os.path.splitext(original_filename)[1]
|
||||
else:
|
||||
# Try to determine extension from content-type if no extension found
|
||||
file_extension = ".bin"
|
||||
|
||||
# Generate random filename with preserved extension
|
||||
filename = str(uuid.uuid4()) + file_extension
|
||||
|
||||
destination_path = os.path.join(cache_dir, filename)
|
||||
print(f"Cache directory: {cache_dir}")
|
||||
print(f"Destination path: {destination_path}")
|
||||
print(
|
||||
"Downloading external file - "
|
||||
+ file_url
|
||||
+ " to "
|
||||
+ destination_path
|
||||
)
|
||||
|
||||
headers = {"User-Agent": "Mozilla/5.0"}
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
file_url,
|
||||
headers=headers,
|
||||
allow_redirects=True,
|
||||
timeout=30, # Add timeout to prevent hanging
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
with open(destination_path, "wb") as out_file:
|
||||
out_file.write(response.content)
|
||||
print(f"External file downloaded: {file_url} to {destination_path}")
|
||||
return (destination_path,)
|
||||
|
||||
except requests.exceptions.HTTPError as e:
|
||||
error_msg = f"HTTP Error {e.response.status_code}: {e.response.reason} for URL: {file_url}"
|
||||
print(f"⚠️ Download failed - {error_msg}")
|
||||
if e.response.status_code == 404:
|
||||
print(
|
||||
"💡 This URL might have expired or the file may have been deleted"
|
||||
)
|
||||
# Return empty string instead of crashing
|
||||
return ("",)
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
error_msg = (
|
||||
f"Network error downloading file from {file_url}: {str(e)}"
|
||||
)
|
||||
print(f"⚠️ Download failed - {error_msg}")
|
||||
return ("",)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = (
|
||||
f"Unexpected error downloading file from {file_url}: {str(e)}"
|
||||
)
|
||||
print(f"⚠️ Download failed - {error_msg}")
|
||||
return ("",)
|
||||
else:
|
||||
print(f"External file loading: {file_url}")
|
||||
return (file_url,)
|
||||
else:
|
||||
print(f"No file URL provided")
|
||||
return ("",)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {"ComfyUIDeployExternalFile": ComfyUIDeployExternalFile}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"ComfyUIDeployExternalFile": "External File (ComfyUI Deploy)"
|
||||
}
|
||||
@@ -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)"
|
||||
}
|
||||
@@ -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)"
|
||||
}
|
||||
@@ -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)"
|
||||
}
|
||||
@@ -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)"
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
# 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)"}
|
||||
@@ -0,0 +1,168 @@
|
||||
import folder_paths
|
||||
import os
|
||||
import shutil
|
||||
import uuid
|
||||
|
||||
|
||||
class AnyType(str):
|
||||
def __ne__(self, __value: object) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
WILDCARD = AnyType("*")
|
||||
|
||||
|
||||
class ComfyDeployOutputFile:
|
||||
def __init__(self):
|
||||
self.output_dir = folder_paths.get_output_directory()
|
||||
self.type = "output"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"file_path": (
|
||||
"STRING",
|
||||
{
|
||||
"forceInput": True,
|
||||
"tooltip": "Path to the file to output and upload.",
|
||||
},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"output_id": (
|
||||
"STRING",
|
||||
{"multiline": False, "default": "output_file"},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
FUNCTION = "run"
|
||||
OUTPUT_NODE = True
|
||||
CATEGORY = "🔗ComfyDeploy"
|
||||
DESCRIPTION = "Outputs any file by path for upload to ComfyDeploy."
|
||||
|
||||
def run(self, file_path, output_id="output_file"):
|
||||
if not file_path or not os.path.exists(file_path):
|
||||
print(f"⚠️ File not found: {file_path}")
|
||||
return {"ui": {"files": []}}
|
||||
|
||||
# Security checks - ensure file is within safe ComfyUI paths
|
||||
try:
|
||||
# Get absolute paths for comparison
|
||||
file_abs_path = os.path.abspath(file_path)
|
||||
base_path = folder_paths.base_path
|
||||
temp_dir = folder_paths.get_temp_directory()
|
||||
|
||||
# Check if file is within ComfyUI base path or temp directory
|
||||
if not (
|
||||
file_abs_path.startswith(os.path.abspath(base_path))
|
||||
or file_abs_path.startswith(os.path.abspath(temp_dir))
|
||||
):
|
||||
print(f"⚠️ Security: File outside allowed ComfyUI paths: {file_path}")
|
||||
return {"ui": {"files": []}}
|
||||
|
||||
# Check for path traversal attempts (but allow absolute paths within ComfyUI)
|
||||
if ".." in file_path:
|
||||
print(f"⚠️ Security: Path traversal attempt detected: {file_path}")
|
||||
return {"ui": {"files": []}}
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠️ Security check failed: {str(e)}")
|
||||
return {"ui": {"files": []}}
|
||||
|
||||
# Get the original filename and extension
|
||||
original_filename = os.path.basename(file_path)
|
||||
file_extension = os.path.splitext(original_filename)[1]
|
||||
|
||||
# Additional filename security check
|
||||
if ".." in original_filename:
|
||||
print(f"⚠️ Security: Insecure filename: {original_filename}")
|
||||
return {"ui": {"files": []}}
|
||||
|
||||
results = []
|
||||
|
||||
# Check if file is in output folder, if not, symlink it there
|
||||
try:
|
||||
if file_path.startswith(self.output_dir):
|
||||
# File is already in output directory - use as is
|
||||
relative_path = os.path.relpath(file_path, self.output_dir)
|
||||
path_parts = relative_path.split(os.sep)
|
||||
|
||||
if len(path_parts) > 1:
|
||||
subfolder = os.sep.join(path_parts[:-1])
|
||||
else:
|
||||
subfolder = ""
|
||||
|
||||
filename = path_parts[-1]
|
||||
file_type = self.type
|
||||
else:
|
||||
# File is not in output folder - symlink it to output/temp
|
||||
print(
|
||||
f"File is not in output folder, symlinking to output/temp: {file_path}"
|
||||
)
|
||||
output_temp_dir = os.path.join(self.output_dir, "temp")
|
||||
if not os.path.exists(output_temp_dir):
|
||||
os.makedirs(output_temp_dir)
|
||||
|
||||
# Use the existing filename but with UUID prefix to avoid conflicts
|
||||
file_ext = os.path.splitext(original_filename)[1]
|
||||
temp_filename = f"{uuid.uuid4()}{file_ext}"
|
||||
temp_path = os.path.join(output_temp_dir, temp_filename)
|
||||
|
||||
# Create symlink to file in output/temp directory where upload system expects it
|
||||
try:
|
||||
# Remove existing symlink if it exists
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
os.symlink(file_path, temp_path)
|
||||
print(f"File symlinked to output/temp: {temp_path} -> {file_path}")
|
||||
except OSError as e:
|
||||
# Fall back to copying if symlink fails
|
||||
print(f"Symlink failed ({e}), falling back to copy")
|
||||
shutil.copy2(file_path, temp_path)
|
||||
print(f"File copied to output/temp: {temp_path}")
|
||||
|
||||
# Use output/temp directory structure for upload
|
||||
subfolder = "temp"
|
||||
filename = temp_filename
|
||||
file_type = self.type
|
||||
|
||||
results.append(
|
||||
{
|
||||
"filename": filename,
|
||||
"subfolder": subfolder,
|
||||
"type": file_type,
|
||||
"output_id": output_id,
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error processing file path: {str(e)}")
|
||||
return {"ui": {"files": []}}
|
||||
|
||||
# Determine the appropriate UI key based on file type
|
||||
file_ext = file_extension.lower()
|
||||
if file_ext in [".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tiff"]:
|
||||
ui_key = "images"
|
||||
elif file_ext in [".mp3", ".wav", ".flac", ".aac", ".ogg"]:
|
||||
ui_key = "audio"
|
||||
elif file_ext in [".txt", ".json", ".md", ".csv"]:
|
||||
ui_key = "text_file"
|
||||
elif file_ext in [".exr", ".hdr"]:
|
||||
ui_key = "images" # EXR files are still images
|
||||
elif file_ext in [".zip", ".psb", ".psd"]:
|
||||
ui_key = "files" # Archives and Photoshop project files
|
||||
else:
|
||||
ui_key = "files" # Generic files
|
||||
|
||||
return {"ui": {ui_key: results}}
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"ComfyDeployOutputFile": ComfyDeployOutputFile,
|
||||
}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"ComfyDeployOutputFile": "File Output (ComfyDeploy)",
|
||||
}
|
||||
@@ -33,6 +33,7 @@ class ComfyDeployOutputImage:
|
||||
"STRING",
|
||||
{"multiline": False, "default": "output_images"},
|
||||
),
|
||||
"remove_metadata": ("BOOLEAN", {"default": True}),
|
||||
},
|
||||
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
|
||||
}
|
||||
@@ -53,6 +54,7 @@ class ComfyDeployOutputImage:
|
||||
output_id="output_images",
|
||||
prompt=None,
|
||||
extra_pnginfo=None,
|
||||
remove_metadata=True,
|
||||
):
|
||||
filename_prefix += self.prefix_append
|
||||
full_output_folder, filename, counter, subfolder, filename_prefix = (
|
||||
@@ -65,11 +67,12 @@ class ComfyDeployOutputImage:
|
||||
i = 255.0 * image.cpu().numpy()
|
||||
img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8))
|
||||
metadata = PngInfo()
|
||||
if prompt is not None:
|
||||
metadata.add_text("prompt", json.dumps(prompt))
|
||||
if extra_pnginfo is not None:
|
||||
for x in extra_pnginfo:
|
||||
metadata.add_text(x, json.dumps(extra_pnginfo[x]))
|
||||
if not remove_metadata:
|
||||
if prompt is not None:
|
||||
metadata.add_text("prompt", json.dumps(prompt))
|
||||
if extra_pnginfo is not None:
|
||||
for x in extra_pnginfo:
|
||||
metadata.add_text(x, json.dumps(extra_pnginfo[x]))
|
||||
|
||||
filename_with_batch_num = filename.replace("%batch_num%", str(batch_number))
|
||||
file = f"{filename_with_batch_num}_{counter:05}_.{file_type}"
|
||||
|
||||
+851
-80
File diff suppressed because it is too large
Load Diff
+1
-16
@@ -18,6 +18,7 @@ class Status(Enum):
|
||||
SUCCESS = "success"
|
||||
FAILED = "failed"
|
||||
UPLOADING = "uploading"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class StreamingPrompt(BaseModel):
|
||||
@@ -56,27 +57,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]
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-deploy"
|
||||
description = "Open source comfyui deployment platform, a vercel for generative workflow infra."
|
||||
version = "2.1.0"
|
||||
version = "2.3.7"
|
||||
license = { file = "LICENSE" }
|
||||
dependencies = ["aiofiles", "pydantic", "opencv-python", "imageio-ffmpeg", "tabulate", "brotli"]
|
||||
|
||||
|
||||
+1294
-429
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,68 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user