Compare commits
50
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a91edf6371 | ||
|
|
967709dd07 | ||
|
|
07a827d56b | ||
|
|
7f6eea361e | ||
|
|
14829a2b12 | ||
|
|
03c4e2d85d | ||
|
|
bad8b1104f | ||
|
|
ac8779dc54 | ||
|
|
2a5223f2f0 | ||
|
|
f02aef4cb3 | ||
|
|
22458a1cd6 | ||
|
|
0a58eba554 | ||
|
|
def54df9c2 | ||
|
|
32a950afe8 | ||
|
|
8130779d94 | ||
|
|
4cbd2a8225 | ||
|
|
d6fb2daeff | ||
|
|
a91effe3c8 | ||
|
|
c015b710fe | ||
|
|
2738d1913a | ||
|
|
3f4c11e3f1 | ||
|
|
089bad5560 | ||
|
|
46010e1dd5 | ||
|
|
52d876fa67 | ||
|
|
f7e7eb19d0 | ||
|
|
cb03b6718e | ||
|
|
7b734c415a | ||
|
|
64d3ec6b45 | ||
|
|
c47865ec26 | ||
|
|
b889f79baf | ||
|
|
1d8fed3534 | ||
|
|
a557788e70 | ||
|
|
05cccaffa2 | ||
|
|
85af9dd68f | ||
|
|
233615ea25 | ||
|
|
8b6aabbfaa | ||
|
|
c72539078c | ||
|
|
95fc642782 | ||
|
|
d88ca5b748 | ||
|
|
e86a484160 | ||
|
|
5d85bfd38f | ||
|
|
77eb9f9805 | ||
|
|
266e9d1024 | ||
|
|
dc7234640c | ||
|
|
4e8417d501 | ||
|
|
62609f9c6a | ||
|
|
438401b8c7 | ||
|
|
9beac36d0f | ||
|
|
313ce956fd | ||
|
|
e18d980b77 |
@@ -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)"
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)"}
|
||||
@@ -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)"
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# In file: comfyui-deploy/comfy-nodes/output_exr.py
|
||||
|
||||
import os
|
||||
import numpy as np
|
||||
import folder_paths
|
||||
|
||||
# Try to set up OpenCV for EXR writing.
|
||||
try:
|
||||
os.environ["OPENCV_IO_ENABLE_OPENEXR"] = "1"
|
||||
import cv2
|
||||
OPENCV_AVAILABLE = True
|
||||
except ImportError:
|
||||
print("Warning: OpenCV not found for ComfyDeployOutputEXR. Please add opencv-python-headless to requirements.txt")
|
||||
OPENCV_AVAILABLE = False
|
||||
|
||||
# ALIGNED: Renamed class to match project conventions
|
||||
class ComfyDeployOutputEXR:
|
||||
def __init__(self):
|
||||
self.output_dir = folder_paths.get_output_directory()
|
||||
self.type = "output"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE", ),
|
||||
"filename_prefix": ("STRING", {"default": "ComfyDeploy_EXR"})
|
||||
},
|
||||
# ADDED: Optional output_id for consistency with other ComfyDeploy nodes
|
||||
"optional": {
|
||||
"output_id": ("STRING", {"multiline": False, "default": "output_exr"}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
# ALIGNED: Changed function name to 'run'
|
||||
FUNCTION = "run"
|
||||
OUTPUT_NODE = True
|
||||
# ALIGNED: Matched the category name
|
||||
CATEGORY = "🔗ComfyDeploy"
|
||||
DESCRIPTION = "Saves the input images as EXR (HDR) files."
|
||||
|
||||
def run(self, images, filename_prefix="ComfyDeploy_EXR", output_id="output_exr"):
|
||||
if not OPENCV_AVAILABLE:
|
||||
raise ImportError("OpenCV is required to save EXR files. Please ensure opencv-python-headless is in requirements.txt.")
|
||||
|
||||
full_output_folder, filename, counter, subfolder, filename_prefix = (
|
||||
folder_paths.get_save_image_path(
|
||||
filename_prefix, self.output_dir, images[0].shape[1], images[0].shape[0]
|
||||
)
|
||||
)
|
||||
results = list()
|
||||
|
||||
for image in images:
|
||||
image_np = image.cpu().numpy()
|
||||
|
||||
if image_np.dtype != np.float32:
|
||||
image_np = image_np.astype(np.float32)
|
||||
|
||||
file = f"{filename}_{counter:05}.exr"
|
||||
file_path = os.path.join(full_output_folder, file)
|
||||
|
||||
image_np_bgr = cv2.cvtColor(image_np, cv2.COLOR_RGB2BGR)
|
||||
cv2.imwrite(file_path, image_np_bgr)
|
||||
|
||||
results.append({
|
||||
"filename": file,
|
||||
"subfolder": subfolder,
|
||||
"type": self.type,
|
||||
"output_id": output_id, # ADDED
|
||||
})
|
||||
counter += 1
|
||||
|
||||
return {"ui": {"images": results}}
|
||||
|
||||
# ALIGNED: Mappings are defined at the bottom of the node file in this project
|
||||
NODE_CLASS_MAPPINGS = {"ComfyDeployOutputEXR": ComfyDeployOutputEXR}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {"ComfyDeployOutputEXR": "EXR Output (ComfyDeploy)"}
|
||||
@@ -0,0 +1,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)"}
|
||||
+581
-112
@@ -31,6 +31,7 @@ import torch
|
||||
import psutil
|
||||
from collections import OrderedDict
|
||||
import io
|
||||
from urllib.parse import urlencode
|
||||
|
||||
# Global session
|
||||
client_session = None
|
||||
@@ -139,7 +140,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 +159,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
|
||||
@@ -260,7 +265,7 @@ def clear_current_prompt(sid):
|
||||
streaming_prompt_metadata[sid].running_prompt_ids.clear()
|
||||
|
||||
|
||||
def post_prompt(json_data):
|
||||
async def post_prompt(json_data):
|
||||
prompt_server = server.PromptServer.instance
|
||||
json_data = prompt_server.trigger_on_prompt(json_data)
|
||||
|
||||
@@ -276,16 +281,58 @@ def post_prompt(json_data):
|
||||
|
||||
if "prompt" in json_data:
|
||||
prompt = json_data["prompt"]
|
||||
valid = execution.validate_prompt(prompt)
|
||||
prompt_id = json_data.get("prompt_id") or str(uuid.uuid4())
|
||||
|
||||
partial_execution_targets = None
|
||||
if "partial_execution_targets" in json_data:
|
||||
partial_execution_targets = json_data["partial_execution_targets"]
|
||||
|
||||
# Handle different validate_prompt signatures (newest to oldest)
|
||||
valid = None
|
||||
last_error = None
|
||||
|
||||
# v0.3.48 (3 args)
|
||||
try:
|
||||
valid = await execution.validate_prompt(
|
||||
prompt_id, prompt, partial_execution_targets
|
||||
)
|
||||
except TypeError as e:
|
||||
last_error = e
|
||||
logger.debug(
|
||||
f"validate_prompt with 3 params not supported, trying with 2. Debug: {last_error}"
|
||||
)
|
||||
|
||||
# v0.3.45 - 0.3.47 (2 args)
|
||||
if valid is None:
|
||||
try:
|
||||
valid = await execution.validate_prompt(prompt_id, prompt)
|
||||
except TypeError as e:
|
||||
last_error = e
|
||||
logger.debug(
|
||||
f"validate_prompt with 2 params not supported, trying legacy signature. Debug: {last_error}"
|
||||
)
|
||||
|
||||
# v0.3.44 or older (1 arg)
|
||||
if valid is None:
|
||||
try:
|
||||
valid = execution.validate_prompt(prompt)
|
||||
except TypeError as e:
|
||||
last_error = e
|
||||
logger.error(
|
||||
f"validate_prompt failed with all signatures. Last error: {last_error}"
|
||||
)
|
||||
raise
|
||||
|
||||
extra_data = {}
|
||||
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]:
|
||||
# if the prompt id is provided
|
||||
prompt_id = json_data.get("prompt_id") or str(uuid.uuid4())
|
||||
outputs_to_execute = valid[2]
|
||||
prompt_server.prompt_queue.put(
|
||||
(number, prompt_id, prompt, extra_data, outputs_to_execute)
|
||||
@@ -314,7 +361,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 +374,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")
|
||||
@@ -413,6 +495,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
|
||||
|
||||
@@ -431,6 +516,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
|
||||
@@ -438,7 +529,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)
|
||||
|
||||
@@ -448,15 +539,15 @@ def send_prompt(sid: str, inputs: StreamingPrompt):
|
||||
|
||||
prompt_id = str(uuid.uuid4())
|
||||
|
||||
prompt = {
|
||||
"prompt": workflow_api,
|
||||
"client_id": sid, # "comfy_deploy_instance", #api.client_id
|
||||
"prompt_id": prompt_id,
|
||||
"extra_data": {"extra_pnginfo": {"workflow": workflow}},
|
||||
}
|
||||
# prompt = {
|
||||
# "prompt": workflow_api,
|
||||
# "client_id": sid, # "comfy_deploy_instance", #api.client_id
|
||||
# "prompt_id": prompt_id,
|
||||
# "extra_data": {"extra_pnginfo": {"workflow": workflow}},
|
||||
# }
|
||||
|
||||
try:
|
||||
res = post_prompt(prompt)
|
||||
# res = post_prompt(prompt)
|
||||
inputs.running_prompt_ids.add(prompt_id)
|
||||
prompt_metadata[prompt_id] = SimplePrompt(
|
||||
status_endpoint=inputs.status_endpoint,
|
||||
@@ -467,7 +558,7 @@ def send_prompt(sid: str, inputs: StreamingPrompt):
|
||||
except Exception as e:
|
||||
error_type = type(e).__name__
|
||||
stack_trace_short = traceback.format_exc().strip().split("\n")[-2]
|
||||
stack_trace = traceback.format_exc().strip()
|
||||
# stack_trace = traceback.format_exc().strip()
|
||||
logger.info(f"error: {error_type}, {e}")
|
||||
logger.info(f"stack trace: {stack_trace_short}")
|
||||
|
||||
@@ -524,7 +615,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 = {
|
||||
@@ -543,7 +634,7 @@ async def comfy_deploy_run(request):
|
||||
)
|
||||
|
||||
try:
|
||||
res = post_prompt(prompt)
|
||||
res = await post_prompt(prompt)
|
||||
except Exception as e:
|
||||
error_type = type(e).__name__
|
||||
stack_trace_short = traceback.format_exc().strip().split("\n")[-2]
|
||||
@@ -591,7 +682,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 = {
|
||||
@@ -612,7 +703,7 @@ async def stream_prompt(data, token):
|
||||
# log('info', "Begin prompt", prompt=prompt)
|
||||
|
||||
try:
|
||||
res = post_prompt(prompt)
|
||||
res = await post_prompt(prompt)
|
||||
except Exception as e:
|
||||
error_type = type(e).__name__
|
||||
stack_trace_short = traceback.format_exc().strip().split("\n")[-2]
|
||||
@@ -1214,22 +1305,11 @@ def handle_execute(class_type, last_node_id, prompt_id, server, unique_id):
|
||||
|
||||
try:
|
||||
origin_execute = execution.execute
|
||||
is_async = asyncio.iscoroutinefunction(origin_execute)
|
||||
|
||||
def swizzle_execute(
|
||||
server,
|
||||
dynprompt,
|
||||
caches,
|
||||
current_item,
|
||||
extra_data,
|
||||
executed,
|
||||
prompt_id,
|
||||
execution_list,
|
||||
pending_subgraph_results,
|
||||
):
|
||||
unique_id = current_item
|
||||
class_type = dynprompt.get_node(unique_id)["class_type"]
|
||||
last_node_id = server.last_node_id
|
||||
result = origin_execute(
|
||||
if is_async:
|
||||
|
||||
async def swizzle_execute(
|
||||
server,
|
||||
dynprompt,
|
||||
caches,
|
||||
@@ -1239,12 +1319,61 @@ try:
|
||||
prompt_id,
|
||||
execution_list,
|
||||
pending_subgraph_results,
|
||||
)
|
||||
handle_execute(class_type, last_node_id, prompt_id, server, unique_id)
|
||||
return result
|
||||
pending_async_nodes,
|
||||
):
|
||||
unique_id = current_item
|
||||
class_type = dynprompt.get_node(unique_id)["class_type"]
|
||||
last_node_id = server.last_node_id
|
||||
|
||||
result = await origin_execute(
|
||||
server,
|
||||
dynprompt,
|
||||
caches,
|
||||
current_item,
|
||||
extra_data,
|
||||
executed,
|
||||
prompt_id,
|
||||
execution_list,
|
||||
pending_subgraph_results,
|
||||
pending_async_nodes,
|
||||
)
|
||||
|
||||
handle_execute(class_type, last_node_id, prompt_id, server, unique_id)
|
||||
return result
|
||||
else:
|
||||
|
||||
def swizzle_execute(
|
||||
server,
|
||||
dynprompt,
|
||||
caches,
|
||||
current_item,
|
||||
extra_data,
|
||||
executed,
|
||||
prompt_id,
|
||||
execution_list,
|
||||
pending_subgraph_results,
|
||||
):
|
||||
unique_id = current_item
|
||||
class_type = dynprompt.get_node(unique_id)["class_type"]
|
||||
last_node_id = server.last_node_id
|
||||
|
||||
result = origin_execute(
|
||||
server,
|
||||
dynprompt,
|
||||
caches,
|
||||
current_item,
|
||||
extra_data,
|
||||
executed,
|
||||
prompt_id,
|
||||
execution_list,
|
||||
pending_subgraph_results,
|
||||
)
|
||||
|
||||
handle_execute(class_type, last_node_id, prompt_id, server, unique_id)
|
||||
return result
|
||||
|
||||
execution.execute = swizzle_execute
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -1310,7 +1439,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
|
||||
@@ -1388,16 +1517,15 @@ async def send_json_override(self, event, data, sid=None):
|
||||
logger.info(format_table(headers, table_data))
|
||||
# print("========================\n")
|
||||
|
||||
timeline = format_execution_timeline(NODE_EXECUTION_TIMES)
|
||||
logger.info(f"\nNode Execution Timeline:\n{timeline}")
|
||||
# Clear the execution times for the next run
|
||||
|
||||
# the last executing event is none, then the workflow is finished
|
||||
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
|
||||
@@ -1838,12 +1966,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
|
||||
|
||||
@@ -1898,16 +2027,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:
|
||||
@@ -1921,15 +2045,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 (
|
||||
@@ -1955,7 +2079,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)
|
||||
@@ -2006,22 +2130,30 @@ 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"),
|
||||
("audio", "format", "audio/mpeg"),
|
||||
]:
|
||||
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
|
||||
@@ -2086,6 +2218,9 @@ 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
|
||||
or "audio" in data
|
||||
)
|
||||
if bypass_upload and have_upload_media:
|
||||
print(
|
||||
@@ -2396,6 +2531,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")
|
||||
@@ -2546,8 +2686,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]
|
||||
@@ -2668,6 +2812,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:
|
||||
@@ -2683,51 +2829,57 @@ class UploadQueue:
|
||||
logger.error(f"Upload failed: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
finally:
|
||||
# Remove this upload from tracking
|
||||
if prompt_id in self.pending_uploads:
|
||||
self.pending_uploads[prompt_id].discard(upload_id)
|
||||
# Remove from node tracking if applicable
|
||||
if (
|
||||
node_id
|
||||
and prompt_id in self.node_uploads
|
||||
and node_id in self.node_uploads[prompt_id]
|
||||
):
|
||||
self.node_uploads[prompt_id][node_id].discard(upload_id)
|
||||
async with self.lock: # Acquire lock to protect shared dict access
|
||||
if prompt_id in self.pending_uploads:
|
||||
self.pending_uploads[prompt_id].discard(upload_id)
|
||||
|
||||
# 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 (
|
||||
node_id
|
||||
and prompt_id in self.node_uploads
|
||||
and node_id in self.node_uploads[prompt_id]
|
||||
):
|
||||
self.node_uploads[prompt_id][node_id].discard(upload_id)
|
||||
|
||||
# Send status update
|
||||
await self.update_queue_status(prompt_id)
|
||||
if not self.node_uploads[prompt_id][node_id]:
|
||||
del self.node_uploads[prompt_id][node_id]
|
||||
|
||||
if (
|
||||
prompt_id in self.node_output_data
|
||||
and node_id in self.node_output_data[prompt_id]
|
||||
):
|
||||
node_data = self.node_output_data[prompt_id][
|
||||
node_id
|
||||
]
|
||||
if node_data["data"]:
|
||||
body = {
|
||||
"run_id": prompt_id,
|
||||
"output_data": node_data["data"],
|
||||
"node_meta": {"node_id": node_id},
|
||||
}
|
||||
try:
|
||||
await async_request_with_retry(
|
||||
"POST",
|
||||
prompt_metadata[
|
||||
prompt_id
|
||||
].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)}"
|
||||
)
|
||||
|
||||
# Safe to delete now (re-check not strictly needed with lock, but harmless)
|
||||
del self.node_output_data[prompt_id][node_id]
|
||||
|
||||
# If no more pending uploads for this prompt and it's done, update status
|
||||
if not self.pending_uploads[prompt_id] and is_prompt_done(
|
||||
prompt_id
|
||||
if (
|
||||
prompt_id in self.pending_uploads
|
||||
and not self.pending_uploads[prompt_id]
|
||||
and is_prompt_done(prompt_id)
|
||||
):
|
||||
# Clean up all data for this prompt
|
||||
if prompt_id in self.node_uploads:
|
||||
@@ -2740,9 +2892,12 @@ class UploadQueue:
|
||||
loop.create_task(update_run(prompt_id, Status.SUCCESS))
|
||||
loop.create_task(send("success", {"prompt_id": prompt_id}))
|
||||
|
||||
# Mark task as done
|
||||
# Mark task as done (outside lock to avoid holding it unnecessarily)
|
||||
self.queue.task_done()
|
||||
|
||||
# Send status update (also outside lock)
|
||||
await self.update_queue_status(prompt_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in upload worker: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
@@ -2813,3 +2968,317 @@ def format_execution_timeline(execution_times):
|
||||
current_time += duration
|
||||
|
||||
return format_table(headers, rows)
|
||||
|
||||
|
||||
@server.PromptServer.instance.routes.get("/comfyui-deploy/auth-response")
|
||||
async def auth_response_proxy(request):
|
||||
request_id = request.rel_url.query.get("request_id")
|
||||
api_url = request.rel_url.query.get("api_url", "https://api.comfydeploy.com")
|
||||
|
||||
if not request_id:
|
||||
return web.json_response({"error": "request_id is required"}, status=400)
|
||||
|
||||
target_url = f"{api_url}/api/platform/comfyui/auth-response?request_id={request_id}"
|
||||
|
||||
try:
|
||||
await ensure_client_session()
|
||||
async with client_session.get(target_url) as response:
|
||||
json_data = await response.json()
|
||||
return web.json_response(json_data, status=response.status)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
@server.PromptServer.instance.routes.post("/comfyui-deploy/workflow")
|
||||
async def create_workflow_proxy(request):
|
||||
data = await request.json()
|
||||
name = data.get("name")
|
||||
workflow_json = data.get("workflow_json")
|
||||
workflow_api = data.get("workflow_api")
|
||||
machine_id = data.get("machine_id")
|
||||
api_url = data.get("api_url", "https://api.comfydeploy.com")
|
||||
|
||||
auth_header = request.headers.get("Authorization")
|
||||
if not auth_header:
|
||||
return web.json_response(
|
||||
{"error": "Authorization header is required"}, status=401
|
||||
)
|
||||
|
||||
if not name or not workflow_json or not workflow_api:
|
||||
return web.json_response(
|
||||
{"error": "name, workflow_json, workflow_api are required"}, status=400
|
||||
)
|
||||
|
||||
target_url = f"{api_url}/api/workflow"
|
||||
|
||||
request_body = {
|
||||
"name": name,
|
||||
"workflow_json": json.dumps(workflow_json),
|
||||
"workflow_api": json.dumps(workflow_api),
|
||||
"machine_id": machine_id,
|
||||
}
|
||||
|
||||
try:
|
||||
await ensure_client_session()
|
||||
async with client_session.post(
|
||||
target_url,
|
||||
json=request_body,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": auth_header,
|
||||
},
|
||||
) as response:
|
||||
json_data = await response.json()
|
||||
return web.json_response(json_data, status=response.status)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
@server.PromptServer.instance.routes.post("/comfyui-deploy/workflow/version")
|
||||
async def create_workflow_version_proxy(request):
|
||||
data = await request.json()
|
||||
workflow_id = data.get("workflow_id")
|
||||
workflow = data.get("workflow")
|
||||
workflow_api = data.get("workflow_api")
|
||||
comment = data.get("comment", "")
|
||||
api_url = data.get("api_url", "https://api.comfydeploy.com")
|
||||
|
||||
auth_header = request.headers.get("Authorization")
|
||||
|
||||
if not auth_header:
|
||||
return web.json_response(
|
||||
{"error": "Authorization header is required"}, status=401
|
||||
)
|
||||
|
||||
target_url = f"{api_url}/api/workflow/{workflow_id}/version"
|
||||
|
||||
request_body = {
|
||||
"workflow": workflow,
|
||||
"workflow_api": workflow_api,
|
||||
"comment": comment,
|
||||
}
|
||||
|
||||
try:
|
||||
await ensure_client_session()
|
||||
async with client_session.post(
|
||||
target_url,
|
||||
json=request_body,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": auth_header,
|
||||
},
|
||||
) as response:
|
||||
json_data = await response.json()
|
||||
return web.json_response(json_data, status=response.status)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
@server.PromptServer.instance.routes.get("/comfyui-deploy/workflows")
|
||||
async def get_workflows_proxy(request):
|
||||
api_url = request.rel_url.query.get("api_url", "https://api.comfydeploy.com")
|
||||
search = request.rel_url.query.get("search", "")
|
||||
limit = request.rel_url.query.get("limit", 10)
|
||||
offset = request.rel_url.query.get("offset", 0)
|
||||
auth_header = request.headers.get("Authorization")
|
||||
|
||||
if not auth_header:
|
||||
return web.json_response(
|
||||
{"error": "Authorization header is required"}, status=401
|
||||
)
|
||||
|
||||
# Build query parameters properly
|
||||
params = {}
|
||||
if search:
|
||||
params["search"] = search
|
||||
if limit:
|
||||
params["limit"] = limit
|
||||
if offset:
|
||||
params["offset"] = offset
|
||||
|
||||
target_url = f"{api_url}/api/workflows"
|
||||
if params:
|
||||
target_url += f"?{urlencode(params)}"
|
||||
|
||||
try:
|
||||
await ensure_client_session()
|
||||
async with client_session.get(
|
||||
target_url,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": auth_header,
|
||||
},
|
||||
) as response:
|
||||
json_data = await response.json()
|
||||
return web.json_response(json_data, status=response.status)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
# for getting a workflow by id
|
||||
@server.PromptServer.instance.routes.get("/comfyui-deploy/workflow")
|
||||
async def get_workflow_proxy(request):
|
||||
workflow_id = request.rel_url.query.get("workflow_id")
|
||||
api_url = request.rel_url.query.get("api_url", "https://api.comfydeploy.com")
|
||||
auth_header = request.headers.get("Authorization")
|
||||
|
||||
if not auth_header:
|
||||
return web.json_response(
|
||||
{"error": "Authorization header is required"}, status=401
|
||||
)
|
||||
|
||||
target_url = f"{api_url}/api/workflow/{workflow_id}"
|
||||
|
||||
try:
|
||||
await ensure_client_session()
|
||||
async with client_session.get(
|
||||
target_url, headers={"Authorization": auth_header}
|
||||
) as response:
|
||||
json_data = await response.json()
|
||||
return web.json_response(json_data, status=response.status)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
# for getting a machine by id
|
||||
@server.PromptServer.instance.routes.get("/comfyui-deploy/machine")
|
||||
async def get_machine_proxy(request):
|
||||
machine_id = request.rel_url.query.get("machine_id")
|
||||
api_url = request.rel_url.query.get("api_url", "https://api.comfydeploy.com")
|
||||
auth_header = request.headers.get("Authorization")
|
||||
|
||||
if not auth_header:
|
||||
return web.json_response(
|
||||
{"error": "Authorization header is required"}, status=401
|
||||
)
|
||||
|
||||
target_url = f"{api_url}/api/machine/{machine_id}"
|
||||
|
||||
try:
|
||||
await ensure_client_session()
|
||||
async with client_session.get(
|
||||
target_url, headers={"Authorization": auth_header}
|
||||
) as response:
|
||||
json_data = await response.json()
|
||||
return web.json_response(json_data, status=response.status)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
# for fetching docker steps from current snapshot
|
||||
@server.PromptServer.instance.routes.post("/comfyui-deploy/snapshot-to-docker")
|
||||
async def snapshot_to_docker_proxy(request):
|
||||
data = await request.json()
|
||||
snapshot = data.get("snapshot")
|
||||
api_url = data.get("api_url", "https://api.comfydeploy.com")
|
||||
auth_header = request.headers.get("Authorization")
|
||||
|
||||
if not auth_header:
|
||||
return web.json_response(
|
||||
{"error": "Authorization header is required"}, status=401
|
||||
)
|
||||
|
||||
target_url = f"{api_url}/api/snapshot-to-docker"
|
||||
|
||||
request_body = snapshot
|
||||
|
||||
try:
|
||||
await ensure_client_session()
|
||||
async with client_session.post(
|
||||
target_url, json=request_body, headers={"Authorization": auth_header}
|
||||
) as response:
|
||||
json_data = await response.json()
|
||||
return web.json_response(json_data, status=response.status)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
# update a serverless machine with machine id
|
||||
@server.PromptServer.instance.routes.post("/comfyui-deploy/machine/update")
|
||||
async def update_machine_proxy(request):
|
||||
data = await request.json()
|
||||
machine_id = data.get("machine_id")
|
||||
comfyui_version = data.get("comfyui_version", None)
|
||||
docker_steps = data.get("docker_steps")
|
||||
api_url = data.get("api_url", "https://api.comfydeploy.com")
|
||||
auth_header = request.headers.get("Authorization")
|
||||
|
||||
if not auth_header:
|
||||
return web.json_response(
|
||||
{"error": "Authorization header is required"}, status=401
|
||||
)
|
||||
|
||||
target_url = f"{api_url}/api/machine/serverless/{machine_id}"
|
||||
|
||||
request_body = {"docker_command_steps": docker_steps}
|
||||
|
||||
if comfyui_version:
|
||||
request_body["comfyui_version"] = comfyui_version
|
||||
|
||||
try:
|
||||
await ensure_client_session()
|
||||
async with client_session.patch(
|
||||
target_url, json=request_body, headers={"Authorization": auth_header}
|
||||
) as response:
|
||||
json_data = await response.json()
|
||||
return web.json_response(json_data, status=response.status)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
@server.PromptServer.instance.routes.post("/comfyui-deploy/machine/create")
|
||||
async def create_machine_proxy(request):
|
||||
data = await request.json()
|
||||
name = data.get("name")
|
||||
docker_command_steps = data.get("docker_command_steps")
|
||||
comfyui_version = data.get("comfyui_version")
|
||||
api_url = data.get("api_url", "https://api.comfydeploy.com")
|
||||
auth_header = request.headers.get("Authorization")
|
||||
|
||||
if not auth_header:
|
||||
return web.json_response(
|
||||
{"error": "Authorization header is required"}, status=401
|
||||
)
|
||||
|
||||
target_url = f"{api_url}/api/machine/serverless"
|
||||
|
||||
request_body = {
|
||||
"name": name,
|
||||
"docker_command_steps": docker_command_steps,
|
||||
"comfyui_version": comfyui_version,
|
||||
"gpu": "A10G",
|
||||
}
|
||||
|
||||
try:
|
||||
await ensure_client_session()
|
||||
async with client_session.post(
|
||||
target_url, json=request_body, headers={"Authorization": auth_header}
|
||||
) as response:
|
||||
json_data = await response.json()
|
||||
return web.json_response(json_data, status=response.status)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
# get latest comfyui version
|
||||
@server.PromptServer.instance.routes.get("/comfyui-deploy/comfyui-version")
|
||||
async def get_comfyui_version_proxy(request):
|
||||
api_url = request.rel_url.query.get("api_url", "https://api.comfydeploy.com")
|
||||
auth_header = request.headers.get("Authorization")
|
||||
|
||||
if not auth_header:
|
||||
return web.json_response(
|
||||
{"error": "Authorization header is required"}, status=401
|
||||
)
|
||||
|
||||
target_url = f"{api_url}/api/latest-hashes"
|
||||
|
||||
try:
|
||||
await ensure_client_session()
|
||||
async with client_session.get(
|
||||
target_url, headers={"Authorization": auth_header}
|
||||
) as response:
|
||||
json_data = await response.json()
|
||||
return web.json_response(json_data, status=response.status)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-deploy"
|
||||
description = "Open source comfyui deployment platform, a vercel for generative workflow infra."
|
||||
version = "2.1.0"
|
||||
version = "2.3.2"
|
||||
license = { file = "LICENSE" }
|
||||
dependencies = ["aiofiles", "pydantic", "opencv-python", "imageio-ffmpeg", "tabulate", "brotli"]
|
||||
|
||||
|
||||
+1234
-566
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
||||
// Snapshot Utilities
|
||||
// Centralized snapshot fetching with ComfyUI version fallback
|
||||
|
||||
/**
|
||||
* Fetches the current snapshot with ComfyUI version fallback
|
||||
* If the snapshot response has null comfyui field, it will fetch the latest ComfyUI version
|
||||
* and update the snapshot with the comfyui_hash
|
||||
*
|
||||
* @param {Function} getDataFn - Function that returns { apiKey, apiUrl } for ComfyUI version API calls
|
||||
* @returns {Promise<Object>} - The snapshot data with comfyui field populated
|
||||
*/
|
||||
export async function fetchSnapshot(getDataFn = null) {
|
||||
try {
|
||||
// Fetch the current snapshot
|
||||
const response = await fetch("/snapshot/get_current");
|
||||
if (!response.ok) {
|
||||
throw new Error(`Snapshot fetch failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const snapshot = await response.json();
|
||||
|
||||
// Check if comfyui field is null and we have getDataFn for fallback
|
||||
if (snapshot.comfyui === null && getDataFn) {
|
||||
console.log(
|
||||
"ComfyUI version is null in snapshot, fetching latest version..."
|
||||
);
|
||||
|
||||
try {
|
||||
const data = getDataFn();
|
||||
if (data && data.apiKey) {
|
||||
const comfyuiVersionResponse = await fetch(
|
||||
`/comfyui-deploy/comfyui-version?api_url=${encodeURIComponent(
|
||||
data.apiUrl || "https://api.comfydeploy.com"
|
||||
)}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${data.apiKey}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (comfyuiVersionResponse.ok) {
|
||||
const versionData = await comfyuiVersionResponse.json();
|
||||
if (versionData.comfyui_hash) {
|
||||
console.log(
|
||||
`Using ComfyUI hash from API: ${versionData.comfyui_hash}`
|
||||
);
|
||||
snapshot.comfyui = versionData.comfyui_hash;
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
"Failed to fetch ComfyUI version from API:",
|
||||
comfyuiVersionResponse.status
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Error fetching ComfyUI version fallback:", error);
|
||||
// Continue with original snapshot even if fallback fails
|
||||
}
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
} catch (error) {
|
||||
console.error("Error fetching snapshot:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple snapshot fetch without ComfyUI version fallback
|
||||
* Use this when you don't need the ComfyUI version fallback logic
|
||||
*
|
||||
* @returns {Promise<Object>} - The snapshot data as-is
|
||||
*/
|
||||
export async function fetchSnapshotSimple() {
|
||||
const response = await fetch("/snapshot/get_current");
|
||||
if (!response.ok) {
|
||||
throw new Error(`Snapshot fetch failed: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
// Workflow list management
|
||||
let workflowsState = {
|
||||
workflows: [],
|
||||
offset: 0,
|
||||
limit: 20,
|
||||
loading: false,
|
||||
hasMore: true,
|
||||
initialized: false,
|
||||
currentSearch: "",
|
||||
};
|
||||
|
||||
// Make workflowsState accessible globally
|
||||
window.workflowsState = workflowsState;
|
||||
|
||||
async function fetchWorkflows(getData, offset = 0, limit = 20, search = "") {
|
||||
try {
|
||||
const data = getData();
|
||||
if (!data.apiKey) {
|
||||
throw new Error("API key not configured");
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
offset: offset.toString(),
|
||||
limit: limit.toString(),
|
||||
api_url: data.apiUrl || "https://api.comfydeploy.com",
|
||||
...(search && { search }),
|
||||
});
|
||||
|
||||
const response = await fetch(`/comfyui-deploy/workflows?${params}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${data.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch workflows: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log("result", result);
|
||||
return Array.isArray(result) ? result : [];
|
||||
} catch (error) {
|
||||
console.error("Error fetching workflows:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function createWorkflowItem(workflow, getTimeAgo, getData) {
|
||||
const li = document.createElement("li");
|
||||
let loadingToast = null;
|
||||
li.style.cssText = `
|
||||
border-bottom: 1px solid #444;
|
||||
background: transparent;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
li.addEventListener("mouseenter", () => {
|
||||
li.style.background = "#333";
|
||||
});
|
||||
|
||||
li.addEventListener("mouseleave", () => {
|
||||
li.style.background = "transparent";
|
||||
});
|
||||
|
||||
// Add click handler to fetch and load workflow data
|
||||
li.addEventListener("click", async () => {
|
||||
try {
|
||||
const data = getData();
|
||||
if (!data.apiKey) {
|
||||
console.error("No API key configured");
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading toast
|
||||
loadingToast = window.app.extensionManager.toast.add({
|
||||
severity: "info",
|
||||
summary: "Loading workflow...",
|
||||
detail: `Loading "${workflow.name}"`,
|
||||
life: 3000,
|
||||
});
|
||||
|
||||
const params = new URLSearchParams({
|
||||
workflow_id: workflow.id,
|
||||
api_url: data.apiUrl || "https://api.comfydeploy.com",
|
||||
});
|
||||
|
||||
const response = await fetch(`/comfyui-deploy/workflow?${params}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${data.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch workflow: ${response.status}`);
|
||||
}
|
||||
|
||||
const workflowData = await response.json();
|
||||
console.log("Workflow data:", workflowData);
|
||||
|
||||
// Load the workflow into the graph
|
||||
if (workflowData.versions && workflowData.versions.length > 0) {
|
||||
const latestVersion = workflowData.versions[0];
|
||||
if (latestVersion.workflow && window.app) {
|
||||
// Load the workflow
|
||||
window.app.loadGraphData(latestVersion.workflow);
|
||||
|
||||
// Wait a bit for the graph to fully load before checking for ComfyDeploy node
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// Check if ComfyDeploy node exists, if not add it back
|
||||
const graph = window.app.graph;
|
||||
let deployMeta = graph.findNodesByType("ComfyDeploy");
|
||||
|
||||
if (deployMeta.length === 0) {
|
||||
// Add ComfyDeploy node with workflow metadata
|
||||
graph.beforeChange();
|
||||
const node = LiteGraph.createNode("ComfyDeploy");
|
||||
node.configure({
|
||||
widgets_values: [
|
||||
workflow.name, // workflow_name
|
||||
workflow.id, // workflow_id
|
||||
latestVersion.version, // version
|
||||
],
|
||||
});
|
||||
node.pos = [0, 0];
|
||||
graph.add(node);
|
||||
graph.afterChange();
|
||||
|
||||
console.log(
|
||||
`Added ComfyDeploy node with: name="${workflow.name}", id="${workflow.id}", version="${latestVersion.version}"`
|
||||
);
|
||||
}
|
||||
|
||||
// Show success toast
|
||||
window.app.extensionManager.toast.add({
|
||||
severity: "success",
|
||||
summary: "Workflow loaded successfully",
|
||||
detail: `Loaded "${workflow.name}" v${latestVersion.version}`,
|
||||
life: 3000,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading workflow:", error);
|
||||
// Show error toast
|
||||
window.app.extensionManager.toast.add({
|
||||
severity: "error",
|
||||
summary: "Failed to load workflow",
|
||||
detail: error.message,
|
||||
life: 5000,
|
||||
});
|
||||
} finally {
|
||||
if (loadingToast) {
|
||||
loadingToast.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const updatedDate = new Date(workflow.updated_at);
|
||||
const timeAgo = getTimeAgo(updatedDate);
|
||||
|
||||
li.innerHTML = `
|
||||
<div style="padding: 12px 16px;">
|
||||
<div style="display: flex; align-items: flex-start; gap: 12px;">
|
||||
${
|
||||
workflow.cover_image
|
||||
? `<img src="${workflow.cover_image}"
|
||||
style="width: 40px; height: 40px; border-radius: 4px; object-fit: cover; flex-shrink: 0;"
|
||||
onerror="this.style.display='none'">`
|
||||
: `<div style="width: 40px; height: 40px; border-radius: 4px; background: #444; flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-size: 14px; color: #888;">
|
||||
${workflow.name.charAt(0).toUpperCase()}
|
||||
</div>`
|
||||
}
|
||||
|
||||
<div style="flex: 1; min-width: 0;">
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 4px;">
|
||||
<h4 style="margin: 0; font-size: 14px; font-weight: 400; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
|
||||
${workflow.name}
|
||||
</h4>
|
||||
${
|
||||
workflow.pinned
|
||||
? `<span style="color: #ffd700; font-size: 12px;">📌</span>`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
|
||||
${
|
||||
workflow.description
|
||||
? `<p style="margin: 0 0 8px 0; font-size: 12px; color: #bbb; line-height: 1.3; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;">
|
||||
${workflow.description}
|
||||
</p>`
|
||||
: ""
|
||||
}
|
||||
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-top: 8px;">
|
||||
<img src="${workflow.user_icon}"
|
||||
style="width: 16px; height: 16px; border-radius: 50%;"
|
||||
onerror="this.style.display='none'">
|
||||
<span style="font-size: 11px; color: #888;">
|
||||
${workflow.user_name} • Updated ${timeAgo}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return li;
|
||||
}
|
||||
|
||||
async function loadMoreWorkflows(element, getData, getTimeAgo) {
|
||||
if (workflowsState.loading || !workflowsState.hasMore) return;
|
||||
|
||||
workflowsState.loading = true;
|
||||
|
||||
const workflowsList = element.querySelector("#workflows-list");
|
||||
const workflowsLoading = element.querySelector("#workflows-loading");
|
||||
|
||||
// Show loading indicator
|
||||
workflowsLoading.style.display = "flex";
|
||||
|
||||
try {
|
||||
const newWorkflows = await fetchWorkflows(
|
||||
getData,
|
||||
workflowsState.offset,
|
||||
workflowsState.limit,
|
||||
workflowsState.currentSearch
|
||||
);
|
||||
|
||||
if (newWorkflows.length === 0) {
|
||||
workflowsState.hasMore = false;
|
||||
} else {
|
||||
workflowsState.workflows.push(...newWorkflows);
|
||||
workflowsState.offset += newWorkflows.length;
|
||||
|
||||
// Render new workflow items
|
||||
newWorkflows.forEach((workflow) => {
|
||||
const workflowItem = createWorkflowItem(workflow, getTimeAgo, getData);
|
||||
workflowsList.appendChild(workflowItem);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading more workflows:", error);
|
||||
} finally {
|
||||
workflowsState.loading = false;
|
||||
workflowsLoading.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
function setupInfiniteScroll(container, element, getData, getTimeAgo) {
|
||||
let isScrolling = false;
|
||||
|
||||
container.addEventListener("scroll", () => {
|
||||
if (isScrolling) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
||||
|
||||
// Load more when scrolled to bottom (with 100px threshold)
|
||||
if (scrollTop + clientHeight >= scrollHeight - 100) {
|
||||
isScrolling = true;
|
||||
loadMoreWorkflows(element, getData, getTimeAgo).finally(() => {
|
||||
isScrolling = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function initializeWorkflowsList(element, getData, getTimeAgo) {
|
||||
const workflowsContainer = element.querySelector("#workflows-container");
|
||||
const workflowsList = element.querySelector("#workflows-list");
|
||||
const workflowsLoading = element.querySelector("#workflows-loading");
|
||||
|
||||
// Check if already initialized AND the DOM elements still exist
|
||||
if (
|
||||
workflowsState.initialized &&
|
||||
workflowsList &&
|
||||
workflowsList.children.length > 0
|
||||
)
|
||||
return;
|
||||
|
||||
try {
|
||||
// Reset state (always reset when reinitializing)
|
||||
workflowsState = {
|
||||
workflows: [],
|
||||
offset: 0,
|
||||
limit: 20,
|
||||
loading: false,
|
||||
hasMore: true,
|
||||
initialized: true,
|
||||
currentSearch: "",
|
||||
};
|
||||
|
||||
// Clear existing content in case of reinitialization
|
||||
if (workflowsList) {
|
||||
workflowsList.innerHTML = "";
|
||||
}
|
||||
|
||||
// Show container and loading
|
||||
workflowsContainer.style.display = "block";
|
||||
workflowsLoading.style.display = "flex";
|
||||
|
||||
// Style the workflows list for full height scrolling
|
||||
workflowsList.style.cssText = `
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
height: calc(100vh - 550px);
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #666 transparent;
|
||||
border-top: 1px solid #444;
|
||||
`;
|
||||
|
||||
// Add webkit scrollbar styles
|
||||
const style = document.createElement("style");
|
||||
style.textContent = `
|
||||
#workflows-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
#workflows-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
#workflows-list::-webkit-scrollbar-thumb {
|
||||
background: #666;
|
||||
border-radius: 3px;
|
||||
}
|
||||
#workflows-list::-webkit-scrollbar-thumb:hover {
|
||||
background: #777;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
// Setup infinite scroll
|
||||
setupInfiniteScroll(workflowsList, element, getData, getTimeAgo);
|
||||
|
||||
// Load initial workflows
|
||||
await loadMoreWorkflows(element, getData, getTimeAgo);
|
||||
|
||||
// Show the list
|
||||
workflowsList.style.display = "block";
|
||||
} catch (error) {
|
||||
console.error("Error initializing workflows list:", error);
|
||||
workflowsLoading.innerHTML = `
|
||||
<div style="text-align: center; color: #e74c3c; font-size: 12px; padding: 20px;">
|
||||
<div>Failed to load workflows</div>
|
||||
<button onclick="initializeWorkflowsList(this.closest('.comfy-menu'), getData, getTimeAgo)"
|
||||
style="margin-top: 8px; padding: 4px 8px; font-size: 11px; background: #f0f0f0; border: 1px solid #ccc; border-radius: 4px; cursor: pointer;">
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Search functionality
|
||||
function addWorkflowSearch(element, getData, getTimeAgo) {
|
||||
const workflowsContainer = element.querySelector("#workflows-container");
|
||||
const h4 = workflowsContainer.querySelector("h4");
|
||||
|
||||
const searchContainer = document.createElement("div");
|
||||
searchContainer.style.cssText = "margin-bottom: 12px;";
|
||||
|
||||
const searchInput = document.createElement("input");
|
||||
searchInput.type = "text";
|
||||
searchInput.placeholder = "Search workflows...";
|
||||
searchInput.style.cssText = `
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #555;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
box-sizing: border-box;
|
||||
background: #333;
|
||||
color: #fff;
|
||||
`;
|
||||
|
||||
let searchTimeout;
|
||||
searchInput.addEventListener("input", (e) => {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(async () => {
|
||||
const searchTerm = e.target.value.trim();
|
||||
|
||||
// Update the tracked search term
|
||||
workflowsState.currentSearch = searchTerm;
|
||||
|
||||
// Reset state for new search
|
||||
workflowsState.workflows = [];
|
||||
workflowsState.offset = 0;
|
||||
workflowsState.hasMore = true;
|
||||
|
||||
// Clear current list
|
||||
const workflowsList = element.querySelector("#workflows-list");
|
||||
workflowsList.innerHTML = "";
|
||||
|
||||
// Load with search term
|
||||
workflowsState.loading = false;
|
||||
await loadMoreWorkflows(element, getData, getTimeAgo);
|
||||
}, 300);
|
||||
});
|
||||
|
||||
searchContainer.appendChild(searchInput);
|
||||
h4.after(searchContainer);
|
||||
}
|
||||
|
||||
// Export the functions
|
||||
export {
|
||||
initializeWorkflowsList,
|
||||
addWorkflowSearch,
|
||||
workflowsState,
|
||||
fetchWorkflows,
|
||||
loadMoreWorkflows,
|
||||
};
|
||||
Reference in New Issue
Block a user