Compare commits

...
47 Commits
Author SHA1 Message Date
impactframes d84d71541c all new nodes working under EXR tab category 2025-06-15 13:08:42 +01:00
impactframes e8895028a2 Improves external EXR adding Frames and Input / Output EXR single images and Sequences from Cloud Storage 2025-06-15 02:08:19 +01:00
impactframes b5c00450de locally tested websockets IO 2025-06-14 22:24:39 +01:00
impactframes 59083391c7 exr handling nodes fix 2025-06-14 11:02:27 +01:00
impactframes 8f2adacc20 exr handling nodes 2025-06-14 10:34:43 +01:00
KarrixLee 089bad5560 Revert "Implement enhanced media preview functionality in ComfyUI"
This reverts commit 46010e1dd5.
2025-06-09 16:09:38 +08:00
KarrixLee 46010e1dd5 Implement enhanced media preview functionality in ComfyUI
- Added support for video previews alongside existing image previews.
- Introduced helper functions to detect video URLs and display media accordingly.
- Updated URL widget handling to show the appropriate media type based on the input URL.
- Improved error handling for media loading failures.

This change enhances the user experience by providing a seamless way to preview both images and videos.
2025-06-08 19:31:47 +08:00
KarrixLee 52d876fa67 Enhance ComfyUIDeployExternalVideo to support default video URL input
- Added 'default_value_url' parameter to allow fetching videos from a specified URL if the input_id is not a URL.
- Updated the logic to handle video fetching, ensuring it uses the correct URL based on the input.
- Improved the return structure for optional parameters, including the new 'default_value_url' with image preview support.

This change enhances flexibility in video input handling for the ComfyUI Deploy.
2025-06-08 19:24:13 +08:00
BennyKokandDevin AI f7e7eb19d0 Add ComfyUIDeployExternalNumberSliderInt node (#95)
- Implements integer slider node for ComfyUI Deploy
- Returns INT type instead of FLOAT for proper integer input compatibility
- Uses int(round(float(input_id))) for robust conversion
- Default range 0-10 with step=1 for integer appropriateness
- Includes proper range validation and error handling
- Frontend and API support already exists

Fixes COM-1041

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-06-06 12:38:18 +08:00
ImpactFrames cb03b6718e racing condition delete check if [prompt_id] exist (#94) 2025-06-05 16:42:37 +08:00
BennyKok 7b734c415a fix: import 2025-05-27 15:14:29 +08:00
BennyKok 64d3ec6b45 Revert "fix: import issues"
This reverts commit c47865ec26.
2025-05-27 15:12:33 +08:00
bennykok c47865ec26 fix: import issues 2025-05-21 19:10:20 +08:00
BennyKok b889f79baf Merge branch 'benny/support-comfy-api-key' 2025-05-12 17:06:08 +08:00
KarrixLee 1d8fed3534 feat: only register sidebar tab for Comfy Deploy on localhost 2025-05-12 12:38:23 +08:00
BennyKok a557788e70 fix 2025-05-11 10:57:07 +08:00
BennyKok 05cccaffa2 support for API_KEY_COMFY_ORG 2025-05-11 10:41:10 +08:00
KarrixLee 85af9dd68f Test (#91)
* feat: enhance apply_random_seed_to_workflow function to support KSampler node type and handle fixed seed settings

* refactor: add flag to skip randomization in apply_random_seed_to_workflow for KSampler nodes

* tweak
2025-05-10 13:13:02 +08:00
KarrixLee 233615ea25 Karrix/external seed (#90)
* feat: add ComfyUIDeployExternalSeed node for generating random seeds with configurable limits

* refactor: update ComfyUIDeployExternalSeed to use control options for seed generation

* feat: add default_value input for ComfyUIDeployExternalSeed node to enhance seed configuration

* test

* refactor: rename lower_limit and upper_limit to min_value and max_value in ComfyUIDeployExternalSeed for clarity

* refactor: update default_value handling and control options in ComfyUIDeployExternalSeed for improved seed generation logic

* refactor: enhance seed generation logic in ComfyUIDeployExternalSeed by refining control handling and default_value checks

* refactor: update description for default_value in ComfyUIDeployExternalSeed to clarify usage and behavior
2025-05-10 03:39:27 +08:00
KarrixLee 8b6aabbfaa feat: add support for 'result' file type in upload process 2025-05-07 21:42:13 +08:00
Nick Kao c72539078c Merge pull request #89 from BennyKok/nick/private-lora-download
feat: add bearer token param in external lora
2025-05-03 10:28:30 -07:00
KarrixLee 95fc642782 Karrix/fix preview image upload queue (#88)
* refactor: update upload completion logic to rely on UploadQueue worker for final SUCCESS status

* refactor: clean up commented-out code in upload completion logic
2025-04-28 15:56:58 +08:00
KarrixLee d88ca5b748 refactor: rename 'text' to 'text_file' for consistency in file handling 2025-04-25 20:03:02 +08:00
KarrixLee e86a484160 refactor: improve logging format and add validation for file_info in upload process 2025-04-25 13:40:38 +08:00
KarrixLee 5d85bfd38f fix: ensure temp file check handles non-dictionary items 2025-04-25 13:29:34 +08:00
BennyKok 77eb9f9805 Merge branch 'benny/fix-preview-image-stuck' 2025-04-24 15:29:16 +08:00
KarrixLee 266e9d1024 refactor: enhance audio loading with error handling and import checks 2025-04-23 13:36:01 +08:00
KarrixLee dc7234640c tweak 2025-04-22 18:30:34 +08:00
KarrixLee 4e8417d501 add: output text node 2025-04-22 18:15:44 +08:00
BennyKok 62609f9c6a Benny/fix preview image stuck (#87)
* Fix preview image loading and add support for 3D model uploads

* skip: sending the upload status, make sure, we are also sending the success status after all files is uploaded
2025-04-22 12:59:20 +08:00
BennyKok 438401b8c7 fix: external enum node value replace 2025-04-22 10:27:55 +08:00
BennyKok 9beac36d0f skip: sending the upload status, make sure, we are also sending the success status after all files is uploaded 2025-04-21 14:18:18 +08:00
BennyKok 313ce956fd Fix preview image loading and add support for 3D model uploads 2025-04-21 14:08:56 +08:00
BennyKok e18d980b77 Benny/fix 3d upload (#85)
* fix 3d upload with subfolder support and enhanced logging

* Use parent folder name as subfolder for model file uploads
2025-04-20 22:54:51 +08:00
bennykok fe2d9b82b5 fix: extra options form enum list for some node 2025-04-16 17:09:51 +08:00
bennykok 22858abb31 feat: add external enum node 2025-04-16 16:18:56 +08:00
bennykok 99a5f71b2c feat: add external enum node 2025-04-16 11:52:20 +08:00
bennykok 1810d4ecdb feat: improvement to convert external inputs system 2025-04-16 01:12:28 +08:00
bennykok 3593f0b37e fix: convert external inputs layout issues 2025-04-16 00:10:39 +08:00
bennykok 858a3bcda4 fix rendering problems 2025-04-14 18:08:42 +08:00
bennykok 484147f5b4 fix convert shortcut for string type 2025-04-14 17:59:57 +08:00
Vivek 61a8f1123e fixed a minor issue related to external inputs (#84) 2025-04-14 17:56:27 +08:00
EmmanuelMr18 46b056290d fix(workflows): correct typo in masks example workflow name 2025-04-07 01:31:17 -06:00
EmmanuelMr18 2cf0497823 chore(workflows): convert example workflow previews to JPG format 2025-04-07 01:27:01 -06:00
EmmanuelMr18 4b5eec4e4c feat(workflows): add example workflows for ComfyUI templates 2025-04-07 01:20:46 -06:00
Emmanuel Morales 17c48c7d4f chore: remove model_list custom node
This node was created just for an experiment in the previous year, but never was a core feature.

I'm removing it because looks that is braking the import in local machines because i'm seeing this error locally:

```
  File "F:\ComfyUI\custom_nodes\comfyui-deploy\comfy-nodes\model_list.py", line 35, in <module>
    allModels = fetch_files("./models")
                ^^^^^^^^^^^^^^^^^^^^^^^
  File "F:\ComfyUI\custom_nodes\comfyui-deploy\comfy-nodes\model_list.py", line 23, in fetch_files
    fs.extend(fetch_files(f"{dirpath}/{dirname}"))
              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "F:\ComfyUI\custom_nodes\comfyui-deploy\comfy-nodes\model_list.py", line 23, in fetch_files
    fs.extend(fetch_files(f"{dirpath}/{dirname}"))
TypeError: 'NoneType' object is not iterable

Cannot import F:\ComfyUI\custom_nodes\comfyui-deploy module for custom nodes: 'NoneType' object is not iterable
```


I'm not sure why, previously was working but was a long time ago and this node was just an experiment, so i'm deleting it
2025-04-05 21:28:37 -06:00
BennyKok cd3a2ff547 Update custom_routes.py
random seed for XlabsSampler
2025-04-03 16:58:49 +02:00
25 changed files with 2729 additions and 502 deletions
+37 -1
View File
@@ -2,8 +2,9 @@
@author: BennyKok
@title: comfyui-deploy
@nickname: Comfy Deploy
@description:
@description:
"""
import os
import sys
@@ -17,19 +18,23 @@ import requests
import folder_paths
from folder_paths import add_model_folder_path, get_filename_list, get_folder_paths
from tqdm import tqdm
import re
from . import custom_routes
# import routes
ag_path = os.path.join(os.path.dirname(__file__))
def get_python_files(path):
return [f[:-3] for f in os.listdir(path) if f.endswith(".py")]
def append_to_sys_path(path):
if path not in sys.path:
sys.path.append(path)
paths = ["comfy-nodes"]
files = []
@@ -41,14 +46,45 @@ for path in paths:
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
def split_camel_case(name):
# Split on underscores first, then split each part on camelCase
parts = []
for part in name.split("_"):
# Find all camelCase boundaries
words = re.findall("[A-Z][^A-Z]*", part)
if not words: # If no camelCase found, use the whole part
words = [part]
parts.extend(words)
return parts
# Import all the modules and append their mappings
for file in files:
module = importlib.import_module(file)
# Check if the module has explicit mappings
if hasattr(module, "NODE_CLASS_MAPPINGS"):
NODE_CLASS_MAPPINGS.update(module.NODE_CLASS_MAPPINGS)
if hasattr(module, "NODE_DISPLAY_NAME_MAPPINGS"):
NODE_DISPLAY_NAME_MAPPINGS.update(module.NODE_DISPLAY_NAME_MAPPINGS)
# Auto-discover classes with ComfyUI node attributes
for name, obj in inspect.getmembers(module):
# Check if it's a class and has the required ComfyUI node attributes
if (
inspect.isclass(obj)
and hasattr(obj, "INPUT_TYPES")
and hasattr(obj, "RETURN_TYPES")
):
# Use the class name as the key if not already in mappings
if name not in NODE_CLASS_MAPPINGS:
NODE_CLASS_MAPPINGS[name] = obj
# Create a display name by converting camelCase to Title Case with spaces
words = split_camel_case(name.replace("ComfyUIDeploy", ""))
display_name = " ".join(word.capitalize() for word in words)
# print(display_name, name)
NODE_DISPLAY_NAME_MAPPINGS[name] = display_name
WEB_DIRECTORY = "web-plugin"
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
+45 -21
View File
@@ -1,14 +1,13 @@
import os
import io
import torchaudio
from folder_paths import get_annotated_filepath
class ComfyUIDeployExternalAudio:
RETURN_TYPES = ("AUDIO",)
RETURN_NAMES = ("audio",)
FUNCTION = "load_audio"
CATEGORY = "🔗ComfyDeploy"
@classmethod
def INPUT_TYPES(cls):
return {
@@ -29,30 +28,55 @@ class ComfyUIDeployExternalAudio:
"STRING",
{"multiline": False, "default": ""},
),
}
},
}
@classmethod
def VALIDATE_INPUTS(s, audio_file, **kwargs):
return True
def load_audio(self, input_id, audio_file, default_value=None, display_name=None, description=None):
if audio_file and audio_file != "":
if audio_file.startswith(('http://', 'https://')):
# Handle URL input
import requests
response = requests.get(audio_file)
audio_data = io.BytesIO(response.content)
waveform, sample_rate = torchaudio.load(audio_data)
def load_audio(
self,
input_id,
audio_file,
default_value=None,
display_name=None,
description=None,
):
try:
import torchaudio
if audio_file and audio_file != "":
if audio_file.startswith(("http://", "https://")):
# Handle URL input
try:
import requests
response = requests.get(audio_file)
audio_data = io.BytesIO(response.content)
waveform, sample_rate = torchaudio.load(audio_data)
except Exception as e:
print(f"Error loading audio from URL: {e}")
return (default_value,)
else:
# Handle local file
try:
audio_path = get_annotated_filepath(audio_file)
waveform, sample_rate = torchaudio.load(audio_path)
except Exception as e:
print(f"Error loading local audio file: {e}")
return (default_value,)
audio = {"waveform": waveform.unsqueeze(0), "sample_rate": sample_rate}
return (audio,)
else:
# Handle local file
audio_path = get_annotated_filepath(audio_file)
waveform, sample_rate = torchaudio.load(audio_path)
audio = {"waveform": waveform.unsqueeze(0), "sample_rate": sample_rate}
return (audio,)
else:
return (default_value,)
except ImportError as e:
print(f"Error: torchaudio not installed or cannot be imported: {e}")
return (default_value,)
NODE_CLASS_MAPPINGS = {"ComfyUIDeployExternalAudio": ComfyUIDeployExternalAudio}
NODE_DISPLAY_NAME_MAPPINGS = {"ComfyUIDeployExternalAudio": "External Audio (ComfyUI Deploy)"}
NODE_DISPLAY_NAME_MAPPINGS = {
"ComfyUIDeployExternalAudio": "External Audio (ComfyUI Deploy)"
}
+46
View File
@@ -0,0 +1,46 @@
class AnyType(str):
def __ne__(self, __value: object) -> bool:
return False
WILDCARD = AnyType("*")
class ComfyUIDeployExternalEnum:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"input_id": (
"STRING",
{"multiline": False, "default": "input_enum"},
),
},
"optional": {
"default_value": (
"STRING",
{"multiline": False, "default": "", "dynamic_enum": True},
),
"options": (
"STRING",
{"multiline": True, "default": ""},
),
"display_name": (
"STRING",
{"multiline": False, "default": ""},
),
"description": (
"STRING",
{"multiline": True, "default": ""},
),
}
}
RETURN_TYPES = (WILDCARD,)
RETURN_NAMES = ("text",)
FUNCTION = "run"
CATEGORY = "🔗ComfyDeploy"
def run(self, input_id, options=None, default_value=None, display_name=None, description=None):
return [default_value]
-110
View File
@@ -1,110 +0,0 @@
import os
import io
import cv2 as cv
import numpy as np
import torch
import requests
from folder_paths import get_annotated_filepath
class ComfyUIDeployExternalEXR:
RETURN_TYPES = ("IMAGE", "MASK")
RETURN_NAMES = ("image", "mask")
FUNCTION = "load_exr"
CATEGORY = "🔗ComfyDeploy"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"input_id": (
"STRING",
{"multiline": False, "default": "input_exr"},
),
"exr_file": ("STRING", {"default": ""}),
"tonemap": (["linear", "sRGB", "Reinhard"], {"default": "sRGB"}),
},
"optional": {
"default_image": ("IMAGE",),
"default_mask": ("MASK",),
"display_name": (
"STRING",
{"multiline": False, "default": ""},
),
"description": (
"STRING",
{"multiline": False, "default": ""},
),
}
}
@classmethod
def VALIDATE_INPUTS(s, exr_file, **kwargs):
return True
def sRGBtoLinear(self, npArray):
less = npArray <= 0.0404482362771082
npArray[less] = npArray[less] / 12.92
npArray[~less] = np.power((npArray[~less] + 0.055) / 1.055, 2.4)
def linearToSRGB(self, npArray):
less = npArray <= 0.0031308
npArray[less] = npArray[less] * 12.92
npArray[~less] = np.power(npArray[~less], 1/2.4) * 1.055 - 0.055
def load_exr(self, input_id, exr_file, tonemap="sRGB",
default_image=None, default_mask=None,
display_name=None, description=None):
try:
if exr_file and exr_file != "":
if exr_file.startswith(('http://', 'https://')):
# Handle URL input
response = requests.get(exr_file)
# Write to temp buffer
buffer = io.BytesIO(response.content)
nparr = np.frombuffer(buffer.getvalue(), np.uint8)
image = cv.imdecode(nparr, cv.IMREAD_UNCHANGED).astype(np.float32)
else:
# Handle local file
exr_path = get_annotated_filepath(exr_file)
image = cv.imread(exr_path, cv.IMREAD_UNCHANGED).astype(np.float32)
if len(image.shape) == 2:
image = np.repeat(image[..., np.newaxis], 3, axis=2)
# Extract RGB and flip channels
rgb = np.flip(image[:,:,:3], 2).copy()
# Apply tonemapping
if tonemap == "sRGB":
self.linearToSRGB(rgb)
rgb = np.clip(rgb, 0, 1)
elif tonemap == "Reinhard":
rgb = np.clip(rgb, 0, None)
rgb = rgb / (rgb + 1)
self.linearToSRGB(rgb)
rgb = np.clip(rgb, 0, 1)
rgb = torch.unsqueeze(torch.from_numpy(rgb), 0)
# Handle alpha/mask
mask = torch.zeros((1, image.shape[0], image.shape[1]), dtype=torch.float32)
if image.shape[2] > 3:
mask[0] = torch.from_numpy(np.clip(image[:,:,3], 0, 1))
return (rgb, mask)
else:
# Return defaults if no file provided
return (default_image, default_mask)
except Exception as e:
print(f"Error loading EXR: {str(e)}")
# Return defaults on error
return (default_image, default_mask)
NODE_CLASS_MAPPINGS = {
"ComfyUIDeployExternalEXR": ComfyUIDeployExternalEXR
}
NODE_DISPLAY_NAME_MAPPINGS = {
"ComfyUIDeployExternalEXR": "External EXR (ComfyUI Deploy)"
}
+93
View File
@@ -0,0 +1,93 @@
import os
os.environ["OPENCV_IO_ENABLE_OPENEXR"] = "1"
import cv2 as cv
import numpy as np
import torch
from folder_paths import get_annotated_filepath
def linear_to_srgb(np_array):
"""Converts a linear RGB numpy array to sRGB."""
less = np_array <= 0.0031308
np_array[less] = np_array[less] * 12.92
np_array[~less] = np.power(np_array[~less], 1/2.4) * 1.055 - 0.055
return np_array
class ExternalExrInput:
"""
Node to load a single EXR image from a local file path.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"exr_file": ("STRING", {"default": "path/to/image.exr"}),
"tonemap": (["linear", "sRGB", "Reinhard"], {"default": "sRGB"}),
},
"optional": {
"default_image": ("IMAGE",),
"default_mask": ("MASK",),
}
}
RETURN_TYPES = ("IMAGE", "MASK")
RETURN_NAMES = ("image", "mask",)
FUNCTION = "run"
CATEGORY = "🔗ComfyDeploy/EXR"
def run(self, exr_file, tonemap, default_image=None, default_mask=None):
image = None
try:
if exr_file and exr_file.strip() != "":
exr_path = get_annotated_filepath(exr_file)
if os.path.exists(exr_path):
image = cv.imread(exr_path, cv.IMREAD_UNCHANGED).astype(np.float32)
else:
print(f"Warning: File not found at {exr_path}")
if image is None:
raise ValueError("Image could not be loaded.")
if len(image.shape) == 2: # Grayscale
image = np.repeat(image[..., np.newaxis], 3, axis=2)
rgb = np.flip(image[:, :, :3], 2).copy() # BGR to RGB
# Apply tonemapping
if tonemap == "sRGB":
rgb = linear_to_srgb(rgb)
rgb = np.clip(rgb, 0, 1)
elif tonemap == "Reinhard":
rgb = np.clip(rgb, 0, None)
rgb = rgb / (rgb + 1)
rgb = linear_to_srgb(rgb)
rgb = np.clip(rgb, 0, 1)
rgb_tensor = torch.from_numpy(rgb).unsqueeze(0)
# Handle alpha/mask
if image.shape[2] > 3:
mask = np.clip(image[:, :, 3], 0, 1)
else:
mask = np.ones_like(rgb[:, :, 0])
mask_tensor = torch.from_numpy(mask).unsqueeze(0)
return (rgb_tensor, mask_tensor)
except Exception as e:
print(f"Error loading EXR file '{exr_file}': {e}")
if default_image is not None and default_mask is not None:
print("Returning default image.")
return (default_image, default_mask)
print("Warning: Error loading EXR and no default image. Returning a black image.")
blank_image = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
blank_mask = torch.zeros((1, 64, 64), dtype=torch.float32)
return (blank_image, blank_mask)
NODE_CLASS_MAPPINGS = {
"ExternalExrInput": ExternalExrInput
}
NODE_DISPLAY_NAME_MAPPINGS = {
"ExternalExrInput": "External EXR Input (ComfyDeploy)"
}
+73
View File
@@ -0,0 +1,73 @@
import os
os.environ["OPENCV_IO_ENABLE_OPENEXR"] = "1"
import cv2 as cv
import torch
import numpy as np
import folder_paths
def srgb_to_linear(np_array):
"""Converts an sRGB numpy array to linear RGB."""
less = np_array <= 0.0404482362771082
np_array[less] = np_array[less] / 12.92
np_array[~less] = np.power((np_array[~less] + 0.055) / 1.055, 2.4)
return np_array
class ExternalExrOutput:
"""
Node to save a single image as an EXR file to a local path.
"""
def __init__(self):
self.output_dir = folder_paths.get_output_directory()
self.type = "output"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE",),
"filepath": ("STRING", {"default": "/tmp/output.exr"}),
"tonemap": (["linear", "sRGB"], {"default": "linear"}),
},
}
RETURN_TYPES = ()
FUNCTION = "run"
OUTPUT_NODE = True
CATEGORY = "🔗ComfyDeploy/EXR"
def run(self, images, filepath, tonemap):
if not filepath.endswith(".exr"):
raise ValueError("Filepath must end with '.exr'")
output_dir = os.path.dirname(filepath)
if not os.path.isabs(output_dir):
raise ValueError("Filepath must be an absolute path.")
os.makedirs(output_dir, exist_ok=True)
# We only process the first image in the batch
image_tensor = images[0]
linear = image_tensor.cpu().numpy().astype(np.float32)
# If the source is sRGB, convert to linear
if tonemap == "sRGB":
linear[...,:3] = srgb_to_linear(linear[...,:3])
# Convert RGB to BGR for OpenCV
bgr = np.flip(linear, 2).copy()
# Save the image
cv.imwrite(filepath, bgr)
print(f"Saved EXR file to: {filepath}")
return {"ui": {"images": [{"filename": os.path.basename(filepath), "subfolder": os.path.dirname(filepath), "type": self.type}]}}
NODE_CLASS_MAPPINGS = {
"ExternalExrOutput": ExternalExrOutput
}
NODE_DISPLAY_NAME_MAPPINGS = {
"ExternalExrOutput": "External EXR Output (ComfyDeploy)"
}
+159
View File
@@ -0,0 +1,159 @@
import os
os.environ["OPENCV_IO_ENABLE_OPENEXR"] = "1"
import cv2 as cv
import numpy as np
import torch
import re
from folder_paths import get_annotated_filepath
def linear_to_srgb(np_array):
"""Converts a linear RGB numpy array to sRGB."""
less = np_array <= 0.0031308
np_array[less] = np_array[less] * 12.92
np_array[~less] = np.power(np_array[~less], 1/2.4) * 1.055 - 0.055
return np_array
class ExternalExrSequenceInput:
"""
Node to load a sequence of EXR images from a local filepath pattern, a directory,
or a single file within a sequence.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"path_or_pattern": ("STRING", {"default": "path/to/frames_or_pattern"}),
"tonemap": (["linear", "sRGB", "Reinhard"], {"default": "sRGB"}),
"start_frame": ("INT", {"default": 1, "min": 1}),
"end_frame": ("INT", {"default": 50, "min": 1}),
},
"optional": {
"default_image": ("IMAGE",),
"default_mask": ("MASK",),
}
}
RETURN_TYPES = ("IMAGE", "MASK")
RETURN_NAMES = ("image", "mask",)
FUNCTION = "run"
CATEGORY = "🔗ComfyDeploy/EXR"
def get_image_paths(self, path_input, start_frame, end_frame):
image_paths = []
# Case 1: Input is a C-style pattern
if '%' in path_input:
print(f"Pattern detected: {path_input}")
for i in range(start_frame, end_frame + 1):
fpath = get_annotated_filepath(path_input % i)
if os.path.exists(fpath):
image_paths.append(fpath)
return image_paths
annotated_path = get_annotated_filepath(path_input)
# Case 2: Input is a directory
if os.path.isdir(annotated_path):
print(f"Directory detected: {annotated_path}")
files_in_dir = sorted(os.listdir(annotated_path))
for filename in files_in_dir:
if not filename.lower().endswith('.exr'):
continue
matches = re.findall(r'\d+', filename)
if not matches:
continue
frame_number = int(matches[-1])
if start_frame <= frame_number <= end_frame:
image_paths.append(os.path.join(annotated_path, filename))
return image_paths
# Case 3: Input is a single file from a sequence
if os.path.isfile(annotated_path):
print(f"Single file detected: {annotated_path}. Attempting to find sequence.")
base_dir = os.path.dirname(annotated_path)
filename = os.path.basename(annotated_path)
matches = list(re.finditer(r'(\d+)', filename))
if not matches: # It's a single file with no frame number
return [annotated_path]
last_match = matches[-1]
num_start_pos, num_end_pos = last_match.span()
prefix = filename[:num_start_pos]
suffix = filename[num_end_pos:]
padding = len(last_match.group(0))
for i in range(start_frame, end_frame + 1):
potential_filename = f"{prefix}{str(i).zfill(padding)}{suffix}"
potential_path = os.path.join(base_dir, potential_filename)
if os.path.exists(potential_path):
image_paths.append(potential_path)
return image_paths
return [] # Return empty if no cases match
def run(self, path_or_pattern, tonemap, start_frame, end_frame, default_image=None, default_mask=None):
try:
image_paths = self.get_image_paths(path_or_pattern, start_frame, end_frame)
if not image_paths:
raise ValueError(f"No EXR files found for '{path_or_pattern}' between frames {start_frame}-{end_frame}.")
print(f"Found {len(image_paths)} EXR files to load.")
rgb_frames = []
mask_frames = []
for path in image_paths:
image = cv.imread(path, cv.IMREAD_UNCHANGED)
if image is None:
print(f"Warning: Could not read file {path}, skipping.")
continue
image = image.astype(np.float32)
if len(image.shape) == 2:
image = np.repeat(image[..., np.newaxis], 3, axis=2)
rgb = np.flip(image[:, :, :3], 2).copy()
if tonemap == "sRGB":
rgb = linear_to_srgb(rgb)
rgb = np.clip(rgb, 0, 1)
elif tonemap == "Reinhard":
rgb = np.clip(rgb, 0, None)
rgb = rgb / (rgb + 1)
rgb = linear_to_srgb(rgb)
rgb = np.clip(rgb, 0, 1)
rgb_frames.append(torch.from_numpy(rgb))
if image.shape[2] > 3:
mask = np.clip(image[:, :, 3], 0, 1)
else:
mask = np.ones_like(rgb[:, :, 0])
mask_frames.append(torch.from_numpy(mask))
if not rgb_frames:
raise ValueError("No frames were loaded successfully.")
print(f"Successfully loaded {len(rgb_frames)} frames into a batch.")
return (torch.stack(rgb_frames, 0), torch.stack(mask_frames, 0))
except Exception as e:
print(f"Error loading EXR sequence: {e}")
if default_image is not None and default_mask is not None:
print("Returning default image.")
return (default_image, default_mask)
print("Warning: Error loading sequence and no default image. Returning a black image.")
blank_image = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
blank_mask = torch.zeros((1, 64, 64), dtype=torch.float32)
return (blank_image, blank_mask)
NODE_CLASS_MAPPINGS = {
"ExternalExrSequenceInput": ExternalExrSequenceInput
}
NODE_DISPLAY_NAME_MAPPINGS = {
"ExternalExrSequenceInput": "External EXR Sequence Input (ComfyDeploy)"
}
@@ -0,0 +1,88 @@
import os
os.environ["OPENCV_IO_ENABLE_OPENEXR"] = "1"
import cv2 as cv
import torch
import numpy as np
import re
def srgb_to_linear(np_array):
"""Converts an sRGB numpy array to linear RGB."""
less = np_array <= 0.0404482362771082
np_array[less] = np_array[less] / 12.92
np_array[~less] = np.power((np_array[~less] + 0.055) / 1.055, 2.4)
return np_array
class ExternalExrSequenceOutput:
"""
Node to save a sequence of images as EXR files to a local directory.
It uses a filepath pattern like 'path/to/frame_%04d.exr' to save each frame.
"""
def __init__(self):
self.type = "output"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE",),
"filepath_pattern": ("STRING", {"default": "/tmp/exr_sequence/frame_%04d.exr"}),
"tonemap": (["linear", "sRGB"], {"default": "linear"}),
},
}
RETURN_TYPES = ()
FUNCTION = "run"
OUTPUT_NODE = True
CATEGORY = "🔗ComfyDeploy/EXR"
def run(self, images, filepath_pattern, tonemap):
# Basic validation for the filepath pattern
if not re.search(r'%0?\d+d', filepath_pattern):
raise ValueError("Filepath pattern must contain a C-style format specifier like '%04d'.")
if not filepath_pattern.endswith(".exr"):
raise ValueError("Filepath pattern must end with '.exr'.")
output_dir = os.path.dirname(filepath_pattern)
if not os.path.isabs(output_dir):
raise ValueError("Filepath must be an absolute path.")
os.makedirs(output_dir, exist_ok=True)
# Convert tensor to numpy array
linear_images = images.cpu().numpy().astype(np.float32)
# If the source is sRGB, convert to linear
if tonemap == "sRGB":
srgb_to_linear(linear_images[...,:3])
# Convert RGB to BGR for OpenCV
bgr_images = np.flip(linear_images, 3).copy()
results = []
for i, bgr_image in enumerate(bgr_images):
frame_num = i + 1
try:
# Use the pattern to format the full file path
file_path = filepath_pattern % frame_num
except TypeError:
raise ValueError("Invalid format specifier in filepath_pattern. Use '%d', '%04d', etc.")
# Save the image
cv.imwrite(file_path, bgr_image)
results.append({
"filename": os.path.basename(file_path),
"subfolder": os.path.dirname(file_path),
"type": self.type,
})
return {"ui": {"images": results}}
NODE_CLASS_MAPPINGS = {
"ExternalExrSequenceOutput": ExternalExrSequenceOutput
}
NODE_DISPLAY_NAME_MAPPINGS = {
"ExternalExrSequenceOutput": "External EXR Sequence Output (ComfyDeploy)"
}
+10 -5
View File
@@ -1,8 +1,4 @@
import folder_paths
from PIL import Image, ImageOps
import numpy as np
import torch
import folder_paths
class AnyType(str):
@@ -41,6 +37,10 @@ class ComfyUIDeployExternalLora:
"STRING",
{"multiline": False, "default": ""},
),
"bearer_token": (
"STRING",
{"multiline": False, "default": ""},
),
},
}
@@ -57,6 +57,7 @@ class ComfyUIDeployExternalLora:
display_name=None,
description=None,
lora_url=None,
bearer_token=None,
):
import requests
import os
@@ -84,9 +85,13 @@ class ComfyUIDeployExternalLora:
+ " to "
+ destination_path
)
headers = {"User-Agent": "Mozilla/5.0"}
if bearer_token:
headers["Authorization"] = f"Bearer {bearer_token}"
print("using bearer token")
response = requests.get(
lora_url,
headers={"User-Agent": "Mozilla/5.0"},
headers=headers,
allow_redirects=True,
)
with open(destination_path, "wb") as out_file:
+54
View File
@@ -0,0 +1,54 @@
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)"}
+116
View File
@@ -0,0 +1,116 @@
import random
class ComfyUIDeployExternalSeed:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"input_id": (
"STRING",
{"multiline": False, "default": "input_seed"},
),
"default_value": (
"INT",
{"default": -1},
),
"min_value": (
"INT",
{"default": 1, "min": 1, "max": 999999999999999},
),
"max_value": (
"INT",
{"default": 4294967295, "min": 1, "max": 999999999999999},
),
},
"optional": {
"display_name": (
"STRING",
{"multiline": False, "default": ""},
),
"description": (
"STRING",
{
"multiline": True,
"default": 'For default value:\n"-1" (i.e. not in range): Randomize within the min and max value range. \nin range: Fixed, always the same value\n',
},
),
},
}
RETURN_TYPES = ("INT",)
RETURN_NAMES = ("seed",)
FUNCTION = "run"
CATEGORY = "🔗ComfyDeploy"
# Limits
_MAX_LIMIT = 999_999_999_999_999 # 15 digits
# Store cached seed when fixed flag is enabled
_cached_seed = None
@classmethod
def IS_CHANGED(
cls,
input_id,
min_value,
max_value,
default_value=None,
**kwargs,
):
"""Inform ComfyUI whether the node output should be considered changed.
If default_value is within range (Fixed mode), we return the inputs tuple
so the cached result is reused until the user changes something.
For Randomize mode, we force re-execution each queue.
"""
# Clamp values to allowed range for check
min_value = max(1, min_value)
max_value = min(cls._MAX_LIMIT, max_value)
# Fixed mode when default_value is within range
if (
default_value is not None
and default_value >= min_value
and default_value <= max_value
):
return (input_id, default_value)
# For Randomize (default_value is -1 or out of range) we force re-execution
import random as _rnd
return _rnd.random()
def run(
self,
input_id,
min_value: int,
max_value: int,
display_name=None,
description=None,
default_value: int = -1,
):
# Clamp values to allowed range
min_value = max(1, min_value)
max_value = min(self._MAX_LIMIT, max_value)
# Ensure limits are in correct order after clamping
if min_value > max_value:
min_value, max_value = max_value, min_value
# Fixed mode: default_value is within range
if default_value >= min_value and default_value <= max_value:
seed = int(default_value)
self._cached_seed = seed
return [seed]
# Randomize mode: default_value is -1 or out of range
seed = random.randint(min_value, max_value)
self._cached_seed = seed
return [seed]
NODE_CLASS_MAPPINGS = {"ComfyUIDeployExternalSeed": ComfyUIDeployExternalSeed}
NODE_DISPLAY_NAME_MAPPINGS = {
"ComfyUIDeployExternalSeed": "External Seed (ComfyUI Deploy)"
}
+68 -35
View File
@@ -748,36 +748,64 @@ 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": ""},
),
},
"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": ""},
),
"default_value_url": ("STRING", {"image_preview": True, "default": ""}),
},
"hidden": {"unique_id": "UNIQUE_ID"},
}
CATEGORY = "Video Helper Suite 🎥🅥🅗🅢"
@@ -804,16 +832,21 @@ 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"):
if input_id.startswith("http") or (
default_value_url and default_value_url.startswith("http")
):
import requests
print("Fetching video from URL: ", input_id)
response = requests.get(input_id, stream=True)
# 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)
file_size = int(response.headers.get("Content-Length", 0))
file_extension = input_id.split(".")[-1].split("?")[
file_extension = url.split(".")[-1].split("?")[
0
] # Extract extension and handle URLs with parameters
if file_extension not in video_extensions:
+111
View File
@@ -0,0 +1,111 @@
import os
os.environ["OPENCV_IO_ENABLE_OPENEXR"] = "1"
import cv2 as cv
import numpy as np
import torch
import requests
def linear_to_srgb(np_array):
"""Converts a linear RGB numpy array to sRGB."""
less = np_array <= 0.0031308
np_array[less] = np_array[less] * 12.92
np_array[~less] = np.power(np_array[~less], 1/2.4) * 1.055 - 0.055
return np_array
class HttpExrInput:
"""
Node to load a single EXR image from a URL, with optional tonemapping.
This node is designed to be used in a ComfyDeploy environment where input files are provided via signed URLs.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"get_signed_url": ("STRING", {"multiline": True, "default": ""}),
"tonemap": (["linear", "sRGB", "Reinhard"], {"default": "sRGB"}),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}),
},
"optional": {
"default_image": ("IMAGE",),
"default_mask": ("MASK",),
},
}
RETURN_TYPES = ("IMAGE", "MASK")
RETURN_NAMES = ("image", "mask",)
FUNCTION = "run"
CATEGORY = "🔗ComfyDeploy/EXR"
def load_exr_from_data(self, exr_data):
try:
nparr = np.frombuffer(exr_data, np.uint8)
# Use cv.IMREAD_UNCHANGED to keep all channels (e.g., alpha)
image = cv.imdecode(nparr, cv.IMREAD_UNCHANGED)
if image is None:
raise ValueError("Failed to decode EXR data.")
return image.astype(np.float32)
except Exception as e:
print(f"Error decoding EXR data: {e}")
return None
def run(self, get_signed_url, tonemap, seed, default_image=None, default_mask=None):
if not get_signed_url or get_signed_url.strip() == "":
print("Warning: No input URL provided. Returning default image if available.")
if default_image is not None and default_mask is not None:
return (default_image, default_mask)
print("Warning: No input URL and no default image. Returning a black image.")
blank_image = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
blank_mask = torch.zeros((1, 64, 64), dtype=torch.float32)
return (blank_image, blank_mask)
image = None
try:
print(f"Fetching EXR from URL: {get_signed_url}")
response = requests.get(get_signed_url)
response.raise_for_status()
image = self.load_exr_from_data(response.content)
except requests.exceptions.RequestException as e:
print(f"Error fetching EXR from URL {get_signed_url}: {e}")
if image is None:
print("Warning: Could not load or decode EXR image. Returning default image if available.")
if default_image is not None and default_mask is not None:
return (default_image, default_mask)
print("Warning: Failed to load EXR and no default image. Returning a black image.")
blank_image = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
blank_mask = torch.zeros((1, 64, 64), dtype=torch.float32)
return (blank_image, blank_mask)
# BGR to RGB conversion and channel handling
if len(image.shape) == 2: # Grayscale
image = np.repeat(image[..., np.newaxis], 3, axis=2)
rgb = np.flip(image[:, :, :3], 2).copy() # OpenCV loads as BGR, convert to RGB
# Tonemapping
if tonemap == "sRGB":
rgb = linear_to_srgb(rgb)
rgb = np.clip(rgb, 0, 1)
elif tonemap == "Reinhard":
rgb = np.clip(rgb, 0, None) # Ensure no negative values
rgb = rgb / (rgb + 1)
rgb = linear_to_srgb(rgb)
rgb = np.clip(rgb, 0, 1)
# Handle alpha channel if it exists
if image.shape[2] > 3:
mask = np.clip(image[:, :, 3], 0, 1)
else:
mask = np.ones_like(rgb[:, :, 0]) # Create a full white mask if no alpha
return (torch.from_numpy(rgb).unsqueeze(0), torch.from_numpy(mask).unsqueeze(0),)
NODE_CLASS_MAPPINGS = {
"HttpExrInput": HttpExrInput
}
NODE_DISPLAY_NAME_MAPPINGS = {
"HttpExrInput": "HTTP EXR Input (ComfyDeploy)"
}
+80
View File
@@ -0,0 +1,80 @@
import os
os.environ["OPENCV_IO_ENABLE_OPENEXR"] = "1"
import cv2 as cv
import torch
import numpy as np
import requests
def srgb_to_linear(np_array):
"""Converts an sRGB numpy array to linear RGB."""
less = np_array <= 0.0404482362771082
np_array[less] = np_array[less] / 12.92
np_array[~less] = np.power((np_array[~less] + 0.055) / 1.055, 2.4)
return np_array
class HttpExrOutput:
"""
Node to save a single EXR image to a pre-signed URL.
This node is designed for ComfyDeploy to upload the generated EXR file.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE",),
"put_signed_url": ("STRING", {"multiline": True, "default": ""}),
"tonemap": (["linear", "sRGB"], {"default": "linear"}),
},
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
}
RETURN_TYPES = ()
FUNCTION = "run"
OUTPUT_NODE = True
CATEGORY = "ComfyDeploy/EXR"
def run(self, images, put_signed_url, tonemap, prompt=None, extra_pnginfo=None):
if not put_signed_url or put_signed_url.strip() == "":
print("Warning: No put_signed_url provided. Nothing will be uploaded.")
return {"ui": {"images": []}}
# We process only the first image of the batch
image_tensor = images[0]
# Convert tensor to numpy array, assuming it's in range [0, 1]
linear = image_tensor.cpu().numpy().astype(np.float32)
# If the source is sRGB, convert to linear
if tonemap == "sRGB":
linear[...,:3] = srgb_to_linear(linear[...,:3])
# Convert RGB to BGR for OpenCV
bgr = np.flip(linear, 2).copy()
results = []
try:
# Encode the image to the EXR format in memory
is_success, buffer = cv.imencode(".exr", bgr)
if not is_success:
raise Exception("Failed to encode image to EXR format.")
# Upload the image data to the pre-signed URL
response = requests.put(put_signed_url, data=buffer.tobytes(), headers={'Content-Type': 'image/x-exr'})
response.raise_for_status()
print(f"Successfully uploaded EXR to: {put_signed_url}")
# The UI can optionally display a link or confirmation
results.append({"url": put_signed_url, "output_id": "output_http_exr"})
except Exception as e:
print(f"Error uploading EXR to signed URL: {e}")
return {"ui": {"images": results}}
NODE_CLASS_MAPPINGS = {
"HttpExrOutput": HttpExrOutput
}
NODE_DISPLAY_NAME_MAPPINGS = {
"HttpExrOutput": "HTTP EXR Output (ComfyDeploy)"
}
+126
View File
@@ -0,0 +1,126 @@
import os
os.environ["OPENCV_IO_ENABLE_OPENEXR"] = "1"
import cv2 as cv
import numpy as np
import torch
import requests
import json
def linear_to_srgb(np_array):
"""Converts a linear RGB numpy array to sRGB."""
less = np_array <= 0.0031308
np_array[less] = np_array[less] * 12.92
np_array[~less] = np.power(np_array[~less], 1/2.4) * 1.055 - 0.055
return np_array
class HttpExrSequenceInput:
"""
Node to load a sequence of EXR images from a list of URLs provided as a JSON string.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"urls_json": ("STRING", {"multiline": True, "default": "[]"}),
"tonemap": (["linear", "sRGB", "Reinhard"], {"default": "sRGB"}),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}),
},
"optional": {
"default_image": ("IMAGE",),
"default_mask": ("MASK",),
},
}
RETURN_TYPES = ("IMAGE", "MASK")
RETURN_NAMES = ("image", "mask",)
FUNCTION = "run"
CATEGORY = "🔗ComfyDeploy/EXR"
def load_exr_from_data(self, exr_data):
try:
nparr = np.frombuffer(exr_data, np.uint8)
image = cv.imdecode(nparr, cv.IMREAD_UNCHANGED)
if image is None:
raise ValueError("Failed to decode EXR data.")
return image.astype(np.float32)
except Exception as e:
print(f"Error decoding EXR data: {e}")
return None
def run(self, urls_json, tonemap, seed, default_image=None, default_mask=None):
try:
urls = json.loads(urls_json)
if not isinstance(urls, list) or not all(isinstance(u, str) for u in urls):
raise ValueError("urls_json must be a JSON array of URL strings.")
except (json.JSONDecodeError, ValueError) as e:
print(f"Error parsing urls_json: {e}. Using default image if available.")
urls = []
if not urls:
if default_image is not None and default_mask is not None:
return (default_image, default_mask)
print("Warning: No valid URLs and no default image. Returning a black image.")
blank_image = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
blank_mask = torch.zeros((1, 64, 64), dtype=torch.float32)
return (blank_image, blank_mask)
rgb_frames = []
mask_frames = []
for url in urls:
image = None
try:
print(f"Fetching EXR from URL: {url}")
response = requests.get(url)
response.raise_for_status()
image = self.load_exr_from_data(response.content)
except requests.exceptions.RequestException as e:
print(f"Error fetching EXR from URL {url}: {e}")
if image is None:
print(f"Warning: Could not decode EXR from {url}. Skipping frame.")
continue
if len(image.shape) == 2: # Grayscale
image = np.repeat(image[..., np.newaxis], 3, axis=2)
rgb = np.flip(image[:, :, :3], 2).copy() # BGR to RGB
if tonemap == "sRGB":
rgb = linear_to_srgb(rgb)
rgb = np.clip(rgb, 0, 1)
elif tonemap == "Reinhard":
rgb = np.clip(rgb, 0, None)
rgb = rgb / (rgb + 1)
rgb = linear_to_srgb(rgb)
rgb = np.clip(rgb, 0, 1)
rgb_frames.append(torch.from_numpy(rgb))
if image.shape[2] > 3:
mask = np.clip(image[:, :, 3], 0, 1)
else:
mask = np.ones_like(rgb[:, :, 0])
mask_frames.append(torch.from_numpy(mask))
if not rgb_frames:
print("Could not load any frames. Returning default image if available.")
if default_image is not None and default_mask is not None:
return (default_image, default_mask)
print("Warning: Failed to load any frames and no default image. Returning a black image.")
blank_image = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
blank_mask = torch.zeros((1, 64, 64), dtype=torch.float32)
return (blank_image, blank_mask)
print(f"Loaded {len(rgb_frames)} frames successfully.")
return (torch.stack(rgb_frames, 0), torch.stack(mask_frames, 0))
NODE_CLASS_MAPPINGS = {
"HttpExrSequenceInput": HttpExrSequenceInput
}
NODE_DISPLAY_NAME_MAPPINGS = {
"HttpExrSequenceInput": "HTTP EXR Sequence Input (ComfyDeploy)"
}
+91
View File
@@ -0,0 +1,91 @@
import os
os.environ["OPENCV_IO_ENABLE_OPENEXR"] = "1"
import cv2 as cv
import torch
import numpy as np
import requests
import json
def srgb_to_linear(np_array):
"""Converts an sRGB numpy array to linear RGB."""
less = np_array <= 0.0404482362771082
np_array[less] = np_array[less] / 12.92
np_array[~less] = np.power((np_array[~less] + 0.055) / 1.055, 2.4)
return np_array
class HttpExrSequenceOutput:
"""
Node to save a sequence of images as EXR files to a list of pre-signed URLs.
"""
def __init__(self):
self.type = "output"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE",),
"upload_urls_json": ("STRING", {"multiline": True, "default": "[]"}),
"tonemap": (["linear", "sRGB"], {"default": "linear"}),
},
}
RETURN_TYPES = ()
FUNCTION = "run"
OUTPUT_NODE = True
CATEGORY = "🔗ComfyDeploy/EXR"
def run(self, images, upload_urls_json, tonemap):
try:
upload_urls = json.loads(upload_urls_json)
if not isinstance(upload_urls, list) or not all(isinstance(u, str) for u in upload_urls):
raise ValueError("upload_urls_json must be a JSON array of URL strings.")
except (json.JSONDecodeError, ValueError) as e:
print(f"Error parsing upload_urls_json: {e}. Aborting upload.")
return {"ui": {"images": []}}
if not upload_urls:
print("Warning: No upload URLs provided. Nothing will be uploaded.")
return {"ui": {"images": []}}
if len(images) != len(upload_urls):
print(f"Warning: Mismatch between number of images ({len(images)}) and upload URLs ({len(upload_urls)}). Aborting upload.")
return {"ui": {"images": []}}
# Convert tensor to numpy array
linear_images = images.cpu().numpy().astype(np.float32)
# If the source is sRGB, convert all images to linear
if tonemap == "sRGB":
srgb_to_linear(linear_images[...,:3])
# Convert RGB to BGR for OpenCV
bgr_images = np.flip(linear_images, 3).copy()
results = []
for i, (bgr_image, url) in enumerate(zip(bgr_images, upload_urls)):
try:
# Encode the image to the EXR format in memory
is_success, buffer = cv.imencode(".exr", bgr_image)
if not is_success:
raise Exception("Failed to encode image to EXR format.")
# Upload the image data to the pre-signed URL
response = requests.put(url, data=buffer.tobytes(), headers={'Content-Type': 'image/x-exr'})
response.raise_for_status()
print(f"Successfully uploaded frame {i+1} to: {url}")
results.append({"url": url})
except Exception as e:
print(f"Error uploading frame {i+1} to {url}: {e}")
return {"ui": {"images": results}}
NODE_CLASS_MAPPINGS = {
"HttpExrSequenceOutput": HttpExrSequenceOutput
}
NODE_DISPLAY_NAME_MAPPINGS = {
"HttpExrSequenceOutput": "HTTP EXR Sequence Output (ComfyDeploy)"
}
-60
View File
@@ -1,60 +0,0 @@
import folder_paths
class AnyType(str):
def __ne__(self, __value: object) -> bool:
return False
from os import walk
WILDCARD = AnyType("*")
MODEL_EXTENSIONS = {
"safetensors": "SafeTensors file format",
"ckpt": "Checkpoint file",
"pth": "PyTorch serialized file",
"pkl": "Pickle file",
"onnx": "ONNX file",
}
def fetch_files(path):
for (dirpath, dirnames, filenames) in walk(path):
fs = []
if len(dirnames) > 0:
for dirname in dirnames:
fs.extend(fetch_files(f"{dirpath}/{dirname}"))
for filename in filenames:
# Remove "./models/" from the beginning of dirpath
relative_dirpath = dirpath.replace("./models/", "", 1)
file_path = f"{relative_dirpath}/{filename}"
# Only add files that are known model extensions
file_extension = filename.split('.')[-1].lower()
if file_extension in MODEL_EXTENSIONS:
fs.append(file_path)
return fs
allModels = fetch_files("./models")
class ComfyUIDeployModalList:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"model": (allModels, ),
}
}
RETURN_TYPES = (WILDCARD,)
RETURN_NAMES = ("model",)
FUNCTION = "run"
CATEGORY = "🔗ComfyDeploy"
def run(self, model=""):
# Split the model path by '/' and select the last item
model_name = model.split('/')[-1]
return [model_name]
NODE_CLASS_MAPPINGS = {"ComfyUIDeployModelList": ComfyUIDeployModalList}
NODE_DISPLAY_NAME_MAPPINGS = {"ComfyUIDeployModelList": "Model List (ComfyUI Deploy)"}
+99
View File
@@ -0,0 +1,99 @@
import os
import json
import folder_paths
class ComfyDeployOutputText:
def __init__(self):
self.output_dir = folder_paths.get_output_directory()
self.type = "output"
self.prefix_append = ""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"text": (
"STRING",
{
"multiline": True,
"forceInput": True,
"tooltip": "The text to save.",
},
),
"filename_prefix": (
"STRING",
{
"default": "ComfyUI",
"tooltip": "The prefix for the file to save. This may include formatting information such as %date:yyyy-MM-dd% to include values from nodes.",
},
),
"file_type": (["txt", "json", "md"], {"default": "txt"}),
},
"optional": {
"output_id": (
"STRING",
{"multiline": False, "default": "output_text"},
),
},
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
}
RETURN_TYPES = ()
FUNCTION = "run"
OUTPUT_NODE = True
CATEGORY = "🔗ComfyDeploy"
DESCRIPTION = "Saves the input text to your ComfyUI output directory."
def run(
self,
text,
filename_prefix="ComfyUI",
file_type="txt",
output_id="output_text",
prompt=None,
extra_pnginfo=None,
):
filename_prefix += self.prefix_append
# For text, we don't need dimensions, so pass 0, 0
full_output_folder, filename, counter, subfolder, filename_prefix = (
folder_paths.get_save_image_path(filename_prefix, self.output_dir, 0, 0)
)
results = list()
# Create file path
file = f"{filename}_{counter:05}_.{file_type}"
file_path = os.path.join(full_output_folder, file)
# Save the text based on file type
if file_type == "json":
try:
# Try to save as JSON if the text is valid JSON
json_data = json.loads(text) if isinstance(text, str) else text
with open(file_path, "w", encoding="utf-8") as f:
json.dump(json_data, f, indent=2)
except json.JSONDecodeError:
# Fall back to saving as plain text if not valid JSON
with open(file_path, "w", encoding="utf-8") as f:
f.write(text)
else:
# Save as plain text for txt and md
with open(file_path, "w", encoding="utf-8") as f:
f.write(text)
results.append(
{
"filename": file,
"subfolder": subfolder,
"type": self.type,
"output_id": output_id,
}
)
return {"ui": {"text_file": results}}
NODE_CLASS_MAPPINGS = {"ComfyDeployOutputText": ComfyDeployOutputText}
NODE_DISPLAY_NAME_MAPPINGS = {"ComfyDeployOutputText": "Text Output (ComfyDeploy)"}
+135 -57
View File
@@ -139,7 +139,9 @@ async def async_request_with_retry(
logger.error(f"Error response body: {error_body}")
if attempt == max_retries - 1:
logger.error(f"Request {method} : {url} failed after {max_retries} attempts: {e}")
logger.error(
f"Request {method} : {url} failed after {max_retries} attempts: {e}"
)
raise
await asyncio.sleep(retry_delay)
@@ -156,6 +158,8 @@ from logging import basicConfig, getLogger
# Check for an environment variable to enable/disable Logfire
use_logfire = os.environ.get("USE_LOGFIRE", "false").lower() == "true"
API_KEY_COMFY_ORG = os.environ.get("API_KEY_COMFY_ORG", None)
if use_logfire:
try:
import logfire
@@ -281,6 +285,9 @@ def post_prompt(json_data):
if "extra_data" in json_data:
extra_data = json_data["extra_data"]
if API_KEY_COMFY_ORG is not None:
extra_data["api_key_comfy_org"] = API_KEY_COMFY_ORG
if "client_id" in json_data:
extra_data["client_id"] = json_data["client_id"]
if valid[0]:
@@ -314,7 +321,7 @@ def randomSeed(num_digits=15):
return random.randint(range_start, range_end)
def apply_random_seed_to_workflow(workflow_api):
def apply_random_seed_to_workflow(workflow_api, workflow):
"""
Applies a random seed to each element in the workflow_api that has a 'seed' input.
@@ -327,6 +334,41 @@ def apply_random_seed_to_workflow(workflow_api):
# If seed is a list, it's an input from another node (generally `external number int`)
if isinstance(workflow_api[key]["inputs"]["seed"], list):
continue
# Check node type in workflow to determine if we should randomize
node_id = key
should_skip = (
False # Add a flag to track if we should skip randomization
)
for node in workflow["nodes"]:
if str(node["id"]) == node_id and node["type"] == "KSampler":
# Check if this node has widgets_values and if seed setting is not "fixed"
if "widgets_values" in node and len(node["widgets_values"]) > 1:
seed_mode = node["widgets_values"][1]
if seed_mode == "fixed":
# Skip randomization for fixed seeds
logger.info(
f"Skipping random seed for KSampler (node {node_id}) as it's set to fixed"
)
should_skip = True # Set the flag to skip randomization
break # Exit the inner loop
# Apply random seed for non-fixed seeds (randomize, iter, etc.)
workflow_api[key]["inputs"]["seed"] = randomSeed()
logger.info(
f"Applied random seed {workflow_api[key]['inputs']['seed']} to KSampler (node {node_id})"
)
should_skip = (
True # Set the flag to skip default randomization
)
break # Exit the inner loop
break # This break will skip checking other nodes if widgets_values doesn't exist
# Skip the rest of the code for this key if we already handled it
if should_skip:
continue
# Special case for SONICSampler
if workflow_api[key]["class_type"] == "SONICSampler":
workflow_api[key]["inputs"]["seed"] = randomSeed("sonic")
@@ -367,6 +409,12 @@ def apply_random_seed_to_workflow(workflow_api):
f"Applied random noise_seed {workflow_api[key]['inputs']['noise_seed']} to SamplerCustom"
)
continue
if workflow_api[key]["class_type"] == "XlabsSampler":
workflow_api[key]["inputs"]["noise_seed"] = randomSeed()
logger.info(
f"Applied random noise_seed {workflow_api[key]['inputs']['noise_seed']} to SamplerCustom"
)
continue
def apply_inputs_to_workflow(workflow_api: Any, inputs: Any, sid: str = None):
@@ -407,6 +455,9 @@ def apply_inputs_to_workflow(workflow_api: Any, inputs: Any, sid: str = None):
if value["class_type"] == "ComfyUIDeployExternalImageBatch":
value["inputs"]["images"] = new_value
if value["class_type"] == "ComfyUIDeployExternalEnum":
value["inputs"]["default_value"] = new_value
if value["class_type"] == "ComfyUIDeployExternalLora":
value["inputs"]["lora_url"] = new_value
@@ -425,6 +476,12 @@ def apply_inputs_to_workflow(workflow_api: Any, inputs: Any, sid: str = None):
if value["class_type"] == "ComfyUIDeployExternalEXR":
value["inputs"]["exr_file"] = new_value
if value["class_type"] == "ComfyUIDeployExternalSeed":
logger.info(
f"Applied random seed {new_value} to {value['class_type']}"
)
value["inputs"]["default_value"] = new_value
def send_prompt(sid: str, inputs: StreamingPrompt):
# workflow_api = inputs.workflow_api
@@ -432,7 +489,7 @@ def send_prompt(sid: str, inputs: StreamingPrompt):
workflow = copy.deepcopy(inputs.workflow)
# Random seed
apply_random_seed_to_workflow(workflow_api)
apply_random_seed_to_workflow(workflow_api, workflow)
logger.info("getting inputs", inputs.inputs)
@@ -518,7 +575,7 @@ async def comfy_deploy_run(request):
workflow = data.get("workflow")
# Now it handles directly in here
apply_random_seed_to_workflow(workflow_api)
apply_random_seed_to_workflow(workflow_api, workflow)
apply_inputs_to_workflow(workflow_api, inputs)
prompt = {
@@ -585,7 +642,7 @@ async def stream_prompt(data, token):
gpu_event_id = data.get("gpu_event_id", None)
# Now it handles directly in here
apply_random_seed_to_workflow(workflow_api)
apply_random_seed_to_workflow(workflow_api, workflow)
apply_inputs_to_workflow(workflow_api, inputs)
prompt = {
@@ -1304,7 +1361,7 @@ send_json = prompt_server.send_json
async def send_json_override(self, event, data, sid=None):
# logger.info("INTERNAL:", event, data, sid)
# logger.info(f"INTERNAL: event={event}, data={data}, sid={sid}")
prompt_id = data.get("prompt_id")
target_sid = sid
@@ -1389,9 +1446,12 @@ async def send_json_override(self, event, data, sid=None):
# the last executing event is none, then the workflow is finished
if event == "executing" and data.get("node") is None:
mark_prompt_done(prompt_id=prompt_id)
# We will now rely on the UploadQueue worker to set the final SUCCESS status
# after all uploads are confirmed complete.
if not have_pending_upload(prompt_id):
await update_run(prompt_id, Status.SUCCESS)
if prompt_id in prompt_metadata:
# await update_run(prompt_id, Status.SUCCESS) # <-- REMOVE/COMMENT OUT
if prompt_id in prompt_metadata: # <-- REMOVE/COMMENT OUT THIS BLOCK
current_time = time.perf_counter()
if prompt_metadata[prompt_id].start_time is not None:
elapsed_time = current_time - prompt_metadata[prompt_id].start_time
@@ -1832,12 +1892,13 @@ async def upload_file(
def have_pending_upload(prompt_id):
# Check if there are pending uploads in the queue
if (
prompt_id in prompt_metadata
and len(prompt_metadata[prompt_id].uploading_nodes) > 0
prompt_id in upload_queue.pending_uploads
and upload_queue.pending_uploads[prompt_id]
):
logger.info(
f"Have pending upload {len(prompt_metadata[prompt_id].uploading_nodes)}"
f"Have pending upload {len(upload_queue.pending_uploads[prompt_id])}"
)
return True
@@ -1892,16 +1953,11 @@ async def handle_error(prompt_id, data, e: Exception):
async def update_file_status(
prompt_id: str, data, uploading, have_error=False, node_id=None
):
# if 'uploading_nodes' not in prompt_metadata[prompt_id]:
# prompt_metadata[prompt_id]['uploading_nodes'] = set()
# We're using upload_queue as the single source of truth for tracking uploads
# The upload_queue.pending_uploads is managed by the UploadQueue class itself
# We no longer need to track uploading_nodes in prompt_metadata
if node_id is not None:
if uploading:
prompt_metadata[prompt_id].uploading_nodes.add(node_id)
else:
prompt_metadata[prompt_id].uploading_nodes.discard(node_id)
# logger.info(f"Remaining uploads: {prompt_metadata[prompt_id].uploading_nodes}")
# logger.info(f"Pending uploads in queue: {upload_queue.pending_uploads.get(prompt_id, set())}")
# Update the remote status
if have_error:
@@ -1915,15 +1971,15 @@ async def update_file_status(
return
# if there are still nodes that are uploading, then we set the status to uploading
if uploading:
if prompt_metadata[prompt_id].status != Status.UPLOADING:
await update_run(prompt_id, Status.UPLOADING)
await send(
"uploading",
{
"prompt_id": prompt_id,
},
)
# if uploading:
# if prompt_metadata[prompt_id].status != Status.UPLOADING:
# await update_run(prompt_id, Status.UPLOADING)
# await send(
# "uploading",
# {
# "prompt_id": prompt_id,
# },
# )
# if there are no nodes that are uploading, then we set the status to success
elif (
@@ -1949,7 +2005,7 @@ async def handle_upload(
for item in items:
# Skipping temp files
if item.get("type") == "temp":
if isinstance(item, dict) and item.get("type") == "temp":
continue
file_type = item.get(content_type_key, default_content_type)
@@ -2000,22 +2056,29 @@ async def upload_in_background(
("files", "content_type", "image/png"),
("gifs", "format", "image/gif"),
("model_file", "format", "application/octet-stream"),
("result", "format", "application/octet-stream"),
("text_file", "format", "text/plain"),
]:
items = data.get(file_type, [])
for item in items:
# if is model_file, just add it to the data
if file_type == "model_file":
if file_type == "model_file" or file_type == "result":
if isinstance(item, str):
filename = os.path.basename(item)
# Extract folder name from the path
folder_path = os.path.dirname(item)
subfolder = (
os.path.basename(folder_path) if folder_path else ""
)
item = {
"filename": filename,
"subfolder": "",
"subfolder": subfolder,
"type": "output",
}
# Skip temp files
if item.get("type") == "temp":
if isinstance(item, dict) and item.get("type") == "temp":
continue
# Add to the upload queue instead of uploading immediately
@@ -2080,6 +2143,8 @@ async def update_run_with_output(
or "files" in data
or "gifs" in data
or "model_file" in data
or "result" in data
or "text_file" in data
)
if bypass_upload and have_upload_media:
print(
@@ -2390,6 +2455,11 @@ class UploadQueue:
logger.warning(f"No upload endpoint for prompt ID: {prompt_id}")
return
# Check if file_info is a valid dictionary with a filename
if not isinstance(file_info, dict) or "filename" not in file_info:
logger.warning(f"Invalid file_info for prompt ID {prompt_id}: {file_info}")
return
filename = file_info.get("filename")
subfolder = file_info.get("subfolder")
file_type = file_info.get("type", "output")
@@ -2540,8 +2610,12 @@ class UploadQueue:
# If this was the last file for this prompt, show the stats summary
if (
prompt_id in self.pending_uploads
# We now rely on the worker's finally block for the final SUCCESS update.
# Check if the set becomes empty *after* removal in the worker.
and len(self.pending_uploads[prompt_id]) == 1
):
# await update_run(prompt_id, Status.SUCCESS) # <-- REMOVE/COMMENT OUT
self._log_upload_stats(prompt_id)
# Clean up stats
del self.upload_stats[prompt_id]
@@ -2662,6 +2736,8 @@ class UploadQueue:
node_id = upload_task["node_id"]
upload_id = upload_task["upload_id"]
print(file_info)
try:
# Coordinate the actual start of the upload
async with self.upload_lock:
@@ -2691,30 +2767,32 @@ 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 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 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]
# Send status update
await self.update_queue_status(prompt_id)
Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

+152
View File
@@ -0,0 +1,152 @@
{
"id": "ed93ac94-4f26-4ed3-a57b-73cd8f4d3494",
"revision": 0,
"last_node_id": 5,
"last_link_id": 1,
"nodes": [
{
"id": 2,
"type": "LoraLoader",
"pos": [
736.646728515625,
628.3823852539062
],
"size": [
315,
126
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": null
},
{
"name": "clip",
"type": "CLIP",
"link": null
},
{
"name": "lora_name",
"type": "COMBO",
"widget": {
"name": "lora_name"
},
"link": 1
}
],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"links": null
},
{
"name": "CLIP",
"type": "CLIP",
"links": null
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.27",
"Node name for S&R": "LoraLoader"
},
"widgets_values": [
"1-292.safetensors",
1,
1
]
},
{
"id": 1,
"type": "ComfyUIDeployExternalLora",
"pos": [
299.6898498535156,
624.7929077148438
],
"size": [
400,
208
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "path",
"type": "*",
"links": [
1
]
}
],
"properties": {
"cnr_id": "comfyui-deploy",
"ver": "cd3a2ff5471828f9c840e746551592e882c05aa4",
"Node name for S&R": "ComfyUIDeployExternalLora"
},
"widgets_values": [
"input_lora",
"HyperSD\\FLUX.1\\Hyper-FLUX.1-dev-16steps-lora.safetensors",
"",
"",
"",
""
]
},
{
"id": 5,
"type": "Note",
"pos": [
302.09033203125,
401.2951965332031
],
"size": [
479.4894104003906,
161.61924743652344
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {},
"widgets_values": [
"\"External Lora\" node will let you to use different loras from the Comfy Deploy UI or even via API.\n\n- lora_url:\n url that will be used to download your LoRA model in execution time\n\n- lora_save_name:\n when we download your model, this will be saved in your private storage, \n give it a good name :D"
],
"color": "#432",
"bgcolor": "#653"
}
],
"links": [
[
1,
1,
0,
2,
2,
"COMBO"
]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1.167184107045006,
"offset": [
298.431389807788,
-207.58877445762934
]
},
"VHS_latentpreview": false,
"VHS_latentpreviewrate": 0,
"VHS_MetadataImage": true,
"VHS_KeepIntermediate": true
},
"version": 0.4
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

+873
View File
@@ -0,0 +1,873 @@
{
"id": "351f402b-62f2-4f62-8a5e-0b9d3510e8f9",
"revision": 0,
"last_node_id": 29,
"last_link_id": 28,
"nodes": [
{
"id": 11,
"type": "JoinImageWithAlpha",
"pos": [
814.478271484375,
419.3052062988281
],
"size": [
264.5999755859375,
46
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": 10
},
{
"name": "alpha",
"type": "MASK",
"link": 12
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
11
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.27",
"Node name for S&R": "JoinImageWithAlpha"
},
"widgets_values": []
},
{
"id": 15,
"type": "PreviewImage",
"pos": [
1950,
640
],
"size": [
210,
246
],
"flags": {},
"order": 18,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 16
}
],
"outputs": [],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.27",
"Node name for S&R": "PreviewImage"
},
"widgets_values": []
},
{
"id": 10,
"type": "LoadImage",
"pos": [
467.8168640136719,
422.453857421875
],
"size": [
315,
314
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
10
]
},
{
"name": "MASK",
"type": "MASK",
"links": [
12
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.27",
"Node name for S&R": "LoadImage"
},
"widgets_values": [
"Bob-Minion-Background-PNG-Image.png",
"image",
""
]
},
{
"id": 18,
"type": "Note",
"pos": [
460.8001708984375,
263.64251708984375
],
"size": [
379.4292297363281,
88
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {},
"widgets_values": [
"Option 1: CREATE THE MASK FROM THE ALPHA CHANNEL (Useful for example to generate the background of an image)\n\nMake sure that you are using \"External Image Alpha\". \n"
],
"color": "#432",
"bgcolor": "#653"
},
{
"id": 21,
"type": "LoadImage",
"pos": [
469.57025146484375,
1506.3018798828125
],
"size": [
315,
314
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
18
]
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.27",
"Node name for S&R": "LoadImage"
},
"widgets_values": [
"ComfyUI_temp_otmos_00005_.png",
"image",
""
]
},
{
"id": 25,
"type": "MaskToImage",
"pos": [
1283.1668701171875,
1579.603271484375
],
"size": [
176.39999389648438,
26
],
"flags": {},
"order": 13,
"mode": 0,
"inputs": [
{
"name": "mask",
"type": "MASK",
"link": 28
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
21
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.27",
"Node name for S&R": "MaskToImage"
},
"widgets_values": []
},
{
"id": 13,
"type": "SplitImageWithAlpha",
"pos": [
1602.5518798828125,
417.59869384765625
],
"size": [
277.20001220703125,
46
],
"flags": {},
"order": 11,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": 13
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
14,
25
]
},
{
"name": "MASK",
"type": "MASK",
"links": [
15,
26
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.27",
"Node name for S&R": "SplitImageWithAlpha"
},
"widgets_values": []
},
{
"id": 29,
"type": "VAEEncodeForInpaint",
"pos": [
2220.89453125,
401.0885009765625
],
"size": [
340.20001220703125,
98
],
"flags": {},
"order": 16,
"mode": 0,
"inputs": [
{
"name": "pixels",
"type": "IMAGE",
"link": 25
},
{
"name": "vae",
"type": "VAE",
"link": null
},
{
"name": "mask",
"type": "MASK",
"link": 26
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.27",
"Node name for S&R": "VAEEncodeForInpaint"
},
"widgets_values": [
6
]
},
{
"id": 14,
"type": "PreviewImage",
"pos": [
1950,
350
],
"size": [
210,
246
],
"flags": {},
"order": 14,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 14
}
],
"outputs": [],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.27",
"Node name for S&R": "PreviewImage"
},
"widgets_values": []
},
{
"id": 17,
"type": "MaskToImage",
"pos": [
1752.33203125,
572.061767578125
],
"size": [
176.39999389648438,
26
],
"flags": {},
"order": 15,
"mode": 0,
"inputs": [
{
"name": "mask",
"type": "MASK",
"link": 15
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
16
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.27",
"Node name for S&R": "MaskToImage"
},
"widgets_values": []
},
{
"id": 27,
"type": "PreviewImage",
"pos": [
1329.2960205078125,
1132.759765625
],
"size": [
210,
246
],
"flags": {},
"order": 10,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 22
}
],
"outputs": [],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.27",
"Node name for S&R": "PreviewImage"
},
"widgets_values": []
},
{
"id": 20,
"type": "LoadImage",
"pos": [
468.1963806152344,
1128.9498291015625
],
"size": [
315,
314
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
17
]
},
{
"name": "MASK",
"type": "MASK",
"links": []
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.27",
"Node name for S&R": "LoadImage"
},
"widgets_values": [
"clipspace/clipspace-mask-1126817.300000012.png [input]",
"image",
""
]
},
{
"id": 24,
"type": "ImageToMask",
"pos": [
1255.560302734375,
1468.347900390625
],
"size": [
315,
58
],
"flags": {},
"order": 9,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": 19
}
],
"outputs": [
{
"name": "MASK",
"type": "MASK",
"links": [
24,
28
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.27",
"Node name for S&R": "ImageToMask"
},
"widgets_values": [
"red"
]
},
{
"id": 26,
"type": "PreviewImage",
"pos": [
1488.1925048828125,
1579.146728515625
],
"size": [
210,
246
],
"flags": {},
"order": 17,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 21
}
],
"outputs": [],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.27",
"Node name for S&R": "PreviewImage"
},
"widgets_values": []
},
{
"id": 28,
"type": "VAEEncodeForInpaint",
"pos": [
1637.603271484375,
1182.81298828125
],
"size": [
340.20001220703125,
98
],
"flags": {},
"order": 12,
"mode": 0,
"inputs": [
{
"name": "pixels",
"type": "IMAGE",
"link": 23
},
{
"name": "vae",
"type": "VAE",
"link": null
},
{
"name": "mask",
"type": "MASK",
"link": 24
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.27",
"Node name for S&R": "VAEEncodeForInpaint"
},
"widgets_values": [
6
]
},
{
"id": 19,
"type": "Note",
"pos": [
467.4615173339844,
985.1439819335938
],
"size": [
379.4292297363281,
88
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {},
"widgets_values": [
"Option 2: UPLOAD THE MASK AS OTHER IMAGE (useful if you require the image that is below the mask to do inpainting, in this case to generate new glasses)"
],
"color": "#432",
"bgcolor": "#653"
},
{
"id": 22,
"type": "ComfyUIDeployExternalImage",
"pos": [
910.8199462890625,
1132.306396484375
],
"size": [
390.5999755859375,
154
],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{
"name": "default_value",
"shape": 7,
"type": "IMAGE",
"link": 17
}
],
"outputs": [
{
"name": "image",
"type": "IMAGE",
"links": [
22,
23
]
}
],
"properties": {
"cnr_id": "comfyui-deploy",
"ver": "cd3a2ff5471828f9c840e746551592e882c05aa4",
"Node name for S&R": "ComfyUIDeployExternalImage"
},
"widgets_values": [
"input_image",
"",
"",
"",
""
]
},
{
"id": 23,
"type": "ComfyUIDeployExternalImage",
"pos": [
843.6348876953125,
1467.8876953125
],
"size": [
390.5999755859375,
154
],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "default_value",
"shape": 7,
"type": "IMAGE",
"link": 18
}
],
"outputs": [
{
"name": "image",
"type": "IMAGE",
"links": [
19
]
}
],
"properties": {
"cnr_id": "comfyui-deploy",
"ver": "cd3a2ff5471828f9c840e746551592e882c05aa4",
"Node name for S&R": "ComfyUIDeployExternalImage"
},
"widgets_values": [
"input_image_mask",
"",
"",
"",
""
]
},
{
"id": 12,
"type": "ComfyUIDeployExternalImageAlpha",
"pos": [
1107.891357421875,
418.2679138183594
],
"size": [
466.1999816894531,
200
],
"flags": {},
"order": 8,
"mode": 0,
"inputs": [
{
"name": "default_value",
"shape": 7,
"type": "IMAGE",
"link": 11
}
],
"outputs": [
{
"name": "image",
"type": "IMAGE",
"links": [
13
]
}
],
"properties": {
"cnr_id": "comfyui-deploy",
"ver": "cd3a2ff5471828f9c840e746551592e882c05aa4",
"Node name for S&R": "ComfyUIDeployExternalImageAlpha"
},
"widgets_values": [
"input_image_alpha",
"",
""
]
}
],
"links": [
[
10,
10,
0,
11,
0,
"IMAGE"
],
[
11,
11,
0,
12,
0,
"IMAGE"
],
[
12,
10,
1,
11,
1,
"MASK"
],
[
13,
12,
0,
13,
0,
"IMAGE"
],
[
14,
13,
0,
14,
0,
"IMAGE"
],
[
15,
13,
1,
17,
0,
"MASK"
],
[
16,
17,
0,
15,
0,
"IMAGE"
],
[
17,
20,
0,
22,
0,
"IMAGE"
],
[
18,
21,
0,
23,
0,
"IMAGE"
],
[
19,
23,
0,
24,
0,
"IMAGE"
],
[
21,
25,
0,
26,
0,
"IMAGE"
],
[
22,
22,
0,
27,
0,
"IMAGE"
],
[
23,
22,
0,
28,
0,
"IMAGE"
],
[
24,
24,
0,
28,
2,
"MASK"
],
[
25,
13,
0,
29,
0,
"IMAGE"
],
[
26,
13,
1,
29,
2,
"MASK"
],
[
28,
24,
0,
25,
0,
"MASK"
]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 0.9646149645000013,
"offset": [
48.66905973637718,
-817.5683540167485
]
},
"VHS_latentpreview": false,
"VHS_latentpreviewrate": 0,
"VHS_MetadataImage": true,
"VHS_KeepIntermediate": true
},
"version": 0.4
}
+16
View File
@@ -56,11 +56,27 @@ 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]
+257 -213
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 {
@@ -168,12 +168,12 @@ function setSelectedWorkflowInfo(info) {
const VALID_TYPES = [
"STRING",
"combo",
"number",
"toggle",
"BOOLEAN",
"text",
"string",
"combo",
];
function hideWidget(node, widget, suffix = "") {
@@ -181,7 +181,9 @@ function hideWidget(node, widget, suffix = "") {
widget.origType = widget.type;
widget.origComputeSize = widget.computeSize;
widget.origSerializeValue = widget.serializeValue;
widget.computeSize = () => [0, -4];
// console.log(widget.origComputeSize);
// console.log(LiteGraph.NODE_SLOT_HEIGHT);
// widget.computeSize = () => [0, 0];
widget.type = CONVERTED_TYPE + suffix;
widget.serializeValue = () => {
if (!node.inputs) {
@@ -210,10 +212,37 @@ function getWidgetType(config) {
return { type };
}
const GET_CONFIG = Symbol();
async function convertToInput(node, widget, config) {
const { type } = getWidgetType(config);
console.log(node, widget, config);
const result = await app.extensionManager.dialog.prompt(
{
title: "Convert " + widget.name + " to external input",
message: "Input name",
defaultValue: widget.name,
}
);
if (!result) return;
// Check for duplicate input IDs across existing external input nodes
const existingInputIds = Object.values(app.graph.nodes)
.filter(n => n.type.startsWith("ComfyUIDeployExternal"))
.map(n => n.widgets_values?.[0])
.filter(Boolean);
if (existingInputIds.includes(result)) {
app.extensionManager.toast.add({
severity: 'error',
summary: 'Input ID already exists',
detail: 'Please choose a different name.',
life: 3000
});
return;
}
function convertToInput(node, widget, config) {
console.log(node);
if (node.type == "LoadImage") {
var inputNode = LiteGraph.createNode("ComfyUIDeployExternalImage");
console.log(widget);
@@ -235,54 +264,93 @@ function convertToInput(node, widget, config) {
const links = app.graph.links;
console.log(currentOutputsLinks);
// console.log(currentOutputsLinks);
for (let i = 0; i < currentOutputsLinks.length; i++) {
const link = currentOutputsLinks[i];
const llink = links[link];
console.log(links[link]);
setTimeout(
() => inputNode.connect(0, llink.target_id, llink.target_slot),
100,
);
}
if (currentOutputsLinks)
for (let i = 0; i < currentOutputsLinks.length; i++) {
const link = currentOutputsLinks[i];
const llink = links[link];
console.log(links[link]);
setTimeout(
() => inputNode.connect(0, llink.target_id, llink.target_slot),
100,
);
}
node.connect(0, inputNode, 0);
return null;
}
hideWidget(node, widget);
const { type } = getWidgetType(config);
const sz = node.size;
const inputIsOptional = !!widget.options?.inputIsOptional;
const input = node.addInput(widget.name, type, {
widget: { name: widget.name, [GET_CONFIG]: () => config },
...(inputIsOptional ? { shape: LiteGraph.SlotShape.HollowCircle } : {}),
});
for (const widget2 of node.widgets) {
widget2.last_y += LiteGraph.NODE_SLOT_HEIGHT;
}
node.setSize([Math.max(sz[0], node.size[0]), Math.max(sz[1], node.size[1])]);
let externalNode = "";
let inputId = result;
if (type == "STRING") {
var inputNode = LiteGraph.createNode("ComfyUIDeployExternalText");
console.log(widget);
const index = node.inputs.findIndex((x) => x.name == widget.name);
console.log(node.widgets_values, index);
if (type === "INT") {
externalNode = "ComfyUIDeployExternalNumberInt";
// inputId = "input_number";
}
if (type === "FLOAT") {
externalNode = "ComfyUIDeployExternalNumberSlider";
// inputId = "input_number";
}
if (type === "STRING") {
externalNode = "ComfyUIDeployExternalText";
// inputId = "input_text";
}
if (type === "COMBO") {
externalNode = "ComfyUIDeployExternalEnum";
// inputId = "input_enum";
}
if (!externalNode || !inputId) return;
node.convertWidgetToInput(widget);
var inputNode = LiteGraph.createNode(externalNode, "External Input: " + inputId);
// if (type === "COMBO") {
// inputNode = LiteGraph.createNode(externalNode, "External Input: " + inputId, {
// dynamic_enum_options: config[0],
// });
// console.log(inputNode);
// const options = config[0];
// console.log(options);
// } else {
// inputNode = LiteGraph.createNode(externalNode, "External Input: " + inputId);
// }
var options;
const index = node.inputs.findIndex((x) => x.name == widget.name);
if (type === "COMBO") {
options = widget.options?.values ?? config[0];
inputNode.configure({
widgets_values: ["input_text", widget.value],
widgets_values: [inputId, widget.value, JSON.stringify(options)],
});
inputNode.id = ++app.graph.last_node_id;
inputNode.pos = node.pos;
inputNode.pos[0] -= node.size[0] + 40;
} else {
inputNode.configure({
widgets_values: [inputId, widget.value],
});
}
inputNode.id = ++app.graph.last_node_id;
inputNode.pos = node.pos;
inputNode.pos[0] -= node.size[0] + 160;
if (type === "COMBO") {
console.log(inputNode);
console.log(app.graph);
app.graph.add(inputNode);
inputNode.connect(0, node, index);
console.log(options);
inputNode.widgets.find((x) => x.name == "default_value").options.values = options;
}
return input;
app.graph.add(inputNode);
inputNode.connect(0, node, index);
app.graph.setDirtyCanvas(true, true);
return node.inputs.find((x) => x.name == widget.name);
}
const CONVERTED_TYPE = "converted-widget";
@@ -295,7 +363,12 @@ function getConfig(widgetName) {
);
}
function isConvertibleWidget(widget, config) {
function isConvertibleWidget(node, widget, config) {
// console.log(config);
if (node.type === "LoadImage" && widget.type === "combo" && widget.name == "image") {
return true;
}
return (
(VALID_TYPES.includes(widget.type) || VALID_TYPES.includes(config[0])) &&
!widget.options?.forceInput
@@ -442,11 +515,11 @@ const ext = {
w.type,
w.options || {},
];
if (isConvertibleWidget(w, config)) {
if (isConvertibleWidget(this, w, config)) {
toInput.push({
content: `Convert ${w.name} to external input`,
callback: /* @__PURE__ */ __name(
() => convertToInput(this, w, config),
async () => convertToInput(this, w, config),
"callback",
),
className: "comfydeploy-menu-item",
@@ -501,6 +574,13 @@ const ext = {
console.log(nodeData.input.optional.default_value_url);
}
if (
nodeData?.input?.optional?.default_value?.[1]?.dynamic_enum === true
) {
nodeData.input.optional.default_value = ["DYNAMIC_ENUM"];
// console.log(nodeData.input.optional.default_value);
}
// const origonNodeCreated = nodeType.prototype.onNodeCreated;
// nodeType.prototype.onNodeCreated = function () {
// const r = origonNodeCreated
@@ -535,6 +615,10 @@ const ext = {
// };
},
// async nodeCreated(node) {
// },
registerCustomNodes() {
/** @type {LGraphNode}*/
class ComfyDeploy extends LGraphNode {
@@ -696,9 +780,37 @@ const ext = {
return { widget: urlWidget };
},
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: [] },
);
return { widget: enumWidget };
},
};
},
async afterConfigureGraph() {
app.graph.nodes.forEach(node => {
if (node.type === "ComfyUIDeployExternalEnum") {
const default_value_index = node.widgets.findIndex(x => x.name === "default_value");
const options_index = node.widgets.findIndex(x => x.name === "options");
var dynamic_enum_options = [node.widgets[default_value_index].value];
if (node.widgets[options_index].value) {
dynamic_enum_options = JSON.parse(node.widgets[options_index].value);
}
// console.log("dynamic_enum_options", dynamic_enum_options);
node.widgets[default_value_index].options.values = dynamic_enum_options;
}
});
},
async setup() {
// const graphCanvas = document.getElementById("graph-canvas");
@@ -886,6 +998,7 @@ const ext = {
}
})(app.graph.onAfterChange);
sendEventToCD("cd_plugin_setup");
},
};
@@ -1071,6 +1184,8 @@ async function deployWorkflow() {
const prompt = await app.graphToPrompt();
let deps = undefined;
console.log(prompt);
if (includeDeps) {
loadingDialog.showLoading("Fetching existing version");
@@ -1280,85 +1395,6 @@ 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");
@@ -1845,85 +1881,93 @@ 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/`,
// });
app.extensionManager.registerSidebarTab({
id: "search",
icon: "pi pi-cloud-upload",
title: "Deploy",
tooltip: "Deploy and Configure",
type: "custom",
render: (el) => {
el.innerHTML = `
<div style="padding: 20px;">
<h3>Comfy Deploy</h3>
<div id="deploy-container" style="margin-bottom: 20px;"></div>
<div id="workflows-container" style="display: none;">
<h4>Your Workflows</h4>
<div id="workflows-loading" style="display: flex; justify-content: center; align-items: center; height: 100px;">
${loadingIcon}
// Check if the current URL hostname starts with localhost or 127.0.0.1
const isLocalhost =
window.location.hostname === "localhost" ||
window.location.hostname === "127.0.0.1";
// Only register the sidebar tab if we're on localhost
if (isLocalhost) {
app.extensionManager.registerSidebarTab({
id: "search",
icon: "pi pi-cloud-upload",
title: "Deploy",
tooltip: "Deploy and Configure",
type: "custom",
render: (el) => {
el.innerHTML = `
<div style="padding: 20px;">
<h3>Comfy Deploy</h3>
<div id="deploy-container" style="margin-bottom: 20px;"></div>
<div id="workflows-container" style="display: none;">
<h4>Your Workflows</h4>
<div id="workflows-loading" style="display: flex; justify-content: center; align-items: center; height: 100px;">
${loadingIcon}
</div>
<ul id="workflows-list" style="list-style-type: none; padding: 0; display: none;"></ul>
</div>
<ul id="workflows-list" style="list-style-type: none; padding: 0; display: none;"></ul>
<div id="config-container"></div>
</div>
<div id="config-container"></div>
</div>
`;
`;
// Add deploy button
const deployContainer = el.querySelector("#deploy-container");
const deployButton = document.createElement("button");
deployButton.id = "sidebar-deploy-button";
deployButton.style.display = "flex";
deployButton.style.alignItems = "center";
deployButton.style.justifyContent = "center";
deployButton.style.width = "100%";
deployButton.style.marginBottom = "10px";
deployButton.style.padding = "10px";
deployButton.style.fontSize = "16px";
deployButton.style.fontWeight = "bold";
deployButton.style.backgroundColor = "#4CAF50";
deployButton.style.color = "white";
deployButton.style.border = "none";
deployButton.style.borderRadius = "5px";
deployButton.style.cursor = "pointer";
deployButton.innerHTML = `<i class="pi pi-cloud-upload" style="margin-right: 8px;"></i><div id='sidebar-button-title'>Deploy</div>`;
deployButton.onclick = async () => {
await deployWorkflow();
// Refresh the workflows list after deployment
refreshWorkflowsList(el);
};
deployContainer.appendChild(deployButton);
// Add deploy button
const deployContainer = el.querySelector("#deploy-container");
const deployButton = document.createElement("button");
deployButton.id = "sidebar-deploy-button";
deployButton.style.display = "flex";
deployButton.style.alignItems = "center";
deployButton.style.justifyContent = "center";
deployButton.style.width = "100%";
deployButton.style.marginBottom = "10px";
deployButton.style.padding = "10px";
deployButton.style.fontSize = "16px";
deployButton.style.fontWeight = "bold";
deployButton.style.backgroundColor = "#4CAF50";
deployButton.style.color = "white";
deployButton.style.border = "none";
deployButton.style.borderRadius = "5px";
deployButton.style.cursor = "pointer";
deployButton.innerHTML = `<i class="pi pi-cloud-upload" style="margin-right: 8px;"></i><div id='sidebar-button-title'>Deploy</div>`;
deployButton.onclick = async () => {
await deployWorkflow();
// Refresh the workflows list after deployment
// refreshWorkflowsList(el);
};
deployContainer.appendChild(deployButton);
// Add config button
const configContainer = el.querySelector("#config-container");
const configButton = document.createElement("button");
configButton.style.display = "flex";
configButton.style.alignItems = "center";
configButton.style.justifyContent = "center";
configButton.style.width = "100%";
configButton.style.padding = "8px";
configButton.style.fontSize = "14px";
configButton.style.backgroundColor = "#f0f0f0";
configButton.style.color = "#333";
configButton.style.border = "1px solid #ccc";
configButton.style.borderRadius = "5px";
configButton.style.cursor = "pointer";
configButton.innerHTML = `<i class="pi pi-cog" style="margin-right: 8px;"></i>Configure`;
configButton.onclick = () => {
configDialog.show();
};
deployContainer.appendChild(configButton);
// Add config button
const configContainer = el.querySelector("#config-container");
const configButton = document.createElement("button");
configButton.style.display = "flex";
configButton.style.alignItems = "center";
configButton.style.justifyContent = "center";
configButton.style.width = "100%";
configButton.style.padding = "8px";
configButton.style.fontSize = "14px";
configButton.style.backgroundColor = "#f0f0f0";
configButton.style.color = "#333";
configButton.style.border = "1px solid #ccc";
configButton.style.borderRadius = "5px";
configButton.style.cursor = "pointer";
configButton.innerHTML = `<i class="pi pi-cog" style="margin-right: 8px;"></i>Configure`;
configButton.onclick = () => {
configDialog.show();
};
deployContainer.appendChild(configButton);
// Fetch and display workflows
const workflowsList = el.querySelector("#workflows-list");
const workflowsLoading = el.querySelector("#workflows-loading");
// Fetch and display workflows
const workflowsList = el.querySelector("#workflows-list");
const workflowsLoading = el.querySelector("#workflows-loading");
refreshWorkflowsList(el);
},
});
// refreshWorkflowsList(el);
},
});
}
function getTimeAgo(date) {
const seconds = Math.floor((new Date() - date) / 1000);
@@ -1940,24 +1984,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) => {