Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7254cbf641 | ||
|
|
cb56e5330b | ||
|
|
0fe6323086 | ||
|
|
9d1406e421 | ||
|
|
e9772858ec | ||
|
|
251b2602ba | ||
|
|
d1723004d2 | ||
|
|
7565758b9d | ||
|
|
bf4414bacf | ||
|
|
d89731c1a1 | ||
|
|
660a8e6ba2 | ||
|
|
e3bd863ae7 | ||
|
|
bed78a3a41 | ||
|
|
ff818e29a6 | ||
|
|
9c0197be7f | ||
|
|
bad78c37e8 | ||
|
|
360a365963 | ||
|
|
6179409369 | ||
|
|
8ccc45168b | ||
|
|
978b8b9ff0 | ||
|
|
d7785478e5 | ||
|
|
bd14213f46 | ||
|
|
2b0da4086a | ||
|
|
1ea3be0749 | ||
|
|
e4522362cb | ||
|
|
a8ec38e6fe | ||
|
|
f1d3af75eb | ||
|
|
9dd2ab639e | ||
|
|
016423c9f5 | ||
|
|
6e3bd0d282 | ||
|
|
d9e4d3b9f0 | ||
|
|
5259505cb6 | ||
|
|
9628377e2c | ||
|
|
9d03466734 | ||
|
|
e66465d572 | ||
|
|
4c9a6c2bbb | ||
|
|
3a5fc6dfa1 | ||
|
|
f638a9ddcc | ||
|
|
f05c5912a1 | ||
|
|
ad2f6a67be | ||
|
|
2c79b7bb69 | ||
|
|
20ce3fa584 | ||
|
|
c7b48c033b | ||
|
|
1f8ed12a56 | ||
|
|
b7f49d6152 | ||
|
|
7b9d37580b | ||
|
|
3c02c51333 | ||
|
|
dbca147dbf | ||
|
|
f4f1d2ce4b | ||
|
|
50165f80d6 | ||
|
|
463d15656c | ||
|
|
e01c6bac12 | ||
|
|
017c49f1af | ||
|
|
da6dfc5fd5 | ||
|
|
ff7a55a804 | ||
|
|
f680263631 | ||
|
|
704937f2b9 | ||
|
|
3b016e2fa4 | ||
|
|
1e946b4e48 | ||
|
|
21b13b56c0 | ||
|
|
7b9f0ec026 | ||
|
|
cf8ea2b875 |
@@ -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
|
||||
|
||||
+11
@@ -1,6 +1,17 @@
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from .nodes import api # noqa
|
||||
from .nodes.nodes import 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__),
|
||||
)
|
||||
|
||||
+222
-51
@@ -1,40 +1,102 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
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(zf: zipfile.ZipFile) -> None:
|
||||
print("Pack => Writing requirements.txt")
|
||||
with zf.open("requirements.txt", "w") as f:
|
||||
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)
|
||||
f.write(stdout.decode().rstrip("\n") + "\n")
|
||||
if extras:
|
||||
f.write("\n".join(extras) + "\n")
|
||||
|
||||
|
||||
async def _write_models(zf: zipfile.ZipFile) -> None:
|
||||
print("Pack => Writing models")
|
||||
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",
|
||||
@@ -43,36 +105,47 @@ async def _write_models(zf: zipfile.ZipFile) -> None:
|
||||
stdout=subprocess.PIPE,
|
||||
)
|
||||
stdout, _ = await proc.communicate()
|
||||
with zf.open("models.json", "w") as f:
|
||||
data = []
|
||||
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)
|
||||
with open(filename, "rb") as model:
|
||||
data.append(
|
||||
{
|
||||
"filename": relpath,
|
||||
"sha256": hashlib.sha256(model.read()).hexdigest(),
|
||||
}
|
||||
)
|
||||
f.write(json.dumps(data, indent=2).encode())
|
||||
|
||||
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 _write_workflow(zf: zipfile.ZipFile, data: dict) -> None:
|
||||
print("Pack => Writing workflow")
|
||||
with zf.open("workflow_api.json", "w") as f:
|
||||
f.write(json.dumps(data["workflow_api"], indent=2).encode())
|
||||
with zf.open("workflow.json", "w") as f:
|
||||
f.write(json.dumps(data["workflow"], indent=2).encode())
|
||||
|
||||
|
||||
GIT_URL_REGEX = re.compile(r"(?<=:|/)[^:/]+/[^:/]+(?=\.git|$)")
|
||||
|
||||
|
||||
async def _write_custom_nodes(zf: zipfile.ZipFile) -> None:
|
||||
print("Pack => Writing custom nodes")
|
||||
async def _get_custom_nodes() -> list:
|
||||
print("Package => Writing custom nodes")
|
||||
custom_nodes = os.path.join(folder_paths.base_path, "custom_nodes")
|
||||
coros = []
|
||||
|
||||
@@ -108,21 +181,39 @@ async def _write_custom_nodes(zf: zipfile.ZipFile) -> None:
|
||||
continue
|
||||
coros.append(get_node_info(subdir))
|
||||
|
||||
with zf.open("custom_nodes.json", "w") as f:
|
||||
data = await asyncio.gather(*coros)
|
||||
f.write(json.dumps(data, indent=2).encode())
|
||||
return await asyncio.gather(*coros)
|
||||
|
||||
|
||||
async def _write_inputs(zf: zipfile.ZipFile) -> None:
|
||||
print("Pack => Writing inputs")
|
||||
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()
|
||||
|
||||
for root_path, _, files in os.walk(input_dir):
|
||||
for file in files:
|
||||
file_path = os.path.join(root_path, file)
|
||||
relpath = os.path.relpath(file_path, input_dir)
|
||||
with zf.open(f"inputs/{relpath}", "w") as f:
|
||||
with open(file_path, "rb") as input_file:
|
||||
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)
|
||||
|
||||
|
||||
@@ -138,11 +229,11 @@ async def pack_workspace(request):
|
||||
zip_filename = f"{uuid.uuid4()}.zip"
|
||||
|
||||
with zipfile.ZipFile(TEMP_FOLDER / zip_filename, "w") as zf:
|
||||
await _write_requirements(zf)
|
||||
await _write_models(zf)
|
||||
await _write_workflow(zf, data)
|
||||
await _write_custom_nodes(zf)
|
||||
await _write_inputs(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}"})
|
||||
|
||||
@@ -151,3 +242,83 @@ async def pack_workspace(request):
|
||||
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)})
|
||||
|
||||
@@ -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
|
||||
|
||||
+88
-25
@@ -1,6 +1,7 @@
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import shutil
|
||||
|
||||
import folder_paths
|
||||
@@ -31,15 +32,15 @@ class OutputFile:
|
||||
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,8 +178,8 @@ 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")
|
||||
FUNCTION = "load_image"
|
||||
|
||||
@@ -256,8 +257,8 @@ class FileInput:
|
||||
RETURN_TYPES = (anytype,)
|
||||
RETURN_NAMES = ("path",)
|
||||
FUNCTION = "identity"
|
||||
BENTOML_NODE = True
|
||||
CATEGORY = "ComfyUI-IDL/input"
|
||||
CPACK_NODE = True
|
||||
CATEGORY = "ComfyPack/input"
|
||||
|
||||
def identity(self, path):
|
||||
return (path,)
|
||||
@@ -268,7 +269,65 @@ class FileInput:
|
||||
return True
|
||||
|
||||
|
||||
class ValueInput:
|
||||
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
|
||||
@@ -282,8 +341,8 @@ class ValueInput:
|
||||
RETURN_TYPES = (anytype,)
|
||||
RENAME = ("value",)
|
||||
FUNCTION = "identity"
|
||||
BENTOML_NODE = True
|
||||
CATEGORY = "ComfyUI-IDL/input"
|
||||
CPACK_NODE = True
|
||||
CATEGORY = "ComfyPack/input"
|
||||
|
||||
def identity(self, input):
|
||||
return (input,)
|
||||
@@ -295,17 +354,21 @@ class ValueInput:
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"BentoOutputFile": OutputFile,
|
||||
"BentoOutputImage": OutputImage,
|
||||
"BentoInputImage": ImageInput,
|
||||
"BentoInputFile": FileInput,
|
||||
"BentoInputValue": ValueInput,
|
||||
"CPackOutputFile": OutputFile,
|
||||
"CPackOutputImage": OutputImage,
|
||||
"CPackInputImage": ImageInput,
|
||||
"CPackInputString": StringInput,
|
||||
"CPackInputInt": IntInput,
|
||||
"CPackInputFile": FileInput,
|
||||
"CPackInputAny": AnyInput,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"BentoInputImage": "Image Input",
|
||||
"BentoInputFile": "File Input",
|
||||
"BentoOutputImage": "Image Output",
|
||||
"BentoOutputFile": "File Output",
|
||||
"BentoInputValue": "Plain Value Input",
|
||||
"CPackInputImage": "Image Input",
|
||||
"CPackInputString": "String Input",
|
||||
"CPackInputInt": "Int Input",
|
||||
"CPackInputFile": "File Input",
|
||||
"CPackInputAny": "Any Input",
|
||||
"CPackOutputImage": "Image Output",
|
||||
"CPackOutputFile": "File Output",
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
bentoml
|
||||
fastapi
|
||||
@@ -1,5 +1,5 @@
|
||||
from comfyui_idl.run import WorkflowRunner
|
||||
from comfyui_idl.utils import (
|
||||
from .run import WorkflowRunner
|
||||
from .utils import (
|
||||
generate_input_model,
|
||||
parse_workflow,
|
||||
populate_workflow,
|
||||
@@ -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"])
|
||||
@@ -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,89 +1,60 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
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():
|
||||
def _probe_comfyui_server(port: int) -> None:
|
||||
from urllib import parse, request
|
||||
url = 'http://127.0.0.1:8188/api/customnode/getmappings'
|
||||
params = {'mode': 'nickname'}
|
||||
|
||||
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.
|
||||
"""
|
||||
@@ -93,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 = [
|
||||
@@ -115,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.
|
||||
|
||||
@@ -137,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:
|
||||
"""
|
||||
@@ -160,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.
|
||||
|
||||
@@ -174,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,20 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Literal, Union
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pydantic import BaseModel
|
||||
|
||||
BENTO_OUTPUT_NODES = {
|
||||
"BentoOutputPath",
|
||||
"BentoOutputImage",
|
||||
CPACK_OUTPUT_NODES = {
|
||||
"CPackOutputFile",
|
||||
"CPackOutputImage",
|
||||
}
|
||||
|
||||
BENTO_PATH_INPUT_NODES = {
|
||||
"BentoInputPath",
|
||||
"BentoInputImage",
|
||||
CPACK_PATH_INPUT_NODES = {
|
||||
"CPackInputFile",
|
||||
"CPackInputImage",
|
||||
}
|
||||
|
||||
|
||||
@@ -48,14 +49,23 @@ 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)
|
||||
|
||||
|
||||
@@ -84,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}"
|
||||
@@ -126,9 +140,19 @@ def generate_input_model(workflow: dict) -> type[BaseModel]:
|
||||
input_fields = {}
|
||||
for name, node in inputs.items():
|
||||
class_type = node["class_type"]
|
||||
if class_type in BENTO_PATH_INPUT_NODES:
|
||||
if class_type in CPACK_PATH_INPUT_NODES:
|
||||
field = (Path, Field())
|
||||
elif class_type == "BentoInputValue":
|
||||
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:
|
||||
@@ -140,14 +164,14 @@ def generate_input_model(workflow: dict) -> type[BaseModel]:
|
||||
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
|
||||
field = (
|
||||
type_,
|
||||
Field(
|
||||
default=value,
|
||||
ge=options.get("min", PydanticUndefined),
|
||||
le=options.get("max", PydanticUndefined),
|
||||
),
|
||||
)
|
||||
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:
|
||||
@@ -156,7 +180,12 @@ def generate_input_model(workflow: dict) -> type[BaseModel]:
|
||||
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.
|
||||
|
||||
@@ -174,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
|
||||
|
||||
@@ -190,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.
|
||||
@@ -220,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
|
||||
+28
-13
@@ -3,14 +3,26 @@ import { app } from "../../scripts/app.js";
|
||||
function mimicNode(node, target, slot) {
|
||||
const getWidget = (widget) => {
|
||||
if (typeof widget.origType === "undefined") return widget;
|
||||
const newWidget = Object.assign({}, widget);
|
||||
for (const key in widget) {
|
||||
if (key.startsWith("orig") || key === "computeSize") {
|
||||
delete newWidget[key];
|
||||
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;
|
||||
}
|
||||
}
|
||||
newWidget.type = widget.origType;
|
||||
newWidget.name = inputName;
|
||||
});
|
||||
return newWidget;
|
||||
}
|
||||
const input = target.inputs[slot];
|
||||
@@ -32,15 +44,15 @@ function mimicNode(node, target, slot) {
|
||||
|
||||
app.registerExtension({
|
||||
name: "Comfy.DynamicInput",
|
||||
extensionNodes: ["BentoInputValue", "BentoInputFile"],
|
||||
dynamicNodes: ["CPackInputAny", "CPackInputFile"],
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (!this.extensionNodes.includes(nodeData.name)) return;
|
||||
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 === "BentoInputFile" && !["COMBO", "STRING"].includes(target.inputs[arguments[4]].type))
|
||||
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;
|
||||
@@ -49,7 +61,7 @@ app.registerExtension({
|
||||
|
||||
async setup(app) {
|
||||
app.graph.nodes.forEach((node) => {
|
||||
if (!this.extensionNodes.includes(node.type)) return;
|
||||
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);
|
||||
@@ -63,11 +75,14 @@ app.registerExtension({
|
||||
app.graphToPrompt = async function(graph = app.graph, clean = true) {
|
||||
const { workflow, output } = await originalToPrompt(graph, clean);
|
||||
Object.entries(output).forEach(([id, nodeData]) => {
|
||||
if (!self.extensionNodes.includes(nodeData.class_type)) return;
|
||||
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"] = Object.assign({}, nodeData["_meta"] || {}, { options: widget.options });
|
||||
nodeData["_meta"] = { ...nodeData["_meta"], options: widget.options };
|
||||
});
|
||||
return { workflow, output };
|
||||
};
|
||||
|
||||
+488
-20
@@ -1,33 +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.BentoExtension",
|
||||
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);
|
||||
separator.style.margin = "20px 0";
|
||||
separator.style.width = "100%";
|
||||
menu.append(separator);
|
||||
const packButton = document.createElement("button");
|
||||
packButton.textContent = "Package";
|
||||
packButton.onclick = async () => {
|
||||
packButton.disabled = true;
|
||||
try {
|
||||
const { workflow, output: workflow_api } = await app.graphToPrompt();
|
||||
const body = JSON.stringify({ workflow, workflow_api });
|
||||
const resp = await api.fetchApi("/bentoml/pack", { method: "POST", body, headers: { "Content-Type": "application/json" } });
|
||||
const downloadUrl = (await resp.json())["download_url"];
|
||||
const link = document.createElement("a");
|
||||
link.href = downloadUrl;
|
||||
link.download = "workspace.zip";
|
||||
link.click();
|
||||
} finally {
|
||||
packButton.disabled = false;
|
||||
}
|
||||
}
|
||||
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.');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user