Compare commits

...
Author SHA1 Message Date
bennykok 1a8e089c7d version bump 2025-09-12 10:37:45 -07:00
bennykok 557799cdb1 fix 2025-09-11 23:56:33 -07:00
bennykok d5f52131db fix 2025-09-11 23:53:26 -07:00
bennykok 755399a255 fix: symlink 2025-09-11 23:44:38 -07:00
bennykok ee3717b4b3 any file + psd psb support 2025-09-11 23:31:28 -07:00
KarrixLee c13828af56 fix: comfy manager error, bump to v2.3.6 2025-09-11 04:13:36 +08:00
KarrixLee ffda951cfa Bump version to 2.3.5 in Manager 2025-09-10 15:14:56 +08:00
KarrixLee ac0d8ba725 Bump version to 2.3.5 in Manager (#107)
* init

* tweak

* add: model display

* test

* add: real logic to upload model

* fix native mode

* tweak again

* add: workflow version control

* fix: versioning display

* refactor: remove unnecessary code
2025-09-10 15:13:04 +08:00
bennykok 31c38ddb9b version bump 2025-08-29 15:48:42 -07:00
bennykok 2f2c63fa11 proxy interrupt request 2025-08-29 15:47:54 -07:00
BennyKok 7675c5ba90 Update README with demo link and recruitment note
Added a link to the latest local demo and a call for team members.
2025-08-29 13:03:39 -07:00
bennykok 7d65d23f84 fix: add null check in findIndex to prevent TypeError in context menu
- Fixed TypeError when accessing 'content' property of null/undefined array elements
- Removed empty options.push() that was adding undefined to the array
- Bump version to 2.3.3
2025-08-26 18:34:20 -07:00
KarrixLee a91edf6371 Fix serverless machine (#106)
* refresh the machine list automatically

* update ui

* sync non local workflow

* Bump CD ver to 2.3.2 in Manager
2025-08-26 17:38:34 +08:00
11 changed files with 3708 additions and 228 deletions
+2
View File
@@ -2,6 +2,8 @@
Open source comfyui deployment platform, a `vercel` for generative workflow infra. (serverless hosted gpu with vertical intergation with comfyui)
Check out our latest lcoal demo -> https://github.com/comfy-deploy/comfyui-api-comfydeploy
> [!NOTE]
> Im looking for creative hacker to join ComfyDeploy's core team! DM me on [twitter](https://x.com/BennyKokMusic)
+137
View File
@@ -0,0 +1,137 @@
import folder_paths
class AnyType(str):
def __ne__(self, __value: object) -> bool:
return False
WILDCARD = AnyType("*")
class ComfyUIDeployExternalFile:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"input_id": (
"STRING",
{"multiline": False, "default": "input_file"},
),
},
"optional": {
"display_name": (
"STRING",
{"multiline": False, "default": ""},
),
"description": (
"STRING",
{"multiline": True, "default": ""},
),
"file_url": (
"STRING",
{"multiline": False, "default": ""},
),
},
}
RETURN_TYPES = (WILDCARD,)
RETURN_NAMES = ("path",)
FUNCTION = "run"
CATEGORY = "🔗ComfyDeploy"
def run(
self,
input_id,
display_name=None,
description=None,
file_url=None,
):
import requests
import os
import uuid
from urllib.parse import urlparse
if file_url:
if file_url.startswith("http"):
# Use cache directory for saving files
cache_dir = folder_paths.get_temp_directory()
if not os.path.exists(cache_dir):
os.makedirs(cache_dir)
# Always generate random filename to avoid conflicts
parsed_url = urlparse(file_url)
original_filename = os.path.basename(parsed_url.path)
# Extract file extension from original filename if available
file_extension = ""
if original_filename and "." in original_filename:
file_extension = os.path.splitext(original_filename)[1]
else:
# Try to determine extension from content-type if no extension found
file_extension = ".bin"
# Generate random filename with preserved extension
filename = str(uuid.uuid4()) + file_extension
destination_path = os.path.join(cache_dir, filename)
print(f"Cache directory: {cache_dir}")
print(f"Destination path: {destination_path}")
print(
"Downloading external file - "
+ file_url
+ " to "
+ destination_path
)
headers = {"User-Agent": "Mozilla/5.0"}
try:
response = requests.get(
file_url,
headers=headers,
allow_redirects=True,
timeout=30, # Add timeout to prevent hanging
)
response.raise_for_status()
with open(destination_path, "wb") as out_file:
out_file.write(response.content)
print(f"External file downloaded: {file_url} to {destination_path}")
return (destination_path,)
except requests.exceptions.HTTPError as e:
error_msg = f"HTTP Error {e.response.status_code}: {e.response.reason} for URL: {file_url}"
print(f"⚠️ Download failed - {error_msg}")
if e.response.status_code == 404:
print(
"💡 This URL might have expired or the file may have been deleted"
)
# Return empty string instead of crashing
return ("",)
except requests.exceptions.RequestException as e:
error_msg = (
f"Network error downloading file from {file_url}: {str(e)}"
)
print(f"⚠️ Download failed - {error_msg}")
return ("",)
except Exception as e:
error_msg = (
f"Unexpected error downloading file from {file_url}: {str(e)}"
)
print(f"⚠️ Download failed - {error_msg}")
return ("",)
else:
print(f"External file loading: {file_url}")
return (file_url,)
else:
print(f"No file URL provided")
return ("",)
NODE_CLASS_MAPPINGS = {"ComfyUIDeployExternalFile": ComfyUIDeployExternalFile}
NODE_DISPLAY_NAME_MAPPINGS = {
"ComfyUIDeployExternalFile": "External File (ComfyUI Deploy)"
}
+168
View File
@@ -0,0 +1,168 @@
import folder_paths
import os
import shutil
import uuid
class AnyType(str):
def __ne__(self, __value: object) -> bool:
return False
WILDCARD = AnyType("*")
class ComfyDeployOutputFile:
def __init__(self):
self.output_dir = folder_paths.get_output_directory()
self.type = "output"
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"file_path": (
"STRING",
{
"forceInput": True,
"tooltip": "Path to the file to output and upload.",
},
),
},
"optional": {
"output_id": (
"STRING",
{"multiline": False, "default": "output_file"},
),
},
}
RETURN_TYPES = ()
FUNCTION = "run"
OUTPUT_NODE = True
CATEGORY = "🔗ComfyDeploy"
DESCRIPTION = "Outputs any file by path for upload to ComfyDeploy."
def run(self, file_path, output_id="output_file"):
if not file_path or not os.path.exists(file_path):
print(f"⚠️ File not found: {file_path}")
return {"ui": {"files": []}}
# Security checks - ensure file is within safe ComfyUI paths
try:
# Get absolute paths for comparison
file_abs_path = os.path.abspath(file_path)
base_path = folder_paths.base_path
temp_dir = folder_paths.get_temp_directory()
# Check if file is within ComfyUI base path or temp directory
if not (
file_abs_path.startswith(os.path.abspath(base_path))
or file_abs_path.startswith(os.path.abspath(temp_dir))
):
print(f"⚠️ Security: File outside allowed ComfyUI paths: {file_path}")
return {"ui": {"files": []}}
# Check for path traversal attempts (but allow absolute paths within ComfyUI)
if ".." in file_path:
print(f"⚠️ Security: Path traversal attempt detected: {file_path}")
return {"ui": {"files": []}}
except Exception as e:
print(f"⚠️ Security check failed: {str(e)}")
return {"ui": {"files": []}}
# Get the original filename and extension
original_filename = os.path.basename(file_path)
file_extension = os.path.splitext(original_filename)[1]
# Additional filename security check
if ".." in original_filename:
print(f"⚠️ Security: Insecure filename: {original_filename}")
return {"ui": {"files": []}}
results = []
# Check if file is in output folder, if not, symlink it there
try:
if file_path.startswith(self.output_dir):
# File is already in output directory - use as is
relative_path = os.path.relpath(file_path, self.output_dir)
path_parts = relative_path.split(os.sep)
if len(path_parts) > 1:
subfolder = os.sep.join(path_parts[:-1])
else:
subfolder = ""
filename = path_parts[-1]
file_type = self.type
else:
# File is not in output folder - symlink it to output/temp
print(
f"File is not in output folder, symlinking to output/temp: {file_path}"
)
output_temp_dir = os.path.join(self.output_dir, "temp")
if not os.path.exists(output_temp_dir):
os.makedirs(output_temp_dir)
# Use the existing filename but with UUID prefix to avoid conflicts
file_ext = os.path.splitext(original_filename)[1]
temp_filename = f"{uuid.uuid4()}{file_ext}"
temp_path = os.path.join(output_temp_dir, temp_filename)
# Create symlink to file in output/temp directory where upload system expects it
try:
# Remove existing symlink if it exists
if os.path.exists(temp_path):
os.remove(temp_path)
os.symlink(file_path, temp_path)
print(f"File symlinked to output/temp: {temp_path} -> {file_path}")
except OSError as e:
# Fall back to copying if symlink fails
print(f"Symlink failed ({e}), falling back to copy")
shutil.copy2(file_path, temp_path)
print(f"File copied to output/temp: {temp_path}")
# Use output/temp directory structure for upload
subfolder = "temp"
filename = temp_filename
file_type = self.type
results.append(
{
"filename": filename,
"subfolder": subfolder,
"type": file_type,
"output_id": output_id,
}
)
except Exception as e:
print(f"⚠️ Error processing file path: {str(e)}")
return {"ui": {"files": []}}
# Determine the appropriate UI key based on file type
file_ext = file_extension.lower()
if file_ext in [".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tiff"]:
ui_key = "images"
elif file_ext in [".mp3", ".wav", ".flac", ".aac", ".ogg"]:
ui_key = "audio"
elif file_ext in [".txt", ".json", ".md", ".csv"]:
ui_key = "text_file"
elif file_ext in [".exr", ".hdr"]:
ui_key = "images" # EXR files are still images
elif file_ext in [".zip", ".psb", ".psd"]:
ui_key = "files" # Archives and Photoshop project files
else:
ui_key = "files" # Generic files
return {"ui": {ui_key: results}}
NODE_CLASS_MAPPINGS = {
"ComfyDeployOutputFile": ComfyDeployOutputFile,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"ComfyDeployOutputFile": "File Output (ComfyDeploy)",
}
+374
View File
@@ -516,6 +516,9 @@ def apply_inputs_to_workflow(workflow_api: Any, inputs: Any, sid: str = None):
if value["class_type"] == "ComfyUIDeployExternalEXR":
value["inputs"]["exr_file"] = new_value
if value["class_type"] == "ComfyUIDeployExternalFile":
value["inputs"]["file_url"] = new_value
if value["class_type"] == "ComfyUIDeployExternalSeed":
logger.info(
f"Applied random seed {new_value} to {value['class_type']}"
@@ -672,6 +675,14 @@ async def comfy_deploy_run(request):
return web.json_response(res, status=status)
@server.PromptServer.instance.routes.post("/comfyui-deploy/interrupt")
async def interrupt_prompt(request):
data = await request.json()
prompt_id = data.get("prompt_id")
await update_run(prompt_id, Status.CANCELLED)
return web.json_response({"message": "Prompt interrupted"}, status=200)
async def stream_prompt(data, token):
# 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")
@@ -888,6 +899,10 @@ async def upload_file_endpoint(request):
file_type = "image/png"
elif file_extension == ".webp":
file_type = "image/webp"
elif file_extension == ".zip":
file_type = "application/zip"
elif file_extension in [".psd", ".psb"]:
file_type = "image/vnd.adobe.photoshop"
else:
file_type = (
"application/octet-stream" # Default to binary file type if unknown
@@ -2090,6 +2105,10 @@ async def handle_upload(
file_type = "image/png"
elif file_extension == ".webp":
file_type = "image/webp"
elif file_extension == ".zip":
file_type = "application/zip"
elif file_extension in [".psd", ".psb"]:
file_type = "image/vnd.adobe.photoshop"
upload_tasks.append(
upload_file(
@@ -2555,6 +2574,10 @@ class UploadQueue:
content_type = "image/webp"
elif file_extension == ".gif":
content_type = "image/gif"
elif file_extension == ".zip":
content_type = "application/zip"
elif file_extension in [".psd", ".psb"]:
content_type = "image/vnd.adobe.photoshop"
else:
content_type = file_info.get("content_type", "application/octet-stream")
@@ -3140,6 +3163,68 @@ async def get_workflow_proxy(request):
return web.json_response({"error": str(e)}, status=500)
# fetch workflow versions (infinite scroll support)
@server.PromptServer.instance.routes.get("/comfyui-deploy/workflow/versions")
async def get_workflow_versions_proxy(request):
workflow_id = request.rel_url.query.get("workflow_id")
api_url = request.rel_url.query.get("api_url", "https://api.comfydeploy.com")
search = request.rel_url.query.get("search", "")
limit = request.rel_url.query.get("limit", "20")
offset = request.rel_url.query.get("offset", "0")
auth_header = request.headers.get("Authorization")
if not auth_header:
return web.json_response(
{"error": "Authorization header is required"}, status=401
)
# Build target URL with query params
params = {"limit": limit, "offset": offset}
if search:
params["search"] = search
query = urlencode(params)
target_url = f"{api_url}/api/workflow/{workflow_id}/versions"
if query:
target_url += f"?{query}"
try:
await ensure_client_session()
async with client_session.get(
target_url, headers={"Authorization": auth_header}
) as response:
json_data = await response.json()
return web.json_response(json_data, status=response.status)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
# fetch a specific workflow version json
@server.PromptServer.instance.routes.get("/comfyui-deploy/workflow/version")
async def get_workflow_version_proxy(request):
workflow_id = request.rel_url.query.get("workflow_id")
version = request.rel_url.query.get("version")
api_url = request.rel_url.query.get("api_url", "https://api.comfydeploy.com")
auth_header = request.headers.get("Authorization")
if not auth_header:
return web.json_response(
{"error": "Authorization header is required"}, status=401
)
target_url = f"{api_url}/api/workflow/{workflow_id}/version/{version}"
try:
await ensure_client_session()
async with client_session.get(
target_url, headers={"Authorization": auth_header}
) as response:
json_data = await response.json()
return web.json_response(json_data, status=response.status)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
# for getting a machine by id
@server.PromptServer.instance.routes.get("/comfyui-deploy/machine")
async def get_machine_proxy(request):
@@ -3282,3 +3367,292 @@ async def get_comfyui_version_proxy(request):
return web.json_response(json_data, status=response.status)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
# Proxy: generate single-part upload URL
@server.PromptServer.instance.routes.post(
"/comfyui-deploy/volume/file/generate-upload-url"
)
async def proxy_generate_upload_url(request):
data = await request.json()
api_url = data.get("api_url", "https://api.comfydeploy.com")
auth_header = request.headers.get("Authorization")
if not auth_header:
return web.json_response(
{"error": "Authorization header is required"}, status=401
)
target_url = f"{api_url}/api/volume/file/generate-upload-url"
try:
await ensure_client_session()
async with client_session.post(
target_url,
json={
"filename": data.get("filename"),
"contentType": data.get("contentType"),
"size": data.get("size"),
},
headers={"Authorization": auth_header},
) as response:
json_data = await response.json()
return web.json_response(json_data, status=response.status)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
# Proxy: initiate multipart upload
@server.PromptServer.instance.routes.post(
"/comfyui-deploy/volume/file/initiate-multipart-upload"
)
async def proxy_initiate_multipart_upload(request):
data = await request.json()
api_url = data.get("api_url", "https://api.comfydeploy.com")
auth_header = request.headers.get("Authorization")
if not auth_header:
return web.json_response(
{"error": "Authorization header is required"}, status=401
)
target_url = f"{api_url}/api/volume/file/initiate-multipart-upload"
try:
await ensure_client_session()
async with client_session.post(
target_url,
json={
"filename": data.get("filename"),
"contentType": data.get("contentType"),
"size": data.get("size"),
},
headers={"Authorization": auth_header},
) as response:
json_data = await response.json()
return web.json_response(json_data, status=response.status)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
# Proxy: generate part upload URL
@server.PromptServer.instance.routes.post(
"/comfyui-deploy/volume/file/generate-part-upload-url"
)
async def proxy_generate_part_upload_url(request):
data = await request.json()
api_url = data.get("api_url", "https://api.comfydeploy.com")
auth_header = request.headers.get("Authorization")
if not auth_header:
return web.json_response(
{"error": "Authorization header is required"}, status=401
)
target_url = f"{api_url}/api/volume/file/generate-part-upload-url"
try:
await ensure_client_session()
async with client_session.post(
target_url,
json={
"uploadId": data.get("uploadId"),
"key": data.get("key"),
"partNumber": data.get("partNumber"),
},
headers={"Authorization": auth_header},
) as response:
json_data = await response.json()
return web.json_response(json_data, status=response.status)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
# Proxy: complete multipart upload
@server.PromptServer.instance.routes.post(
"/comfyui-deploy/volume/file/complete-multipart-upload"
)
async def proxy_complete_multipart_upload(request):
data = await request.json()
api_url = data.get("api_url", "https://api.comfydeploy.com")
auth_header = request.headers.get("Authorization")
if not auth_header:
return web.json_response(
{"error": "Authorization header is required"}, status=401
)
target_url = f"{api_url}/api/volume/file/complete-multipart-upload"
try:
await ensure_client_session()
async with client_session.post(
target_url,
json={
"uploadId": data.get("uploadId"),
"key": data.get("key"),
"parts": data.get("parts"),
},
headers={"Authorization": auth_header},
) as response:
json_data = await response.json()
return web.json_response(json_data, status=response.status)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
# Proxy: abort multipart upload
@server.PromptServer.instance.routes.post(
"/comfyui-deploy/volume/file/abort-multipart-upload"
)
async def proxy_abort_multipart_upload(request):
data = await request.json()
api_url = data.get("api_url", "https://api.comfydeploy.com")
auth_header = request.headers.get("Authorization")
if not auth_header:
return web.json_response(
{"error": "Authorization header is required"}, status=401
)
target_url = f"{api_url}/api/volume/file/abort-multipart-upload"
try:
await ensure_client_session()
async with client_session.post(
target_url,
json={
"uploadId": data.get("uploadId"),
"key": data.get("key"),
},
headers={"Authorization": auth_header},
) as response:
json_data = await response.json()
return web.json_response(json_data, status=response.status)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
# Proxy: add model (unified endpoint)
@server.PromptServer.instance.routes.post("/comfyui-deploy/volume/model")
async def proxy_add_model(request):
data = await request.json()
api_url = data.get("api_url", "https://api.comfydeploy.com")
auth_header = request.headers.get("Authorization")
if not auth_header:
return web.json_response(
{"error": "Authorization header is required"}, status=401
)
target_url = f"{api_url}/api/volume/model"
# pass body through but remove api_url key
forward_body = dict(data)
if "api_url" in forward_body:
forward_body.pop("api_url")
try:
await ensure_client_session()
async with client_session.post(
target_url, json=forward_body, headers={"Authorization": auth_header}
) as response:
json_data = await response.json()
return web.json_response(json_data, status=response.status)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
# FS: stat file (size)
@server.PromptServer.instance.routes.get("/comfyui-deploy/fs/stat")
async def fs_stat(request):
try:
import os
file_path = request.rel_url.query.get("path")
if not file_path:
return web.json_response({"error": "path is required"}, status=400)
# Basic safeguard: ensure it's a ComfyUI models path
if "/models/" not in file_path:
return web.json_response({"error": "invalid path"}, status=400)
st = os.stat(file_path)
return web.json_response({"size": st.st_size})
except FileNotFoundError:
return web.json_response({"error": "not found"}, status=404)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
# Upload a multipart part directly from machine filesystem to S3 upload URL
@server.PromptServer.instance.routes.post(
"/comfyui-deploy/volume/file/upload-part-from-path"
)
async def upload_part_from_path(request):
try:
import os
import io
data = await request.json()
file_path = data.get("filePath")
upload_url = data.get("uploadUrl")
start = int(data.get("start", 0))
end = int(data.get("end", 0))
if not file_path or not upload_url:
return web.json_response(
{"error": "filePath and uploadUrl are required"}, status=400
)
if "/models/" not in file_path:
return web.json_response({"error": "invalid filePath"}, status=400)
if end <= start:
return web.json_response({"error": "invalid byte range"}, status=400)
size = end - start
await ensure_client_session()
# Important: S3 pre-signed part uploads do not support chunked transfer
# Buffer the exact part into memory to provide a Content-Length header
buffer = bytearray()
chunk_size = 4 * 1024 * 1024
with open(file_path, "rb") as f:
f.seek(start)
remaining = size
while remaining > 0:
to_read = chunk_size if remaining >= chunk_size else remaining
chunk = f.read(to_read)
if not chunk:
break
buffer.extend(chunk)
remaining -= len(chunk)
if len(buffer) != size:
return web.json_response(
{
"error": "read size mismatch",
"expected": size,
"actual": len(buffer),
},
status=500,
)
headers = {
"Content-Length": str(size),
"Content-Type": "application/octet-stream",
}
async with client_session.put(
upload_url, data=bytes(buffer), headers=headers
) as resp:
text = await resp.text()
if resp.status < 200 or resp.status >= 300:
return web.json_response(
{"error": f"upload failed: {resp.status}", "body": text},
status=resp.status,
)
etag = resp.headers.get("ETag") or resp.headers.get("etag") or ""
etag = etag.replace('"', "")
return web.json_response({"eTag": etag, "bytesSent": size})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
+1
View File
@@ -18,6 +18,7 @@ class Status(Enum):
SUCCESS = "success"
FAILED = "failed"
UPLOADING = "uploading"
CANCELLED = "cancelled"
class StreamingPrompt(BaseModel):
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "comfyui-deploy"
description = "Open source comfyui deployment platform, a vercel for generative workflow infra."
version = "2.3.1"
version = "2.3.7"
license = { file = "LICENSE" }
dependencies = ["aiofiles", "pydantic", "opencv-python", "imageio-ffmpeg", "tabulate", "brotli"]
+1049 -208
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,7 +1,7 @@
// Machine Manager Script
// Handles local storage machine ID functionality
import { fetchSnapshot, fetchSnapshotSimple } from "./snapshot-utils.js";
import { fetchSnapshot } from "./snapshot-utils.js";
const MACHINE_STORAGE_KEY = "comfy_deploy_machine_id";
@@ -88,7 +88,7 @@ function showNoMachineMessage(machineLoading, machineList) {
<line x1="6" x2="6.01" y1="18" y2="18"/>
</svg>
</div>
<div style="font-weight: 500; margin-bottom: 12px;">No machine found</div>
<div style="font-weight: 500; margin-bottom: 12px; font-size: 12px;">No machine found</div>
<div style="display: flex; gap: 8px; justify-content: center;">
<button
onclick="showAddMachineDialog()"
File diff suppressed because it is too large Load Diff
-14
View File
@@ -66,17 +66,3 @@ export async function fetchSnapshot(getDataFn = null) {
throw error;
}
}
/**
* Simple snapshot fetch without ComfyUI version fallback
* Use this when you don't need the ComfyUI version fallback logic
*
* @returns {Promise<Object>} - The snapshot data as-is
*/
export async function fetchSnapshotSimple() {
const response = await fetch("/snapshot/get_current");
if (!response.ok) {
throw new Error(`Snapshot fetch failed: ${response.status}`);
}
return response.json();
}
File diff suppressed because it is too large Load Diff