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 | ||
|
|
20bd7b379d | ||
|
|
64a8f7f712 | ||
|
|
1a24cd2343 | ||
|
|
27eb860c93 | ||
|
|
deb787096f | ||
|
|
b6d712ec1c | ||
|
|
0b1341a5a3 | ||
|
|
2b01701d0a | ||
|
|
7ecf35c76d | ||
|
|
5f7c3d8a10 |
@@ -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/
|
||||
|
||||
@@ -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
@@ -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
@@ -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)})
|
||||
@@ -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
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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,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",
|
||||
@@ -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,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
|
||||
@@ -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
@@ -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.');
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user