Compare commits

..
72 Commits
Author SHA1 Message Date
bojiang 7254cbf641 Update README.md 2024-11-26 10:51:01 +08:00
bojiang cb56e5330b Update README.md 2024-11-26 10:44:49 +08:00
Frost Ming 0fe6323086 fix: build package button ui
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-25 18:52:51 +08:00
Frost Ming 9d1406e421 fix: add meta title
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-25 18:26:03 +08:00
Frost Ming e9772858ec fix: class_type match
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-25 18:22:51 +08:00
Frost Ming 251b2602ba Merge branch 'main' of github.com:bentoml/ComfyUI-IDL 2024-11-25 18:17:54 +08:00
Frost Ming d1723004d2 fix: add meta title
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-25 18:17:48 +08:00
bojiang 7565758b9d chore: model hash cache 2024-11-25 17:56:21 +08:00
bojiang bf4414bacf fix: copy inputs 2024-11-22 14:37:15 +00:00
Frost Ming d89731c1a1 Merge branch 'main' of github.com:bentoml/comfy-pack
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-22 18:54:09 +08:00
Frost Ming 660a8e6ba2 fix: use proxy instead of copy
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-22 18:53:05 +08:00
bojiang e3bd863ae7 fix: copy dir 2024-11-22 18:36:01 +08:00
bojiang bed78a3a41 fix: include dir 2024-11-22 18:31:52 +08:00
bojiang ff818e29a6 chore: skip exists during restore 2024-11-22 17:34:29 +08:00
bojiang 9c0197be7f fix: python path 2024-11-22 17:19:46 +08:00
bojiang bad78c37e8 fix: probe 2024-11-22 16:24:38 +08:00
bojiang 360a365963 fix: int node 2024-11-22 16:17:16 +08:00
bojiang 6179409369 fix: int input 2024-11-22 16:08:17 +08:00
bojiang 8ccc45168b fix: String input 2024-11-22 16:05:20 +08:00
bojiang 978b8b9ff0 chore: add IntInput 2024-11-22 16:03:47 +08:00
bojiang d7785478e5 feat: ramdom port; TextInput 2024-11-22 15:53:08 +08:00
bojiang bd14213f46 fix: filter out nodes without inputs 2024-11-22 10:22:21 +08:00
bojiang 2b0da4086a fix: remove model module 2024-11-22 09:55:08 +08:00
bojiang 1ea3be0749 fix: identifier 2024-11-22 09:38:53 +08:00
Frost Ming e4522362cb fix: get workspace location by snapshot checksum
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-22 08:21:45 +08:00
Frost Ming a8ec38e6fe fix: fix the file name of stored model
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-21 20:34:26 +08:00
bojiang f1d3af75eb fix: clean code 2024-11-21 20:25:40 +08:00
bojiang 9dd2ab639e fix(custom_node): call install.py 2024-11-21 20:24:20 +08:00
Frost Ming 016423c9f5 fix: invoke install.py if exists
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-21 20:03:51 +08:00
Frost Ming 6e3bd0d282 fix: add submodules
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-21 18:22:26 +08:00
Frost Ming d9e4d3b9f0 fix: get workspace
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-21 17:56:58 +08:00
Frost Ming 5259505cb6 fix: comfy workspace path
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-21 17:50:17 +08:00
Frost Ming 9628377e2c fix: don't recreate if exists
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-21 17:30:37 +08:00
Frost Ming 9d03466734 fix: install workspace
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-21 17:20:10 +08:00
Frost Ming e66465d572 fix: serialization value issue
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-21 17:05:08 +08:00
Frost Ming 4c9a6c2bbb fix: don't show duplicate modals
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-21 15:33:19 +08:00
Frost Ming 3a5fc6dfa1 Merge branch 'main' of github.com:bentoml/ComfyUI-IDL 2024-11-21 15:06:10 +08:00
Frost Ming f638a9ddcc fix: copyable bento name
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-21 15:06:04 +08:00
bojiang f05c5912a1 fix: use same model repo 2024-11-21 12:06:45 +08:00
Frost Ming ad2f6a67be fix: write snapshot
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-21 12:00:16 +08:00
Frost Ming 2c79b7bb69 fix: for new UI
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-21 11:50:49 +08:00
bojiang 20ce3fa584 fix: copy 2024-11-21 11:33:40 +08:00
Frost Ming c7b48c033b Feat/build (#3)
* feat: build bento

Signed-off-by: Frost Ming <me@frostming.com>

* fix bugs

Signed-off-by: Frost Ming <me@frostming.com>

* fix: refactor main.js

Signed-off-by: Frost Ming <me@frostming.com>

* feat: build bento button

Signed-off-by: Frost Ming <me@frostming.com>
2024-11-21 11:12:15 +08:00
bojiang 1f8ed12a56 chore: add model size 2024-11-20 21:01:29 +08:00
bojiang b7f49d6152 chore: add explicit flag to models 2024-11-20 17:15:44 +08:00
Frost Ming 7b9d37580b fix: use relative imports
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-20 15:52:47 +08:00
bojiang 3c02c51333 fix: comfy-pack restore 2024-11-20 15:24:47 +08:00
bojiang dbca147dbf chore: rename to comfy-pack 2024-11-20 15:03:59 +08:00
bojiang f4f1d2ce4b chore: rename to comfy-pack 2024-11-20 14:50:48 +08:00
bojiang 50165f80d6 chore: rename bento to cpack 2024-11-20 14:42:12 +08:00
bojiang 463d15656c chore: pretty output 2024-11-20 14:35:21 +08:00
bojiang e01c6bac12 chore: use verbose 2024-11-20 13:51:56 +08:00
bojiang 017c49f1af simplify temp dir 2024-11-20 12:25:48 +08:00
bojiang da6dfc5fd5 feat: more cli command 2024-11-20 12:01:04 +08:00
Frost Ming ff7a55a804 feat: add cli entrypoint
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-15 08:49:45 +08:00
bojiang f680263631 chore: install package 2024-11-15 05:59:12 +08:00
bojiang 704937f2b9 chore: filter used inputs only 2024-11-14 20:49:26 +08:00
bojiang 3b016e2fa4 chore: clean 2024-11-14 20:34:33 +08:00
bojiang 1e946b4e48 chore: input filename 2024-11-14 19:59:36 +08:00
agent 21b13b56c0 chore: add packaging progress bar 2024-11-14 17:06:17 +08:00
Frost Ming 7b9f0ec026 fix whitespaces
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-14 10:38:02 +08:00
Frost Ming cf8ea2b875 feat: collect comfyui hash, models, custom nodes into snapshot.json (#2)
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-14 10:36:03 +08:00
Frost Ming 20bd7b379d fix: disable button when processing
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-13 20:00:36 +08:00
Frost Ming 64a8f7f712 fix: get node info
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-13 19:10:03 +08:00
Frost Ming 1a24cd2343 fix: clean older files
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-13 19:02:13 +08:00
Frost Ming 27eb860c93 feat: package workspace
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-13 18:18:47 +08:00
Frost Ming deb787096f fix: rename input node names
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-13 11:38:24 +08:00
Frost Ming b6d712ec1c fix: get widget metadata and construct correct input model
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-13 09:59:48 +08:00
Frost Ming 0b1341a5a3 Merge branch 'main' of github.com:bentoml/ComfyUI-IDL
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-12 18:19:27 +08:00
Frost Ming 2b01701d0a feat: dynamic node to retrieve the metadata
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-12 18:05:43 +08:00
bojiang 7ecf35c76d chore: prompt users to save api format 2024-11-11 18:52:22 +08:00
Zhao Shenyang 5f7c3d8a10 fix: use urllib instead of requests 2024-11-11 17:33:06 +08:00
17 changed files with 3188 additions and 256 deletions
+1
View File
@@ -160,3 +160,4 @@ cython_debug/
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
/temp/
+72 -2
View File
@@ -1,3 +1,73 @@
# ComfyUI-IDL
# ComfyPack
ComfyUI custom nodes for input and output specification.
A comprehensive toolkit for standardizing, packaging and deploying ComfyUI workflows as reproducible environments and production-ready REST services.
## Features
- **Package Everything**: Create reproducible `.cpack.zip` files containing your workflow, custom nodes, model versions, and all dependencies
- **Standardize Parameters**: Define and validate workflow inputs through UI nodes for images, text, numbers and more
- **CLI Support**: Restore environment and run inference from command line
- **REST API Generation**: Auto-convert any workflow into REST service with OpenAPI docs
## Quick Start
### Installation
```bash
pip install comfy-pack
```
### Create a Pack
1. Install ComfyPack custom nodes in ComfyUI
2. Design your workflow with parameter nodes
3. Click "Package" button to create `.cpack.zip`
### Restore to a ComfyUI project
```bash
# Restore environment from pack, will install everything needed except the models.
comfy-pack restore workflow.cpack.zip --dir ./
```
### Run Inference
```bash
# Get the workflow input spec
comfy-pack info workflow.cpack.zip
# Run
comfy-pack run workflow.cpack.zip --src-image image.png --video video.mp4
```
### Online REST service
under development
## Parameter Nodes
ComfyPack provides custom nodes for standardizing inputs:
- ImageInput
- StringInput
- IntInput
- AnyInput
- ImageOutput
- FileOutput
- ...
These nodes help define clear interfaces for your workflow.
## Docker Support
Under development
## Examples
Check our [examples folder](examples/) for:
- Basic workflow packaging
- Parameter configuration
- API integration
- Docker deployment
## License
MIT License
## Community
- Issues & Feature Requests: GitHub Issues
- Questions & Discussion: Discord Server
Detailed documentation: under development
+15 -1
View File
@@ -1,3 +1,17 @@
import os
import subprocess
import sys
from .nodes import api # noqa
from .nodes.nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
WEB_DIRECTORY = "./web"
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
print("Comfy-Pack => Installing Python dependencies")
subprocess.check_call(
[sys.executable, "-m", "pip", "install", "-r", "requirements.txt"],
stdout=subprocess.DEVNULL,
cwd=os.path.dirname(__file__),
)
+324
View File
@@ -0,0 +1,324 @@
from __future__ import annotations
import asyncio
import hashlib
import json
import os
import shutil
import subprocess
import sys
import tempfile
import time
import uuid
import zipfile
from pathlib import Path
from typing import Union
import folder_paths
from aiohttp import web
from server import PromptServer
ZPath = Union[Path, zipfile.Path]
TEMP_FOLDER = Path(__file__).parent.parent / "temp"
COMFY_PACK_DIR = Path(__file__).parent.parent / "src/comfy_pack"
EXCLUDE_PACKAGES = ["bentoml", "onnxruntime"] # TODO: standardize this
async def _write_requirements(path: ZPath, extras: list[str] | None = None) -> None:
print("Package => Writing requirements.txt")
with path.joinpath("requirements.txt").open("w") as f:
proc = await asyncio.subprocess.create_subprocess_exec(
sys.executable,
"-m",
"pip",
"freeze",
"--exclude-editable",
*[f"--exclude={p}" for p in EXCLUDE_PACKAGES],
stdout=subprocess.PIPE,
)
stdout, _ = await proc.communicate()
f.write(stdout.decode().rstrip("\n") + "\n")
if extras:
f.write("\n".join(extras) + "\n")
async def _write_snapshot(path: ZPath, data: dict, models: list | None = None) -> None:
proc = await asyncio.subprocess.create_subprocess_exec(
"git", "rev-parse", "HEAD", stdout=subprocess.PIPE, cwd=folder_paths.base_path
)
stdout, _ = await proc.communicate()
if models is None:
models = await _get_models(data)
with path.joinpath("snapshot.json").open("w") as f:
data = {
"python": f"{sys.version_info.major}.{sys.version_info.minor}",
"comfyui": stdout.decode().strip(),
"models": models,
"custom_nodes": await _get_custom_nodes(),
}
f.write(json.dumps(data, indent=2))
def _get_file_info_key(file_path: Path) -> tuple:
# use mtime, size to determine if a file has changed
return (file_path.stat().st_mtime, file_path.stat().st_size)
def _get_hash(file_path: Path) -> str:
cpack_hash_cache_file = TEMP_FOLDER / "model_sha_cache.json"
try:
with cpack_hash_cache_file.open("r") as f:
cache = json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
cache = {}
mtime, size = _get_file_info_key(file_path)
if file_path.absolute().as_posix() in cache:
if (
cache[file_path.absolute().as_posix()]["mtime"] == mtime
and cache[file_path.absolute().as_posix()]["size"] == size
):
return cache[file_path.absolute().as_posix()]["sha256"]
with cpack_hash_cache_file.open("w") as f:
hasher = hashlib.sha256()
with open(file_path, "rb") as file:
for chunk in iter(lambda: file.read(4096), b""):
hasher.update(chunk)
sha = hasher.hexdigest()
cache[file_path.absolute().as_posix()] = {
"sha256": sha,
"mtime": mtime,
"size": size,
}
json.dump(cache, f)
return sha
async def _get_models(data: dict, store_models: bool = False) -> list:
print("Package => Writing models")
proc = await asyncio.subprocess.create_subprocess_exec(
"git",
"ls-files",
"--others",
folder_paths.models_dir,
stdout=subprocess.PIPE,
)
stdout, _ = await proc.communicate()
used_inputs = set()
for node in data["workflow_api"].values():
for _, v in node["inputs"].items():
if isinstance(v, str):
used_inputs.add(v)
models = []
for line in stdout.decode().splitlines():
if os.path.basename(line).startswith("."):
continue
filename = os.path.abspath(line)
relpath = os.path.relpath(filename, folder_paths.base_path)
relpath_path = Path(relpath)
model_data = {
"filename": relpath,
"sha256": _get_hash(Path(filename)),
"explicit": relpath_path.name in used_inputs,
"size": os.path.getsize(filename),
}
if store_models:
import bentoml
model_tag = f'cpack-model:{model_data["sha256"][:16]}'
try:
model = bentoml.models.get(model_tag)
except bentoml.exceptions.NotFound:
with bentoml.models.create(
model_tag,
labels={"filename": filename},
) as model:
shutil.copy(filename, model.path_of("model.bin"))
model_data["model_tag"] = model_tag
models.append(model_data)
return models
async def _get_custom_nodes() -> list:
print("Package => Writing custom nodes")
custom_nodes = os.path.join(folder_paths.base_path, "custom_nodes")
coros = []
async def get_node_info(subdir: Path) -> dict:
proc = await asyncio.subprocess.create_subprocess_exec(
"git",
"config",
"--get",
"remote.origin.url",
cwd=subdir,
stdout=subprocess.PIPE,
)
stdout, _ = await proc.communicate()
url = stdout.decode().strip()
proc = await asyncio.subprocess.create_subprocess_exec(
"git",
"rev-parse",
"HEAD",
cwd=subdir,
stdout=subprocess.PIPE,
)
stdout, _ = await proc.communicate()
commit_hash = stdout.decode().strip()
return {
"url": url,
"commit_hash": commit_hash,
"disabled": subdir.name.endswith(".disabled"),
}
for subdir in Path(custom_nodes).iterdir():
if not subdir.is_dir() or not subdir.joinpath(".git").exists():
continue
coros.append(get_node_info(subdir))
return await asyncio.gather(*coros)
async def _write_workflow(path: ZPath, data: dict) -> None:
print("Package => Writing workflow")
with path.joinpath("workflow_api.json").open("w") as f:
f.write(json.dumps(data["workflow_api"], indent=2))
with path.joinpath("workflow.json").open("w") as f:
f.write(json.dumps(data["workflow"], indent=2))
async def _write_inputs(path: ZPath, data: dict) -> None:
print("Package => Writing inputs")
if isinstance(path, Path):
path.joinpath("input").mkdir(exist_ok=True)
input_dir = folder_paths.get_input_directory()
used_inputs = set()
for node in data["workflow_api"].values():
for _, v in node["inputs"].items():
if isinstance(v, str):
used_inputs.add(v)
src_root = Path(input_dir).absolute()
for src in src_root.glob("**/*"):
rel = src.relative_to(src_root)
if src.is_dir():
if isinstance(path, Path):
path.joinpath("input").joinpath(rel).mkdir(parents=True, exist_ok=True)
if src.is_file():
with path.joinpath("input").joinpath(rel).open("wb") as f:
with open(src, "rb") as input_file:
shutil.copyfileobj(input_file, f)
@PromptServer.instance.routes.post("/bentoml/pack")
async def pack_workspace(request):
data = await request.json()
TEMP_FOLDER.mkdir(exist_ok=True)
older_than_1h = time.time() - 60 * 60
for file in TEMP_FOLDER.iterdir():
if file.is_file() and file.stat().st_ctime < older_than_1h:
file.unlink()
zip_filename = f"{uuid.uuid4()}.zip"
with zipfile.ZipFile(TEMP_FOLDER / zip_filename, "w") as zf:
path = zipfile.Path(zf)
await _write_requirements(path)
await _write_snapshot(path, data)
await _write_workflow(path, data)
await _write_inputs(path, data)
return web.json_response({"download_url": f"/bentoml/download/{zip_filename}"})
@PromptServer.instance.routes.get("/bentoml/download/{zip_filename}")
async def download_workspace(request):
zip_filename = request.match_info["zip_filename"]
return web.FileResponse(TEMP_FOLDER / zip_filename)
@PromptServer.instance.routes.post("/bentoml/build")
async def build_bento(request):
"""Request body: {
workflow_api: dict,
workflow: dict,
bento_name: str,
push?: bool,
api_key?: str,
endpoint?: str,
system_packages?: list[str]
}"""
import bentoml
data = await request.json()
with tempfile.TemporaryDirectory(suffix="-bento", prefix="comfy-pack-") as temp_dir:
temp_dir_path = Path(temp_dir)
# copy comfy_pack source code into the bento
shutil.copytree(COMFY_PACK_DIR, temp_dir_path / COMFY_PACK_DIR.name)
models = await _get_models(data, store_models=True)
await _write_requirements(temp_dir_path, ["comfy-cli", "fastapi"])
await _write_snapshot(temp_dir_path, data, models)
await _write_workflow(temp_dir_path, data)
await _write_inputs(temp_dir_path, data)
shutil.copy(
Path(__file__).with_name("service.py"),
temp_dir_path / "service.py",
)
# create a bento
try:
bento = bentoml.build(
"service:ComfyService",
name=data["bento_name"],
build_ctx=temp_dir,
models=[m["model_tag"] for m in models if "model_tag" in m],
docker={
"python_version": f"{sys.version_info.major}.{sys.version_info.minor}",
"system_packages": [
"git",
"libglib2.0-0",
"libsm6",
"libxrender1",
"libxext6",
"ffmpeg",
"libstdc++-12-dev",
*data.get("system_packages", []),
],
},
python={"requirements_txt": "requirements.txt", "lock_packages": True},
)
except bentoml.exceptions.BentoMLException as e:
return web.json_response(
{
"result": "error",
"error": f"Build failed: {e.__class__.__name__}: {e}",
},
)
if data.get("push", False):
credentials = {}
if api_key := data.get("api_key"):
credentials["api_key"] = api_key
if endpoint := data.get("endpoint"):
credentials["endpoint"] = endpoint
client = bentoml.cloud.BentoCloudClient(**credentials)
try:
client.bento.push(bento)
except bentoml.exceptions.BentoMLException as e:
return web.json_response(
{
"result": "error",
"error": f"Push failed: {e.__class__.__name__}: {e}",
}
)
return web.json_response({"result": "success", "bento": str(bento.tag)})
+7 -7
View File
@@ -2,8 +2,8 @@ from threading import Lock
import execution
BENTOML_LAST_ID = None
BENTOML_OUTPUT_CACHE = {}
CPACK_LAST_ID = None
CPACK_OUTPUT_CACHE = {}
_lock = Lock()
@@ -12,12 +12,12 @@ def store_bentoml_value(func):
def wrapped(
inputs, class_def, unique_id, outputs=None, dynprompt=None, extra_data={}
):
global BENTOML_LAST_ID
if getattr(class_def, "BENTOML_NODE", False):
global CPACK_LAST_ID
if getattr(class_def, "CPACK_NODE", False):
with _lock:
BENTOML_LAST_ID = unique_id
CPACK_LAST_ID = unique_id
if outputs is None:
outputs = BENTOML_OUTPUT_CACHE
outputs = CPACK_OUTPUT_CACHE
return func(inputs, class_def, unique_id, outputs, dynprompt, extra_data)
return wrapped
@@ -28,4 +28,4 @@ execution.get_input_data = store_bentoml_value(execution.get_input_data)
def set_bentoml_output(output):
with _lock:
BENTOML_OUTPUT_CACHE[BENTOML_LAST_ID] = output
CPACK_OUTPUT_CACHE[CPACK_LAST_ID] = output
+114 -136
View File
@@ -1,6 +1,7 @@
import hashlib
import json
import os
import sys
import shutil
import folder_paths
@@ -22,7 +23,7 @@ class AnyType(str):
anytype = AnyType("*") # when a != b is called, it will always return False
class OutputPath:
class OutputFile:
COLOR = (142, 36, 170)
OUTPUT_NODE = True
@@ -31,15 +32,15 @@ class OutputPath:
return {
"required": {
"filename": ("STRING", {"default": "", "forceInput": True}),
"filename_prefix": ("STRING", {"default": "ComfyUI_IDL_"}),
"filename_prefix": ("STRING", {"default": "cpack_output_"}),
},
}
RETURN_TYPES = ()
CATEGORY = "ComfyUI-IDL/output"
BENTOML_NODE = True
CATEGORY = "ComfyPack/output"
CPACK_NODE = True
FUNCTION = "save"
DESCRIPTION = "Save the input data for IDL output"
DESCRIPTION = "Save the input data for comfy-pack output"
def save(self, filename, filename_prefix):
if not filename_prefix:
@@ -109,7 +110,7 @@ class OutputImage:
return {
"required": {
"images": ("IMAGE", {"tooltip": "The images to save."}),
"filename_prefix": ("STRING", {"default": "ComfyUI_IDL_"}),
"filename_prefix": ("STRING", {"default": "cpack_output_"}),
},
"hidden": {
"prompt": "PROMPT",
@@ -119,14 +120,14 @@ class OutputImage:
RETURN_TYPES = ()
FUNCTION = "save_images"
BENTOML_NODE = True
CPACK_NODE = True
OUTPUT_NODE = True
CATEGORY = "ComfyUI-IDL/output"
CATEGORY = "ComfyPack/output"
DESCRIPTION = "Saves the input images to your ComfyUI output directory."
def save_images(
self, images, filename_prefix="ComfyUI_IDL_", prompt=None, extra_pnginfo=None
self, images, filename_prefix="cpack_output_", prompt=None, extra_pnginfo=None
):
filename_prefix += self.prefix_append
full_output_folder, filename, counter, subfolder, filename_prefix = (
@@ -177,10 +178,9 @@ class ImageInput:
"required": {"image": (sorted(files), {"image_upload": True})},
}
CATEGORY = "ComfyUI-IDL/input"
BENTOML_NODE = True
CATEGORY = "ComfyPack/input"
CPACK_NODE = True
RETURN_TYPES = ("IMAGE", "MASK")
RETUEN_NAMES = ("image", "mask")
FUNCTION = "load_image"
def load_image(self, image):
@@ -243,111 +243,7 @@ class ImageInput:
return True
class StringInput:
COLOR = (142, 36, 170)
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"value": ("STRING", {"default": ""}),
}
}
RETUEN_NAMES = ("string",)
RETURN_TYPES = (anytype,)
BENTOML_NODE = True
FUNCTION = "string_input"
CATEGORY = "ComfyUI-IDL/input"
def string_input(self, value):
return (value,)
@classmethod
def VALIDATE_INPUTS(s, value):
set_bentoml_output([(value,)])
return True
class IntegerInput:
COLOR = (142, 36, 170)
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"value": ("INT", {"default": 0}),
}
}
RETUEN_NAMES = ("int",)
RETURN_TYPES = (anytype,)
FUNCTION = "identity"
BENTOML_NODE = True
CATEGORY = "ComfyUI-IDL/input"
def identity(self, value):
return (value,)
@classmethod
def VALIDATE_INPUTS(s, value):
set_bentoml_output([(value,)])
return True
class FloatInput:
COLOR = (142, 36, 170)
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"value": ("FLOAT", {"default": 0.0}),
}
}
RETUEN_NAMES = ("float",)
RETURN_TYPES = (anytype,)
FUNCTION = "identity"
BENTOML_NODE = True
CATEGORY = "ComfyUI-IDL/input"
def identity(self, value):
return (value,)
@classmethod
def VALIDATE_INPUTS(s, value):
set_bentoml_output([(value,)])
return True
class BooleanInput:
COLOR = (142, 36, 170)
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"value": ("BOOLEAN", {"default": False}),
}
}
RETUEN_NAMES = ("bool",)
RETURN_TYPES = (anytype,)
FUNCTION = "identity"
CATEGORY = "ComfyUI-IDL/input"
BENTOML_NODE = True
def identity(self, value):
return (value,)
@classmethod
def VALIDATE_INPUTS(s, value):
set_bentoml_output([(value,)])
return True
class PathInput:
class FileInput:
COLOR = (142, 36, 170)
@classmethod
@@ -359,10 +255,10 @@ class PathInput:
}
RETURN_TYPES = (anytype,)
RETUEN_NAMES = ("path",)
RETURN_NAMES = ("path",)
FUNCTION = "identity"
BENTOML_NODE = True
CATEGORY = "ComfyUI-IDL/input"
CPACK_NODE = True
CATEGORY = "ComfyPack/input"
def identity(self, path):
return (path,)
@@ -373,24 +269,106 @@ class PathInput:
return True
class StringInput:
COLOR = (142, 36, 170)
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"value": ("STRING", {"default": ""}),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("value",)
FUNCTION = "identity"
CPACK_NODE = True
CATEGORY = "ComfyPack/input"
def identity(self, value):
return (value,)
@classmethod
def VALIDATE_INPUTS(s, value):
set_bentoml_output([(value,)])
return True
class IntInput:
COLOR = (142, 36, 170)
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"value": ("INT", {"default": 0}),
},
"optional": {
"min": ("INT", {"default": -sys.maxsize}),
"max": ("INT", {"default": sys.maxsize}),
},
}
RETURN_TYPES = ("INT",)
RETURN_NAMES = ("value",)
FUNCTION = "identity"
CPACK_NODE = True
CATEGORY = "ComfyPack/input"
def identity(self, value, min=None, max=None):
return (value,)
@classmethod
def VALIDATE_INPUTS(s, value, min=None, max=None):
if min is not None and max is not None and min > max:
return f"Value must be greater than or equal to {min}"
set_bentoml_output([(value,)])
return True
class AnyInput:
COLOR = (142, 36, 170)
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"input": ("*", {"default": ""}),
}
}
RETURN_TYPES = (anytype,)
RENAME = ("value",)
FUNCTION = "identity"
CPACK_NODE = True
CATEGORY = "ComfyPack/input"
def identity(self, input):
return (input,)
@classmethod
def VALIDATE_INPUTS(s, input):
set_bentoml_output([(input,)])
return True
NODE_CLASS_MAPPINGS = {
"BentoOutputPath": OutputPath,
"BentoOutputImage": OutputImage,
"BentoInputImage": ImageInput,
"BentoInputString": StringInput,
"BentoInputInteger": IntegerInput,
"BentoInputFloat": FloatInput,
"BentoInputBoolean": BooleanInput,
"BentoInputPath": PathInput,
"CPackOutputFile": OutputFile,
"CPackOutputImage": OutputImage,
"CPackInputImage": ImageInput,
"CPackInputString": StringInput,
"CPackInputInt": IntInput,
"CPackInputFile": FileInput,
"CPackInputAny": AnyInput,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"BentoInputImage": "Image Input",
"BentoInputPath": "Path Input",
"BentoInputInteger": "Integer Input",
"BentoInputFloat": "Float Input",
"BentoInputBoolean": "Boolean Input",
"BentoInputString": "String Input",
"BentoOutputImage": "Image Output",
"BentoOutputPath": "Path Output",
"CPackInputImage": "Image Input",
"CPackInputString": "String Input",
"CPackInputInt": "Int Input",
"CPackInputFile": "File Input",
"CPackInputAny": "Any Input",
"CPackOutputImage": "Image Output",
"CPackOutputFile": "File Output",
}
+121
View File
@@ -0,0 +1,121 @@
from __future__ import annotations
import json
import logging
import os
import shutil
from functools import lru_cache
from pathlib import Path
from typing import Any, cast
import bentoml
import fastapi
import comfy_pack
import comfy_pack.run
REQUEST_TIMEOUT = 360
BASE_DIR = Path(__file__).parent
WORKFLOW_FILE = BASE_DIR / "workflow_api.json"
COPY_THRESHOLD = 10 * 1024 * 1024
INPUT_DIR = BASE_DIR / "input"
logger = logging.getLogger("bentoml.service")
with open(WORKFLOW_FILE, "r") as f:
workflow = json.load(f)
InputModel = comfy_pack.generate_input_model(workflow)
app = fastapi.FastAPI()
@lru_cache
def _get_workspace() -> Path:
import hashlib
from bentoml._internal.configuration.containers import BentoMLContainer
snapshot = BASE_DIR / "snapshot.json"
checksum = hashlib.md5(snapshot.read_bytes()).hexdigest()
wp = Path(BentoMLContainer.bentoml_home.get()) / "run/comfy_workspace" / checksum
wp.parent.mkdir(parents=True, exist_ok=True)
return wp
@app.get("/workflow.json")
def workflow_json():
return workflow
@bentoml.mount_asgi_app(app, path="/comfy")
@bentoml.service(traffic={"timeout": REQUEST_TIMEOUT * 2})
class ComfyService:
def __init__(self):
self.comfy_proc = comfy_pack.run.WorkflowRunner(
str(_get_workspace()),
str(INPUT_DIR),
)
self.comfy_proc.start(verbose=int("BENTOML_DEBUG" in os.environ))
@bentoml.api(input_spec=InputModel)
def generate(
self,
*,
ctx: bentoml.Context,
**kwargs: Any,
) -> Path:
verbose = int("BENTOML_DEBUG" in os.environ)
ret = self.comfy_proc.run_workflow(
workflow,
output_dir=ctx.temp_dir,
timeout=REQUEST_TIMEOUT,
verbose=verbose,
**kwargs,
)
if isinstance(ret, list):
ret = ret[-1]
return ret
@bentoml.on_shutdown
def on_shutdown(self):
self.comfy_proc.stop()
@bentoml.on_deployment
@staticmethod
def prepare_comfy_workspace():
from comfy_pack.package import install_comfyui, install_custom_modules
verbose = int("BENTOML_DEBUG" in os.environ)
comfy_workspace = _get_workspace()
with BASE_DIR.joinpath("snapshot.json").open("rb") as f:
snapshot = json.load(f)
if not comfy_workspace.joinpath(".DONE").exists():
if comfy_workspace.exists():
logger.info("Removing existing workspace")
shutil.rmtree(comfy_workspace, ignore_errors=True)
install_comfyui(snapshot, comfy_workspace, verbose=verbose)
for model in snapshot["models"]:
model_tag = model.get("model_tag")
if not model_tag:
logger.warning(
"Model %s is not in model store, the workflow may not work",
model["filename"],
)
continue
model_path = comfy_workspace / cast(str, model["filename"])
model_path.parent.mkdir(parents=True, exist_ok=True)
bento_model = bentoml.models.get(model_tag)
model_file = bento_model.path_of("model.bin")
logger.info("Copying %s to %s", model_file, model_path)
model_path.symlink_to(model_file)
for f in INPUT_DIR.glob("*"):
if f.is_file():
shutil.copy(f, comfy_workspace / "input" / f.name)
elif f.is_dir():
shutil.copytree(f, comfy_workspace / "input" / f.name)
install_custom_modules(snapshot, comfy_workspace, verbose=verbose)
comfy_workspace.joinpath(".DONE").touch()
+6 -1
View File
@@ -1,10 +1,12 @@
[project]
name = "comfyui-idl"
name = "comfy-pack"
description = "ComfyUI Interface Definition Language"
authors = [{ name = "Frost Ming", email = "frost@bentoml.com" }]
readme = "README.md"
requires-python = ">=3.9"
dependencies = [
"bentoml>=1.3.13",
"click>=8.1.7",
"comfy-cli>=1.2.8",
"pydantic>=2.9",
]
@@ -19,6 +21,9 @@ classifiers = [
[project.urls]
Homepage = "https://github.com/bentoml/ComfyUI-IDL"
[project.scripts]
comfy-pack = "comfy_pack.cli:main"
[build-system]
requires = ["pdm-backend"]
build-backend = "pdm.backend"
+2
View File
@@ -0,0 +1,2 @@
bentoml
fastapi
@@ -1,12 +1,11 @@
from comfyui_idl.utils import (
parse_workflow,
from .run import WorkflowRunner
from .utils import (
generate_input_model,
parse_workflow,
populate_workflow,
retrieve_workflow_outputs,
)
from comfyui_idl.run import WorkflowRunner
__all__ = [
"WorkflowRunner",
"parse_workflow",
+197
View File
@@ -0,0 +1,197 @@
import click
import hashlib
import functools
import json
from pathlib import Path
import shutil
import tempfile
@click.group()
@click.option(
"--verbose",
"-v",
count=True,
help="Increase verbosity level (use multiple times for more verbosity)",
)
@click.pass_context
def main(ctx, verbose):
"""ComfyUI Pack CLI"""
ctx.ensure_object(dict)
ctx.obj["verbose"] = verbose
@main.command(
name="restore",
help="Restore the ComfyUI workspace to specified directory",
)
@click.argument("cpack", type=click.Path(exists=True, dir_okay=False))
@click.option(
"--dir",
"-d",
default="ComfyUI",
help="target directory to restore the ComfyUI project",
type=click.Path(file_okay=False),
)
@click.pass_context
def restore_cmd(ctx, cpack: str, dir: str):
from .package import install
from rich.console import Console
console = Console()
install(cpack, dir, ctx.obj["verbose"])
console.print("\n[green]✓ ComfyUI Workspace is restored![/green]")
console.print(f"{dir}")
console.print(
"\n[green] Next steps: [/green]\n"
"1. Change directory to the restored workspace\n"
"2. Source the virtual environment by running `source .venv/bin/activate`\n"
"3. Run the ComfyUI project by running `python main.py`"
)
def _print_schema(schema, verbose: int = 0):
from rich.table import Table
from rich.console import Console
table = Table(title="")
# Add columns
table.add_column("Input", style="cyan")
table.add_column("Type", style="green")
table.add_column("Required", style="yellow")
table.add_column("Default", style="blue")
table.add_column("Range", style="magenta")
# Get required fields
required = schema.get("required", [])
# Add rows
for field, info in schema["properties"].items():
range_str = ""
if "minimum" in info or "maximum" in info:
min_val = info.get("minimum", "")
max_val = info.get("maximum", "")
range_str = f"{min_val} to {max_val}"
table.add_row(
field,
info.get("format", "") or info.get("type", ""),
"✓" if field in required else "",
str(info.get("default", "")),
range_str,
)
Console().print(table)
@main.command(name="info")
@click.argument("cpack", type=click.Path(exists=True, dir_okay=False))
@click.pass_context
def info_cmd(ctx, cpack: str):
"""
Display information about the ComfyUI package.
Example:
$ comfy_pack info workspace.cpack.zip
"""
from .utils import generate_input_model
with tempfile.TemporaryDirectory() as temp_dir:
pack_dir = Path(temp_dir) / ".cpack"
shutil.unpack_archive(cpack, pack_dir)
workflow = json.loads((pack_dir / "workflow_api.json").read_text())
inputs = generate_input_model(workflow)
_print_schema(inputs.model_json_schema(), ctx.obj["verbose"])
@functools.lru_cache
def _get_cache_workspace(cpack: str):
m = hashlib.sha256()
with open(cpack, "rb") as f:
m.update(f.read())
return Path.home() / ".comfypack" / "workspace" / m.hexdigest()[0:8]
@main.command(
context_settings={
"ignore_unknown_options": True,
"allow_extra_args": True,
},
help="Run a ComfyUI package with the given inputs",
)
@click.argument("cpack", type=click.Path(exists=True, dir_okay=False))
@click.option("--output-dir", "-o", type=click.Path(), default=".")
@click.pass_context
def run(ctx, cpack: str, output_dir: str):
from .utils import generate_input_model
from pydantic import ValidationError
from rich.console import Console
inputs = dict(
zip([k.lstrip("-").replace("-", "_") for k in ctx.args[::2]], ctx.args[1::2])
)
console = Console()
with tempfile.TemporaryDirectory() as temp_dir:
pack_dir = Path(temp_dir) / ".cpack"
shutil.unpack_archive(cpack, pack_dir)
workflow = json.loads((pack_dir / "workflow_api.json").read_text())
input_model = generate_input_model(workflow)
try:
validated_data = input_model(**inputs)
console.print("[green]✓ Input is valid![/green]")
for field, value in validated_data.model_dump().items():
console.print(f"{field}: {value}")
except ValidationError as e:
console.print("[red]✗ Validation failed![/red]")
for error in e.errors():
console.print(f"- {error['loc'][0]}: {error['msg']}")
return 1
from .package import install
workspace = _get_cache_workspace(cpack)
if not (workspace / "DONE").exists():
console.print("\n[green]✓ Restoring ComfyUI Workspace...[/green]")
if workspace.exists():
shutil.rmtree(workspace)
install(cpack, workspace)
with open(workspace / "DONE", "w") as f:
f.write("DONE")
console.print("\n[green]✓ ComfyUI Workspace is restored![/green]")
console.print(f"{workspace}")
from .run import WorkflowRunner
runner = None
try:
runner = WorkflowRunner(str(workspace.absolute()))
runner.start()
console.print("\n[green]✓ ComfyUI is launched in the background![/green]")
results = runner.run_workflow(
workflow,
Path(output_dir).absolute(),
verbose=ctx.obj["verbose"],
**validated_data.model_dump(),
)
console.print("\n[green]✓ Workflow is executed successfully![/green]")
if results:
console.print("\n[green]✓ Retrieved outputs:[/green]")
if isinstance(results, dict):
for field, value in results.items():
console.print(f"{field}: {value}")
elif isinstance(results, list):
for i, value in enumerate(results):
console.print(f"{i}: {value}")
else:
console.print(results)
finally:
if runner:
runner.stop(verbose=ctx.obj["verbose"])
+177
View File
@@ -0,0 +1,177 @@
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
COMFYUI_REPO = "https://github.com/comfyanonymous/ComfyUI.git"
def _clone_commit(url: str, commit: str, dir: Path, verbose: int = 0):
stdout = None if verbose > 0 else subprocess.DEVNULL
stderr = None if verbose > 1 else subprocess.DEVNULL
subprocess.check_call(
["git", "clone", "--recurse-submodules", "--filter=blob:none", url, dir],
stdout=stdout,
stderr=stderr,
)
subprocess.check_call(
["git", "fetch", "-q", url, commit],
cwd=dir,
stdout=stdout,
stderr=stderr,
)
subprocess.check_call(
["git", "checkout", "FETCH_HEAD"],
cwd=dir,
stdout=stdout,
stderr=stderr,
)
subprocess.check_call(
["git", "submodule", "update", "--init", "--recursive"],
cwd=dir,
stdout=stdout,
stderr=stderr,
)
def install_comfyui(snapshot, workspace: Path, verbose: int = 0):
print("Installing ComfyUI")
comfyui_commit = snapshot["comfyui"]
if workspace.exists():
if workspace.joinpath(".DONE").exists():
commit = (workspace / ".DONE").read_text()
if commit.strip() == comfyui_commit:
print("ComfyUI is already installed")
return
shutil.rmtree(workspace)
_clone_commit(COMFYUI_REPO, comfyui_commit, workspace, verbose=verbose)
with open(workspace / ".DONE", "w") as f:
f.write(comfyui_commit)
def install_custom_modules(snapshot, workspace: Path, verbose: int = 0):
print("Installing custom nodes")
for module in snapshot["custom_nodes"]:
url = module["url"]
directory = url.split("/")[-1].split(".")[0]
module_dir = workspace / "custom_nodes" / directory
if module_dir.exists():
if module_dir.joinpath(".DONE").exists():
commit = (module_dir / ".DONE").read_text()
if commit.strip() == module["commit_hash"]:
print(f"{directory} is already installed")
continue
shutil.rmtree(module_dir)
commit_hash = module["commit_hash"]
_clone_commit(url, commit_hash, module_dir, verbose=verbose)
if module_dir.joinpath("install.py").exists():
venv = workspace / ".venv"
if venv.exists():
python = (
venv / "Scripts" / "python.exe"
if os.name == "nt"
else venv / "bin" / "python"
)
else:
python = Path(sys.executable)
subprocess.check_call(
[str(python.absolute()), "install.py"],
cwd=module_dir,
stdout=subprocess.DEVNULL if verbose == 0 else None,
)
with open(module_dir / ".DONE", "w") as f:
f.write(commit_hash)
def install_dependencies(
snapshot: dict,
req_file: str,
workspace: Path,
verbose: int = 0,
):
if verbose > 0:
print("Installing Python dependencies")
python_version = snapshot["python"]
stdout = None if verbose > 0 else subprocess.DEVNULL
stderr = None if verbose > 1 else subprocess.DEVNULL
subprocess.check_call(
["uv", "python", "install", python_version],
cwd=workspace,
stdout=stdout,
stderr=stderr,
)
venv = workspace / ".venv"
if (venv / "DONE").exists():
return
venv_py = (
venv / "Scripts" / "python.exe" if os.name == "nt" else venv / "bin" / "python"
)
subprocess.check_call(
[
"uv",
"venv",
"--python",
python_version,
venv,
],
stdout=stdout,
stderr=stderr,
)
subprocess.check_call(
[
"uv",
"pip",
"install",
"-p",
str(venv_py),
"pip",
],
stdout=stdout,
stderr=stderr,
)
subprocess.check_call(
[
"uv",
"pip",
"install",
"-p",
str(venv_py),
"-r",
req_file,
"--no-deps",
],
stdout=stdout,
stderr=stderr,
)
with open(venv / "DONE", "w") as f:
f.write("DONE")
return venv_py
def install(cpack: str | Path, workspace: str | Path = "workspace", verbose: int = 0):
workspace = Path(workspace)
with tempfile.TemporaryDirectory() as temp_dir:
pack_dir = Path(temp_dir) / ".cpack"
shutil.unpack_archive(cpack, pack_dir)
snapshot = json.loads((pack_dir / "snapshot.json").read_text())
req_txt_file = pack_dir / "requirements.txt"
install_comfyui(snapshot, workspace, verbose=verbose)
install_dependencies(snapshot, str(req_txt_file), workspace, verbose=verbose)
install_custom_modules(snapshot, workspace, verbose=verbose)
for f in (pack_dir / "input").glob("*"):
if f.is_file():
shutil.copy(f, workspace / "input" / f.name)
elif f.is_dir():
shutil.copytree(f, workspace / "input" / f.name)
@@ -1,88 +1,60 @@
from __future__ import annotations
import random
import copy
import json
import logging
import os
import requests
import shutil
import subprocess
import tempfile
from pathlib import Path
from typing import Any, Union
from comfyui_idl.utils import (
populate_workflow,
retrieve_workflow_outputs,
)
from .utils import populate_workflow, retrieve_workflow_outputs
logger = logging.getLogger(__name__)
def _probe_comfyui_server():
url = 'http://127.0.0.1:8188/api/customnode/getmappings'
params = {'mode': 'nickname'}
def _probe_comfyui_server(port: int) -> None:
from urllib import parse, request
_ = requests.get(url, params=params)
url = f"http://127.0.0.1:{port}/api/customnode/getmappings"
params = {"mode": "nickname"}
full_url = f"{url}?{parse.urlencode(params)}"
req = request.Request(full_url)
_ = request.urlopen(req)
class WorkflowRunner:
"""
A class to manage and run ComfyUI workflows.
This class handles the initialization, starting, stopping, and execution of ComfyUI workflows.
It manages temporary and output directories, and provides methods to run workflows with specified parameters.
Attributes:
temp_dir (Path): The temporary directory for ComfyUI operations.
output_dir (Path): The output directory for ComfyUI results.
workspace (str): The workspace path for ComfyUI.
is_running (bool): Flag indicating whether ComfyUI is currently running.
"""
def __init__(
self,
workspace: str,
temp_dir: Union[str, Path, None] = None,
output_dir: Union[str, Path, None] = None,
input_dir: str | None = None,
port: int | None = None,
) -> None:
"""
Initialize the WorkflowRunner.
Args:
workspace (str): The workspace path for ComfyUI.
temp_dir (Union[str, Path, None], optional): The temporary directory. Defaults to None.
output_dir (Union[str, Path, None], optional): The output directory. Defaults to None.
"""
if temp_dir is None:
self.temp_dir = Path(tempfile.mkdtemp())
self._cleanup_temp_dir = True
elif isinstance(temp_dir, str):
self.temp_dir = Path(temp_dir)
self._cleanup_temp_dir = False
else:
self.temp_dir = temp_dir
self._cleanup_temp_dir = False
if output_dir is None:
self.output_dir = self.temp_dir
elif isinstance(output_dir, str):
self.output_dir = Path(output_dir)
else:
self.output_dir = output_dir
self.workspace = workspace
# The ComfyUI process
self.temp_dir = Path(workspace) / "cli_run" / "temp"
self.output_dir = Path(workspace) / "cli_run" / "output"
self.input_dir = input_dir
self.is_running = False
self.port = port if port else random.randint(58000, 58999)
def start(self) -> None:
def start(self, verbose: int = 0) -> None:
"""
Start the ComfyUI process.
This method starts ComfyUI in the background, sets up necessary directories,
and disables tracking for workaround purposes.
Args:
verbose (int, optional): Verbosity level. If 0, suppress stdout. Defaults to 0.
Raises:
RuntimeError: If ComfyUI is already running.
"""
@@ -92,15 +64,15 @@ class WorkflowRunner:
logger.info(
"Disable tracking from Comfy CLI, not for privacy concerns, but to workaround a bug"
)
self.temp_dir.mkdir(parents=True, exist_ok=True)
self.output_dir.mkdir(parents=True, exist_ok=True)
stdout = None if verbose > 0 else subprocess.DEVNULL
command = ["comfy", "--skip-prompt", "tracking", "disable"]
subprocess.run(command, check=True)
subprocess.run(command, check=True, stdout=stdout)
logger.info("Successfully disabled Comfy CLI tracking")
logger.info("Preparing directories required by ComfyUI...")
os.makedirs(self.output_dir, exist_ok=True)
os.makedirs(self.temp_dir, exist_ok=True)
print("Comfy Output Path:", self.output_dir)
print("Comfy Temp Path:", self.temp_dir)
logger.info("Starting ComfyUI in the background...")
command = [
@@ -114,15 +86,19 @@ class WorkflowRunner:
self.output_dir,
"--temp-directory",
self.temp_dir,
"--port",
str(self.port),
]
if subprocess.run(command, check=True):
if self.input_dir:
command.extend(["--input-directory", self.input_dir])
if subprocess.run(command, check=True, stdout=stdout):
self.is_running = True
_probe_comfyui_server()
_probe_comfyui_server(self.port)
logger.info("Successfully started ComfyUI in the background")
else:
logger.error("Failed to start ComfyUI in the background")
def stop(self) -> None:
def stop(self, verbose: int = 0) -> None:
"""
Stop the ComfyUI process.
@@ -136,19 +112,23 @@ class WorkflowRunner:
logger.info("Stopping ComfyUI...")
command = ["comfy", "stop"]
subprocess.run(command, check=True)
stdout = None if verbose > 0 else subprocess.DEVNULL
subprocess.run(command, check=True, stdout=stdout)
logger.info("Successfully stopped ComfyUI")
if self._cleanup_temp_dir:
logger.info("Cleaning up temporary directory...")
shutil.rmtree(self.temp_dir)
logger.info("Successfully cleaned up temporary directory")
logger.info("Cleaning up temporary directory...")
shutil.rmtree(self.temp_dir, ignore_errors=True)
shutil.rmtree(self.output_dir, ignore_errors=True)
logger.info("Successfully cleaned up temporary directory")
self.is_running = False
def run_workflow(
self,
workflow: dict,
temp_dir: Union[str, Path, None] = None,
output_dir: Union[str, Path, None] = None,
timeout: int = 300,
verbose: int = 0,
**kwargs: Any,
) -> Any:
"""
@@ -159,7 +139,7 @@ class WorkflowRunner:
Args:
workflow (dict): The workflow to run.
temp_dir (Union[str, Path, None], optional): Temporary directory for the workflow. Defaults to None.
output_dir (Union[str, Path, None], optional): Temporary directory for the workflow. Defaults to None.
timeout (int, optional): Timeout for the workflow execution in seconds. Defaults to 300.
**kwargs: Additional keyword arguments for workflow population.
@@ -173,39 +153,47 @@ class WorkflowRunner:
raise RuntimeError("ComfyUI Runner is not started yet")
workflow_copy = copy.deepcopy(workflow)
if temp_dir is None:
temp_dir = self.temp_dir
if isinstance(temp_dir, str):
temp_dir = Path(temp_dir)
if output_dir is None:
output_dir = Path(".")
if isinstance(output_dir, str):
output_dir = Path(output_dir)
run_id = os.urandom(8).hex()
populate_workflow(
workflow_copy,
temp_dir,
output_dir,
session_id=run_id,
**kwargs,
)
workflow_file_path = temp_dir / "workflow.json"
workflow_file_path = Path(self.workspace) / "workflow.json"
with open(workflow_file_path, "w") as file:
json.dump(workflow_copy, file)
extra_args = []
if "BENTOML_DEBUG" in os.environ:
if verbose > 0:
extra_args.append("--verbose")
# Execute the workflow
command = [
"comfy",
"run",
"--workflow",
workflow_file_path.as_posix(),
"--port",
str(self.port),
"--timeout",
str(timeout),
"--wait",
*extra_args,
]
subprocess.run(command, check=True)
env = os.environ.copy()
env["NO_COLOR"] = "1"
subprocess.run(command, check=True, env=env)
# retrieve the output
return retrieve_workflow_outputs(
workflow_copy,
temp_dir,
output_dir,
session_id=run_id,
)
@@ -1,29 +1,21 @@
from __future__ import annotations
import re
import sys
from pathlib import Path
from typing import TYPE_CHECKING, Any, Union
from typing import TYPE_CHECKING, Any, Literal, Union
if TYPE_CHECKING:
from pydantic import BaseModel
CLASS_TYPES = {
"BentoInputString": str,
"BentoInputBoolean": bool,
"BentoInputInteger": int,
"BentoInputFloat": float,
"BentoInputPath": Path,
"BentoInputImage": Path,
CPACK_OUTPUT_NODES = {
"CPackOutputFile",
"CPackOutputImage",
}
BENTO_OUTPUT_NODES = {
"BentoOutputPath",
"BentoOutputImage",
}
BENTO_PATH_INPUT_NODES = {
"BentoInputPath",
"BentoInputImage",
CPACK_PATH_INPUT_NODES = {
"CPackInputFile",
"CPackInputImage",
}
@@ -57,17 +49,33 @@ def _get_node_identifier(node, dep_map=None) -> str:
"""
Get the input name from the node
"""
title = node["_meta"]["title"]
if "_meta" in node and "title" in node["_meta"]:
title = node["_meta"]["title"]
else:
title = ""
if title.isidentifier():
return title
nid = node["id"]
if dep_map and (nid, 0) in dep_map:
_, input_name = dep_map[(nid, 0)]
return _normalize_to_identifier(input_name)
if not title:
klass = node.get("class_type", "cpack_input")
name = klass.lstrip("CPack").lstrip("Input")
return _normalize_to_identifier(name)
return _normalize_to_identifier(title)
API_WORKFLOW_MESSAGE = """
It seems you are trying to parse an ordinary Workflow json file.
Please save the Workflow with the Save (API Format).
If you don't have this button, you must enable the "Dev mode Options" by clicking the Settings button on the top right (gear icon). Check the setting option "Enable Dev Mode options". After that, the Button Save (API Format) should appear.
"""
def _parse_workflow(workflow: dict) -> tuple[dict[str, Any], dict[str, Any]]:
"""
Parse the workflow template and return the input and output definition
@@ -76,6 +84,9 @@ def _parse_workflow(workflow: dict) -> tuple[dict[str, Any], dict[str, Any]]:
outputs = {}
dep_map = {}
if "last_node_id" in workflow:
raise ValueError(API_WORKFLOW_MESSAGE)
for id, node in workflow.items():
for input_name, v in node["inputs"].items():
if isinstance(v, list) and len(v) == 2: # is a link
@@ -83,12 +94,16 @@ def _parse_workflow(workflow: dict) -> tuple[dict[str, Any], dict[str, Any]]:
for id, node in workflow.items():
node["id"] = id
if node["class_type"].startswith("BentoInput"):
if node["class_type"].startswith("CPackInput"):
if not node.get("inputs"):
continue
name = _get_node_identifier(node, dep_map)
if name in inputs:
name = f"{name}_{id}"
inputs[name] = node
elif node["class_type"].startswith("BentoOutput"):
elif node["class_type"].startswith("CPackOutput"):
if not node.get("inputs"):
continue
name = _get_node_identifier(node)
if name in inputs:
name = f"{name}_{id}"
@@ -118,25 +133,59 @@ def generate_input_model(workflow: dict) -> type[BaseModel]:
ValueError: If an unsupported class type is encountered in the workflow.
"""
from pydantic import Field, create_model
from pydantic_core import PydanticUndefined
inputs, _ = _parse_workflow(workflow)
input_fields = {}
for name, node in inputs.items():
class_type = node["class_type"]
if class_type in CLASS_TYPES:
ann = CLASS_TYPES[class_type]
if class_type in BENTO_PATH_INPUT_NODES:
field = (ann, Field())
if class_type in CPACK_PATH_INPUT_NODES:
field = (Path, Field())
elif class_type == "CPackInputString":
value = _get_node_value(node)
field = (str, Field(default=value))
elif class_type == "CPackInputInt":
value, min, max = tuple(node["inputs"].values())
if min == -sys.maxsize:
min = PydanticUndefined
if max == sys.maxsize:
max = PydanticUndefined
field = (int, Field(default=value, ge=min, le=max))
elif class_type == "CPackInputAny":
options = node.get("_meta", {}).get("options")
value = _get_node_value(node)
if not options:
field = (type(value), Field(default=value))
else:
field = (ann, Field(default=_get_node_value(node)))
input_fields[name] = field
if values := options.get("values"): # combo type
field = (Literal[tuple(values)], Field(default=value))
elif any(
f in options for f in ("min", "max", "round", "precision", "step")
): # must be number types
type_ = float if options.get("round", 1) < 1 else int
min_value = options.get("min", PydanticUndefined)
max_value = options.get("max", PydanticUndefined)
if type_ is int:
if min_value < -sys.maxsize:
min_value = PydanticUndefined
if max_value > sys.maxsize:
max_value = PydanticUndefined
field = (type_, Field(default=value, ge=min_value, le=max_value))
else:
field = (type(value), Field(default=value))
else:
raise ValueError(f"Unsupported class type: {class_type}")
input_fields[name] = field
return create_model("ParsedWorkflowTemplate", **input_fields)
def populate_workflow(workflow: dict, output_path: Path, **inputs) -> dict:
def populate_workflow(
workflow: dict,
output_path: Path,
session_id: str = "",
**inputs,
) -> dict:
"""
Fills the input values and output path into the workflow.
@@ -154,15 +203,15 @@ def populate_workflow(workflow: dict, output_path: Path, **inputs) -> dict:
input_spec, output_spec = _parse_workflow(workflow)
for k, v in inputs.items():
node = input_spec[k]
if not node["class_type"].startswith("BentoInput"):
if not node["class_type"].startswith("CPackInput"):
raise ValueError(f"Node {k} is not an input node")
_set_node_value(workflow[node["id"]], v)
for _, node in output_spec.items():
node_id = node["id"]
if node["class_type"] in BENTO_OUTPUT_NODES:
if node["class_type"] in CPACK_OUTPUT_NODES:
workflow[node_id]["inputs"]["filename_prefix"] = (
output_path / f"{node_id}_"
output_path / f"{session_id}{node_id}_"
).as_posix()
return workflow
@@ -170,6 +219,7 @@ def populate_workflow(workflow: dict, output_path: Path, **inputs) -> dict:
def retrieve_workflow_outputs(
workflow: dict,
output_path: Path,
session_id: str = "",
) -> Union[Path, list[Path], dict[str, Path], dict[str, list[Path]]]:
"""
Gets the output file(s) from the workflow.
@@ -200,11 +250,11 @@ def retrieve_workflow_outputs(
return value_map
name, node = next(iter(outputs.items()))
if node["class_type"] not in BENTO_OUTPUT_NODES:
raise ValueError(f"Output node {name} is not of type {BENTO_OUTPUT_NODES}")
if node["class_type"] not in CPACK_OUTPUT_NODES:
raise ValueError(f"Output node {name} is not of type {CPACK_OUTPUT_NODES}")
node_id = node["id"]
outs = list(output_path.glob(f"{node_id}_*"))
outs = list(output_path.glob(f"{session_id}{node_id}_*"))
if len(outs) == 1:
return outs[0]
return outs
Generated
+1417 -2
View File
File diff suppressed because it is too large Load Diff
+90
View File
@@ -0,0 +1,90 @@
import { app } from "../../scripts/app.js";
function mimicNode(node, target, slot) {
const getWidget = (widget) => {
if (typeof widget.origType === "undefined") return widget;
const newWidget = new Proxy(widget, {
has(target, prop) {
return prop in target && prop !== "origType" && prop !== "computeSize";
},
get(target, prop) {
if (prop.startsWith("orig") || prop === "computeSize") {
return undefined;
} else if (prop === "type") {
return target.origType;
} else if (prop === "name") {
return inputName;
} else {
return target[prop];
}
},
set(target, prop, value) {
target[prop] = value;
return true;
}
});
return newWidget;
}
const input = target.inputs[slot];
const inputName = node.inputs.length > 0 ? node.inputs[0].name : node.widgets[0].name;
if (typeof input.widget === "undefined") { // This is an input
node.inputs = [{ ...input, name: inputName }];
node.widgets = [];
} else { // This is a widget
node.widgets = [getWidget(target.widgets.find(w => w.name === input.name))];
node.inputs = [];
node.size = node.computeSize();
requestAnimationFrame(() => {
if (node.onResize) {
node.onResize(node.size);
}
});
}
}
app.registerExtension({
name: "Comfy.DynamicInput",
dynamicNodes: ["CPackInputAny", "CPackInputFile"],
async beforeRegisterNodeDef(nodeType, nodeData) {
if (!this.dynamicNodes.includes(nodeData.name)) return;
nodeType.prototype.onConnectOutput = function () {
if (this.outputs[0].links?.length > 0) return false;
const target = arguments[3];
if (this.type === "CPackInputFile" && !["COMBO", "STRING"].includes(target.inputs[arguments[4]].type))
return false;
mimicNode(this, target, arguments[4]);
this.title = target.inputs[arguments[4]].name;
}
},
async setup(app) {
app.graph.nodes.forEach((node) => {
if (!this.dynamicNodes.includes(node.type)) return;
if (node.outputs.length > 0 && node.outputs[0].links.length > 0) {
const link = node.graph.links[node.outputs[0].links[0]];
mimicNode(node, app.graph.getNodeById(link.target_id), link.target_slot);
}
})
},
async init(app) {
const originalToPrompt = app.graphToPrompt;
const self = this;
app.graphToPrompt = async function(graph = app.graph, clean = true) {
const { workflow, output } = await originalToPrompt(graph, clean);
Object.entries(output).forEach(([id, nodeData]) => {
if (!nodeData.class_type.startsWith("CPackInput")) return;
const node = graph.getNodeById(parseInt(id));
if (!nodeData["_meta"]) {
nodeData["_meta"] = { title: node.title };
}
if (node.widgets.length === 0) return;
const widget = node.widgets[0];
nodeData["_meta"] = { ...nodeData["_meta"], options: widget.options };
});
return { workflow, output };
};
}
});
+501
View File
@@ -0,0 +1,501 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
const style = `
.cpack-modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: #1a1a1a;
padding: 20px;
border-radius: 8px;
z-index: 1000;
color: white;
min-width: 360px;
}
.cpack-input {
width: 100%;
padding: 8px;
background: #333;
border: 1px solid #444;
border-radius: 4px;
color: white;
box-sizing: border-box;
}
.cpack-btn {
padding: 6px 12px;
background: #666;
border: none;
border-radius: 4px;
color: white;
cursor: pointer;
}
.cpack-btn.primary {
background: #00a67d;
}
.cpack-btn-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
gap: 10px;
}
.cpack-title {
margin-bottom: 15px;
font-size: 1.3em;
font-weight: bold;
}
.cpack-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
}
.cpack-input-row {
display: flex;
align-items: center;
gap: 5px;
margin-bottom: 5px;
}
.cpack-form-item {
margin-bottom: 15px;
}
.cpack-form-item label {
margin-bottom: 5px;
}
#build-info {
display: flex;
padding: 10px;
flex-direction: column;
align-items: center;
}
#build-error {
display: none;
color: #ff8383;
text-wrap: auto;
overflow-x: auto;
background: #333;
padding: 10px;
}
.cpack-copyable {
display: flex;
justify-content: center;
align-items: center;
gap: 5px;
}
.cpack-copyable > span {
border: 1px solid #ccc;
padding: 3px 10px;
border-radius: 3px;
background: #606060;
}
`
function createModal(modal) {
const overlay = document.createElement("div");
overlay.className = "cpack-overlay";
document.body.appendChild(overlay);
document.body.appendChild(modal);
return {
close: () => {
modal.remove();
overlay.remove();
}
};
}
function createInputModal() {
return new Promise((resolve) => {
const modal = document.createElement("div");
modal.className = "cpack-modal";
modal.id = "input-modal";
const title = document.createElement("div");
title.textContent = "Package Worlflow";
title.className = "cpack-title";
const input = document.createElement("input");
input.type = "text";
input.value = "package";
input.className = "cpack-input";
const buttonContainer = document.createElement("div");
buttonContainer.className = "cpack-btn-container";
const confirmButton = document.createElement("button");
confirmButton.textContent = "Confirm";
confirmButton.className = "cpack-btn primary";
const cancelButton = document.createElement("button");
cancelButton.textContent = "Cancel";
cancelButton.className = "cpack-btn";
buttonContainer.appendChild(cancelButton);
buttonContainer.appendChild(confirmButton);
modal.appendChild(title);
modal.appendChild(input);
modal.appendChild(buttonContainer);
const { close } = createModal(modal);
confirmButton.onclick = () => {
const filename = input.value.trim();
if (filename) {
close();
resolve(filename);
}
};
cancelButton.onclick = () => {
close();
resolve(null);
};
input.addEventListener("keyup", (e) => {
if (e.key === "Enter") {
confirmButton.click();
}
});
input.select();
});
}
function createDownloadModal() {
const modal = document.createElement("div");
modal.className = "cpack-modal";
modal.id = "download-modal";
const title = document.createElement("div");
title.textContent = "Packaging...";
title.style.marginBottom = "15px";
title.style.color = "#fff";
const progress = document.createElement("div");
progress.style.cssText = `
width: 100%;
height: 20px;
background: #333;
border-radius: 10px;
overflow: hidden;
`;
const progressBar = document.createElement("div");
progressBar.style.cssText = `
width: 0%;
height: 100%;
background: #00a67d;
transition: width 0.3s ease;
`;
progress.appendChild(progressBar);
modal.appendChild(title);
modal.appendChild(progress);
const { close } = createModal(modal);
return {
updateProgress: (percent) => {
progressBar.style.width = `${percent}%`;
},
close
};
}
async function downloadPackage() {
if (document.getElementById("input-modal")) return;
if (document.getElementById("download-modal")) return;
const filename = await createInputModal();
if (!filename) return;
const downloadModal = createDownloadModal();
try {
downloadModal.updateProgress(20);
const { workflow, output: workflow_api } = await app.graphToPrompt();
downloadModal.updateProgress(40);
const body = JSON.stringify({ workflow, workflow_api });
downloadModal.updateProgress(60);
const resp = await api.fetchApi("/bentoml/pack", { method: "POST", body, headers: { "Content-Type": "application/json" } });
downloadModal.updateProgress(80);
const downloadUrl = (await resp.json())["download_url"];
downloadModal.updateProgress(100);
const link = document.createElement("a");
link.href = downloadUrl;
link.download = filename + ".cpack.zip";
link.click();
setTimeout(() => {
downloadModal.close();
}, 1000);
} catch (error) {
console.error("Package failed:", error);
downloadModal.close();
}
}
async function buildBento() {
if (document.getElementById("build-modal")) return;
if (document.getElementById("building-modal")) return;
const data = await createBuildModal();
console.log(data);
await createBuildingModal(data);
};
const buildForm = `
<div class="cpack-form-item">
<label for="bentoName">Bento Name</label>
<input type="text" class="cpack-input" name="bentoName" value="comfy-pack" />
</div>
<div class="cpack-form-item">
<label for="systemPackages">System Packages</label>
<div id="system-packages-array">
<button class="cpack-btn" id="add-button" style="margin: 5px 0px">Add</button>
</div>
</div>
<div class="cpack-form-item">
<label for="push">Push to Cloud</label>
<input type="checkbox" name="push" id="push-switch" />
</div>
<div id="credentials-group" style="display: none">
<div class="cpack-form-item">
<label for="bentoName">BentoCloud API Key</label>
<input type="password" class="cpack-input" name="apiKey" />
</div>
<div class="cpack-form-item">
<label for="bentoName">BentoCloud Endpoint</label>
<input type="text" class="cpack-input" name="endpoint" />
</div>
<p style="font-size: 0.85em;">Leave these empty to use the credentials stored in local machine</p>
</div>
`
function createBuildModal() {
const modal = document.createElement("div");
modal.id = "build-modal";
modal.className = "cpack-modal";
modal.style.width = "400px";
const title = document.createElement("div");
title.textContent = "Build Workflow As Bento";
title.className = "cpack-title";
const form = document.createElement("form");
form.innerHTML = buildForm;
const addButton = form.querySelector("#add-button");
const systemPackagesArray = form.querySelector("#system-packages-array");
addButton.addEventListener("click", (e) => {
e.preventDefault();
const row = document.createElement("div");
row.className = "cpack-input-row";
row.innerHTML = `
<div style="flex: 1"><input type="text" class="cpack-input" name="systemPackages" /></div>
<button class="cpack-btn" style="margin-left: 10px">Remove</button>
`
systemPackagesArray.appendChild(row);
row.querySelector("button").onclick = (e) => {
e.preventDefault();
row.remove();
}
});
const pushSwitch = form.querySelector("#push-switch");
const credentialsGroup = form.querySelector("#credentials-group");
pushSwitch.addEventListener("change", (e) => {
credentialsGroup.style.display = e.target.checked ? "block" : "none";
});
const buttonContainer = document.createElement("div");
buttonContainer.className = "cpack-btn-container";
const confirmButton = document.createElement("button");
confirmButton.textContent = "Build";
confirmButton.className = "cpack-btn primary";
const cancelButton = document.createElement("button");
cancelButton.textContent = "Cancel";
cancelButton.className = "cpack-btn";
buttonContainer.appendChild(cancelButton);
buttonContainer.appendChild(confirmButton);
modal.appendChild(title);
modal.appendChild(form);
modal.appendChild(buttonContainer);
const { close } = createModal(modal);
cancelButton.onclick = close;
return new Promise((resolve) => {
form.querySelector("input[name='bentoName']").select();
confirmButton.onclick = async (e) => {
e.preventDefault();
const formData = new FormData(form);
const { workflow, output: workflow_api } = await app.graphToPrompt();
const data = {
bento_name: formData.get("bentoName"),
system_packages: Array.from(formData.getAll("systemPackages").filter(Boolean)),
push: formData.get("push") === "on",
api_key: formData.get("apiKey"),
endpoint: formData.get("endpoint"),
workflow,
workflow_api
};
close();
resolve(data);
}
});
}
const spinner = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z" opacity=".25"/>
<path fill="currentColor" d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z">
<animateTransform attributeName="transform" dur="0.75s" repeatCount="indefinite" type="rotate" values="0 12 12;360 12 12"/>
</path>
</svg>`
async function createBuildingModal(data) {
const modal = document.createElement("div");
modal.className = "cpack-modal";
modal.style.width = "400px";
modal.id = "building-modal";
const title = document.createElement("div");
title.textContent = "Building...";
title.className = "cpack-title";
const body = document.createElement("div");
body.innerHTML = `
<div id="build-info"></div>
<pre id="build-error"></pre>
`;
const buttonContainer = document.createElement("div");
buttonContainer.className = "cpack-btn-container";
const closeButton = document.createElement("button");
closeButton.textContent = "Close";
closeButton.className = "cpack-btn";
buttonContainer.appendChild(closeButton);
modal.appendChild(title);
modal.appendChild(body);
modal.appendChild(buttonContainer);
const { close } = createModal(modal);
closeButton.onclick = close;
closeButton.disabled = true;
const info = body.querySelector("#build-info");
const setError = (error) => {
title.textContent = "Build Failed";
info.style.display = "none";
const pre = body.querySelector("#build-error");
pre.textContent = error;
pre.style.display = "block";
}
info.innerHTML = spinner;
try {
const resp = await api.fetchApi("/bentoml/build", {
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" }
});
const respData = await resp.json();
if (respData.error) {
setError(respData.error);
} else {
title.textContent = "Build Completed";
info.innerHTML = `<div class="cpack-copyable"><span style="flex:1">${respData.bento}</span><button class="cpack-btn"><i class="mdi mdi-content-copy" /></button></div>`;
const copyButton = info.querySelector("button");
copyButton.onclick = () => {
navigator.clipboard.writeText(respData.bento);
const icon = copyButton.querySelector("i");
icon.classList.remove("mdi-content-copy");
icon.classList.add("mdi-check");
setTimeout(() => {
icon.classList.remove("mdi-check");
icon.classList.add("mdi-content-copy");
}, 2000);
}
}
} catch(e) {
setError(e.message);
} finally {
closeButton.disabled = false;
}
}
app.registerExtension({
name: "Comfy.CPackExtension",
async setup() {
const styleTag = document.createElement("style");
styleTag.innerHTML = style;
document.head.appendChild(styleTag);
const menu = document.querySelector(".comfy-menu");
const separator = document.createElement("hr");
separator.style.margin = "20px 0";
separator.style.width = "100%";
menu.append(separator);
const packButton = document.createElement("button");
packButton.textContent = "Package";
packButton.onclick = downloadPackage;
menu.append(packButton);
const buildButton = document.createElement("button");
buildButton.textContent = "Build";
buildButton.onclick = buildBento;
menu.append(buildButton);
try {
// new style Manager buttons
// unload models button into new style Manager button
let cmGroup = new (await import("../../scripts/ui/components/buttonGroup.js")).ComfyButtonGroup(
new(await import("../../scripts/ui/components/button.js")).ComfyButton({
icon: "package-variant-closed",
action: downloadPackage,
tooltip: "Package Workflow",
content: "Package",
classList: "comfyui-button comfyui-menu-mobile-collapse"
}).element,
new(await import("../../scripts/ui/components/button.js")).ComfyButton({
icon: "package-up",
action: buildBento,
tooltip: "Build Workflow as Bento",
content: "Build Bento",
classList: "comfyui-button comfyui-menu-mobile-collapse primary"
}).element,
);
app.menu?.settingsGroup.element.before(cmGroup.element);
}
catch(exception) {
console.log('ComfyUI is outdated. New style menu based features are disabled.');
}
}
});