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) {