Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
174863596f | ||
|
|
45cb9e3ce6 | ||
|
|
938be883ad | ||
|
|
5abbce1512 | ||
|
|
3264df7836 | ||
|
|
62e368f812 | ||
|
|
f5242b0488 | ||
|
|
9e50767bf9 | ||
|
|
b1a5a3739e | ||
|
|
8c770b0313 | ||
|
|
27175ceb09 | ||
|
|
bfdc3ef4dd | ||
|
|
2f9d3df5aa | ||
|
|
01eb014aeb | ||
|
|
06aa2c6d68 | ||
|
|
fc5dd9e2df | ||
|
|
3ac04864e8 | ||
|
|
0e9f7dc860 | ||
|
|
c6459d8448 | ||
|
|
54198b9679 | ||
|
|
d5b527a0eb | ||
|
|
8677e0446f | ||
|
|
5f72b80651 | ||
|
|
c41cf14996 | ||
|
|
b335f98143 | ||
|
|
09803955b2 | ||
|
|
61026459c9 | ||
|
|
0079d8700f | ||
|
|
803aa1d88d | ||
|
|
dc343856ba | ||
|
|
94f24ce096 | ||
|
|
f6c3eb6e89 | ||
|
|
95f3dd41e1 | ||
|
|
2356537a5d | ||
|
|
e0ceef7b8d | ||
|
|
fe9132bd76 | ||
|
|
5bccfb9adc | ||
|
|
052c9d5eb6 | ||
|
|
30b3e11325 | ||
|
|
a0f5bbf1d4 | ||
|
|
181781247f | ||
|
|
9e9b4b3392 | ||
|
|
0f97cfc01e | ||
|
|
dca5255f70 | ||
|
|
e3555bab49 | ||
|
|
e757c308b6 | ||
|
|
d325fe2a98 | ||
|
|
73e663c18a | ||
|
|
2ab442fd2e | ||
|
|
e76578beb9 | ||
|
|
74e5c65afc | ||
|
|
06bd72103b | ||
|
|
d16fc5bc96 | ||
|
|
44ace4c9f5 | ||
|
|
f90d348217 | ||
|
|
79bc15cf7c | ||
|
|
d4718c9e50 | ||
|
|
50d4675884 | ||
|
|
fa850833a5 | ||
|
|
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 |
@@ -22,7 +22,7 @@ jobs:
|
||||
id-token: write
|
||||
environment:
|
||||
name: pypi
|
||||
url: https://pypi.org/project/comfyui-idl/${{ github.ref_name }}
|
||||
url: https://pypi.org/project/comfy-pack/${{ github.ref_name }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-package
|
||||
|
||||
|
||||
@@ -1,3 +1,75 @@
|
||||
# ComfyUI-IDL
|
||||
# Comfy-Pack
|
||||
|
||||
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. Search&Install ComfyUI-IDL custom nodes in ComfyUI with Manager
|
||||
2. Design your workflow with comfy-pack input/output 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
|
||||
```
|
||||
|
||||
### Develop REST service
|
||||

|
||||
|
||||
## 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__),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import sys
|
||||
import pathlib
|
||||
|
||||
|
||||
SRC_DIR = pathlib.Path(__file__).parent.parent / "src"
|
||||
|
||||
if str(SRC_DIR) not in sys.path:
|
||||
sys.path.append(str(SRC_DIR))
|
||||
|
||||
+427
-41
@@ -1,54 +1,103 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Union
|
||||
|
||||
import folder_paths
|
||||
from aiohttp import web
|
||||
from server import PromptServer
|
||||
|
||||
from comfy_pack.hash import async_batch_get_sha256
|
||||
from comfy_pack.model_helper import alookup_model_source
|
||||
|
||||
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:
|
||||
async def _write_requirements(path: ZPath, extras: list[str] | None = None) -> None:
|
||||
print("Package => Writing requirements.txt")
|
||||
with zf.open("requirements.txt", "w") as f:
|
||||
with path.joinpath("requirements.txt").open("w") as f:
|
||||
proc = await asyncio.subprocess.create_subprocess_exec(
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pip",
|
||||
"list",
|
||||
"--format",
|
||||
"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_snapshot(zf: zipfile.ZipFile) -> None:
|
||||
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()
|
||||
with zf.open("snapshot.json", "w") as f:
|
||||
if models is None:
|
||||
print("Package => Writing models")
|
||||
models = await _get_models()
|
||||
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": await _get_models(),
|
||||
"models": models,
|
||||
"custom_nodes": await _get_custom_nodes(),
|
||||
}
|
||||
f.write(json.dumps(data, indent=2).encode())
|
||||
f.write(json.dumps(data, indent=2))
|
||||
|
||||
|
||||
async def _get_models() -> list:
|
||||
print("Package => Writing models")
|
||||
def _is_port_in_use(port: int | str, host="localhost"):
|
||||
if isinstance(port, str):
|
||||
port = int(port)
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
try:
|
||||
s.connect((host, port))
|
||||
return True
|
||||
except ConnectionRefusedError:
|
||||
return False
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
def _is_file_refered(file_path: Path, workflow_api: dict) -> bool:
|
||||
""" """
|
||||
used_inputs = set()
|
||||
for node in workflow_api.values():
|
||||
for _, v in node["inputs"].items():
|
||||
if isinstance(v, str):
|
||||
used_inputs.add(v)
|
||||
all_inputs = "\n".join(used_inputs)
|
||||
file_path = file_path.absolute().relative_to(folder_paths.base_path)
|
||||
relpath = Path(*file_path.parts[2:])
|
||||
return str(relpath) in all_inputs
|
||||
|
||||
|
||||
async def _get_models(
|
||||
store_models: bool = False,
|
||||
workflow_api: dict | None = None,
|
||||
model_filter: set[str] | None = None,
|
||||
ensure_sha=True,
|
||||
ensure_source=True,
|
||||
) -> list:
|
||||
proc = await asyncio.subprocess.create_subprocess_exec(
|
||||
"git",
|
||||
"ls-files",
|
||||
@@ -57,19 +106,53 @@ async def _get_models() -> list:
|
||||
stdout=subprocess.PIPE,
|
||||
)
|
||||
stdout, _ = await proc.communicate()
|
||||
|
||||
models = []
|
||||
for line in stdout.decode().splitlines():
|
||||
if os.path.basename(line).startswith("."):
|
||||
continue
|
||||
filename = os.path.abspath(line)
|
||||
model_filenames = [
|
||||
os.path.abspath(line)
|
||||
for line in stdout.decode().splitlines()
|
||||
if not os.path.basename(line).startswith(".")
|
||||
]
|
||||
model_hashes = await async_batch_get_sha256(
|
||||
model_filenames,
|
||||
cache_only=not (ensure_sha or store_models),
|
||||
)
|
||||
|
||||
for filename in model_filenames:
|
||||
relpath = os.path.relpath(filename, folder_paths.base_path)
|
||||
with open(filename, "rb") as model:
|
||||
models.append(
|
||||
{
|
||||
"filename": relpath,
|
||||
"sha256": hashlib.sha256(model.read()).hexdigest(),
|
||||
}
|
||||
)
|
||||
|
||||
model_data = {
|
||||
"filename": relpath,
|
||||
"size": os.path.getsize(filename),
|
||||
"atime": os.path.getatime(filename),
|
||||
"ctime": os.path.getctime(filename),
|
||||
"disabled": relpath not in model_filter
|
||||
if model_filter is not None
|
||||
else False,
|
||||
"sha256": model_hashes.get(filename),
|
||||
}
|
||||
|
||||
model_data["source"] = await alookup_model_source(
|
||||
model_data["sha256"],
|
||||
cache_only=not ensure_source,
|
||||
)
|
||||
|
||||
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": relpath}
|
||||
) as model:
|
||||
shutil.copy(filename, model.path_of("model.bin"))
|
||||
model_data["model_tag"] = model_tag
|
||||
models.append(model_data)
|
||||
if workflow_api:
|
||||
for model in models:
|
||||
model["refered"] = _is_file_refered(Path(model["filename"]), workflow_api)
|
||||
return models
|
||||
|
||||
|
||||
@@ -113,33 +196,36 @@ async def _get_custom_nodes() -> list:
|
||||
return await asyncio.gather(*coros)
|
||||
|
||||
|
||||
async def _write_workflow(zf: zipfile.ZipFile, data: dict) -> None:
|
||||
async def _write_workflow(path: ZPath, data: dict) -> None:
|
||||
print("Package => 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())
|
||||
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(zf: zipfile.ZipFile, data: dict) -> None:
|
||||
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 k, v in node["inputs"].items():
|
||||
# if k in ("image", "path"):
|
||||
for _, v in node["inputs"].items():
|
||||
if isinstance(v, str):
|
||||
used_inputs.add(v)
|
||||
|
||||
for root_path, _, files in os.walk(input_dir):
|
||||
for file in files:
|
||||
if file not in used_inputs:
|
||||
continue
|
||||
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:
|
||||
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)
|
||||
|
||||
|
||||
@@ -155,15 +241,315 @@ 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_snapshot(zf)
|
||||
await _write_workflow(zf, data)
|
||||
await _write_inputs(zf, data)
|
||||
path = zipfile.Path(zf)
|
||||
await _prepare_bento_project(
|
||||
path,
|
||||
data,
|
||||
)
|
||||
|
||||
return web.json_response({"download_url": f"/bentoml/download/{zip_filename}"})
|
||||
|
||||
|
||||
class DevServer:
|
||||
TIMEOUT = 3600 * 24
|
||||
proc: Union[None, subprocess.Popen] = None
|
||||
watch_dog_task: asyncio.Task | None = None
|
||||
last_feed = 0
|
||||
run_dir: Path | None = None
|
||||
port = 0
|
||||
|
||||
@classmethod
|
||||
def start(cls, workflow_api: dict, port: int = 3000):
|
||||
cls.stop()
|
||||
|
||||
cls.port = port
|
||||
# prepare a temporary directory
|
||||
cls.run_dir = Path(tempfile.mkdtemp(suffix="-bento", prefix="comfy-pack-"))
|
||||
with cls.run_dir.joinpath("workflow_api.json").open("w") as f:
|
||||
f.write(json.dumps(workflow_api, indent=2))
|
||||
shutil.copy(
|
||||
Path(__file__).with_name("service.py"),
|
||||
cls.run_dir / "service.py",
|
||||
)
|
||||
shutil.copytree(COMFY_PACK_DIR, cls.run_dir / COMFY_PACK_DIR.name)
|
||||
|
||||
# find a free port
|
||||
self_port = 8188
|
||||
for i, arg in enumerate(sys.argv):
|
||||
if arg == "--port" or arg == "-p":
|
||||
self_port = int(sys.argv[i + 1])
|
||||
break
|
||||
|
||||
print(f"Starting dev server at port {port}, comfyui at port {self_port}")
|
||||
cls.proc = subprocess.Popen(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"bentoml",
|
||||
"serve",
|
||||
"service:ComfyService",
|
||||
"--port",
|
||||
str(port),
|
||||
],
|
||||
cwd=str(cls.run_dir.absolute()),
|
||||
env={
|
||||
**os.environ,
|
||||
"COMFYUI_SERVER": f"localhost:{self_port}",
|
||||
},
|
||||
)
|
||||
cls.watch_dog_task = asyncio.create_task(cls.watch_dog())
|
||||
cls.last_feed = time.time()
|
||||
|
||||
@classmethod
|
||||
async def watch_dog(cls):
|
||||
while True:
|
||||
await asyncio.sleep(0.1)
|
||||
if cls.last_feed + cls.TIMEOUT < time.time():
|
||||
cls.stop()
|
||||
break
|
||||
|
||||
@classmethod
|
||||
def feed_watch_dog(cls):
|
||||
if cls.proc:
|
||||
if cls.proc.poll() is None:
|
||||
cls.last_feed = time.time()
|
||||
return True
|
||||
else:
|
||||
cls.stop()
|
||||
return False
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def stop(cls):
|
||||
if cls.proc:
|
||||
cls.proc.terminate()
|
||||
cls.proc.wait()
|
||||
cls.proc = None
|
||||
time.sleep(1)
|
||||
print("Dev server stopped")
|
||||
if cls.watch_dog_task:
|
||||
cls.watch_dog_task.cancel()
|
||||
cls.watch_dog_task = None
|
||||
cls.last_feed = 0
|
||||
if cls.run_dir:
|
||||
shutil.rmtree(cls.run_dir)
|
||||
cls.run_dir = None
|
||||
|
||||
|
||||
def _parse_workflow(workflow: dict) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
inputs = {}
|
||||
outputs = {}
|
||||
dep_map = {}
|
||||
|
||||
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
|
||||
dep_map[tuple(v)] = node, input_name
|
||||
|
||||
for id, node in workflow.items():
|
||||
node["id"] = id
|
||||
if node["class_type"].startswith("CPackInput"):
|
||||
if not node.get("inputs"):
|
||||
continue
|
||||
inputs[id] = node
|
||||
elif node["class_type"].startswith("CPackOutput"):
|
||||
if not node.get("inputs"):
|
||||
continue
|
||||
outputs[id] = node
|
||||
|
||||
return inputs, outputs
|
||||
|
||||
|
||||
def _validate_workflow(data: dict):
|
||||
workflow = data.get("workflow_api", {})
|
||||
if not workflow:
|
||||
return web.json_response(
|
||||
{
|
||||
"result": "error",
|
||||
"error": "empty workflow",
|
||||
},
|
||||
)
|
||||
input_spec, output_spec = _parse_workflow(workflow)
|
||||
if not input_spec:
|
||||
return web.json_response(
|
||||
{
|
||||
"result": "error",
|
||||
"error": "At least one ComfyPack input node is required",
|
||||
},
|
||||
)
|
||||
if not output_spec:
|
||||
return web.json_response(
|
||||
{
|
||||
"result": "error",
|
||||
"error": "At least one ComfyPack output node is required",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@PromptServer.instance.routes.post("/bentoml/serve")
|
||||
async def serve(request):
|
||||
data = await request.json()
|
||||
|
||||
if (error := _validate_workflow(data)) is not None:
|
||||
return error
|
||||
|
||||
DevServer.stop()
|
||||
|
||||
if _is_port_in_use(data.get("port", 3000), host=data.get("host", "localhost")):
|
||||
return web.json_response(
|
||||
{
|
||||
"result": "error",
|
||||
"error": "Port is already in use",
|
||||
},
|
||||
)
|
||||
try:
|
||||
DevServer.start(workflow_api=data["workflow_api"], port=data.get("port", 3000))
|
||||
return web.json_response(
|
||||
{
|
||||
"result": "success",
|
||||
"url": f"http://{data.get('host', 'localhost')}:{data.get('port', 3000)}",
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
return web.json_response(
|
||||
{
|
||||
"result": "error",
|
||||
"error": f"Build failed: {e.__class__.__name__}: {e}",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@PromptServer.instance.routes.post("/bentoml/serve/heartbeat")
|
||||
async def heartbeat(_):
|
||||
running = DevServer.feed_watch_dog()
|
||||
|
||||
if running:
|
||||
return web.json_response({"ready": True})
|
||||
else:
|
||||
return web.json_response({"error": "Server is not running"})
|
||||
|
||||
|
||||
@PromptServer.instance.routes.post("/bentoml/serve/terminate")
|
||||
async def terminate(_):
|
||||
DevServer.stop()
|
||||
return web.json_response({"result": "success"})
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
async def _prepare_bento_project(
|
||||
working_dir: ZPath,
|
||||
data: dict,
|
||||
store_models: bool = False,
|
||||
):
|
||||
model_filter = set(data.get("models", []))
|
||||
models = await _get_models(
|
||||
store_models=store_models,
|
||||
model_filter=model_filter,
|
||||
)
|
||||
|
||||
await _write_requirements(working_dir, ["comfy-cli", "fastapi"])
|
||||
await _write_snapshot(working_dir, data, models)
|
||||
await _write_workflow(working_dir, data)
|
||||
await _write_inputs(working_dir, data)
|
||||
with working_dir.joinpath("service.py").open("w") as f:
|
||||
f.write(Path(__file__).with_name("service.py").read_text())
|
||||
|
||||
# Copy comfy_pack directory
|
||||
if isinstance(working_dir, Path):
|
||||
shutil.copytree(COMFY_PACK_DIR, working_dir / COMFY_PACK_DIR.name)
|
||||
else: # zipfile.Path
|
||||
for src in COMFY_PACK_DIR.rglob("*"):
|
||||
if src.is_file():
|
||||
rel_path = src.relative_to(COMFY_PACK_DIR.parent)
|
||||
with working_dir.joinpath(rel_path).open("wb") as f:
|
||||
f.write(src.read_bytes())
|
||||
models = [m for m in models if not m["disabled"]] # filter out disabled models
|
||||
return models
|
||||
|
||||
|
||||
@PromptServer.instance.routes.post("/bentoml/model/query")
|
||||
async def get_models(request):
|
||||
data = await request.json()
|
||||
models = await _get_models(
|
||||
workflow_api=data.get("workflow_api"),
|
||||
ensure_sha=False,
|
||||
ensure_source=False,
|
||||
)
|
||||
return web.json_response({"models": models})
|
||||
|
||||
|
||||
@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()
|
||||
|
||||
if (error := _validate_workflow(data)) is not None:
|
||||
return error
|
||||
|
||||
with tempfile.TemporaryDirectory(suffix="-bento", prefix="comfy-pack-") as temp_dir:
|
||||
temp_dir_path = Path(temp_dir)
|
||||
models = await _prepare_bento_project(temp_dir_path, data, store_models=True)
|
||||
|
||||
# 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,178 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import threading
|
||||
import time
|
||||
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")
|
||||
|
||||
|
||||
EXISTING_COMFYUI_SERVER = os.environ.get("COMFYUI_SERVER")
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def _watch_server(server: comfy_pack.run.ComfyUIServer):
|
||||
while True:
|
||||
time.sleep(1)
|
||||
if not server.is_running():
|
||||
if server.server_proc is not None:
|
||||
logger.warning(
|
||||
"Server exited with code %s", server.server_proc.returncode
|
||||
)
|
||||
os.kill(os.getpid(), signal.SIGTERM)
|
||||
break
|
||||
|
||||
|
||||
@bentoml.mount_asgi_app(app, path="/comfy")
|
||||
@bentoml.service(traffic={"timeout": REQUEST_TIMEOUT * 2}, resources={"gpu": 1})
|
||||
class ComfyService:
|
||||
def __init__(self):
|
||||
logger = logging.getLogger("comfy_pack")
|
||||
logger.setLevel(logging.INFO)
|
||||
if not EXISTING_COMFYUI_SERVER:
|
||||
self.server = comfy_pack.run.ComfyUIServer(
|
||||
str(_get_workspace()),
|
||||
str(INPUT_DIR),
|
||||
verbose=int("BENTOML_DEBUG" in os.environ),
|
||||
)
|
||||
self.server.start()
|
||||
logger.info(
|
||||
"ComfyUI Server started at %s:%s", self.server.host, self.server.port
|
||||
)
|
||||
self.host = self.server.host
|
||||
self.port = self.server.port
|
||||
self.watch_thread = threading.Thread(
|
||||
target=_watch_server,
|
||||
args=(self.server,),
|
||||
daemon=True,
|
||||
)
|
||||
self.watch_thread.start()
|
||||
logger.info("Watch thread started")
|
||||
else:
|
||||
logger.info("Attaching to ComfyUI server: %s", EXISTING_COMFYUI_SERVER)
|
||||
if ":" in EXISTING_COMFYUI_SERVER:
|
||||
self.host, port = EXISTING_COMFYUI_SERVER.split(":")
|
||||
self.port = int(port)
|
||||
else:
|
||||
self.host = EXISTING_COMFYUI_SERVER
|
||||
self.port = 80
|
||||
|
||||
@bentoml.api(input_spec=InputModel)
|
||||
def generate(
|
||||
self,
|
||||
*,
|
||||
ctx: bentoml.Context,
|
||||
**kwargs: Any,
|
||||
) -> Path:
|
||||
verbose = int("BENTOML_DEBUG" in os.environ)
|
||||
ret = comfy_pack.run_workflow(
|
||||
self.host,
|
||||
self.port,
|
||||
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):
|
||||
logger.info("Shutting down")
|
||||
if not EXISTING_COMFYUI_SERVER:
|
||||
self.server.stop()
|
||||
logger.info("server stopped")
|
||||
self.watch_thread.join()
|
||||
logger.info("Watch thread finished")
|
||||
|
||||
@bentoml.on_deployment
|
||||
@staticmethod
|
||||
def prepare_comfy_workspace():
|
||||
if EXISTING_COMFYUI_SERVER:
|
||||
return
|
||||
|
||||
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"]:
|
||||
if model.get("disabled", False):
|
||||
continue
|
||||
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()
|
||||
+8
-4
@@ -1,10 +1,14 @@
|
||||
[project]
|
||||
name = "comfyui-idl"
|
||||
description = "ComfyUI Interface Definition Language"
|
||||
name = "comfy-pack"
|
||||
description = """\
|
||||
A comprehensive toolkit for standardizing, packaging and deploying ComfyUI workflows \
|
||||
as reproducible environments and production-ready REST services\
|
||||
"""
|
||||
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",
|
||||
@@ -18,10 +22,10 @@ classifiers = [
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/bentoml/ComfyUI-IDL"
|
||||
Homepage = "https://github.com/bentoml/comfy-pack"
|
||||
|
||||
[project.scripts]
|
||||
comfy-idl = "comfyui_idl.cli:main"
|
||||
comfy-pack = "comfy_pack.cli:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["pdm-backend"]
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
bentoml
|
||||
fastapi
|
||||
comfy-cli
|
||||
googlesearch-python
|
||||
@@ -1,5 +1,5 @@
|
||||
from comfyui_idl.run import WorkflowRunner
|
||||
from comfyui_idl.utils import (
|
||||
from .run import ComfyUIServer, run_workflow
|
||||
from .utils import (
|
||||
generate_input_model,
|
||||
parse_workflow,
|
||||
populate_workflow,
|
||||
@@ -7,7 +7,8 @@ from comfyui_idl.utils import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"WorkflowRunner",
|
||||
"ComfyUIServer",
|
||||
"run_workflow",
|
||||
"parse_workflow",
|
||||
"generate_input_model",
|
||||
"populate_workflow",
|
||||
@@ -0,0 +1,192 @@
|
||||
import click
|
||||
import functools
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import tempfile
|
||||
from .const import WORKSPACE_DIR
|
||||
from .hash import get_sha256
|
||||
|
||||
|
||||
@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):
|
||||
sha = get_sha256(cpack)
|
||||
return WORKSPACE_DIR / sha[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 ComfyUIServer, run_workflow
|
||||
|
||||
with ComfyUIServer(str(workspace.absolute()), verbose=ctx.obj["verbose"]) as server:
|
||||
console.print("\n[green]✓ ComfyUI is launched in the background![/green]")
|
||||
results = run_workflow(
|
||||
server.host,
|
||||
server.port,
|
||||
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)
|
||||
@@ -0,0 +1,18 @@
|
||||
import pathlib
|
||||
import os
|
||||
|
||||
|
||||
CPACK_HOME = (
|
||||
pathlib.Path.home() / ".comfypack"
|
||||
if not os.environ.get("CPACK_HOME", "")
|
||||
else pathlib.Path(os.environ.get("CPACK_HOME", ""))
|
||||
)
|
||||
if not CPACK_HOME.exists():
|
||||
CPACK_HOME.mkdir()
|
||||
|
||||
MODEL_DIR = CPACK_HOME / "models"
|
||||
WORKSPACE_DIR = CPACK_HOME / "workspace"
|
||||
SHA_CACHE_FILE = CPACK_HOME / "sha_cache.json"
|
||||
MODEL_SOURCE_CACHE_FILE = CPACK_HOME / "model_source_cache.json"
|
||||
|
||||
COMFYUI_REPO = "https://github.com/comfyanonymous/ComfyUI.git"
|
||||
@@ -0,0 +1,122 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
from functools import partial
|
||||
from typing import Dict, List
|
||||
|
||||
from .const import SHA_CACHE_FILE
|
||||
|
||||
CALC_CMD = """
|
||||
import hashlib
|
||||
import sys
|
||||
|
||||
filepath = sys.argv[1]
|
||||
chunk_size = int(sys.argv[2])
|
||||
|
||||
sha256 = hashlib.sha256()
|
||||
with open(filepath, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(chunk_size), b""):
|
||||
sha256.update(chunk)
|
||||
print(sha256.hexdigest())
|
||||
"""
|
||||
|
||||
|
||||
def calculate_sha256_worker(filepath: str, chunk_size: int = 4 * 1024 * 1024) -> str:
|
||||
"""Calculate SHA-256 in a separate process"""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", CALC_CMD, filepath, str(chunk_size)],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def get_sha256(filepath: str) -> str:
|
||||
return batch_get_sha256([filepath])[filepath]
|
||||
|
||||
|
||||
def async_get_sha256(filepath: str) -> str:
|
||||
return asyncio.run(async_batch_get_sha256([filepath]))[filepath]
|
||||
|
||||
|
||||
def batch_get_sha256(filepaths: List[str], cache_only: bool = False) -> Dict[str, str]:
|
||||
return asyncio.run(async_batch_get_sha256(filepaths, cache_only=cache_only))
|
||||
|
||||
|
||||
async def async_batch_get_sha256(
|
||||
filepaths: List[str],
|
||||
cache_only: bool = False,
|
||||
) -> Dict[str, str]:
|
||||
# Load cache
|
||||
cache = {}
|
||||
if SHA_CACHE_FILE.exists():
|
||||
try:
|
||||
with SHA_CACHE_FILE.open("r") as f:
|
||||
cache = json.load(f)
|
||||
except (json.JSONDecodeError, IOError):
|
||||
pass
|
||||
|
||||
# Initialize process pool
|
||||
max_workers = max(1, (os.cpu_count() or 1))
|
||||
|
||||
# Process files
|
||||
results = {}
|
||||
new_cache = {}
|
||||
async with asyncio.Lock():
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
for filepath in filepaths:
|
||||
if not os.path.exists(filepath):
|
||||
results[filepath] = None
|
||||
continue
|
||||
|
||||
# Get file info
|
||||
stat = os.stat(filepath)
|
||||
current_size = stat.st_size
|
||||
current_time = stat.st_ctime
|
||||
|
||||
# Check cache
|
||||
cache_entry = cache.get(filepath)
|
||||
if cache_entry:
|
||||
if (
|
||||
cache_entry["size"] == current_size
|
||||
and cache_entry["birthtime"] == current_time
|
||||
):
|
||||
results[filepath] = cache_entry["sha256"]
|
||||
continue
|
||||
|
||||
if cache_only:
|
||||
results[filepath] = ""
|
||||
continue
|
||||
|
||||
# Calculate new SHA
|
||||
calc_func = partial(calculate_sha256_worker, filepath)
|
||||
sha256 = await loop.run_in_executor(pool, calc_func)
|
||||
|
||||
# Update cache and results
|
||||
new_cache[filepath] = {
|
||||
"sha256": sha256,
|
||||
"size": current_size,
|
||||
"birthtime": current_time,
|
||||
"last_verified": datetime.now().isoformat(),
|
||||
}
|
||||
results[filepath] = sha256
|
||||
|
||||
# Save cache
|
||||
try:
|
||||
with SHA_CACHE_FILE.open("r") as f:
|
||||
cache = json.load(f)
|
||||
cache.update(new_cache)
|
||||
with SHA_CACHE_FILE.open("w") as f:
|
||||
json.dump(cache, f, indent=2)
|
||||
except (IOError, OSError):
|
||||
pass
|
||||
|
||||
return results
|
||||
@@ -0,0 +1,161 @@
|
||||
from .const import MODEL_SOURCE_CACHE_FILE
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
|
||||
# MODEL_NAME = r"[a-zA-Z0-9-._]+"
|
||||
# COMMIT = r"[a-f0-9]+"
|
||||
|
||||
COMMIT_PATTERN = re.compile(r'href="/([a-zA-Z0-9-._/]+)/commit/([a-f0-9]+)"')
|
||||
|
||||
PATH_PATTERN = re.compile(
|
||||
r'data-target="CopyButton" data-props="{"value":"([^&]+)"'
|
||||
)
|
||||
|
||||
|
||||
async def _lookup_huggingface_model(model_sha: str) -> dict:
|
||||
from googlesearch import search
|
||||
import aiohttp
|
||||
|
||||
query = f"site:huggingface.co blob {model_sha}"
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
for url in search(query, num_results=5):
|
||||
assert isinstance(url, str)
|
||||
if "blob" not in url:
|
||||
continue
|
||||
try:
|
||||
async with session.get(url) as resp:
|
||||
if resp.status != 200:
|
||||
continue
|
||||
text = await resp.text()
|
||||
if commit_match := COMMIT_PATTERN.search(text):
|
||||
repo, commit = commit_match.groups()
|
||||
if path_match := PATH_PATTERN.search(text):
|
||||
path = path_match.group(1)
|
||||
download_url = f"https://huggingface.co/{repo}/resolve/{commit}/{path}?download=true"
|
||||
url = f"https://huggingface.co/{repo}/blob/{commit}/{path}"
|
||||
info = {
|
||||
"download_url": download_url,
|
||||
"url": url,
|
||||
"repo": repo,
|
||||
"commit": commit,
|
||||
"path": path,
|
||||
"source": "huggingface",
|
||||
}
|
||||
return info
|
||||
except aiohttp.ClientError:
|
||||
continue
|
||||
return {}
|
||||
|
||||
|
||||
async def _loopup_civitai_model(model_sha: str) -> dict:
|
||||
import aiohttp
|
||||
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.post(
|
||||
"https://meilisearch-v1-9.civitai.com/multi-search",
|
||||
headers={
|
||||
"accept": "*/*",
|
||||
"accept-language": "en,zh;q=0.9,zh-CN;q=0.8",
|
||||
"cache-control": "no-cache",
|
||||
"content-type": "application/json",
|
||||
"origin": "https://civitai.com",
|
||||
"pragma": "no-cache",
|
||||
"priority": "u=1, i",
|
||||
"referer": "https://civitai.com/",
|
||||
"sec-ch-ua": '"Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"',
|
||||
"sec-ch-ua-mobile": "?0",
|
||||
"sec-ch-ua-platform": '"macOS"',
|
||||
"sec-fetch-dest": "empty",
|
||||
"sec-fetch-mode": "cors",
|
||||
"sec-fetch-site": "same-site",
|
||||
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
||||
"x-meilisearch-client": "Meilisearch instant-meilisearch (v0.13.5) ; Meilisearch JavaScript (v0.34.0)",
|
||||
},
|
||||
json={
|
||||
"queries": [
|
||||
{
|
||||
"q": model_sha,
|
||||
"indexUid": "models_v9",
|
||||
"facets": [
|
||||
"category.name",
|
||||
"checkpointType",
|
||||
"fileFormats",
|
||||
"lastVersionAtUnix",
|
||||
"tags.name",
|
||||
"type",
|
||||
"user.username",
|
||||
"version.baseModel",
|
||||
],
|
||||
"attributesToHighlight": [],
|
||||
"highlightPreTag": "__ais-highlight__",
|
||||
"highlightPostTag": "__/ais-highlight__",
|
||||
"limit": 51,
|
||||
"offset": 0,
|
||||
"filter": ["nsfwLevel=1"],
|
||||
}
|
||||
]
|
||||
},
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
return {}
|
||||
data = await resp.json()
|
||||
|
||||
if len(data.get("results", [])) == 0:
|
||||
return {}
|
||||
|
||||
if len(data["results"][0]["hits"]) == 0:
|
||||
return {}
|
||||
|
||||
hit = data["results"][0]["hits"][0]
|
||||
repo_id = hit["id"]
|
||||
repo_name = hit["name"]
|
||||
versions = hit["versions"]
|
||||
|
||||
for version in versions:
|
||||
if model_sha.upper() in version["hashes"]:
|
||||
break
|
||||
else:
|
||||
return {}
|
||||
version_id = version["id"]
|
||||
version_name = version["name"]
|
||||
return {
|
||||
"download_url": f"https://civitai.com/api/download/models/{version_id}",
|
||||
"url": f"https://civitai.com/models/{repo_id}?modelVersionId={version_id}",
|
||||
"repo": repo_id,
|
||||
"commit": version_id,
|
||||
"source": "civitai",
|
||||
"repo_name": repo_name,
|
||||
"version_name": version_name,
|
||||
}
|
||||
|
||||
|
||||
async def alookup_model_source(model_sha: str, cache_only=False) -> dict:
|
||||
if not model_sha:
|
||||
return {}
|
||||
try:
|
||||
model_source_cache = json.loads(MODEL_SOURCE_CACHE_FILE.read_text())
|
||||
except Exception:
|
||||
with open(MODEL_SOURCE_CACHE_FILE, "w") as f:
|
||||
f.write("{}")
|
||||
model_source_cache = {}
|
||||
if model_source_cache.get(model_sha):
|
||||
return model_source_cache[model_sha]
|
||||
if cache_only:
|
||||
return {}
|
||||
|
||||
info = await _lookup_huggingface_model(model_sha)
|
||||
if not info:
|
||||
info = await _loopup_civitai_model(model_sha)
|
||||
|
||||
# elemental read and write
|
||||
model_source_cache = json.loads(MODEL_SOURCE_CACHE_FILE.read_text())
|
||||
model_source_cache[model_sha] = info
|
||||
with open(MODEL_SOURCE_CACHE_FILE, "w") as f:
|
||||
json.dump(model_source_cache, f)
|
||||
|
||||
return info
|
||||
|
||||
|
||||
def lookup_model_source(model_sha: str, cache_only=False) -> dict:
|
||||
return asyncio.run(alookup_model_source(model_sha, cache_only=cache_only))
|
||||
@@ -0,0 +1,384 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import urllib.parse
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import urllib.request
|
||||
from .hash import get_sha256
|
||||
from pathlib import Path
|
||||
from .const import MODEL_DIR, COMFYUI_REPO
|
||||
|
||||
|
||||
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 get_google_search_url(sha: str) -> str:
|
||||
"""Generate Google custom search URLs for model on HuggingFace and CivitAI"""
|
||||
base_url = "https://www.google.com/search"
|
||||
sha = sha.upper()
|
||||
hf_query = f"{sha} OR {sha[:10]}"
|
||||
hf_query = urllib.parse.quote(hf_query)
|
||||
return f"{base_url}?q={hf_query}"
|
||||
|
||||
|
||||
def download_file(url: str, dest_path: Path, progress_callback=None):
|
||||
"""Download file with progress tracking"""
|
||||
if subprocess.call(["curl", "--version"], stdout=subprocess.DEVNULL) == 0:
|
||||
subprocess.check_call(
|
||||
["curl", "-L", url, "-o", str(dest_path)],
|
||||
)
|
||||
return True
|
||||
try:
|
||||
with urllib.request.urlopen(url) as response:
|
||||
total_size = int(response.headers.get("content-length", 0))
|
||||
block_size = 8192
|
||||
downloaded = 0
|
||||
|
||||
with open(dest_path, "wb") as f:
|
||||
while True:
|
||||
buffer = response.read(block_size)
|
||||
if not buffer:
|
||||
break
|
||||
downloaded += len(buffer)
|
||||
f.write(buffer)
|
||||
if progress_callback:
|
||||
progress = (
|
||||
(downloaded / total_size) * 100 if total_size > 0 else 0
|
||||
)
|
||||
progress_callback(progress)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Download failed: {e}")
|
||||
if dest_path.exists():
|
||||
dest_path.unlink()
|
||||
return False
|
||||
|
||||
|
||||
def show_progress(filename: str):
|
||||
"""Progress callback function"""
|
||||
|
||||
def callback(progress):
|
||||
print(f"\rDownloading {filename}: {progress:.1f}%", end="")
|
||||
|
||||
return callback
|
||||
|
||||
|
||||
def create_model_symlink(global_path: Path, sha: str, target_path: Path, filename: str):
|
||||
"""Create symlink from global storage to workspace"""
|
||||
source = global_path / sha
|
||||
target = target_path / filename
|
||||
|
||||
if target.exists():
|
||||
if target.is_symlink():
|
||||
target.unlink()
|
||||
else:
|
||||
raise RuntimeError(f"File {target} already exists and is not a symlink")
|
||||
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
os.symlink(source, target)
|
||||
|
||||
|
||||
def retrive_models(
|
||||
snapshot: dict,
|
||||
workspace: Path,
|
||||
download: bool = True,
|
||||
verbose: int = 0,
|
||||
):
|
||||
"""Retrieve models from user downloads"""
|
||||
models = snapshot.get("models", [])
|
||||
if not models:
|
||||
return
|
||||
|
||||
MODEL_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for model in models:
|
||||
sha = model["sha256"]
|
||||
filename = model["filename"]
|
||||
disabled = model.get("disabled", False)
|
||||
if (workspace / filename).exists():
|
||||
if not (MODEL_DIR / sha).exists() and (workspace / filename).is_file():
|
||||
shutil.move(workspace / filename, MODEL_DIR / sha)
|
||||
create_model_symlink(MODEL_DIR, sha, workspace, filename)
|
||||
continue
|
||||
|
||||
if (MODEL_DIR / sha).exists():
|
||||
print(f"Model {filename} already exists in cache")
|
||||
create_model_symlink(MODEL_DIR, sha, workspace, filename)
|
||||
continue
|
||||
|
||||
if disabled:
|
||||
continue
|
||||
|
||||
if not download:
|
||||
continue
|
||||
|
||||
print(f"\nModel {filename} is never downloaded before")
|
||||
if source := model.get("source"):
|
||||
url = source["download_url"]
|
||||
target_path = MODEL_DIR / sha
|
||||
download_thread = threading.Thread(
|
||||
target=download_file,
|
||||
args=(url, target_path, show_progress(filename)),
|
||||
)
|
||||
download_thread.start()
|
||||
download_thread.join()
|
||||
|
||||
if not target_path.exists():
|
||||
print("\nDownload failed!")
|
||||
continue
|
||||
|
||||
print("\nDownload completed! Verifying SHA256...")
|
||||
terget_sha = get_sha256(str(target_path))
|
||||
if terget_sha != sha:
|
||||
print("SHA256 verification failed! File may be corrupted or incorrect.")
|
||||
target_path.unlink()
|
||||
else:
|
||||
continue
|
||||
|
||||
google_url = get_google_search_url(sha)
|
||||
print(f"Search URL: {google_url}")
|
||||
print(f"Path: {workspace / filename}")
|
||||
|
||||
while True:
|
||||
path = input("Enter path to downloaded file (or 'skip' to skip): ")
|
||||
if path.lower() == "skip":
|
||||
break
|
||||
|
||||
try:
|
||||
# Check if input is a URL
|
||||
if path.startswith(("http://", "https://")):
|
||||
url = path
|
||||
target_path = MODEL_DIR / sha
|
||||
|
||||
# Start download in a separate thread
|
||||
download_thread = threading.Thread(
|
||||
target=download_file,
|
||||
args=(url, target_path, show_progress(filename)),
|
||||
)
|
||||
download_thread.start()
|
||||
download_thread.join()
|
||||
|
||||
if not target_path.exists():
|
||||
print("\nDownload failed!")
|
||||
continue
|
||||
|
||||
print("\nDownload completed! Verifying SHA256...")
|
||||
terget_sha = get_sha256(str(target_path))
|
||||
if terget_sha != sha:
|
||||
print(
|
||||
"SHA256 verification failed! File may be corrupted or incorrect."
|
||||
)
|
||||
target_path.unlink()
|
||||
continue
|
||||
|
||||
print("SHA256 verification successful!")
|
||||
else:
|
||||
# Handle local file
|
||||
downloaded_path = Path(path)
|
||||
if not downloaded_path.exists():
|
||||
print("File does not exist!")
|
||||
continue
|
||||
|
||||
# Verify SHA256 before copying
|
||||
print("Verifying SHA256...")
|
||||
target_sha = get_sha256(str(downloaded_path))
|
||||
if target_sha != sha:
|
||||
print(
|
||||
f"Downloaded file SHA256 does not match expected SHA256: {target_sha} != {sha}"
|
||||
)
|
||||
continue
|
||||
|
||||
print("SHA256 verification successful!")
|
||||
# Copy to global storage
|
||||
shutil.copy2(downloaded_path, MODEL_DIR / sha)
|
||||
|
||||
# Create symlink
|
||||
create_model_symlink(MODEL_DIR, sha, workspace, filename)
|
||||
print(f"Model {filename} installed successfully")
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"Error processing file: {e}")
|
||||
continue
|
||||
|
||||
|
||||
def install(
|
||||
cpack: str | Path,
|
||||
workspace: str | Path = "workspace",
|
||||
preheat: bool = True,
|
||||
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)
|
||||
|
||||
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, dirs_exist_ok=True)
|
||||
|
||||
retrive_models(snapshot, workspace, verbose=verbose, download=False)
|
||||
|
||||
install_custom_modules(snapshot, workspace, verbose=verbose)
|
||||
if preheat:
|
||||
from .run import ComfyUIServer
|
||||
|
||||
with ComfyUIServer(str(workspace), verbose=verbose):
|
||||
pass
|
||||
|
||||
retrive_models(snapshot, workspace, verbose=verbose)
|
||||
@@ -0,0 +1,284 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Union
|
||||
|
||||
import psutil
|
||||
|
||||
from .utils import populate_workflow, retrieve_workflow_outputs
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _probe_comfyui_server(port: int) -> None:
|
||||
from urllib import parse, request
|
||||
|
||||
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)
|
||||
|
||||
full_url = f"http://127.0.0.1:{port}/api/object_info"
|
||||
req = request.Request(full_url)
|
||||
_ = request.urlopen(req)
|
||||
|
||||
|
||||
def _is_port_in_use(port: int | str, host="localhost"):
|
||||
if isinstance(port, str):
|
||||
port = int(port)
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
try:
|
||||
s.connect((host, port))
|
||||
return True
|
||||
except ConnectionRefusedError:
|
||||
return False
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
class ComfyUIServer:
|
||||
def __init__(
|
||||
self,
|
||||
workspace: str,
|
||||
input_dir: str | None = None,
|
||||
host: str = "localhost",
|
||||
port: int | None = None,
|
||||
verbose: int = 0,
|
||||
) -> None:
|
||||
"""
|
||||
Args:
|
||||
workspace (str, optional): The workspace path for ComfyUI. If not specified, runner will try to connect to an existing ComfyUI server.
|
||||
input_dir (str, optional): The input directory for ComfyUI. Defaults to None.
|
||||
port (int, optional): The port number for ComfyUI. Defaults to None. If 8188 is in use, a random port will be chosen.
|
||||
"""
|
||||
self.workspace = workspace
|
||||
self.input_dir = input_dir
|
||||
self.verbose = verbose
|
||||
self.host = host
|
||||
self.server_proc: subprocess.Popen | None = None
|
||||
self._children_procs: list[psutil.Process] = []
|
||||
|
||||
run_dir = Path(workspace) / "cli_run"
|
||||
self.temp_dir = run_dir / "temp"
|
||||
self.output_dir = run_dir / "output"
|
||||
|
||||
if port is None:
|
||||
if _is_port_in_use(8188):
|
||||
self.port = port if port else random.randint(58000, 58999)
|
||||
else:
|
||||
self.port = 8188
|
||||
else:
|
||||
self.port = port
|
||||
|
||||
def start(self) -> 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.
|
||||
"""
|
||||
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 self.verbose > 0 else subprocess.DEVNULL
|
||||
command = ["comfy", "--skip-prompt", "tracking", "disable"]
|
||||
subprocess.run(command, check=True, stdout=stdout)
|
||||
logger.info("Successfully disabled Comfy CLI tracking")
|
||||
|
||||
logger.info("Preparing directories required by ComfyUI...")
|
||||
|
||||
logger.info("Starting ComfyUI in the background...")
|
||||
command = [
|
||||
"comfy",
|
||||
"--workspace",
|
||||
self.workspace,
|
||||
"launch",
|
||||
"--",
|
||||
"--output-directory",
|
||||
self.output_dir,
|
||||
"--temp-directory",
|
||||
self.temp_dir,
|
||||
"--port",
|
||||
str(self.port),
|
||||
"--listen",
|
||||
self.host,
|
||||
]
|
||||
if self.input_dir:
|
||||
command.extend(["--input-directory", self.input_dir])
|
||||
|
||||
def preexec_fn():
|
||||
os.setpgrp()
|
||||
|
||||
self.server_proc = subprocess.Popen(
|
||||
command,
|
||||
stdout=stdout,
|
||||
stderr=None,
|
||||
preexec_fn=preexec_fn,
|
||||
)
|
||||
|
||||
if _wait_for_startup(self.host, self.port):
|
||||
_probe_comfyui_server(self.port)
|
||||
logger.info("Successfully started ComfyUI in the background")
|
||||
else:
|
||||
logger.error("Failed to start ComfyUI in the background")
|
||||
self._children_procs = psutil.Process(self.server_proc.pid).children(
|
||||
recursive=True
|
||||
)
|
||||
|
||||
def is_running(self) -> bool:
|
||||
if self.server_proc is None:
|
||||
return False
|
||||
if self.server_proc.poll() is not None:
|
||||
return False
|
||||
for child in self._children_procs:
|
||||
if not child.is_running():
|
||||
return False
|
||||
return True
|
||||
|
||||
def stop(self) -> None:
|
||||
"""
|
||||
Stop the ComfyUI process.
|
||||
|
||||
This method stops the running ComfyUI process and cleans up the temporary directory if necessary.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If ComfyUI is not currently running.
|
||||
"""
|
||||
if self.server_proc is None:
|
||||
raise RuntimeError("ComfyUI server is not started yet")
|
||||
proc = self.server_proc
|
||||
self.server_proc = None
|
||||
logger.info("Stopping ComfyUI...")
|
||||
for child in self._children_procs:
|
||||
child.terminate()
|
||||
child.wait()
|
||||
proc.terminate()
|
||||
proc.wait()
|
||||
self._children_procs.clear()
|
||||
logger.info("Successfully stopped ComfyUI")
|
||||
|
||||
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")
|
||||
|
||||
def __enter__(self):
|
||||
self.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.stop()
|
||||
|
||||
|
||||
def _wait_for_startup(host: str, port: int, timeout: int = 1800) -> bool:
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < timeout:
|
||||
if _is_port_in_use(port, host):
|
||||
return True
|
||||
time.sleep(1)
|
||||
return False
|
||||
|
||||
|
||||
def run_workflow(
|
||||
host: str,
|
||||
port: int,
|
||||
workflow: dict,
|
||||
output_dir: Union[str, Path, None] = None,
|
||||
timeout: int = 300,
|
||||
verbose: int = 0,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""
|
||||
Run a ComfyUI workflow.
|
||||
|
||||
This method executes a given workflow, populates it with input data,
|
||||
and retrieves the output.
|
||||
|
||||
Args:
|
||||
workflow (dict): The workflow to run.
|
||||
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.
|
||||
|
||||
Returns:
|
||||
Any: The output of the workflow.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If ComfyUI is not started.
|
||||
"""
|
||||
run_id = uuid.uuid4().hex[:8]
|
||||
|
||||
workflow_copy = copy.deepcopy(workflow)
|
||||
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,
|
||||
output_dir,
|
||||
session_id=run_id,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
workflow_file_path = output_dir / f"workflow_{run_id}.json"
|
||||
with open(workflow_file_path, "w") as file:
|
||||
json.dump(workflow_copy, file)
|
||||
|
||||
extra_args = []
|
||||
if verbose > 0:
|
||||
extra_args.append("--verbose")
|
||||
|
||||
stdout = None if verbose > 0 else subprocess.DEVNULL
|
||||
command = ["comfy", "--skip-prompt", "tracking", "disable"]
|
||||
subprocess.run(command, check=True, stdout=stdout)
|
||||
|
||||
# Execute the workflow
|
||||
command = [
|
||||
"comfy",
|
||||
"--skip-prompt",
|
||||
"run",
|
||||
"--workflow",
|
||||
workflow_file_path.as_posix(),
|
||||
"--port",
|
||||
str(port),
|
||||
"--host",
|
||||
host,
|
||||
"--timeout",
|
||||
str(timeout),
|
||||
"--wait",
|
||||
*extra_args,
|
||||
]
|
||||
env = os.environ.copy()
|
||||
env["NO_COLOR"] = "1"
|
||||
subprocess.run(command, check=True, env=env)
|
||||
|
||||
workflow_file_path.unlink()
|
||||
|
||||
# retrieve the output
|
||||
return retrieve_workflow_outputs(
|
||||
workflow_copy,
|
||||
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
|
||||
@@ -1,34 +0,0 @@
|
||||
import click
|
||||
|
||||
|
||||
@click.group()
|
||||
def main():
|
||||
"""ComfyUI IDL CLI"""
|
||||
|
||||
|
||||
@main.command(name="install")
|
||||
@click.option(
|
||||
"--workspace",
|
||||
"-w",
|
||||
default="workspace",
|
||||
help="Workspace directory",
|
||||
type=click.Path(file_okay=False),
|
||||
)
|
||||
@click.argument("cpack", type=click.Path(exists=True, dir_okay=False))
|
||||
def install_cmd(cpack: str, workspace: str):
|
||||
"""
|
||||
Install ComfyUI workspace from a zipped package.
|
||||
|
||||
Example:
|
||||
|
||||
# Install to the default directory(`workspace`)
|
||||
|
||||
$ comfyui_idl install workspace.cpack.zip
|
||||
|
||||
# Install to a different directory
|
||||
|
||||
$ comfyui_idl install -w my_workspace workspace.cpack.zip
|
||||
"""
|
||||
from .package import install
|
||||
|
||||
install(cpack, workspace)
|
||||
@@ -1,103 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
COMFYUI_REPO = "https://github.com/comfyanonymous/ComfyUI.git"
|
||||
|
||||
|
||||
def _clone_commit(url: str, commit: str, dir: Path):
|
||||
subprocess.check_call(["git", "clone", "--filter=blob:none", url, dir])
|
||||
subprocess.check_call(["git", "fetch", "-q", url, commit], cwd=dir)
|
||||
subprocess.check_call(["git", "checkout", "FETCH_HEAD"], cwd=dir)
|
||||
|
||||
|
||||
def install_comfyui(snapshot, workspace: Path):
|
||||
print("Installing ComfyUI")
|
||||
comfyui_commit = snapshot["comfyui"]
|
||||
|
||||
_clone_commit(COMFYUI_REPO, comfyui_commit, workspace)
|
||||
|
||||
|
||||
def install_custom_modules(snapshot, workspace: Path):
|
||||
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
|
||||
|
||||
commit_hash = module["commit_hash"]
|
||||
_clone_commit(url, commit_hash, module_dir)
|
||||
|
||||
|
||||
def install_dependencies(snapshot: dict, req_file: str, workspace: Path):
|
||||
print("Installing Python dependencies")
|
||||
python_version = snapshot["python"]
|
||||
subprocess.check_call(
|
||||
["uv", "python", "install", python_version],
|
||||
cwd=workspace,
|
||||
)
|
||||
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,
|
||||
],
|
||||
)
|
||||
subprocess.check_call(
|
||||
[
|
||||
"uv",
|
||||
"pip",
|
||||
"install",
|
||||
"-p",
|
||||
str(venv_py),
|
||||
"pip",
|
||||
],
|
||||
)
|
||||
subprocess.check_call(
|
||||
[
|
||||
"uv",
|
||||
"pip",
|
||||
"install",
|
||||
"-p",
|
||||
str(venv_py),
|
||||
"-r",
|
||||
req_file,
|
||||
"--no-deps",
|
||||
],
|
||||
)
|
||||
with open(venv / "DONE", "w") as f:
|
||||
f.write("DONE")
|
||||
|
||||
|
||||
def install(cpack: str | Path, workspace: str | Path = "workspace") -> None:
|
||||
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)
|
||||
install_custom_modules(snapshot, workspace)
|
||||
install_dependencies(snapshot, str(req_txt_file), workspace)
|
||||
for f in (pack_dir / "inputs").glob("*"):
|
||||
shutil.copy(f, workspace / "input" / f.name)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
install(sys.argv[1])
|
||||
@@ -1,212 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _probe_comfyui_server():
|
||||
from urllib import parse, request
|
||||
url = 'http://127.0.0.1:8188/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,
|
||||
) -> 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.is_running = False
|
||||
|
||||
def start(self) -> None:
|
||||
"""
|
||||
Start the ComfyUI process.
|
||||
|
||||
This method starts ComfyUI in the background, sets up necessary directories,
|
||||
and disables tracking for workaround purposes.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If ComfyUI is already running.
|
||||
"""
|
||||
if self.is_running:
|
||||
raise RuntimeError("ComfyUI Runner is already started")
|
||||
|
||||
logger.info(
|
||||
"Disable tracking from Comfy CLI, not for privacy concerns, but to workaround a bug"
|
||||
)
|
||||
command = ["comfy", "--skip-prompt", "tracking", "disable"]
|
||||
subprocess.run(command, check=True)
|
||||
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 = [
|
||||
"comfy",
|
||||
"--workspace",
|
||||
self.workspace,
|
||||
"launch",
|
||||
"--background",
|
||||
"--",
|
||||
"--output-directory",
|
||||
self.output_dir,
|
||||
"--temp-directory",
|
||||
self.temp_dir,
|
||||
]
|
||||
if subprocess.run(command, check=True):
|
||||
self.is_running = True
|
||||
_probe_comfyui_server()
|
||||
logger.info("Successfully started ComfyUI in the background")
|
||||
else:
|
||||
logger.error("Failed to start ComfyUI in the background")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""
|
||||
Stop the ComfyUI process.
|
||||
|
||||
This method stops the running ComfyUI process and cleans up the temporary directory if necessary.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If ComfyUI is not currently running.
|
||||
"""
|
||||
if not self.is_running:
|
||||
raise RuntimeError("ComfyUI Runner is not started yet")
|
||||
|
||||
logger.info("Stopping ComfyUI...")
|
||||
command = ["comfy", "stop"]
|
||||
subprocess.run(command, check=True)
|
||||
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")
|
||||
self.is_running = False
|
||||
|
||||
def run_workflow(
|
||||
self,
|
||||
workflow: dict,
|
||||
temp_dir: Union[str, Path, None] = None,
|
||||
timeout: int = 300,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""
|
||||
Run a ComfyUI workflow.
|
||||
|
||||
This method executes a given workflow, populates it with input data,
|
||||
and retrieves the output.
|
||||
|
||||
Args:
|
||||
workflow (dict): The workflow to run.
|
||||
temp_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.
|
||||
|
||||
Returns:
|
||||
Any: The output of the workflow.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If ComfyUI is not started.
|
||||
"""
|
||||
if not self.is_running:
|
||||
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)
|
||||
|
||||
populate_workflow(
|
||||
workflow_copy,
|
||||
temp_dir,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
workflow_file_path = temp_dir / "workflow.json"
|
||||
with open(workflow_file_path, "w") as file:
|
||||
json.dump(workflow_copy, file)
|
||||
|
||||
extra_args = []
|
||||
if "BENTOML_DEBUG" in os.environ:
|
||||
extra_args.append("--verbose")
|
||||
# Execute the workflow
|
||||
command = [
|
||||
"comfy",
|
||||
"run",
|
||||
"--workflow",
|
||||
workflow_file_path.as_posix(),
|
||||
"--timeout",
|
||||
str(timeout),
|
||||
"--wait",
|
||||
*extra_args,
|
||||
]
|
||||
subprocess.run(command, check=True)
|
||||
|
||||
# retrieve the output
|
||||
return retrieve_workflow_outputs(
|
||||
workflow_copy,
|
||||
temp_dir,
|
||||
)
|
||||
+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 };
|
||||
};
|
||||
|
||||
+859
-144
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user