diff --git a/__init__.py b/__init__.py
index 37ad24b..d411420 100755
--- a/__init__.py
+++ b/__init__.py
@@ -2,6 +2,8 @@ import hashlib
import io
import json
import os
+import platform
+import sys
import time
import aiohttp
import git
@@ -13,11 +15,13 @@ from typing import Callable
from aiohttp import web
from aiohttp_retry import ExponentialRetry, RetryClient
from tqdm.asyncio import tqdm
+from .exports import generate_export_json_file
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
+
WEB_DIRECTORY = "./web"
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
@@ -114,7 +118,7 @@ def find_file_paths(base_dir, file_names):
"""Find the paths of the files in the base directory."""
file_paths = {}
- for root, dirs, files in os.walk(base_dir):
+ for root, dirs, files in os.walk(base_dir, followlinks=True):
# Exclude certain directories
dirs[:] = [d for d in dirs if d not in ['.git']]
@@ -136,12 +140,18 @@ class CallbackBytesIO(io.BytesIO):
return data
DEPLOY_PROGRESS = {}
+EXPORT_PROGRESS = {}
@server.PromptServer.instance.routes.get("/cw/upload_progress")
async def api_comfyworkflows_upload_progress(request):
global DEPLOY_PROGRESS
return web.json_response(DEPLOY_PROGRESS)
+@server.PromptServer.instance.routes.get("/cw/export_progress")
+async def api_comfyworkflows_export_progress(request):
+ global EXPORT_PROGRESS
+ return web.json_response(EXPORT_PROGRESS)
+
UPLOAD_CHUNK_SIZE = 100_000_000 # 100 MB
def get_num_chunks(file_size):
@@ -151,6 +161,36 @@ def get_num_chunks(file_size):
num_chunks += 1
return num_chunks
+@server.PromptServer.instance.routes.get("/comfyui_interface")
+async def get_comfyui_interface(request):
+ print(os.path.join(server.PromptServer.instance.web_root, "comfyui_index.html"))
+ return web.FileResponse(os.path.join(server.PromptServer.instance.web_root, "comfyui_index.html"))
+
+@server.PromptServer.instance.routes.get("/cw/current_graph")
+async def api_comfyworkflows_current_graph(request):
+ current_file_directory = os.path.dirname(os.path.abspath(__file__))
+ current_graph_filepath = os.path.join(current_file_directory, "current_graph.json")
+ if not os.path.exists(current_graph_filepath):
+ return web.Response(status=404)
+ return web.json_response(json.load(open(current_graph_filepath, "r")))
+
+@server.PromptServer.instance.routes.post("/cw/save_graph")
+async def api_comfyworkflows_save_graph(request):
+ json_data = await request.json()
+ current_file_directory = os.path.dirname(os.path.abspath(__file__))
+ current_graph_filepath = os.path.join(current_file_directory, "current_graph.json")
+ with open(current_graph_filepath, "w") as f:
+ json.dump(json_data, f)
+ return web.Response(status=200)
+
+@server.PromptServer.instance.routes.post("/cw/reset_load_default_graph")
+async def api_comfyworkflows_reset_load_default_graph(request):
+ current_file_directory = os.path.dirname(os.path.abspath(__file__))
+ load_default_graph_filepath = os.path.join(current_file_directory, "load_default_graph.txt")
+ if os.path.exists(load_default_graph_filepath):
+ os.remove(load_default_graph_filepath)
+ return web.Response(status=200)
+
@server.PromptServer.instance.routes.post("/cw/upload")
async def api_comfyworkflows_upload(request):
global DEPLOY_PROGRESS
@@ -195,7 +235,7 @@ async def api_comfyworkflows_upload(request):
]
for folder in extra_folders_to_upload:
abs_folder_path = os.path.abspath(folder)
- for root, dirs, files in os.walk(abs_folder_path):
+ for root, dirs, files in os.walk(abs_folder_path, followlinks=True):
for file in files:
file_path = os.path.join(root, file)
file_checksum = get_file_sha256_checksum(file_path)
@@ -378,4 +418,231 @@ async def api_comfyworkflows_upload(request):
print(f"Successfully uploaded workflow: ", workflow_deploy_url)
# Now, return a json response with the workflow ID
- return web.json_response({"deploy_url": workflow_deploy_url})
\ No newline at end of file
+ return web.json_response({"deploy_url": workflow_deploy_url})
+
+
+@server.PromptServer.instance.routes.post("/cw/export")
+async def api_comfyworkflows_export(request):
+ global EXPORT_PROGRESS
+ print("Exporting workflow...")
+ json_data = await request.json()
+
+ prompt = json_data['prompt']
+ filteredNodeTypeToNodeData = json_data['filteredNodeTypeToNodeData']
+
+ # Example usage
+ base_directory = folder_paths.base_path #"./"
+
+ # Parse the JSON
+ parsed_json = prompt
+
+ EXPORT_PROGRESS = {
+ "status" : "preparing export...",
+ }
+
+ # TODO: For now, we assume that there are no duplicate files with the same name at 2 or more different paths.
+
+ # Extract file names
+ file_names = set(extract_file_names(parsed_json))
+ print("File names: ", file_names)
+
+ # Find file paths
+ file_paths = find_file_paths(base_directory, file_names)
+ print("File paths: ", file_paths)
+
+ all_file_info = {}
+ for file_name, file_path in file_paths.items():
+ file_checksum = get_file_sha256_checksum(file_path)
+ all_file_info[file_name] = {
+ 'path': file_path,
+ 'size': os.path.getsize(file_path),
+ 'dest_relative_path': os.path.relpath(file_path, base_directory),
+ 'checksum': file_checksum
+ }
+
+ extra_folders_to_upload = [
+ ]
+ for folder in extra_folders_to_upload:
+ abs_folder_path = os.path.abspath(folder)
+ for root, dirs, files in os.walk(abs_folder_path, followlinks=True):
+ for file in files:
+ file_path = os.path.join(root, file)
+ file_checksum = get_file_sha256_checksum(file_path)
+ all_file_info[file] = {
+ 'path': file_path,
+ 'size': os.path.getsize(file_path),
+ 'dest_relative_path': os.path.relpath(file_path, base_directory),
+ 'checksum': file_checksum
+ }
+
+ total_num_chunks = 0
+ for file_name, file_info in all_file_info.items():
+ num_chunks = get_num_chunks(file_info['size'])
+ total_num_chunks += num_chunks
+
+ EXPORT_PROGRESS = {
+ "status" : "creating snapshot...",
+ }
+
+ # Compute snapshot
+ # TODO: Support non-public custom nodes
+ snapshot_json = get_current_snapshot()
+
+ raise_for_status = {x for x in range(100, 600)}
+ raise_for_status.remove(200)
+ raise_for_status.remove(429)
+
+ pip_packages = []
+ installed_packages = pkg_resources.working_set
+ for package in installed_packages:
+ pip_package = package.__dict__
+ if "_provider" in pip_package:
+ del pip_package["_provider"]
+ if "location" in pip_package:
+ del pip_package["location"]
+ pip_packages.append(pip_package)
+
+ files_data = []
+
+ # First, create the runnable workflow object
+ async with aiohttp.ClientSession(trust_env=True, connector=aiohttp.TCPConnector(verify_ssl=False)) as session:
+ retry_client = RetryClient(session, retry_options=ExponentialRetry(attempts=3), raise_for_status=raise_for_status)
+ # Now, we upload each file
+ EXPORT_PROGRESS = {
+ "status" : f"uploading files... (0%)",
+ }
+ total_num_files = len(all_file_info)
+ current_file_index = -1
+ num_chunks_uploaded = 0
+ for file_name, file_info in all_file_info.items():
+ # print(f"Going to upload file: {file_name}...")
+ EXPORT_PROGRESS = {
+ "status" : f"uploading files... ({round(100.0 * num_chunks_uploaded / total_num_chunks, 2)}%)",
+ }
+
+ num_chunks_for_file = get_num_chunks(file_info['size'])
+ current_file_index += 1
+ async with retry_client.post(
+ f"{CW_ENDPOINT}/api/comfyui-launcher/get_presigned_url_for_launcher_export_file",
+ json={
+ "dest_relative_path" : file_info['dest_relative_path'],
+ "sha256_checksum": file_info['checksum'],
+ 'size': file_info['size'],
+ },
+ ) as resp:
+ assert resp.status == 200
+ upload_json = await resp.json()
+
+ if upload_json['uploadFile'] == False:
+ file_url = upload_json['file_url']
+
+ print(f"Skipping file {file_name} because it already exists in the cloud.")
+ num_chunks_uploaded += num_chunks_for_file
+
+ files_data.append([{
+ "download_url" : file_url,
+ "dest_relative_path" : file_info['dest_relative_path'],
+ "sha256_checksum" : file_info['checksum'],
+ "size" : file_info['size']
+ }])
+ continue
+
+ launcher_file_id = upload_json['launcher_file_id']
+ uploadId = upload_json['uploadId']
+ presigned_urls = upload_json['signedUrlsList']
+ objectKey = upload_json['objectKey']
+
+ t = time.time()
+
+ parts = []
+
+ progress_bar = tqdm(
+ desc=f"Uploading file ({(current_file_index + 1)}/{total_num_files}) {os.path.basename(file_info['path'])}",
+ unit="B",
+ unit_scale=True,
+ total=file_info['size'],
+ unit_divisor=1024,
+ )
+
+ with open(file_info['path'], "rb") as f:
+ chunk_index = 0
+ while True:
+ data = f.read(UPLOAD_CHUNK_SIZE)
+ if not data:
+ break
+
+ max_retries = 5
+ num_retries = 0
+ while num_retries < max_retries:
+ try:
+ async with retry_client.put(presigned_urls[chunk_index],data=data) as resp:
+ assert resp.status == 200
+ parts.append({
+ 'ETag': resp.headers['ETag'],
+ 'PartNumber': chunk_index + 1,
+ })
+ break
+ except:
+ num_retries += 1
+ # print(f"Failed to upload chunk {chunk_index} of file {file_name} to {presigned_urls[chunk_index]}... retrying ({num_retries}/{max_retries})")
+ if num_retries == max_retries:
+ raise Exception(f"Failed to upload file {os.path.basename(file_info['path'])} after {max_retries} retries.")
+
+ progress_bar.update(len(data))
+
+ chunk_index += 1
+
+ num_chunks_uploaded += 1
+ EXPORT_PROGRESS = {
+ "status" : f"uploading files... ({round(100.0 * num_chunks_uploaded / total_num_chunks, 2)}%)",
+ }
+
+ # Complete the multipart upload for this file
+ async with retry_client.post(
+ f"{CW_ENDPOINT}/api/comfyui-launcher/complete_multipart_upload_for_launcher_file",
+ json={
+ "parts": parts,
+ "objectKey": objectKey,
+ "uploadId": uploadId,
+ "launcher_file_id" : launcher_file_id
+ },
+ ) as resp:
+ assert resp.status == 200
+ resp_json = await resp.json()
+ file_url = resp_json['file_url']
+ # print("Upload took {0} seconds".format(time.time() - t))
+
+ files_data.append([{
+ "download_url" : file_url,
+ "dest_relative_path" : file_info['dest_relative_path'],
+ "sha256_checksum" : file_info['checksum'],
+ "size" : file_info['size']
+ }])
+
+ export_json = generate_export_json_file(
+ workflow_json=parsed_json,
+ snapshot_json=snapshot_json,
+ files_data=files_data,
+ pip_reqs=pip_packages,
+ os_type={
+ "name" : os.name,
+ "platform" : platform.system()
+ },
+ python_version={
+ "version" : platform.sys.version,
+ "version_info" : {
+ "major": sys.version_info.major,
+ "minor": sys.version_info.minor,
+ "micro": sys.version_info.micro,
+ "releaselevel": sys.version_info.releaselevel,
+ "serial": sys.version_info.serial,
+ }
+ }
+ )
+
+ EXPORT_PROGRESS = {}
+ print("\n\n")
+ print(f"Successfully exported workflow.")
+
+ # Now, return a json response with the workflow ID
+ return web.json_response(export_json)
\ No newline at end of file
diff --git a/exports.py b/exports.py
new file mode 100644
index 0000000..e21f17e
--- /dev/null
+++ b/exports.py
@@ -0,0 +1,13 @@
+SCHEMA_VERSION = "1.0"
+
+def generate_export_json_file(workflow_json, snapshot_json, files_data, pip_reqs, os_type, python_version):
+ return {
+ "format" : "comfyui_launcher",
+ "version" : SCHEMA_VERSION,
+ "workflow_json" : workflow_json,
+ "snapshot_json" : snapshot_json,
+ "files" : files_data,
+ "pip_requirements" : pip_reqs,
+ "os" : os_type,
+ "python_version" : python_version
+ }
\ No newline at end of file
diff --git a/web/js/cw.js b/web/js/cw.js
index dc45fa0..30b961c 100644
--- a/web/js/cw.js
+++ b/web/js/cw.js
@@ -1,5 +1,6 @@
import { app } from "../../../scripts/app.js";
import { api } from '../../../scripts/api.js'
+import {defaultGraph} from "../../../scripts/defaultGraph.js";
// import { ComfyWidgets } from "../../../scripts/widgets.js"
import { ComfyDialog, $el } from "../../../scripts/ui.js";
// import { ShareDialog, SUPPORTED_OUTPUT_NODE_TYPES, getPotentialOutputsAndOutputNodes } from "./comfyui-share.js";
@@ -26,6 +27,12 @@ docStyle.innerHTML = `
text-align: center;
height: 45px;
}
+.cw3-export-title {
+ padding: 10px 10px 0 10p;
+ background-color: black;
+ text-align: center;
+ height: 75px;
+}
`;
document.head.appendChild(docStyle);
@@ -69,6 +76,7 @@ const style = `
export var cw_instance = null;
export var cw_import_instance = null;
+export var cw_export_instance = null;
export function setCWInstance(obj) {
cw_instance = obj;
@@ -78,6 +86,10 @@ export function setCWImportInstance(obj) {
cw_import_instance = obj;
}
+export function setCWExportInstance(obj) {
+ cw_export_instance = obj;
+}
+
async function fetchNicknames() {
const response1 = await api.fetchApi(`/customnode/getmappings?mode=local`);
const mappings = await response1.json();
@@ -167,9 +179,11 @@ class CWMenuDialog extends ComfyDialog {
const content =
$el("div.cw3-menu-container", //"div.comfy-modal-content",
[
- $el("tr.cw3-title", { width: "100%", style: {
- padding: "10px 10px 10px 10px",
- } }, [
+ $el("tr.cw3-title", {
+ width: "100%", style: {
+ padding: "10px 10px 10px 10px",
+ }
+ }, [
$el("font", { size: 6, color: "white" }, [`Upload your workflow to ComfyWorkflows.com`]),
$el("br", {}, []),
$el("font", { size: 3, color: "white" }, [`This lets people easily run your workflow online & on their computer.`]),
@@ -179,8 +193,8 @@ class CWMenuDialog extends ComfyDialog {
// add "share key" input (required), "title" input (required), "description" input (optional)
// $el("div.cw3-menu-container", {width:"100%"}, [
$el("div.cw3-menu-container", [
- $el("p", { size: 3, color: "white", style: {color: "white"} }, ["Follow these steps to upload your workflow:"]),
- $el("ol", {style: {color: "white"}}, [
+ $el("p", { size: 3, color: "white", style: { color: "white" } }, ["Follow these steps to upload your workflow:"]),
+ $el("ol", { style: { color: "white" } }, [
$el("li", {}, ["Share your workflow online at ComfyWorkflows.com."]),
$el("li", {}, ["Go to your workflow's URL"]),
$el("li", {}, ["Click the 'Enable online workflow' or 'Update online workflow' button on the workflow's page."]),
@@ -326,6 +340,185 @@ class CWMenuDialog extends ComfyDialog {
}
}
+
+
+class CWExportMenuDialog extends ComfyDialog {
+ constructor() {
+ super();
+
+ this.final_message = $el("div", {
+ style: {
+ color: "white",
+ textAlign: "center",
+ // marginTop: "10px",
+ // backgroundColor: "black",
+ padding: "10px",
+ }
+ }, []);
+
+ this.deploy_button = $el("button", {
+ type: "submit",
+ textContent: "Export workflow",
+ style: {
+ backgroundColor: "blue"
+ }
+ }, []);
+
+ const close_button = $el("button", {
+ type: "button", textContent: "Close", onclick: () => {
+ // Reset state
+ this.deploy_button.textContent = "Export workflow";
+ this.deploy_button.style.display = "inline-block";
+ this.final_message.innerHTML = "";
+ this.final_message.style.color = "white";
+ this.close()
+ }
+ });
+
+ const content =
+ $el("div.cw3-menu-container", //"div.comfy-modal-content",
+ [
+ $el("tr.cw3-export-title", {
+ width: "100%", style: {
+ padding: "10px 10px 10px 10px",
+ }
+ }, [
+ $el("font", { size: 6, color: "white" }, [`Export your workflow`]),
+ $el("br", {}, []),
+ $el("font", { size: 3, color: "white" }, [`This will let anyone import & run this workflow with ZERO setup, using ComfyUI-Launcher.`]),
+ $el("br", {}, []),
+ // https://github.com/thecooltechguy/ComfyUI-Launcher
+ $el("font", { size: 2, color: "white" }, ["https://github.com/thecooltechguy/ComfyUI-Launcher"]),
+ ]),
+ $el("br", {}, []),
+ this.final_message,
+ $el("br", {}, []),
+ this.deploy_button,
+ close_button,
+ ],
+ );
+
+ this.deploy_button.onclick = async () => {
+ const prompt = await app.graphToPrompt();
+
+ const workflowNodes = prompt.workflow.nodes;
+ const filteredNodeTypeToNodeData = {};
+ for (const workflowNode of workflowNodes) {
+ const workflowNodeData = NODE_TYPE_X_NODE_DATA[workflowNode.type];
+ if (workflowNodeData) {
+ filteredNodeTypeToNodeData[workflowNode.type] = workflowNodeData;
+ }
+ }
+
+ // Change the text of the share button to "Sharing..." to indicate that the share process has started
+ this.deploy_button.textContent = "Exporting...";
+ this.final_message.style.color = "white"; //"green";
+ const initialFinalMessage = "This may take a few minutes. Please do not close this window. See the console for the export progress.";
+ this.final_message.innerHTML = initialFinalMessage;
+
+ // set an interval to call /cw/export_progress every 1 second to get the export progress and set the text of the final message
+ // cancel the interval once the /cw/export endpoint returns a response
+
+ const deployProgressInterval = setInterval(async () => {
+ const deployProgressResp = await api.fetchApi(`/cw/export_progress`, {
+ method: 'GET',
+ headers: { 'Content-Type': 'application/json' },
+ });
+
+ if (deployProgressResp.status == 200) {
+ try {
+ const deployProgressResp_json = await deployProgressResp.json();
+ const statusText = deployProgressResp_json.status;
+ if (statusText) {
+ this.final_message.innerHTML = initialFinalMessage + "
" + statusText;
+ }
+ } catch (e) {
+ // console.log(e);
+ }
+ }
+ }, 1_000);
+
+ const response = await api.fetchApi(`/cw/export`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ prompt,
+ filteredNodeTypeToNodeData
+ })
+ });
+
+ clearInterval(deployProgressInterval);
+
+ if (response.status != 200) {
+ try {
+ const response_json = await response.json();
+ if (response_json.error) {
+ alert(response_json.error);
+ this.deploy_button.textContent = "Export workflow";
+ this.deploy_button.style.display = "inline-block";
+ this.final_message.innerHTML = "";
+ this.final_message.style.color = "white";
+ this.close();
+ return;
+ } else {
+ alert("Failed to export your workflow. Please try again.");
+ this.deploy_button.textContent = "Export workflow";
+ this.deploy_button.style.display = "inline-block";
+ this.final_message.innerHTML = "";
+ this.final_message.style.color = "white";
+ this.close();
+ return;
+ }
+ } catch (e) {
+ alert("Failed to export your workflow. Please try again.");
+ this.deploy_button.textContent = "Export workflow";
+ this.deploy_button.style.display = "inline-block";
+ this.final_message.innerHTML = "";
+ this.final_message.style.color = "white";
+ this.close();
+ return;
+ }
+ }
+
+ const response_json = await response.json();
+
+ // trigger a download of a json file containing the response_json as content
+ const blob = new Blob([JSON.stringify(response_json)], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = 'comfyui-launcher.json';
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ URL.revokeObjectURL(url);
+
+ this.final_message.innerHTML = "Your workflow has been exported & downloaded to your computer (as a comfyui-launcher.json file). Now, anyone can run your workflow with ZERO setup using ComfyUI-Launcher.";
+ this.final_message.style.color = "white"; //"green";
+
+ // hide the share button
+ this.deploy_button.textContent = "Exported!";
+ this.deploy_button.style.display = "none";
+ }
+
+
+ content.style.width = '100%';
+ content.style.height = '100%';
+
+ this.element = $el("div.comfy-modal", { parent: document.body }, [content]);
+ this.element.style.width = '1000px';
+ // this.element.style.height = '400px';
+ this.element.style.zIndex = 10000;
+ }
+
+ show() {
+ this.element.style.display = "block";
+ }
+}
+
+
+
+
app.registerExtension({
name: "ComfyUI.ComfyWorkflows",
init() {
@@ -350,8 +543,41 @@ app.registerExtension({
setCWInstance(new CWMenuDialog());
cw_instance.show();
}
-
menu.append(deployButton);
+
+ const exportButton = document.createElement("button");
+ exportButton.textContent = "Export workflow (Launcher)";
+ exportButton.onclick = () => {
+ if (!cw_export_instance)
+ setCWExportInstance(new CWExportMenuDialog());
+ cw_export_instance.show();
+ }
+ menu.append(exportButton);
+
+ // if this is the first time the user is opening this project, load the default graph for this project
+ // this is necessary because the user may have previously run a different comfyui on the same port as this project, so the local storage would have that workflow's graph
+ const res = await api.fetchApi(`/cw/current_graph`, {
+ method: 'GET',
+ headers: { 'Content-Type': 'application/json' },
+ });
+ if (res.status === 200) {
+ const res_json = await res.json();
+ if (res_json) {
+ await app.loadGraphData(res_json);
+ } else {
+ await app.loadGraphData(defaultGraph);
+ }
+
+ // note how we only start the interval to save the graph to the server after the graph has been loaded initially
+ setInterval(async () => {
+ const graph = await app.graphToPrompt();
+ const res = await api.fetchApi(`/cw/save_graph`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(graph['workflow']),
+ });
+ }, 1_000);
+ }
},
async beforeRegisterNodeDef(nodeType, nodeData, app) {