Compare commits

..
Author SHA1 Message Date
karrix a1031487e1 add: all node support name and description 2024-08-20 20:15:29 +08:00
bennykok ca41207192 feat: max min int for all number inputs to enable negative number input 2024-08-19 13:27:46 -07:00
bennykok 507d5ef631 feat: add a init timeout of 10 seconds for retry logic 2024-08-18 17:31:48 -07:00
bennykok dd1d9df23f fix: resolve false possible error 2024-08-18 15:38:16 -07:00
bennykok 3a14e49ca5 fix: refresh workflows list 2024-08-17 16:04:14 -07:00
nick 8147c4bfb7 video node' 2024-08-15 12:50:29 -07:00
bennykok 10268825d9 feat: support new frontend! 2024-08-14 11:09:58 -07:00
bennykok f6ea252652 fix: log when random seed is applied 2024-08-10 10:35:48 -07:00
bennykok 98cd5ef79c fix: randomize noise RandomNoise, KSamplerAdvanced, SamplerCustom 2024-08-10 10:02:01 -07:00
Emmanuel Morales 4bce5cadfb fix(text): return correctly the text in external_text_list node 2024-08-10 09:44:37 -06:00
Nick Kao f362671041 Merge pull request #61 from BennyKok/node-error-no-throw
block on bad prompt
2024-08-08 10:01:33 -07:00
nick 0582d1d869 merge 2024-08-07 20:43:38 -07:00
nick ce073a86c7 block on bad prompt 2024-08-07 20:42:12 -07:00
Emmanuel Morales 3a85a1edf2 feat(text): create node for external text list (#60)
* feat(text): create node for external text list 

This is to send a list of texts to other nodes

* refactor: remove prints and rename variable

* style: update comment

* refactor: remove unused optional inputs
2024-08-06 21:35:46 -06:00
karrix 369c1456a9 add: node focusing function 2024-08-05 00:59:52 +08:00
bennykok 01e323b7e2 fix: excessive log 2024-08-03 22:22:06 -07:00
bennykok db684d044a fix: not yield 2024-08-03 21:56:16 -07:00
BennyKok 8e12803ea1 Retry logic when calling api (#57)
* fix: retry logic, bypass logfire, clean up log

* fix: max_retries and retry_delay_multiplier, do not throw when pass the retry failed
2024-08-01 20:43:21 -07:00
Nick Kao 7585d5049a Merge pull request #58 from GwonHyeok/main
fix: ExternalLoRA node Make downloaded files reusable
2024-08-01 19:50:59 -07:00
GwonHyeok 772bb09240 fix: ExternalLoRA node Make downloaded files reusable 2024-08-02 10:29:24 +09:00
bennykok 9a7e18e651 fix: fe communication 2024-08-01 10:50:08 -07:00
Hmily a02c8d237f fix: Fix request deploy service interface error (#56) 2024-08-01 10:47:45 -07:00
nick 2ba5a0ff3d external lora 2024-08-01 10:43:24 -07:00
bennykok e0eae1068b fix: make external lora and checkpoint wildcard 2024-07-26 17:39:40 -07:00
bennykok 4f1a80fb64 fix: log issues with websocket 2024-07-22 13:36:39 -07:00
Hmily b4273b1907 fix: update next version and routing parameter errors (#55) 2024-07-22 09:40:23 -07:00
nick 10ba00e3dd update: external video node 2024-07-20 00:16:39 -07:00
nick eb40fddb76 Merge branch 'main' of https://github.com/bennykok/comfyui-deploy 2024-07-20 00:16:27 -07:00
nick 3c9d1865ca video node 2024-07-20 00:15:41 -07:00
bennykok 6fa38e9bb8 fix 2024-07-13 19:17:30 -07:00
bennykok 6e4532078f feat: update plugin js 2024-07-12 12:24:10 -07:00
nick 48d21f8d52 feat: audio output from external video node 2024-07-12 11:20:18 -07:00
BennyKokandnick a2ac1adf01 Streaming support (#52)
* feat: add streaming endpoint

* fix: run issues

* feat(plugin): add dispatchAPIEventData

* fix(plugin): event

* fix: streaming event format

* fix: prompt error

* fix: node_error proxy

* chore(plugin): add log

* custom route

---------

Co-authored-by: nick <[email protected]>
2024-07-11 20:03:41 -07:00
Emmanuel Morales 716790e344 fix(media upload): skip when using the CD_BYPASS_UPLOAD env var (#51)
* fix(image upload): skip when using the CD_BYPASS_UPLOAD env var

* Revert "fix(image upload): skip when using the CD_BYPASS_UPLOAD env var"

This reverts commit 384eda63e6.

* fix(upload outputs): skip images/gifs/files/mesh when env var is true

The env var is `CD_BYPASS_UPLOAD`.
When that variables is `True`, we don't upload the media to our comfy
deploy s3 bucket.

There are 2 steps.
1. save the file into our s3 bucket
2. save the saving into our database.

When `CD_BYPASS_UPLOAD` is True:
1. Skip the save file into our s3 bucket
2. Skip the save into our database

Previously we were skipping the step 1, but not the step 2. So that is
the reason of why we keep seeing the comfy deploy URL when fetching the
run details:

```
outputs: [
  {
    data:{
      gifs: [
        {
          url: "https://comfy-deploy-output.s3.amazonaws.com/video.mp4"
        }
      ],
      text: [
        "A text that you displayed with show text node"
      ]
    }
  }
]
```

With the new changes we don't save that into our database, and fetching
the details of a run will look like this:
```
outputs: [
  {
    data:{
      text: [
        "A text that you displayed with show text node"
      ]
    }
  }
]
```
2024-07-07 22:04:00 -07:00
nick c6fe88bf66 new route 2024-06-15 17:29:51 -07:00
bennykok 9b24b12006 fix: file upload issues with cloudflare 2024-06-11 17:42:52 -07:00
bennykok ff70bbdcec fix: correctly set the file content type for images, webp, jepg, png 2024-05-29 08:59:53 -07:00
haohaocreates 840bea79e8 chore(publish): Add Github Action for Publishing to Comfy Registry (#48) 2024-05-26 23:25:15 +08:00
BennyKok 0f423ce1c3 Update pyproject.toml 2024-05-26 23:21:13 +08:00
haohaocreates 2aa1a446e5 chore(pyproject): Add pyproject.toml for Custom Node Registry (#47) 2024-05-26 23:20:50 +08:00
karrix 07a7feb6ac add: slider number support 2024-05-11 14:50:46 +08:00
bennykok c5ac1b5f94 perf: turn back on async file upload 2024-05-10 13:08:37 +09:00
bennykok 00d827e232 feat: CD_BYPASS_UPLOAD 2024-05-10 11:36:00 +09:00
karrix 697fd52349 add: bool custom node 2024-05-09 14:26:43 +08:00
karrix 6b9c431df8 add: boolean input and 3d mesh support 2024-05-09 14:25:22 +08:00
bennykok 3c508c7eec feat: redirect queue prompt to iframe event in workspace mode 2024-05-07 00:42:36 +08:00
Nick Kao 409ca6f1dd Merge pull request #45 from NicholasKao1029/main
video node
2024-05-04 10:19:07 -07:00
nick df391e867e video node 2024-05-04 10:14:33 -07:00
Nick Kao c37b8be00a Merge pull request #44 from NicholasKao1029/main
Video node
2024-04-30 12:56:30 -07:00
nick a5a73e4209 clean up 2024-04-30 12:55:04 -07:00
nick c7841deea2 vid node 2024-04-30 12:19:41 -07:00
nick b0b1d64b6b external video 2024-04-27 13:32:50 -07:00
bennykok c8dc189f99 fix: external number input 2024-04-25 18:36:24 +08:00
bennykok cd5e4a5d01 fix: duplicated file upload 2024-04-25 16:14:14 +08:00
bennykok 95c15f095d chore: add file upload time log 2024-04-25 15:55:34 +08:00
nick b4c27bbbea fix: external lora 2024-04-24 23:27:01 -07:00
bennykok 810aec5135 fix: empty inputs causing run issues 2024-04-25 13:15:55 +08:00
nick c843926d6e fix: external lora takes in value outside of default 2024-04-24 17:35:09 -07:00
22 changed files with 2253 additions and 277 deletions
+21
View File
@@ -0,0 +1,21 @@
name: Publish to Comfy registry
on:
workflow_dispatch:
push:
branches:
- main
paths:
- "pyproject.toml"
jobs:
publish-node:
name: Publish Custom Node to registry
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Publish Custom Node
uses: Comfy-Org/publish-node-action@main
with:
## Add your own personal access token to your Github Repository secrets and reference it here.
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
+35
View File
@@ -0,0 +1,35 @@
class ComfyUIDeployExternalBoolean:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"input_id": (
"STRING",
{"multiline": False, "default": "input_bool"},
),
"default_value": ("BOOLEAN", {"default": False})
},
"optional": {
"display_name": (
"STRING",
{"multiline": False, "default": "Name of the node (optional)"},
),
"description": (
"STRING",
{"multiline": True, "default": "Description of the node (optional)"},
),
}
}
RETURN_TYPES = ("BOOLEAN",)
RETURN_NAMES = ("bool_value",)
FUNCTION = "run"
def run(self, input_id, default_value=None, display_name=None, description=None):
print(f"Node '{input_id}' processing with switch set to {default_value}")
return [default_value]
NODE_CLASS_MAPPINGS = {"ComfyUIDeployExternalBoolean": ComfyUIDeployExternalBoolean}
NODE_DISPLAY_NAME_MAPPINGS = {"ComfyUIDeployExternalBoolean": "External Boolean (ComfyUI Deploy)"}
+16 -2
View File
@@ -5,6 +5,12 @@ import torch
import folder_paths
from tqdm import tqdm
class AnyType(str):
def __ne__(self, __value: object) -> bool:
return False
WILDCARD = AnyType("*")
class ComfyUIDeployExternalCheckpoint:
@classmethod
def INPUT_TYPES(s):
@@ -17,17 +23,25 @@ class ComfyUIDeployExternalCheckpoint:
},
"optional": {
"default_value": (folder_paths.get_filename_list("checkpoints"), ),
"display_name": (
"STRING",
{"multiline": False, "default": "Name of the node (optional)"},
),
"description": (
"STRING",
{"multiline": True, "default": "Description of the node (optional)"},
),
}
}
RETURN_TYPES = (folder_paths.get_filename_list("checkpoints"),)
RETURN_TYPES = (WILDCARD,)
RETURN_NAMES = ("path",)
FUNCTION = "run"
CATEGORY = "deploy"
def run(self, input_id, default_value=None):
def run(self, input_id, default_value=None, display_name=None, description=None):
import requests
import os
import uuid
+9 -1
View File
@@ -15,6 +15,14 @@ class ComfyUIDeployExternalImage:
},
"optional": {
"default_value": ("IMAGE",),
"display_name": (
"STRING",
{"multiline": False, "default": "Name of the node (optional)"},
),
"description": (
"STRING",
{"multiline": True, "default": "Description of the node (optional)"},
),
}
}
@@ -25,7 +33,7 @@ class ComfyUIDeployExternalImage:
CATEGORY = "image"
def run(self, input_id, default_value=None):
def run(self, input_id, default_value=None, display_name=None, description=None):
image = default_value
try:
if input_id.startswith('http'):
+9 -1
View File
@@ -15,6 +15,14 @@ class ComfyUIDeployExternalImageAlpha:
},
"optional": {
"default_value": ("IMAGE",),
"display_name": (
"STRING",
{"multiline": False, "default": "Name of the node (optional)"},
),
"description": (
"STRING",
{"multiline": True, "default": "Description of the node (optional)"},
),
}
}
@@ -25,7 +33,7 @@ class ComfyUIDeployExternalImageAlpha:
CATEGORY = "image"
def run(self, input_id, default_value=None):
def run(self, input_id, default_value=None, display_name=None, description=None):
image = default_value
try:
if input_id.startswith('http'):
+9 -1
View File
@@ -21,6 +21,14 @@ class ComfyUIDeployExternalImageBatch:
},
"optional": {
"default_value": ("IMAGE",),
"display_name": (
"STRING",
{"multiline": False, "default": "Name of the node (optional)"},
),
"description": (
"STRING",
{"multiline": True, "default": "Description of the node (optional)"},
),
}
}
@@ -31,7 +39,7 @@ class ComfyUIDeployExternalImageBatch:
CATEGORY = "image"
def run(self, input_id, images=None, default_value=None):
def run(self, input_id, images=None, default_value=None, display_name=None, description=None):
processed_images = []
try:
images_list = json.loads(images) # Assuming images is a JSON array string
+47 -18
View File
@@ -5,6 +5,14 @@ import torch
import folder_paths
class AnyType(str):
def __ne__(self, __value: object) -> bool:
return False
WILDCARD = AnyType("*")
class ComfyUIDeployExternalLora:
@classmethod
def INPUT_TYPES(s):
@@ -17,42 +25,63 @@ class ComfyUIDeployExternalLora:
},
"optional": {
"default_lora_name": (folder_paths.get_filename_list("loras"),),
}
"lora_save_name": ( # if `default_lora_name` is a link to download a file, we will attempt to save it with this name
"STRING",
{"multiline": False, "default": ""},
),
"display_name": (
"STRING",
{"multiline": False, "default": "Name of the node (optional)"},
),
"description": (
"STRING",
{"multiline": True, "default": "Description of the node (optional)"},
),
},
}
RETURN_TYPES = (folder_paths.get_filename_list("loras"),)
RETURN_TYPES = (WILDCARD,)
RETURN_NAMES = ("path",)
FUNCTION = "run"
CATEGORY = "deploy"
def run(self, input_id, default_lora_name=None):
def run(self, input_id, default_lora_name=None, lora_save_name=None, display_name=None, description=None):
import requests
import os
import uuid
print('external lora using')
print("input id: ", input_id)
print("default lora : ", default_lora_name)
if input_id:
if input_id.startswith('http'):
unique_filename = str(uuid.uuid4()) + ".safetensors"
print(unique_filename)
if default_lora_name.startswith("http"):
if lora_save_name:
existing_loras = folder_paths.get_filename_list("loras")
# Check if lora_save_name exists in the list
if lora_save_name in existing_loras:
print(f"using lora: {lora_save_name}")
return (lora_save_name,)
else:
lora_save_name = str(uuid.uuid4()) + ".safetensors"
print(lora_save_name)
print(folder_paths.folder_names_and_paths["loras"][0][0])
destination_path = os.path.join(folder_paths.folder_names_and_paths["loras"][0][0], unique_filename)
destination_path = os.path.join(
folder_paths.folder_names_and_paths["loras"][0][0], lora_save_name
)
print(destination_path)
print("Downloading external lora - " + input_id + " to " + destination_path)
response = requests.get(input_id, headers={'User-Agent': 'Mozilla/5.0'}, allow_redirects=True)
with open(destination_path, 'wb') as out_file:
response = requests.get(
input_id,
headers={"User-Agent": "Mozilla/5.0"},
allow_redirects=True,
)
with open(destination_path, "wb") as out_file:
out_file.write(response.content)
return (unique_filename,)
return (lora_save_name,)
else:
return (input_id,)
print(f"using lora: {default_lora_name}")
return (default_lora_name,)
NODE_CLASS_MAPPINGS = {"ComfyUIDeployExternalLora": ComfyUIDeployExternalLora}
NODE_DISPLAY_NAME_MAPPINGS = {"ComfyUIDeployExternalLora": "External Lora (ComfyUI Deploy)"}
NODE_DISPLAY_NAME_MAPPINGS = {
"ComfyUIDeployExternalLora": "External Lora (ComfyUI Deploy)"
}
+10 -2
View File
@@ -16,7 +16,15 @@ class ComfyUIDeployExternalNumber:
"optional": {
"default_value": (
"FLOAT",
{"multiline": True, "display": "number", "default": 0, "step": 0.01},
{"multiline": True, "display": "number", "default": 0, "min": -2147483647, "max": 2147483647, "step": 0.01},
),
"display_name": (
"STRING",
{"multiline": False, "default": "Name of the node (optional)"},
),
"description": (
"STRING",
{"multiline": True, "default": "Description of the node (optional)"},
),
}
}
@@ -28,7 +36,7 @@ class ComfyUIDeployExternalNumber:
CATEGORY = "number"
def run(self, input_id, default_value=None):
def run(self, input_id, default_value=None, display_name=None, description=None):
try:
float_value = float(input_id)
print("my number", float_value)
+11 -3
View File
@@ -16,7 +16,15 @@ class ComfyUIDeployExternalNumberInt:
"optional": {
"default_value": (
"INT",
{"multiline": True, "display": "number", "default": 0},
{"multiline": True, "display": "number", "min": -2147483647, "max": 2147483647, "default": 0},
),
"display_name": (
"STRING",
{"multiline": False, "default": "Name of the node (optional)"},
),
"description": (
"STRING",
{"multiline": True, "default": "Description of the node (optional)"},
),
}
}
@@ -28,8 +36,8 @@ class ComfyUIDeployExternalNumberInt:
CATEGORY = "number"
def run(self, input_id, default_value=None):
if not input_id or not input_id.strip().isdigit():
def run(self, input_id, default_value=None, display_name=None, description=None):
if not input_id or (isinstance(input_id, str) and not input_id.strip().isdigit()):
return [default_value]
return [int(input_id)]
+56
View File
@@ -0,0 +1,56 @@
class ComfyUIDeployExternalNumberSlider:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"input_id": (
"STRING",
{"multiline": False, "default": "input_number_slider"},
),
},
"optional": {
"default_value": (
"FLOAT",
{"multiline": True, "display": "number", "min": -2147483647, "max": 2147483647, "default": 0.5, "step": 0.01},
),
"min_value": (
"FLOAT",
{"multiline": True, "display": "number", "min": -2147483647, "max": 2147483647, "default": 0, "step": 0.01},
),
"max_value": (
"FLOAT",
{"multiline": True, "display": "number", "min": -2147483647, "max": 2147483647, "default": 1, "step": 0.01},
),
"display_name": (
"STRING",
{"multiline": False, "default": "Name of the node (optional)"},
),
"description": (
"STRING",
{"multiline": True, "default": "Description of the node (optional)"},
),
}
}
RETURN_TYPES = ("FLOAT",)
RETURN_NAMES = ("value",)
FUNCTION = "run"
CATEGORY = "number"
def run(self, input_id, default_value=None, min_value=0, max_value=1, display_name=None, description=None):
try:
float_value = float(input_id)
if min_value <= float_value <= max_value:
print("my number", float_value)
return [float_value]
else:
print("Number out of range. Returning default value:", default_value)
return [default_value]
except ValueError:
print("Invalid input. Returning default value:", default_value)
return [default_value]
NODE_CLASS_MAPPINGS = {"ComfyUIDeployExternalNumberSlider": ComfyUIDeployExternalNumberSlider}
NODE_DISPLAY_NAME_MAPPINGS = {"ComfyUIDeployExternalNumberSlider": "External Number Slider (ComfyUI Deploy)"}
+9 -1
View File
@@ -18,6 +18,14 @@ class ComfyUIDeployExternalText:
"STRING",
{"multiline": True, "default": ""},
),
"display_name": (
"STRING",
{"multiline": False, "default": "Name of the node (optional)"},
),
"description": (
"STRING",
{"multiline": True, "default": "Description of the node (optional)"},
),
}
}
@@ -28,7 +36,7 @@ class ComfyUIDeployExternalText:
CATEGORY = "text"
def run(self, input_id, default_value=None):
def run(self, input_id, default_value=None, display_name=None, description=None):
return [default_value]
+52
View File
@@ -0,0 +1,52 @@
import folder_paths
from PIL import Image, ImageOps
import numpy as np
import torch
import json
class ComfyUIDeployExternalTextList:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"input_id": (
"STRING",
{"multiline": False, "default": 'input_text_list'},
),
"text": (
"STRING",
{"multiline": True, "default": "[]"},
),
},
"optional": {
"display_name": (
"STRING",
{"multiline": False, "default": "Name of the node (optional)"},
),
"description": (
"STRING",
{"multiline": True, "default": "Description of the node (optional)"},
),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("text",)
OUTPUT_IS_LIST = (True,)
FUNCTION = "run"
CATEGORY = "text"
def run(self, input_id, text=None, display_name=None, description=None):
text_list = []
try:
text_list = json.loads(text) # Assuming text is a JSON array string
except Exception as e:
print(f"Error processing images: {e}")
pass
return ([text_list],)
NODE_CLASS_MAPPINGS = {"ComfyUIDeployExternalTextList": ComfyUIDeployExternalTextList}
NODE_DISPLAY_NAME_MAPPINGS = {"ComfyUIDeployExternalTextList": "External Text List (ComfyUI Deploy)"}
+78
View File
@@ -0,0 +1,78 @@
import os
import folder_paths
import uuid
from tqdm import tqdm
video_extensions = ["webm", "mp4", "mkv", "gif"]
class ComfyUIDeployExternalVideo:
@classmethod
def INPUT_TYPES(s):
input_dir = folder_paths.get_input_directory()
files = []
for f in os.listdir(input_dir):
if os.path.isfile(os.path.join(input_dir, f)):
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"},
),
},
"optional": {
"meta_batch": ("VHS_BatchManager",),
"default_value": (sorted(files),),
},
}
CATEGORY = "Video Helper Suite 🎥🅥🅗🅢"
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("video")
FUNCTION = "load_video"
def load_video(self, input_id, default_value):
input_dir = folder_paths.get_input_directory()
if input_id.startswith("http"):
import requests
print("Fetching video from URL: ", input_id)
response = requests.get(input_id, stream=True)
file_size = int(response.headers.get("Content-Length", 0))
file_extension = input_id.split(".")[-1].split("?")[
0
] # Extract extension and handle URLs with parameters
if file_extension not in video_extensions:
file_extension = ".mp4"
unique_filename = str(uuid.uuid4()) + "." + file_extension
video_path = os.path.join(input_dir, unique_filename)
chunk_size = 1024 # 1 Kibibyte
num_bars = int(file_size / chunk_size)
with open(video_path, "wb") as out_file:
for chunk in tqdm(
response.iter_content(chunk_size=chunk_size),
total=num_bars,
unit="KB",
desc="Downloading",
leave=True,
):
out_file.write(chunk)
else:
video_path = os.path.abspath(os.path.join(input_dir, default_value))
return (video_path,)
NODE_CLASS_MAPPINGS = {"ComfyUIDeployExternalVid": ComfyUIDeployExternalVideo}
NODE_DISPLAY_NAME_MAPPINGS = {
"ComfyUIDeployExternalVid": "External Video (ComfyUI Deploy) path"
}
+864
View File
@@ -0,0 +1,864 @@
# credit goes to https://github.com/Kosinkadink/ComfyUI-VideoHelperSuite
# Intended to work with https://github.com/NicholasKao1029/ComfyUI-VideoHelperSuite/tree/main
import os
import itertools
import numpy as np
import torch
from typing import Union
from torch import Tensor
import cv2
import psutil
from collections.abc import Mapping
import folder_paths
from comfy.utils import common_upscale
### Utils
import hashlib
from typing import Iterable
import shutil
import subprocess
import re
import uuid
import server
from tqdm import tqdm
BIGMIN = -(2**53 - 1)
BIGMAX = 2**53 - 1
DIMMAX = 8192
def ffmpeg_suitability(path):
try:
version = subprocess.run(
[path, "-version"], check=True, capture_output=True
).stdout.decode("utf-8")
except:
return 0
score = 0
# rough layout of the importance of various features
simple_criterion = [
("libvpx", 20),
("264", 10),
("265", 3),
("svtav1", 5),
("libopus", 1),
]
for criterion in simple_criterion:
if version.find(criterion[0]) >= 0:
score += criterion[1]
# obtain rough compile year from copyright information
copyright_index = version.find("2000-2")
if copyright_index >= 0:
copyright_year = version[copyright_index + 6 : copyright_index + 9]
if copyright_year.isnumeric():
score += int(copyright_year)
return score
if "VHS_FORCE_FFMPEG_PATH" in os.environ:
ffmpeg_path = os.environ.get("VHS_FORCE_FFMPEG_PATH")
else:
ffmpeg_paths = []
try:
from imageio_ffmpeg import get_ffmpeg_exe
imageio_ffmpeg_path = get_ffmpeg_exe()
ffmpeg_paths.append(imageio_ffmpeg_path)
except:
if "VHS_USE_IMAGEIO_FFMPEG" in os.environ:
raise
if "VHS_USE_IMAGEIO_FFMPEG" in os.environ:
ffmpeg_path = imageio_ffmpeg_path
else:
system_ffmpeg = shutil.which("ffmpeg")
if system_ffmpeg is not None:
ffmpeg_paths.append(system_ffmpeg)
if os.path.isfile("ffmpeg"):
ffmpeg_paths.append(os.path.abspath("ffmpeg"))
if os.path.isfile("ffmpeg.exe"):
ffmpeg_paths.append(os.path.abspath("ffmpeg.exe"))
if len(ffmpeg_paths) == 0:
ffmpeg_path = None
elif len(ffmpeg_paths) == 1:
# Evaluation of suitability isn't required, can take sole option
# to reduce startup time
ffmpeg_path = ffmpeg_paths[0]
else:
ffmpeg_path = max(ffmpeg_paths, key=ffmpeg_suitability)
gifski_path = os.environ.get("VHS_GIFSKI", None)
if gifski_path is None:
gifski_path = os.environ.get("JOV_GIFSKI", None)
if gifski_path is None:
gifski_path = shutil.which("gifski")
def is_safe_path(path):
if "VHS_STRICT_PATHS" not in os.environ:
return True
basedir = os.path.abspath(".")
try:
common_path = os.path.commonpath([basedir, path])
except:
# Different drive on windows
return False
return common_path == basedir
def get_sorted_dir_files_from_directory(
directory: str,
skip_first_images: int = 0,
select_every_nth: int = 1,
extensions: Iterable = None,
):
directory = strip_path(directory)
dir_files = os.listdir(directory)
dir_files = sorted(dir_files)
dir_files = [os.path.join(directory, x) for x in dir_files]
dir_files = list(filter(lambda filepath: os.path.isfile(filepath), dir_files))
# filter by extension, if needed
if extensions is not None:
extensions = list(extensions)
new_dir_files = []
for filepath in dir_files:
ext = "." + filepath.split(".")[-1]
if ext.lower() in extensions:
new_dir_files.append(filepath)
dir_files = new_dir_files
# start at skip_first_images
dir_files = dir_files[skip_first_images:]
dir_files = dir_files[0::select_every_nth]
return dir_files
# modified from https://stackoverflow.com/questions/22058048/hashing-a-file-in-python
def calculate_file_hash(filename: str, hash_every_n: int = 1):
# Larger video files were taking >.5 seconds to hash even when cached,
# so instead the modified time from the filesystem is used as a hash
h = hashlib.sha256()
h.update(filename.encode())
h.update(str(os.path.getmtime(filename)).encode())
return h.hexdigest()
prompt_queue = server.PromptServer.instance.prompt_queue
def requeue_workflow_unchecked():
"""Requeues the current workflow without checking for multiple requeues"""
currently_running = prompt_queue.currently_running
(_, _, prompt, extra_data, outputs_to_execute) = next(
iter(currently_running.values())
)
# Ensure batch_managers are marked stale
prompt = prompt.copy()
for uid in prompt:
if prompt[uid]["class_type"] == "VHS_BatchManager":
prompt[uid]["inputs"]["requeue"] = (
prompt[uid]["inputs"].get("requeue", 0) + 1
)
# execution.py has guards for concurrency, but server doesn't.
# TODO: Check that this won't be an issue
number = -server.PromptServer.instance.number
server.PromptServer.instance.number += 1
prompt_id = str(server.uuid.uuid4())
prompt_queue.put((number, prompt_id, prompt, extra_data, outputs_to_execute))
requeue_guard = [None, 0, 0, {}]
def requeue_workflow(requeue_required=(-1, True)):
assert len(prompt_queue.currently_running) == 1
global requeue_guard
(run_number, _, prompt, _, _) = next(iter(prompt_queue.currently_running.values()))
if requeue_guard[0] != run_number:
# Calculate a count of how many outputs are managed by a batch manager
managed_outputs = 0
for bm_uid in prompt:
if prompt[bm_uid]["class_type"] == "VHS_BatchManager":
for output_uid in prompt:
if prompt[output_uid]["class_type"] in ["VHS_VideoCombine"]:
for inp in prompt[output_uid]["inputs"].values():
if inp == [bm_uid, 0]:
managed_outputs += 1
requeue_guard = [run_number, 0, managed_outputs, {}]
requeue_guard[1] = requeue_guard[1] + 1
requeue_guard[3][requeue_required[0]] = requeue_required[1]
if requeue_guard[1] == requeue_guard[2] and max(requeue_guard[3].values()):
requeue_workflow_unchecked()
def get_audio(file, start_time=0, duration=0):
args = [ffmpeg_path, "-i", file]
if start_time > 0:
args += ["-ss", str(start_time)]
if duration > 0:
args += ["-t", str(duration)]
try:
# TODO: scan for sample rate and maintain
res = subprocess.run(
args + ["-f", "f32le", "-"], capture_output=True, check=True
)
audio = torch.frombuffer(bytearray(res.stdout), dtype=torch.float32)
match = re.search(", (\\d+) Hz, (\\w+), ", res.stderr.decode("utf-8"))
except subprocess.CalledProcessError as e:
raise Exception(
f"VHS failed to extract audio from {file}:\n" + e.stderr.decode("utf-8")
)
if match:
ar = int(match.group(1))
# NOTE: Just throwing an error for other channel types right now
# Will deal with issues if they come
ac = {"mono": 1, "stereo": 2}[match.group(2)]
else:
ar = 44100
ac = 2
audio = audio.reshape((-1, ac)).transpose(0, 1).unsqueeze(0)
return {"waveform": audio, "sample_rate": ar}
class LazyAudioMap(Mapping):
def __init__(self, file, start_time, duration):
self.file = file
self.start_time = start_time
self.duration = duration
self._dict = None
def __getitem__(self, key):
if self._dict is None:
self._dict = get_audio(self.file, self.start_time, self.duration)
return self._dict[key]
def __iter__(self):
if self._dict is None:
self._dict = get_audio(self.file, self.start_time, self.duration)
return iter(self._dict)
def __len__(self):
if self._dict is None:
self._dict = get_audio(self.file, self.start_time, self.duration)
return len(self._dict)
def lazy_get_audio(file, start_time=0, duration=0):
return LazyAudioMap(file, start_time, duration)
def lazy_eval(func):
class Cache:
def __init__(self, func):
self.res = None
self.func = func
def get(self):
if self.res is None:
self.res = self.func()
return self.res
cache = Cache(func)
return lambda: cache.get()
def is_url(url):
return url.split("://")[0] in ["http", "https"]
def validate_sequence(path):
# Check if path is a valid ffmpeg sequence that points to at least one file
(path, file) = os.path.split(path)
if not os.path.isdir(path):
return False
match = re.search("%0?\d+d", file)
if not match:
return False
seq = match.group()
if seq == "%d":
seq = "\\\\d+"
else:
seq = "\\\\d{%s}" % seq[1:-1]
file_matcher = re.compile(re.sub("%0?\d+d", seq, file))
for file in os.listdir(path):
if file_matcher.fullmatch(file):
return True
return False
def strip_path(path):
# This leaves whitespace inside quotes and only a single "
# thus ' ""test"' -> '"test'
# consider path.strip(string.whitespace+"\"")
# or weightier re.fullmatch("[\\s\"]*(.+?)[\\s\"]*", path).group(1)
path = path.strip()
if path.startswith('"'):
path = path[1:]
if path.endswith('"'):
path = path[:-1]
return path
def hash_path(path):
if path is None:
return "input"
if is_url(path):
return "url"
return calculate_file_hash(path.strip('"'))
def validate_path(path, allow_none=False, allow_url=True):
if path is None:
return allow_none
if is_url(path):
# Probably not feasible to check if url resolves here
return True if allow_url else "URLs are unsupported for this path"
if not os.path.isfile(path.strip('"')):
return "Invalid file path: {}".format(path)
return True
### Utils
video_extensions = ["webm", "mp4", "mkv", "gif"]
def is_gif(filename) -> bool:
file_parts = filename.split(".")
return len(file_parts) > 1 and file_parts[-1] == "gif"
def target_size(
width, height, force_size, custom_width, custom_height
) -> tuple[int, int]:
if force_size == "Custom":
return (custom_width, custom_height)
elif force_size == "Custom Height":
force_size = "?x" + str(custom_height)
elif force_size == "Custom Width":
force_size = str(custom_width) + "x?"
if force_size != "Disabled":
force_size = force_size.split("x")
if force_size[0] == "?":
width = (width * int(force_size[1])) // height
# Limit to a multple of 8 for latent conversion
width = int(width) + 4 & ~7
height = int(force_size[1])
elif force_size[1] == "?":
height = (height * int(force_size[0])) // width
height = int(height) + 4 & ~7
width = int(force_size[0])
else:
width = int(force_size[0])
height = int(force_size[1])
return (width, height)
def validate_index(
index: int,
length: int = 0,
is_range: bool = False,
allow_negative=False,
allow_missing=False,
) -> int:
# if part of range, do nothing
if is_range:
return index
# otherwise, validate index
# validate not out of range - only when latent_count is passed in
if length > 0 and index > length - 1 and not allow_missing:
raise IndexError(f"Index '{index}' out of range for {length} item(s).")
# if negative, validate not out of range
if index < 0:
if not allow_negative:
raise IndexError(f"Negative indeces not allowed, but was '{index}'.")
conv_index = length + index
if conv_index < 0 and not allow_missing:
raise IndexError(
f"Index '{index}', converted to '{conv_index}' out of range for {length} item(s)."
)
index = conv_index
return index
def convert_to_index_int(
raw_index: str,
length: int = 0,
is_range: bool = False,
allow_negative=False,
allow_missing=False,
) -> int:
try:
return validate_index(
int(raw_index),
length=length,
is_range=is_range,
allow_negative=allow_negative,
allow_missing=allow_missing,
)
except ValueError as e:
raise ValueError(f"Index '{raw_index}' must be an integer.", e)
def convert_str_to_indexes(
indexes_str: str, length: int = 0, allow_missing=False
) -> list[int]:
if not indexes_str:
return []
int_indexes = list(range(0, length))
allow_negative = length > 0
chosen_indexes = []
# parse string - allow positive ints, negative ints, and ranges separated by ':'
groups = indexes_str.split(",")
groups = [g.strip() for g in groups]
for g in groups:
# parse range of indeces (e.g. 2:16)
if ":" in g:
index_range = g.split(":", 2)
index_range = [r.strip() for r in index_range]
start_index = index_range[0]
if len(start_index) > 0:
start_index = convert_to_index_int(
start_index,
length=length,
is_range=True,
allow_negative=allow_negative,
allow_missing=allow_missing,
)
else:
start_index = 0
end_index = index_range[1]
if len(end_index) > 0:
end_index = convert_to_index_int(
end_index,
length=length,
is_range=True,
allow_negative=allow_negative,
allow_missing=allow_missing,
)
else:
end_index = length
# support step as well, to allow things like reversing, every-other, etc.
step = 1
if len(index_range) > 2:
step = index_range[2]
if len(step) > 0:
step = convert_to_index_int(
step,
length=length,
is_range=True,
allow_negative=True,
allow_missing=True,
)
else:
step = 1
# if latents were passed in, base indeces on known latent count
if len(int_indexes) > 0:
chosen_indexes.extend(int_indexes[start_index:end_index][::step])
# otherwise, assume indeces are valid
else:
chosen_indexes.extend(list(range(start_index, end_index, step)))
# parse individual indeces
else:
chosen_indexes.append(
convert_to_index_int(
g,
length=length,
allow_negative=allow_negative,
allow_missing=allow_missing,
)
)
return chosen_indexes
def select_indexes(input_obj: Union[Tensor, list], idxs: list):
if type(input_obj) == Tensor:
return input_obj[idxs]
else:
return [input_obj[i] for i in idxs]
def select_indexes_from_str(
input_obj: Union[Tensor, list], indexes: str, err_if_missing=True, err_if_empty=True
):
real_idxs = convert_str_to_indexes(
indexes, len(input_obj), allow_missing=not err_if_missing
)
if err_if_empty and len(real_idxs) == 0:
raise Exception(f"Nothing was selected based on indexes found in '{indexes}'.")
return select_indexes(input_obj, real_idxs)
###
def cv_frame_generator(
video,
force_rate,
frame_load_cap,
skip_first_frames,
select_every_nth,
meta_batch=None,
unique_id=None,
):
video_cap = cv2.VideoCapture(strip_path(video))
if not video_cap.isOpened():
raise ValueError(f"{video} could not be loaded with cv.")
pbar = None
# extract video metadata
fps = video_cap.get(cv2.CAP_PROP_FPS)
width = int(video_cap.get(cv2.CAP_PROP_FRAME_WIDTH))
height = int(video_cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
total_frames = int(video_cap.get(cv2.CAP_PROP_FRAME_COUNT))
duration = total_frames / fps
# set video_cap to look at start_index frame
total_frame_count = 0
total_frames_evaluated = -1
frames_added = 0
base_frame_time = 1 / fps
prev_frame = None
if force_rate == 0:
target_frame_time = base_frame_time
else:
target_frame_time = 1 / force_rate
yield (width, height, fps, duration, total_frames, target_frame_time)
if meta_batch is not None:
yield min(frame_load_cap, total_frames)
time_offset = target_frame_time - base_frame_time
while video_cap.isOpened():
if time_offset < target_frame_time:
is_returned = video_cap.grab()
# if didn't return frame, video has ended
if not is_returned:
break
time_offset += base_frame_time
if time_offset < target_frame_time:
continue
time_offset -= target_frame_time
# if not at start_index, skip doing anything with frame
total_frame_count += 1
if total_frame_count <= skip_first_frames:
continue
else:
total_frames_evaluated += 1
# if should not be selected, skip doing anything with frame
if total_frames_evaluated % select_every_nth != 0:
continue
# opencv loads images in BGR format (yuck), so need to convert to RGB for ComfyUI use
# follow up: can videos ever have an alpha channel?
# To my testing: No. opencv has no support for alpha
unused, frame = video_cap.retrieve()
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
# convert frame to comfyui's expected format
# TODO: frame contains no exif information. Check if opencv2 has already applied
frame = np.array(frame, dtype=np.float32)
torch.from_numpy(frame).div_(255)
if prev_frame is not None:
inp = yield prev_frame
if inp is not None:
# ensure the finally block is called
return
prev_frame = frame
frames_added += 1
if pbar is not None:
pbar.update_absolute(frames_added, frame_load_cap)
# if cap exists and we've reached it, stop processing frames
if frame_load_cap > 0 and frames_added >= frame_load_cap:
break
if meta_batch is not None:
meta_batch.inputs.pop(unique_id)
meta_batch.has_closed_inputs = True
if prev_frame is not None:
yield prev_frame
def batched(it, n):
while batch := tuple(itertools.islice(it, n)):
yield batch
def batched_vae_encode(images, vae, frames_per_batch):
for batch in batched(images, frames_per_batch):
image_batch = torch.from_numpy(np.array(batch))
yield from vae.encode(image_batch).numpy()
def load_video_cv(
video: str,
force_rate: int,
force_size: str,
custom_width: int,
custom_height: int,
frame_load_cap: int,
skip_first_frames: int,
select_every_nth: int,
meta_batch=None,
unique_id=None,
memory_limit_mb=None,
vae=None,
):
if meta_batch is None or unique_id not in meta_batch.inputs:
gen = cv_frame_generator(
video,
force_rate,
frame_load_cap,
skip_first_frames,
select_every_nth,
meta_batch,
unique_id,
)
(width, height, fps, duration, total_frames, target_frame_time) = next(gen)
if meta_batch is not None:
meta_batch.inputs[unique_id] = (
gen,
width,
height,
fps,
duration,
total_frames,
target_frame_time,
)
meta_batch.total_frames = min(meta_batch.total_frames, next(gen))
else:
(gen, width, height, fps, duration, total_frames, target_frame_time) = (
meta_batch.inputs[unique_id]
)
memory_limit = None
if memory_limit_mb is not None:
memory_limit *= 2**20
else:
# TODO: verify if garbage collection should be performed here.
# leaves ~128 MB unreserved for safety
try:
memory_limit = (
psutil.virtual_memory().available + psutil.swap_memory().free
) - 2**27
except:
print(
"Failed to calculate available memory. Memory load limit has been disabled"
)
if memory_limit is not None:
if vae is not None:
# space required to load as f32, exist as latent with wiggle room, decode to f32
max_loadable_frames = int(
memory_limit // (width * height * 3 * (4 + 4 + 1 / 10))
)
else:
# TODO: use better estimate for when vae is not None
# Consider completely ignoring for load_latent case?
max_loadable_frames = int(memory_limit // (width * height * 3 * (0.1)))
if meta_batch is not None:
if meta_batch.frames_per_batch > max_loadable_frames:
raise RuntimeError(
f"Meta Batch set to {meta_batch.frames_per_batch} frames but only {max_loadable_frames} can fit in memory"
)
gen = itertools.islice(gen, meta_batch.frames_per_batch)
else:
original_gen = gen
gen = itertools.islice(gen, max_loadable_frames)
downscale_ratio = getattr(vae, "downscale_ratio", 8)
frames_per_batch = (1920 * 1080 * 16) // (width * height) or 1
if force_size != "Disabled" or vae is not None:
new_size = target_size(
width, height, force_size, custom_width, custom_height, downscale_ratio
)
if new_size[0] != width or new_size[1] != height:
def rescale(frame):
s = torch.from_numpy(
np.fromiter(frame, np.dtype((np.float32, (height, width, 3))))
)
s = s.movedim(-1, 1)
s = common_upscale(s, new_size[0], new_size[1], "lanczos", "center")
return s.movedim(1, -1).numpy()
gen = itertools.chain.from_iterable(
map(rescale, batched(gen, frames_per_batch))
)
else:
new_size = width, height
if vae is not None:
gen = batched_vae_encode(gen, vae, frames_per_batch)
vw, vh = new_size[0] // downscale_ratio, new_size[1] // downscale_ratio
images = torch.from_numpy(np.fromiter(gen, np.dtype((np.float32, (4, vh, vw)))))
else:
# Some minor wizardry to eliminate a copy and reduce max memory by a factor of ~2
images = torch.from_numpy(
np.fromiter(gen, np.dtype((np.float32, (new_size[1], new_size[0], 3))))
)
if meta_batch is None and memory_limit is not None:
try:
next(original_gen)
raise RuntimeError(
f"Memory limit hit after loading {len(images)} frames. Stopping execution."
)
except StopIteration:
pass
if len(images) == 0:
raise RuntimeError("No frames generated")
# Setup lambda for lazy audio capture
audio = lazy_get_audio(
video,
skip_first_frames * target_frame_time,
frame_load_cap * target_frame_time * select_every_nth,
)
# Adjust target_frame_time for select_every_nth
target_frame_time *= select_every_nth
video_info = {
"source_fps": fps,
"source_frame_count": total_frames,
"source_duration": duration,
"source_width": width,
"source_height": height,
"loaded_fps": 1 / target_frame_time,
"loaded_frame_count": len(images),
"loaded_duration": len(images) * target_frame_time,
"loaded_width": new_size[0],
"loaded_height": new_size[1],
}
if vae is None:
return (images, len(images), audio, video_info, None)
else:
return (None, len(images), audio, video_info, {"samples": images})
# modeled after Video upload node
class ComfyUIDeployExternalVideo:
@classmethod
def INPUT_TYPES(s):
input_dir = folder_paths.get_input_directory()
files = []
for f in os.listdir(input_dir):
if os.path.isfile(os.path.join(input_dir, f)):
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_value": (sorted(files),),
"display_name": (
"STRING",
{"multiline": False, "default": "Name of the node (optional)"},
),
"description": (
"STRING",
{"multiline": True, "default": "Description of the node (optional)"},
),
},
"hidden": {
"unique_id": "UNIQUE_ID"
},
}
CATEGORY = "Video Helper Suite 🎥🅥🅗🅢"
RETURN_TYPES = ("IMAGE", "INT", "AUDIO", "VHS_VIDEOINFO", "LATENT")
RETURN_NAMES = (
"IMAGE",
"frame_count",
"audio",
"video_info",
"LATENT",
)
FUNCTION = "load_video"
def load_video(self, **kwargs):
input_id = kwargs.get("input_id")
force_rate = kwargs.get("force_rate")
force_size = kwargs.get("force_size", "Disabled")
custom_width = kwargs.get("custom_width")
custom_height = kwargs.get("custom_height")
frame_load_cap = kwargs.get("frame_load_cap")
skip_first_frames = kwargs.get("skip_first_frames")
select_every_nth = kwargs.get("select_every_nth")
meta_batch = kwargs.get("meta_batch")
unique_id = kwargs.get("unique_id")
input_dir = folder_paths.get_input_directory()
if input_id.startswith("http"):
import requests
print("Fetching video from URL: ", input_id)
response = requests.get(input_id, stream=True)
file_size = int(response.headers.get("Content-Length", 0))
file_extension = input_id.split(".")[-1].split("?")[
0
] # Extract extension and handle URLs with parameters
if file_extension not in video_extensions:
file_extension = ".mp4"
unique_filename = str(uuid.uuid4()) + "." + file_extension
video_path = os.path.join(input_dir, unique_filename)
chunk_size = 1024 # 1 Kibibyte
num_bars = int(file_size / chunk_size)
with open(video_path, "wb") as out_file:
for chunk in tqdm(
response.iter_content(chunk_size=chunk_size),
total=num_bars,
unit="KB",
desc="Downloading",
leave=True,
):
out_file.write(chunk)
else:
video = kwargs.get("default_value", "")
if video is None:
raise "No default video given and no external video provided"
video_path = folder_paths.get_annotated_filepath(video.strip('"'))
return load_video_cv(
video=video_path,
force_rate=force_rate,
force_size=force_size,
custom_width=custom_width,
custom_height=custom_height,
frame_load_cap=frame_load_cap,
skip_first_frames=skip_first_frames,
select_every_nth=select_every_nth,
meta_batch=meta_batch,
unique_id=unique_id,
)
@classmethod
def IS_CHANGED(s, video, **kwargs):
image_path = folder_paths.get_annotated_filepath(video)
return calculate_file_hash(image_path)
NODE_CLASS_MAPPINGS = {"ComfyUIDeployExternalVideo": ComfyUIDeployExternalVideo}
NODE_DISPLAY_NAME_MAPPINGS = {
"ComfyUIDeployExternalVideo": "External Video (ComfyUI Deploy x VHS)"
}
+468 -102
View File
@@ -17,29 +17,161 @@ from urllib.parse import quote
import threading
import hashlib
import aiohttp
from aiohttp import ClientSession, web
import aiofiles
from typing import List, Union, Any, Optional
from typing import Dict, List, Union, Any, Optional
from PIL import Image
import copy
import struct
from aiohttp import web, ClientSession, ClientError, ClientTimeout
import atexit
# Global session
client_session = None
# def create_client_session():
# global client_session
# if client_session is None:
# client_session = aiohttp.ClientSession()
async def ensure_client_session():
global client_session
if client_session is None:
client_session = aiohttp.ClientSession()
async def cleanup():
global client_session
if client_session:
await client_session.close()
def exit_handler():
print("Exiting the application. Initiating cleanup...")
loop = asyncio.get_event_loop()
loop.run_until_complete(cleanup())
atexit.register(exit_handler)
max_retries = int(os.environ.get('MAX_RETRIES', '5'))
retry_delay_multiplier = float(os.environ.get('RETRY_DELAY_MULTIPLIER', '2'))
print(f"max_retries: {max_retries}, retry_delay_multiplier: {retry_delay_multiplier}")
async def async_request_with_retry(method, url, **kwargs):
global client_session
await ensure_client_session()
retry_delay = 1 # Start with 1 second delay
initial_timeout = 5 # 5 seconds timeout for the initial connection
for attempt in range(max_retries):
try:
# Set a timeout for the initial connection
timeout = ClientTimeout(total=None, connect=initial_timeout)
kwargs['timeout'] = timeout
async with client_session.request(method, url, **kwargs) as response:
response.raise_for_status()
return response
except asyncio.TimeoutError:
logger.warning(f"Request timed out after {initial_timeout} seconds (attempt {attempt + 1}/{max_retries})")
except ClientError as e:
if attempt == max_retries - 1:
logger.error(f"Request failed after {max_retries} attempts: {e}")
# raise
logger.warning(f"Request failed (attempt {attempt + 1}/{max_retries}): {e}")
# Wait before retrying
await asyncio.sleep(retry_delay)
retry_delay *= retry_delay_multiplier # Exponential backoff
# If all retries fail, raise an exception
raise Exception(f"Request failed after {max_retries} attempts")
from logging import basicConfig, getLogger
# Check for an environment variable to enable/disable Logfire
use_logfire = os.environ.get('USE_LOGFIRE', 'false').lower() == 'true'
if use_logfire:
try:
import logfire
logfire.configure(
send_to_logfire="if-token-present"
)
logger = logfire
except ImportError:
print("Logfire not installed or disabled. Using standard Python logger.")
use_logfire = False
if not use_logfire:
# Use a standard Python logger when Logfire is disabled or not available
logger = getLogger("comfy-deploy")
basicConfig(level="INFO") # You can adjust the logging level as needed
def log(level, message, **kwargs):
if use_logfire:
getattr(logger, level)(message, **kwargs)
else:
getattr(logger, level)(f"{message} {kwargs}")
# For a span, you might need to create a context manager
from contextlib import contextmanager
@contextmanager
def log_span(name):
if use_logfire:
with logger.span(name):
yield
else:
yield
# logger.info(f"Start: {name}")
# yield
# logger.info(f"End: {name}")
from globals import StreamingPrompt, Status, sockets, SimplePrompt, streaming_prompt_metadata, prompt_metadata
class EventEmitter:
def __init__(self):
self.listeners = {}
def on(self, event, listener):
if event not in self.listeners:
self.listeners[event] = []
self.listeners[event].append(listener)
def off(self, event, listener):
if event in self.listeners:
self.listeners[event].remove(listener)
if not self.listeners[event]:
del self.listeners[event]
def emit(self, event, *args, **kwargs):
if event in self.listeners:
for listener in self.listeners[event]:
listener(*args, **kwargs)
# Create a global event emitter instance
event_emitter = EventEmitter()
api = None
api_task = None
cd_enable_log = os.environ.get('CD_ENABLE_LOG', 'false').lower() == 'true'
cd_enable_run_log = os.environ.get('CD_ENABLE_RUN_LOG', 'false').lower() == 'true'
bypass_upload = os.environ.get('CD_BYPASS_UPLOAD', 'false').lower() == 'true'
logger.info(f"CD_BYPASS_UPLOAD {bypass_upload}")
def clear_current_prompt(sid):
prompt_server = server.PromptServer.instance
to_delete = list(streaming_prompt_metadata[sid].running_prompt_ids) # Convert set to list
print("clearning out prompt: ", to_delete)
logger.info(f"clearing out prompt: {to_delete}")
for id_to_delete in to_delete:
delete_func = lambda a: a[1] == id_to_delete
prompt_server.prompt_queue.delete_queue_item(delete_func)
print("deleted prompt: ", id_to_delete, prompt_server.prompt_queue.get_tasks_remaining())
logger.info(f"deleted prompt: {id_to_delete}, remaining tasks: {prompt_server.prompt_queue.get_tasks_remaining()}")
streaming_prompt_metadata[sid].running_prompt_ids.clear()
@@ -80,7 +212,7 @@ def post_prompt(json_data):
}
return response
else:
print("invalid prompt:", valid[1])
logger.info("invalid prompt:", valid[1])
return {"error": valid[1], "node_errors": valid[3]}
else:
return {"error": "no prompt", "node_errors": []}
@@ -102,9 +234,23 @@ def apply_random_seed_to_workflow(workflow_api):
if isinstance(workflow_api[key]['inputs']['seed'], list):
continue
if workflow_api[key]['class_type'] == "PromptExpansion":
workflow_api[key]['inputs']['seed'] = randomSeed(8);
workflow_api[key]['inputs']['seed'] = randomSeed(8)
logger.info(f"Applied random seed {workflow_api[key]['inputs']['seed']} to PromptExpansion")
continue
workflow_api[key]['inputs']['seed'] = randomSeed();
if workflow_api[key]['class_type'] == "RandomNoise":
workflow_api[key]['inputs']['noise_seed'] = randomSeed()
logger.info(f"Applied random noise_seed {workflow_api[key]['inputs']['noise_seed']} to RandomNoise")
continue
if workflow_api[key]['class_type'] == "KSamplerAdvanced":
workflow_api[key]['inputs']['noise_seed'] = randomSeed()
logger.info(f"Applied random noise_seed {workflow_api[key]['inputs']['noise_seed']} to KSamplerAdvanced")
continue
if workflow_api[key]['class_type'] == "SamplerCustom":
workflow_api[key]['inputs']['noise_seed'] = randomSeed()
logger.info(f"Applied random noise_seed {workflow_api[key]['inputs']['noise_seed']} to SamplerCustom")
continue
workflow_api[key]['inputs']['seed'] = randomSeed()
logger.info(f"Applied random seed {workflow_api[key]['inputs']['seed']} to {workflow_api[key]['class_type']}")
def apply_inputs_to_workflow(workflow_api: Any, inputs: Any, sid: str = None):
# Loop through each of the inputs and replace them
@@ -118,7 +264,7 @@ def apply_inputs_to_workflow(workflow_api: Any, inputs: Any, sid: str = None):
if (value["class_type"] == "ComfyDeployWebscoketImageInput"):
value['inputs']["client_id"] = sid
if "input_id" in value['inputs'] and value['inputs']['input_id'] in inputs:
if "input_id" in value['inputs'] and inputs is not None and value['inputs']['input_id'] in inputs:
new_value = inputs[value['inputs']['input_id']]
# Lets skip it if its an image
@@ -138,6 +284,15 @@ 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"] == "ComfyUIDeployExternalLora":
value["inputs"]["default_lora_name"] = new_value
if value["class_type"] == "ComfyUIDeployExternalSlider":
value["inputs"]["default_value"] = new_value
if value["class_type"] == "ComfyUIDeployExternalBoolean":
value["inputs"]["default_value"] = new_value
def send_prompt(sid: str, inputs: StreamingPrompt):
# workflow_api = inputs.workflow_api
workflow_api = copy.deepcopy(inputs.workflow_api)
@@ -145,11 +300,11 @@ def send_prompt(sid: str, inputs: StreamingPrompt):
# Random seed
apply_random_seed_to_workflow(workflow_api)
print("getting inputs" , inputs.inputs)
logger.info("getting inputs" , inputs.inputs)
apply_inputs_to_workflow(workflow_api, inputs.inputs, sid=sid)
print(workflow_api)
logger.info(workflow_api)
prompt_id = str(uuid.uuid4())
@@ -172,12 +327,11 @@ def send_prompt(sid: str, inputs: StreamingPrompt):
error_type = type(e).__name__
stack_trace_short = traceback.format_exc().strip().split('\n')[-2]
stack_trace = traceback.format_exc().strip()
print(f"error: {error_type}, {e}")
print(f"stack trace: {stack_trace_short}")
logger.info(f"error: {error_type}, {e}")
logger.info(f"stack trace: {stack_trace_short}")
@server.PromptServer.instance.routes.post("/comfyui-deploy/run")
async def comfy_deploy_run(request):
prompt_server = server.PromptServer.instance
data = await request.json()
# In older version, we use workflow_api, but this has inputs already swapped in nextjs frontend, which is tricky
@@ -208,8 +362,8 @@ async def comfy_deploy_run(request):
error_type = type(e).__name__
stack_trace_short = traceback.format_exc().strip().split('\n')[-2]
stack_trace = traceback.format_exc().strip()
print(f"error: {error_type}, {e}")
print(f"stack trace: {stack_trace_short}")
logger.info(f"error: {error_type}, {e}")
logger.info(f"stack trace: {stack_trace_short}")
await update_run_with_output(prompt_id, {
"error": {
"error_type": error_type,
@@ -221,15 +375,8 @@ async def comfy_deploy_run(request):
return web.Response(status=500, reason=f"{error_type}: {e}, {stack_trace_short}")
status = 200
# if "error" in res:
# status = 400
# await update_run_with_output(prompt_id, {
# "error": {
# **res
# }
# })
if "node_errors" in res and res["node_errors"]:
if "node_errors" in res and res["node_errors"] is not None and len(res["node_errors"]) > 0:
# Even tho there are node_errors it can still be run
status = 400
await update_run_with_output(prompt_id, {
@@ -244,24 +391,134 @@ async def comfy_deploy_run(request):
return web.json_response(res, status=status)
async def stream_prompt(data):
# In older version, we use workflow_api, but this has inputs already swapped in nextjs frontend, which is tricky
workflow_api = data.get("workflow_api_raw")
# The prompt id generated from comfy deploy, can be None
prompt_id = data.get("prompt_id")
inputs = data.get("inputs")
# Now it handles directly in here
apply_random_seed_to_workflow(workflow_api)
apply_inputs_to_workflow(workflow_api, inputs)
prompt = {
"prompt": workflow_api,
"client_id": "comfy_deploy_instance", #api.client_id
"prompt_id": prompt_id
}
prompt_metadata[prompt_id] = SimplePrompt(
status_endpoint=data.get('status_endpoint'),
file_upload_endpoint=data.get('file_upload_endpoint'),
workflow_api=workflow_api
)
# log('info', "Begin prompt", prompt=prompt)
try:
res = post_prompt(prompt)
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()
logger.info(f"error: {error_type}, {e}")
logger.info(f"stack trace: {stack_trace_short}")
await update_run_with_output(prompt_id, {
"error": {
"error_type": error_type,
"stack_trace": stack_trace
}
})
# When there are critical errors, the prompt is actually not run
await update_run(prompt_id, Status.FAILED)
# return web.Response(status=500, reason=f"{error_type}: {e}, {stack_trace_short}")
# raise Exception("Prompt failed")
status = 200
if "node_errors" in res and res["node_errors"] is not None and len(res["node_errors"]) > 0:
# Even tho there are node_errors it can still be run
status = 400
await update_run_with_output(prompt_id, {
"error": {
**res
}
})
# When there are critical errors, the prompt is actually not run
if "error" in res:
await update_run(prompt_id, Status.FAILED)
# raise Exception("Prompt failed")
return res
# return web.json_response(res, status=status)
comfy_message_queues: Dict[str, asyncio.Queue] = {}
@server.PromptServer.instance.routes.post('/comfyui-deploy/run/streaming')
async def stream_response(request):
response = web.StreamResponse(status=200, reason='OK', headers={'Content-Type': 'text/event-stream'})
await response.prepare(request)
pending = True
data = await request.json()
prompt_id = data.get("prompt_id")
comfy_message_queues[prompt_id] = asyncio.Queue()
with log_span('Streaming Run'):
log('info', 'Streaming prompt')
try:
result = await stream_prompt(data=data)
await response.write(f"event: event_update\ndata: {json.dumps(result)}\n\n".encode('utf-8'))
# await response.write(.encode('utf-8'))
await response.drain() # Ensure the buffer is flushed
while pending:
if prompt_id in comfy_message_queues:
if not comfy_message_queues[prompt_id].empty():
data = await comfy_message_queues[prompt_id].get()
# log('info', data["event"], data=json.dumps(data))
# logger.info("listener", data)
await response.write(f"event: event_update\ndata: {json.dumps(data)}\n\n".encode('utf-8'))
await response.drain() # Ensure the buffer is flushed
if data["event"] == "status":
if data["data"]["status"] in (Status.FAILED.value, Status.SUCCESS.value):
pending = False
await asyncio.sleep(0.1) # Adjust the sleep duration as needed
except asyncio.CancelledError:
log('info', "Streaming was cancelled")
raise
except Exception as e:
log('error', "Streaming error", error=e)
finally:
# event_emitter.off("send_json", task)
await response.write_eof()
comfy_message_queues.pop(prompt_id, None)
return response
def get_comfyui_path_from_file_path(file_path):
file_path_parts = file_path.split("\\")
if file_path_parts[0] == "input":
print("matching input")
logger.info("matching input")
file_path = os.path.join(folder_paths.get_directory_by_type("input"), *file_path_parts[1:])
elif file_path_parts[0] == "models":
print("matching models")
logger.info("matching models")
file_path = folder_paths.get_full_path(file_path_parts[1], os.path.join(*file_path_parts[2:]))
print(file_path)
logger.info(file_path)
return file_path
# Form ComfyUI Manager
async def compute_sha256_checksum(filepath):
print("computing sha256 checksum")
logger.info("computing sha256 checksum")
chunk_size = 1024 * 256 # Example: 256KB
filepath = get_comfyui_path_from_file_path(filepath)
"""Compute the SHA256 checksum of a file, in chunks, asynchronously"""
@@ -284,7 +541,7 @@ async def get_installed_models(request):
file_list = folder_paths.get_filename_list(key)
value_json_compatible = (value[0], list(value[1]), file_list)
new_dict[key] = value_json_compatible
# print(new_dict)
# logger.info(new_dict)
return web.json_response(new_dict)
# This is start uploading the files to Comfy Deploy
@@ -294,7 +551,7 @@ async def upload_file_endpoint(request):
file_path = data.get("file_path")
print("Original file path", file_path)
logger.info("Original file path", file_path)
file_path = get_comfyui_path_from_file_path(file_path)
@@ -333,10 +590,9 @@ async def upload_file_endpoint(request):
if get_url:
try:
async with aiohttp.ClientSession() as session:
headers = {'Authorization': f'Bearer {token}'}
params = {'file_size': file_size, 'type': file_type}
async with session.get(get_url, params=params, headers=headers) as response:
response = await async_request_with_retry('GET', get_url, params=params, headers=headers)
if response.status == 200:
content = await response.json()
upload_url = content["upload_url"]
@@ -344,10 +600,10 @@ async def upload_file_endpoint(request):
with open(file_path, 'rb') as f:
headers = {
"Content-Type": file_type,
"x-amz-acl": "public-read",
# "x-amz-acl": "public-read",
"Content-Length": str(file_size)
}
async with session.put(upload_url, data=f, headers=headers) as upload_response:
upload_response = await async_request_with_retry('PUT', upload_url, data=f, headers=headers)
if upload_response.status == 200:
return web.json_response({
"message": "File uploaded successfully",
@@ -416,7 +672,7 @@ async def get_file_hash(request):
file_hash = await compute_sha256_checksum(full_file_path)
end_time = time.time()
elapsed_time = end_time - start_time
print(f"Cache miss -> Execution time: {elapsed_time} seconds")
logger.info(f"Cache miss -> Execution time: {elapsed_time} seconds")
# Update the in-memory cache
file_hash_cache[full_file_path] = file_hash
@@ -436,10 +692,10 @@ async def update_realtime_run_status(realtime_id: str, status_endpoint: str, sta
"run_id": realtime_id,
"status": status.value,
}
if (status_endpoint is None):
return
# requests.post(status_endpoint, json=body)
async with aiohttp.ClientSession() as session:
async with session.post(status_endpoint, json=body) as response:
pass
await async_request_with_retry('POST', status_endpoint, json=body)
@server.PromptServer.instance.routes.get('/comfyui-deploy/ws')
async def websocket_handler(request):
@@ -460,13 +716,12 @@ async def websocket_handler(request):
status_endpoint = request.rel_url.query.get('status_endpoint', None)
if auth_token is not None and get_workflow_endpoint_url is not None:
async with aiohttp.ClientSession() as session:
headers = {'Authorization': f'Bearer {auth_token}'}
async with session.get(get_workflow_endpoint_url, headers=headers) as response:
response = await async_request_with_retry('GET', get_workflow_endpoint_url, headers=headers)
if response.status == 200:
workflow = await response.json()
print("Loaded workflow version ",workflow["version"])
logger.info(f"Loaded workflow version ${workflow['version']}")
streaming_prompt_metadata[sid] = StreamingPrompt(
workflow_api=workflow["workflow_api"],
@@ -480,7 +735,7 @@ async def websocket_handler(request):
# await send("workflow_api", workflow_api, sid)
else:
error_message = await response.text()
print(f"Failed to fetch workflow endpoint. Status: {response.status}, Error: {error_message}")
logger.info(f"Failed to fetch workflow endpoint. Status: {response.status}, Error: {error_message}")
# await send("error", {"message": error_message}, sid)
try:
@@ -495,10 +750,10 @@ async def websocket_handler(request):
if msg.type == aiohttp.WSMsgType.TEXT:
try:
data = json.loads(msg.data)
print(data)
logger.info(data)
event_type = data.get('event')
if event_type == 'input':
print("Got input: ", data.get("inputs"))
logger.info(f"Got input: ${data.get('inputs')}")
input = data.get('inputs')
streaming_prompt_metadata[sid].inputs.update(input)
elif event_type == 'queue_prompt':
@@ -508,7 +763,7 @@ async def websocket_handler(request):
# Handle other event types
pass
except json.JSONDecodeError:
print('Failed to decode JSON from message')
logger.info('Failed to decode JSON from message')
if msg.type == aiohttp.WSMsgType.BINARY:
data = msg.data
@@ -517,9 +772,9 @@ async def websocket_handler(request):
image_type_code, = struct.unpack("<I", data[4:8])
input_id_bytes = data[8:32] # Extract the next 24 bytes for the input ID
input_id = input_id_bytes.decode('ascii').strip() # Decode the input ID from ASCII
print(event_type)
print(image_type_code)
print(input_id)
logger.info(event_type)
logger.info(image_type_code)
logger.info(input_id)
image_data = data[32:] # The rest is the image data
if image_type_code == 1:
image_type = "JPEG"
@@ -528,7 +783,7 @@ async def websocket_handler(request):
elif image_type_code == 3:
image_type = "WEBP"
else:
print("Unknown image type code:", image_type_code)
logger.info(f"Unknown image type code: ${image_type_code}")
return
image = Image.open(BytesIO(image_data))
# Check if the input ID already exists and replace the input with the new one
@@ -539,14 +794,14 @@ async def websocket_handler(request):
if hasattr(existing_image, 'close'):
existing_image.close()
except Exception as e:
print(f"Error closing previous image for input ID {input_id}: {e}")
logger.info(f"Error closing previous image for input ID {input_id}: {e}")
streaming_prompt_metadata[sid].inputs[input_id] = image
# clear_current_prompt(sid)
# send_prompt(sid, streaming_prompt_metadata[sid])
print(f"Received {image_type} image of size {image.size} with input ID {input_id}")
logger.info(f"Received {image_type} image of size {image.size} with input ID {input_id}")
if msg.type == aiohttp.WSMsgType.ERROR:
print('ws connection closed with exception %s' % ws.exception())
logger.info('ws connection closed with exception %s' % ws.exception())
finally:
sockets.pop(sid, None)
@@ -591,16 +846,60 @@ async def send(event, data, sid=None):
if not ws.closed: # Check if the WebSocket connection is open and not closing
await ws.send_json({ 'event': event, 'data': data })
except Exception as e:
print(f"Exception: {e}")
logger.info(f"Exception: {e}")
traceback.print_exc()
logging.basicConfig(level=logging.INFO)
@server.PromptServer.instance.routes.get('/comfydeploy/{tail:.*}')
@server.PromptServer.instance.routes.post('/comfydeploy/{tail:.*}')
async def proxy_to_comfydeploy(request):
# Get the base URL
base_url = f'https://www.comfydeploy.com/{request.match_info["tail"]}'
# Get all query parameters
query_params = request.query_string
# Construct the full target URL with query parameters
target_url = f"{base_url}?{query_params}" if query_params else base_url
# print(f"Proxying request to: {target_url}")
try:
# Create a new ClientSession for each request
async with ClientSession() as client_session:
# Forward the request
client_req = await client_session.request(
method=request.method,
url=target_url,
headers={k: v for k, v in request.headers.items() if k.lower() not in ('host', 'content-length')},
data=await request.read(),
allow_redirects=False,
)
# Read the entire response content
content = await client_req.read()
# Try to decode the content as JSON
try:
json_data = json.loads(content)
# If successful, return a JSON response
return web.json_response(json_data, status=client_req.status)
except json.JSONDecodeError:
# If it's not valid JSON, return the content as-is
return web.Response(body=content, status=client_req.status, headers=client_req.headers)
except ClientError as e:
print(f"Client error occurred while proxying request: {str(e)}")
return web.Response(status=502, text=f"Bad Gateway: {str(e)}")
except Exception as e:
print(f"Error occurred while proxying request: {str(e)}")
return web.Response(status=500, text=f"Internal Server Error: {str(e)}")
prompt_server = server.PromptServer.instance
send_json = prompt_server.send_json
async def send_json_override(self, event, data, sid=None):
# print("INTERNAL:", event, data, sid)
# logger.info("INTERNAL:", event, data, sid)
prompt_id = data.get('prompt_id')
target_sid = sid
@@ -613,8 +912,19 @@ async def send_json_override(self, event, data, sid=None):
asyncio.create_task(self.send_json_original(event, data, sid))
])
if prompt_id in comfy_message_queues:
comfy_message_queues[prompt_id].put_nowait({
"event": event,
"data": data
})
# event_emitter.emit("send_json", {
# "event": event,
# "data": data
# })
if event == 'execution_start':
update_run(prompt_id, Status.RUNNING)
await update_run(prompt_id, Status.RUNNING)
if prompt_id in prompt_metadata:
prompt_metadata[prompt_id].start_time = time.perf_counter()
@@ -623,12 +933,12 @@ async def send_json_override(self, event, data, sid=None):
if event == 'executing' and data.get('node') is None:
mark_prompt_done(prompt_id=prompt_id)
if not have_pending_upload(prompt_id):
update_run(prompt_id, Status.SUCCESS)
await update_run(prompt_id, Status.SUCCESS)
if prompt_id in prompt_metadata:
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
print(f"Elapsed time: {elapsed_time} seconds")
logger.info(f"Elapsed time: {elapsed_time} seconds")
await send("elapsed_time", {
"prompt_id": prompt_id,
"elapsed_time": elapsed_time
@@ -643,13 +953,14 @@ async def send_json_override(self, event, data, sid=None):
prompt_metadata[prompt_id].progress.add(node)
calculated_progress = len(prompt_metadata[prompt_id].progress) / len(prompt_metadata[prompt_id].workflow_api)
# print("calculated_progress", calculated_progress)
calculated_progress = round(calculated_progress, 2)
# logger.info("calculated_progress", calculated_progress)
if prompt_metadata[prompt_id].last_updated_node is not None and prompt_metadata[prompt_id].last_updated_node == node:
return
prompt_metadata[prompt_id].last_updated_node = node
class_type = prompt_metadata[prompt_id].workflow_api[node]['class_type']
print("updating run live status", class_type)
logger.info(f"At: {calculated_progress * 100}% - {class_type}")
await send("live_status", {
"prompt_id": prompt_id,
"current_node": class_type,
@@ -670,18 +981,19 @@ async def send_json_override(self, event, data, sid=None):
if event == 'execution_error':
# Careful this might not be fully awaited.
await update_run_with_output(prompt_id, data)
update_run(prompt_id, Status.FAILED)
await update_run(prompt_id, Status.FAILED)
# await update_run_with_output(prompt_id, data)
if event == 'executed' and 'node' in data and 'output' in data:
print("executed", data)
if prompt_id in prompt_metadata:
node = data.get('node')
class_type = prompt_metadata[prompt_id].workflow_api[node]['class_type']
print("executed", class_type)
logger.info(f"Executed {class_type} {data}")
if class_type == "PreviewImage":
print("skipping preview image")
logger.info("Skipping preview image")
return
else:
logger.info(f"Executed {data}")
await update_run_with_output(prompt_id, data.get('output'), node_id=data.get('node'))
# await update_run_with_output(prompt_id, data.get('output'), node_id=data.get('node'))
@@ -697,21 +1009,34 @@ async def update_run_live_status(prompt_id, live_status, calculated_progress: fl
if prompt_metadata[prompt_id].is_realtime is True:
return
print("progress", calculated_progress)
status_endpoint = prompt_metadata[prompt_id].status_endpoint
if (status_endpoint is None):
return
# logger.info(f"progress {calculated_progress}")
body = {
"run_id": prompt_id,
"live_status": live_status,
"progress": calculated_progress
}
if prompt_id in comfy_message_queues:
comfy_message_queues[prompt_id].put_nowait({
"event": "live_status",
"data": {
"prompt_id": prompt_id,
"live_status": live_status,
"progress": calculated_progress
}
})
# requests.post(status_endpoint, json=body)
async with aiohttp.ClientSession() as session:
async with session.post(status_endpoint, json=body) as response:
pass
await async_request_with_retry('POST', status_endpoint, json=body)
def update_run(prompt_id: str, status: Status):
async def update_run(prompt_id: str, status: Status):
global last_read_line_number
if prompt_id not in prompt_metadata:
@@ -734,18 +1059,20 @@ def update_run(prompt_id: str, status: Status):
"run_id": prompt_id,
"status": status.value,
}
print(f"Status: {status.value}")
logger.info(f"Status: {status.value}")
try:
requests.post(status_endpoint, json=body)
# requests.post(status_endpoint, json=body)
if (status_endpoint is not None):
await async_request_with_retry('POST', status_endpoint, json=body)
if cd_enable_run_log and (status == Status.SUCCESS or status == Status.FAILED):
if (status_endpoint is not None) and cd_enable_run_log and (status == Status.SUCCESS or status == Status.FAILED):
try:
with open(comfyui_file_path, 'r') as log_file:
# log_data = log_file.read()
# Move to the last read line
all_log_data = log_file.read() # Read all log data
print("All log data before skipping:", all_log_data) # Log all data before skipping
# logger.info("All log data before skipping: ") # Log all data before skipping
log_file.seek(0) # Reset file pointer to the beginning
for _ in range(last_read_line_number):
@@ -753,9 +1080,9 @@ def update_run(prompt_id: str, status: Status):
log_data = log_file.read()
# Update the last read line number
last_read_line_number += log_data.count('\n')
print("last_read_line_number", last_read_line_number)
print("log_data", log_data)
print("log_data.count(n)", log_data.count('\n'))
# logger.info("last_read_line_number", last_read_line_number)
# logger.info("log_data", log_data)
# logger.info("log_data.count(n)", log_data.count('\n'))
body = {
"run_id": prompt_id,
@@ -766,16 +1093,26 @@ def update_run(prompt_id: str, status: Status):
}
]
}
requests.post(status_endpoint, json=body)
await async_request_with_retry('POST', status_endpoint, json=body)
# requests.post(status_endpoint, json=body)
except Exception as log_error:
print(f"Error reading log file: {log_error}")
logger.info(f"Error reading log file: {log_error}")
except Exception as e:
error_type = type(e).__name__
stack_trace = traceback.format_exc().strip()
print(f"Error occurred while updating run: {e} {stack_trace}")
logger.info(f"Error occurred while updating run: {e} {stack_trace}")
finally:
prompt_metadata[prompt_id].status = status
if prompt_id in comfy_message_queues:
comfy_message_queues[prompt_id].put_nowait({
"event": "status",
"data": {
"prompt_id": prompt_id,
"status": status.value,
}
})
async def upload_file(prompt_id, filename, subfolder=None, content_type="image/png", type="output"):
@@ -793,7 +1130,7 @@ async def upload_file(prompt_id, filename, subfolder=None, content_type="image/p
output_dir = folder_paths.get_directory_by_type(type)
if output_dir is None:
print(filename, "Upload failed: output_dir is None")
logger.info(f"{filename} Upload failed: output_dir is None")
return
if subfolder != None:
@@ -805,7 +1142,7 @@ async def upload_file(prompt_id, filename, subfolder=None, content_type="image/p
filename = os.path.basename(filename)
file = os.path.join(output_dir, filename)
print("uploading file", file)
logger.info(f"Uploading file {file}")
file_upload_endpoint = prompt_metadata[prompt_id].file_upload_endpoint
@@ -815,27 +1152,33 @@ async def upload_file(prompt_id, filename, subfolder=None, content_type="image/p
target_url = f"{file_upload_endpoint}?file_name={filename}&run_id={prompt_id}&type={content_type}"
start_time = time.time() # Start timing here
result = requests.get(target_url)
end_time = time.time() # End timing after the request is complete
logger.info("Time taken for getting file upload endpoint: {:.2f} seconds".format(end_time - start_time))
ok = result.json()
start_time = time.time() # Start timing here
with open(file, 'rb') as f:
data = f.read()
headers = {
"x-amz-acl": "public-read",
# "x-amz-acl": "public-read",
"Content-Type": content_type,
"Content-Length": str(len(data)),
}
response = requests.put(ok.get("url"), headers=headers, data=data)
async with aiohttp.ClientSession() as session:
async with session.put(ok.get("url"), headers=headers, data=data) as response:
print("upload file response", response.status)
# response = requests.put(ok.get("url"), headers=headers, data=data)
response = await async_request_with_retry('PUT', ok.get("url"), headers=headers, data=data)
logger.info(f"Upload file response status: {response.status}, status text: {response.reason}")
end_time = time.time() # End timing after the request is complete
logger.info("Upload time: {:.2f} seconds".format(end_time - start_time))
def have_pending_upload(prompt_id):
if prompt_id in prompt_metadata and len(prompt_metadata[prompt_id].uploading_nodes) > 0:
print("have pending upload ", len(prompt_metadata[prompt_id].uploading_nodes))
logger.info(f"Have pending upload {len(prompt_metadata[prompt_id].uploading_nodes)}")
return True
print("no pending upload")
logger.info("No pending upload")
return False
def mark_prompt_done(prompt_id):
@@ -847,7 +1190,7 @@ def mark_prompt_done(prompt_id):
"""
if prompt_id in prompt_metadata:
prompt_metadata[prompt_id].done = True
print("Prompt done")
logger.info("Prompt done")
def is_prompt_done(prompt_id: str):
"""
@@ -879,8 +1222,8 @@ async def handle_error(prompt_id, data, e: Exception):
}
}
await update_file_status(prompt_id, data, False, have_error=True)
print(body)
print(f"Error occurred while uploading file: {e}")
logger.info(body)
logger.info(f"Error occurred while uploading file: {e}")
# Mark the current prompt requires upload, and block it from being marked as success
async def update_file_status(prompt_id: str, data, uploading, have_error=False, node_id=None):
@@ -893,11 +1236,11 @@ async def update_file_status(prompt_id: str, data, uploading, have_error=False,
else:
prompt_metadata[prompt_id].uploading_nodes.discard(node_id)
print(prompt_metadata[prompt_id].uploading_nodes)
logger.info(f"Remaining uploads: {prompt_metadata[prompt_id].uploading_nodes}")
# Update the remote status
if have_error:
update_run(prompt_id, Status.FAILED)
await update_run(prompt_id, Status.FAILED)
await send("failed", {
"prompt_id": prompt_id,
})
@@ -906,15 +1249,15 @@ async def update_file_status(prompt_id: str, data, uploading, have_error=False,
# 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:
update_run(prompt_id, 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 not uploading and not have_pending_upload(prompt_id) and is_prompt_done(prompt_id=prompt_id):
update_run(prompt_id, Status.SUCCESS)
print("Status: SUCCUSS")
await update_run(prompt_id, Status.SUCCESS)
# logger.info("Status: SUCCUSS")
await send("success", {
"prompt_id": prompt_id,
})
@@ -925,12 +1268,22 @@ async def handle_upload(prompt_id: str, data, key: str, content_type_key: str, d
# # Skipping temp files
if item.get("type") == "temp":
continue
file_type = item.get(content_type_key, default_content_type)
file_extension = os.path.splitext(item.get("filename"))[1]
if file_extension in ['.jpg', '.jpeg']:
file_type = 'image/jpeg'
elif file_extension == '.png':
file_type = 'image/png'
elif file_extension == '.webp':
file_type = 'image/webp'
await upload_file(
prompt_id,
item.get("filename"),
subfolder=item.get("subfolder"),
type=item.get("type"),
content_type=item.get(content_type_key, default_content_type)
content_type=file_type
)
# Upload files in the background
@@ -940,6 +1293,7 @@ async def upload_in_background(prompt_id: str, data, node_id=None, have_upload=T
await handle_upload(prompt_id, data, 'files', "content_type", "image/png")
# This will also be mp4
await handle_upload(prompt_id, data, 'gifs', "format", "image/gif")
await handle_upload(prompt_id, data, 'mesh', "format", "application/octet-stream")
if have_upload:
await update_file_status(prompt_id, data, False, node_id=node_id)
@@ -959,21 +1313,27 @@ async def update_run_with_output(prompt_id, data, node_id=None):
"run_id": prompt_id,
"output_data": data
}
have_upload_media = 'images' in data or 'files' in data or 'gifs' in data or 'mesh' in data
if bypass_upload and have_upload_media:
print("CD_BYPASS_UPLOAD is enabled, skipping the upload of the output:", node_id)
return
if have_upload_media:
try:
have_upload = 'images' in data or 'files' in data or 'gifs' in data
print("\nhave_upload", have_upload, node_id)
logger.info(f"\nHave_upload {have_upload_media} Node Id: {node_id}")
if have_upload:
if have_upload_media:
await update_file_status(prompt_id, data, True, node_id=node_id)
asyncio.create_task(upload_in_background(prompt_id, data, node_id=node_id, have_upload=have_upload))
asyncio.create_task(upload_in_background(prompt_id, data, node_id=node_id, have_upload=have_upload_media))
# await upload_in_background(prompt_id, data, node_id=node_id, have_upload=have_upload)
except Exception as e:
await handle_error(prompt_id, data, e)
requests.post(status_endpoint, json=body)
# requests.post(status_endpoint, json=body)
if status_endpoint is not None:
await async_request_with_retry('POST', status_endpoint, json=body)
await send('outputs_uploaded', {
"prompt_id": prompt_id
@@ -1027,3 +1387,9 @@ def run_in_new_thread(coroutine):
if cd_enable_log:
run_in_new_thread(watch_file_changes(log_file_path, send_logs_to_websocket))
# use after calling GET /object_info (it populates the `filename_list_cache` variable)
@server.PromptServer.instance.routes.get("/comfyui-deploy/filename_list_cache")
async def get_filename_list_cache(_):
from folder_paths import filename_list_cache
return web.json_response({'filename_list': filename_list_cache})
+5 -4
View File
@@ -22,12 +22,13 @@ class StreamingPrompt(BaseModel):
auth_token: str
inputs: dict[str, Union[str, bytes, Image.Image]]
running_prompt_ids: set[str] = set()
status_endpoint: str
file_upload_endpoint: str
status_endpoint: Optional[str]
file_upload_endpoint: Optional[str]
class SimplePrompt(BaseModel):
status_endpoint: str
file_upload_endpoint: str
status_endpoint: Optional[str]
file_upload_endpoint: Optional[str]
workflow_api: dict
status: Status = Status.NOT_STARTED
progress: set = set()
+15
View File
@@ -0,0 +1,15 @@
[project]
name = "comfyui-deploy"
description = "Open source comfyui deployment platform, a vercel for generative workflow infra."
version = "1.0.0"
license = "LICENSE"
dependencies = ["aiofiles", "pydantic", "opencv-python", "imageio-ffmpeg"]
[project.urls]
Repository = "https://github.com/BennyKok/comfyui-deploy"
# Used by Comfy Registry https://comfyregistry.org
[tool.comfy]
PublisherId = "comfydeploy"
DisplayName = "comfyui-deploy"
Icon = ""
+4
View File
@@ -1,2 +1,6 @@
aiofiles
pydantic
opencv-python
imageio-ffmpeg
brotli
# logfire
+431 -40
View File
@@ -2,6 +2,7 @@ import { app } from "./app.js";
import { api } from "./api.js";
import { ComfyWidgets, LGraphNode } from "./widgets.js";
import { generateDependencyGraph } from "https://esm.sh/[email protected]";
import { ComfyDeploy } from "https://esm.sh/[email protected]";
const loadingIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><g fill="none" stroke="#888888" stroke-linecap="round" stroke-width="2"><path stroke-dasharray="60" stroke-dashoffset="60" stroke-opacity=".3" d="M12 3C16.9706 3 21 7.02944 21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3Z"><animate fill="freeze" attributeName="stroke-dashoffset" dur="1.3s" values="60;0"/></path><path stroke-dasharray="15" stroke-dashoffset="15" d="M12 3C16.9706 3 21 7.02944 21 12"><animate fill="freeze" attributeName="stroke-dashoffset" dur="0.3s" values="15;0"/><animateTransform attributeName="transform" dur="1.5s" repeatCount="indefinite" type="rotate" values="0 12 12;360 12 12"/></path></g></svg>`;
@@ -13,6 +14,75 @@ function sendEventToCD(event, data) {
window.parent.postMessage(JSON.stringify(message), "*");
}
function dispatchAPIEventData(data) {
const msg = JSON.parse(data);
// Custom parse error
if (msg.error) {
let message = msg.error.message;
if (msg.error.details) message += ": " + msg.error.details;
for (const [nodeID, nodeError] of Object.entries(msg.node_errors)) {
message += "\n" + nodeError.class_type + ":";
for (const errorReason of nodeError.errors) {
message +=
"\n - " + errorReason.message + ": " + errorReason.details;
}
}
app.ui.dialog.show(message);
if (msg.node_errors) {
app.lastNodeErrors = msg.node_errors;
app.canvas.draw(true, true);
}
}
switch (msg.event) {
case "error":
break;
case "status":
if (msg.data.sid) {
// this.clientId = msg.data.sid;
// window.name = this.clientId; // use window name so it isnt reused when duplicating tabs
// sessionStorage.setItem("clientId", this.clientId); // store in session storage so duplicate tab can load correct workflow
}
api.dispatchEvent(new CustomEvent("status", { detail: msg.data.status }));
break;
case "progress":
api.dispatchEvent(new CustomEvent("progress", { detail: msg.data }));
break;
case "executing":
api.dispatchEvent(
new CustomEvent("executing", { detail: msg.data.node }),
);
break;
case "executed":
api.dispatchEvent(new CustomEvent("executed", { detail: msg.data }));
break;
case "execution_start":
api.dispatchEvent(
new CustomEvent("execution_start", { detail: msg.data }),
);
break;
case "execution_error":
api.dispatchEvent(
new CustomEvent("execution_error", { detail: msg.data }),
);
break;
case "execution_cached":
api.dispatchEvent(
new CustomEvent("execution_cached", { detail: msg.data }),
);
break;
default:
api.dispatchEvent(new CustomEvent(msg.type, { detail: msg.data }));
// default:
// if (this.#registered.has(msg.type)) {
// } else {
// throw new Error(`Unknown message type ${msg.type}`);
// }
}
}
/** @typedef {import('../../../web/types/comfy.js').ComfyExtension} ComfyExtension*/
/** @type {ComfyExtension} */
const ext = {
@@ -30,6 +100,29 @@ const ext = {
if (workspace_mode) {
document.querySelector(".comfy-menu").style.display = "none";
sendEventToCD("cd_plugin_onInit");
app.queuePrompt = ((originalFunction) => async () => {
// const prompt = await app.graphToPrompt();
sendEventToCD("cd_plugin_onQueuePromptTrigger");
})(app.queuePrompt);
// // Intercept the onkeydown event
// window.addEventListener(
// "keydown",
// (event) => {
// // Check for specific keys if necessary
// console.log("hi");
// if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
// event.preventDefault();
// event.stopImmediatePropagation();
// event.stopPropagation();
// sendEventToCD("cd_plugin_onQueuePrompt", prompt);
// }
// },
// true,
// );
}
const data = getData();
@@ -114,14 +207,26 @@ const ext = {
ComfyWidgets.STRING(
this,
"workflow_name",
["", { default: this.properties.workflow_name, multiline: false }],
[
"",
{
default: this.properties.workflow_name,
multiline: false,
},
],
app,
);
ComfyWidgets.STRING(
this,
"workflow_id",
["", { default: this.properties.workflow_id, multiline: false }],
[
"",
{
default: this.properties.workflow_id,
multiline: false,
},
],
app,
);
@@ -166,32 +271,110 @@ const ext = {
// const graphCanvas = document.getElementById("graph-canvas");
window.addEventListener("message", async (event) => {
// console.log("message", event);
try {
const message = JSON.parse(event.data);
if (message.type === "graph_load") {
const comfyUIWorkflow = message.data;
console.log("recieved: ", comfyUIWorkflow);
// console.log("recieved: ", comfyUIWorkflow);
// Assuming there's a method to load the workflow data into the ComfyUI
// This part of the code would depend on how the ComfyUI expects to receive and process the workflow data
// For demonstration, let's assume there's a loadWorkflow method in the ComfyUI API
if (comfyUIWorkflow && app && app.loadGraphData) {
console.log("loadGraphData");
app.loadGraphData(comfyUIWorkflow);
}
} else if (message.type === "deploy") {
// deployWorkflow();
const prompt = await app.graphToPrompt();
// api.handlePromptGenerated(prompt);
sendEventToCD("cd_plugin_onDeployChanges", prompt);
} else if (message.type === "queue_prompt") {
const prompt = await app.graphToPrompt();
sendEventToCD("cd_plugin_onQueuePrompt", prompt);
if (typeof api.handlePromptGenerated === "function") {
api.handlePromptGenerated(prompt);
} else {
console.warn("api.handlePromptGenerated is not a function");
}
sendEventToCD("cd_plugin_onQueuePrompt", prompt);
} else if (message.type === "get_prompt") {
const prompt = await app.graphToPrompt();
sendEventToCD("cd_plugin_onGetPrompt", prompt);
} else if (message.type === "event") {
dispatchAPIEventData(message.data);
} else if (message.type === "add_node") {
console.log("add node", message.data);
app.graph.beforeChange();
var node = LiteGraph.createNode(message.data.type);
node.configure({
widgets_values: message.data.widgets_values,
});
console.log("node", node);
const graphMouse = app.canvas.graph_mouse;
node.pos = [graphMouse[0], graphMouse[1]];
app.graph.add(node);
app.graph.afterChange();
} else if (message.type === "zoom_to_node") {
const nodeId = message.data.nodeId;
const position = message.data.position;
const node = app.graph.getNodeById(nodeId);
if (!node) return;
const canvas = app.canvas;
const targetScale = 1;
const targetOffsetX =
canvas.canvas.width / 4 - position[0] - node.size[0] / 2;
const targetOffsetY =
canvas.canvas.height / 4 - position[1] - node.size[1] / 2;
const startScale = canvas.ds.scale;
const startOffsetX = canvas.ds.offset[0];
const startOffsetY = canvas.ds.offset[1];
const duration = 400; // Animation duration in milliseconds
const startTime = Date.now();
function easeOutCubic(t) {
return 1 - Math.pow(1 - t, 3);
}
function lerp(start, end, t) {
return start * (1 - t) + end * t;
}
function animate() {
const currentTime = Date.now();
const elapsedTime = currentTime - startTime;
const t = Math.min(elapsedTime / duration, 1);
const easedT = easeOutCubic(t);
const currentScale = lerp(startScale, targetScale, easedT);
const currentOffsetX = lerp(startOffsetX, targetOffsetX, easedT);
const currentOffsetY = lerp(startOffsetY, targetOffsetY, easedT);
canvas.setZoom(currentScale);
canvas.ds.offset = [currentOffsetX, currentOffsetY];
canvas.draw(true, true);
if (t < 1) {
requestAnimationFrame(animate);
}
}
animate();
}
// else if (message.type === "refresh") {
// sendEventToCD("cd_plugin_onRefresh");
// }
} catch (error) {
// console.error("Error processing message:", error);
}
// if (!event.data.flow || Object.entries(event.data.flow).length <= 0)
// return;
// updateBlendshapesPrompts(event.data.flow);
});
api.addEventListener("executed", (evt) => {
@@ -204,7 +387,8 @@ const ext = {
// }
});
app.graph.onAfterChange = ((originalFunction) => async function () {
app.graph.onAfterChange = ((originalFunction) =>
async function () {
const prompt = await app.graphToPrompt();
sendEventToCD("cd_plugin_onAfterChange", prompt);
@@ -314,6 +498,7 @@ function createDynamicUIHtml(data) {
return html;
}
// Modify the existing deployWorkflow function
async function deployWorkflow() {
const deploy = document.getElementById("deploy-button");
@@ -454,41 +639,36 @@ async function deployWorkflow() {
console.log(file);
loadingDialog.showLoading("Generating hash", file);
const hash = await fetch(
`/comfyui-deploy/get-file-hash?file_path=${encodeURIComponent(
file,
)}`,
`/comfyui-deploy/get-file-hash?file_path=${encodeURIComponent(file)}`,
).then((x) => x.json());
loadingDialog.showLoading("Generating hash", file);
console.log(hash);
return hash.file_hash;
},
handleFileUpload: async (file, hash, prevhash) => {
console.log("Uploading ", file);
loadingDialog.showLoading("Uploading file", file);
try {
const { download_url } = await fetch(
`/comfyui-deploy/upload-file`,
{
method: "POST",
body: JSON.stringify({
file_path: file,
token: apiKey,
url: endpoint + "/api/upload-url",
}),
},
)
.then((x) => x.json())
.catch(() => {
loadingDialog.close();
confirmDialog.confirm("Error", "Unable to upload file " + file);
});
loadingDialog.showLoading("Uploaded file", file);
console.log(download_url);
return download_url;
} catch (error) {
return undefined;
}
},
// handleFileUpload: async (file, hash, prevhash) => {
// console.log("Uploading ", file);
// loadingDialog.showLoading("Uploading file", file);
// try {
// const { download_url } = await fetch(`/comfyui-deploy/upload-file`, {
// method: "POST",
// body: JSON.stringify({
// file_path: file,
// token: apiKey,
// url: endpoint + "/api/upload-url",
// }),
// })
// .then((x) => x.json())
// .catch(() => {
// loadingDialog.close();
// confirmDialog.confirm("Error", "Unable to upload file " + file);
// });
// loadingDialog.showLoading("Uploaded file", file);
// console.log(download_url);
// return download_url;
// } catch (error) {
// return undefined;
// }
// },
existingDependencies: existing_workflow.dependencies,
});
@@ -513,6 +693,15 @@ async function deployWorkflow() {
"Check dependencies",
// JSON.stringify(deps, null, 2),
`
<div>
You will need to create a cloud machine with the following configuration on ComfyDeploy
<ol style="text-align: left; margin-top: 10px;">
<li>Review the dependencies listed in the graph below</li>
<li>Create a new cloud machine with the required configuration</li>
<li>Install missing models and check missing files</li>
<li>Deploy your workflow to the newly created machine</li>
</ol>
</div>
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);">${loadingIcon}</div>
<iframe
style="z-index: 10; min-width: 600px; max-width: 1024px; min-height: 600px; border: none; background-color: transparent;"
@@ -582,6 +771,14 @@ async function deployWorkflow() {
`<span style="color:green;">Deployed successfully!</span> <a style="color:white;" target="_blank" href=${endpoint}/workflows/${data.workflow_id}>-> View here</a> <br/> <br/> Workflow ID: ${data.workflow_id} <br/> Workflow Name: ${workflow_name} <br/> Workflow Version: ${data.version} <br/>`,
);
// // Refresh the workflows list in the sidebar
// const sidebarEl = document.querySelector(
// '.comfy-sidebar-tab[data-id="search"]',
// );
// if (sidebarEl) {
// refreshWorkflowsList(sidebarEl);
// }
setTimeout(() => {
title.textContent = "Deploy";
title.style.color = "white";
@@ -599,6 +796,85 @@ async function deployWorkflow() {
}
}
// Add this function to refresh the workflows list
function refreshWorkflowsList(el) {
const workflowsList = el.querySelector("#workflows-list");
const workflowsLoading = el.querySelector("#workflows-loading");
workflowsLoading.style.display = "flex";
workflowsList.style.display = "none";
workflowsList.innerHTML = "";
client.workflows
.getAll({
page: "1",
pageSize: "10",
})
.then((result) => {
workflowsLoading.style.display = "none";
workflowsList.style.display = "block";
if (result.length === 0) {
workflowsList.innerHTML =
"<li style='color: #bdbdbd;'>No workflows found</li>";
return;
}
result.forEach((workflow) => {
const li = document.createElement("li");
li.style.marginBottom = "15px";
li.style.padding = "15px";
li.style.backgroundColor = "#2a2a2a";
li.style.borderRadius = "8px";
li.style.boxShadow = "0 2px 4px rgba(0,0,0,0.1)";
const lastRun = workflow.runs[0];
const lastRunStatus = lastRun ? lastRun.status : "No runs";
const statusColor =
lastRunStatus === "success"
? "#4CAF50"
: lastRunStatus === "error"
? "#F44336"
: "#FFC107";
const timeAgo = getTimeAgo(new Date(workflow.updatedAt));
li.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<div style="flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
<strong style="font-size: 18px; color: #e0e0e0;">${workflow.name}</strong>
</div>
<span style="font-size: 12px; color: ${statusColor}; margin-left: 10px;">Last run: ${lastRunStatus}</span>
</div>
<div style="font-size: 14px; color: #bdbdbd; margin-bottom: 10px;">Last updated ${timeAgo}</div>
<div style="display: flex; gap: 10px;">
<button class="open-cloud-btn" style="padding: 5px 10px; background-color: #4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer;">Open in Cloud</button>
<button class="load-api-btn" style="padding: 5px 10px; background-color: #2196F3; color: white; border: none; border-radius: 4px; cursor: pointer;">Load Workflow</button>
</div>
`;
const openCloudBtn = li.querySelector(".open-cloud-btn");
openCloudBtn.onclick = () =>
window.open(
`${getData().endpoint}/workflows/${workflow.id}?workspace=true`,
"_blank",
);
const loadApiBtn = li.querySelector(".load-api-btn");
loadApiBtn.onclick = () => loadWorkflowApi(workflow.versions[0].id);
workflowsList.appendChild(li);
});
})
.catch((error) => {
console.error("Error fetching workflows:", error);
workflowsLoading.style.display = "none";
workflowsList.style.display = "block";
workflowsList.innerHTML =
"<li style='color: #F44336;'>Error fetching workflows</li>";
});
}
function addButton() {
const menu = document.querySelector(".comfy-menu");
@@ -608,7 +884,7 @@ function addButton() {
deploy.style.display = "block";
deploy.innerHTML = "<div id='button-title'>Deploy</div>";
deploy.onclick = async () => {
await deployWorkflow()
await deployWorkflow();
};
const config = document.createElement("img");
@@ -1091,3 +1367,118 @@ 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/`,
});
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">
<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>
<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 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");
refreshWorkflowsList(el);
},
});
function getTimeAgo(date) {
const seconds = Math.floor((new Date() - date) / 1000);
let interval = seconds / 31536000;
if (interval > 1) return Math.floor(interval) + " years ago";
interval = seconds / 2592000;
if (interval > 1) return Math.floor(interval) + " months ago";
interval = seconds / 86400;
if (interval > 1) return Math.floor(interval) + " days ago";
interval = seconds / 3600;
if (interval > 1) return Math.floor(interval) + " hours ago";
interval = seconds / 60;
if (interval > 1) return Math.floor(interval) + " minutes ago";
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",
false,
);
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
}
}
+1 -1
View File
@@ -74,7 +74,7 @@
"mitata": "^0.1.6",
"ms": "^2.1.3",
"nanoid": "^5.0.4",
"next": "14.1",
"next": "14.2",
"next-plausible": "^3.12.0",
"next-themes": "^0.2.1",
"next-usequerystate": "^1.13.2",
+3 -1
View File
@@ -51,7 +51,9 @@ const createRunRoute = createRoute({
export const registerCreateRunRoute = (app: App) => {
app.openapi(createRunRoute, async (c) => {
const data = c.req.valid("json");
const origin = new URL(c.req.url).origin;
const proto = c.req.headers.get('x-forwarded-proto') || "http";
const host = c.req.headers.get('x-forwarded-host') || c.req.headers.get('host');
const origin = `${proto}://${host}` || new URL(c.req.url).origin;
const apiKeyTokenData = c.get("apiKeyTokenData")!;
const { deployment_id, inputs } = data;
+1 -1
View File
@@ -102,7 +102,7 @@ export const createRun = withServerPromise(
let prompt_id: string | undefined = undefined;
const shareData = {
workflow_api: workflow_api,
workflow_api_raw: workflow_api,
status_endpoint: `${origin}/api/update-run`,
file_upload_endpoint: `${origin}/api/file-upload`,
};