Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62029e4a30 | ||
|
|
ca97c40d9f | ||
|
|
230e25dbf0 | ||
|
|
9347df2159 | ||
|
|
0d582b6458 | ||
|
|
2878a6fcdf | ||
|
|
367ea64dcd | ||
|
|
a6c05d3b48 | ||
|
|
4bfd08cead | ||
|
|
c75539c167 | ||
|
|
962aa5b573 | ||
|
|
068e055501 | ||
|
|
feb9c12bd2 | ||
|
|
5b10d3dcfb | ||
|
|
a58907ed7f | ||
|
|
8a12a9b5fa | ||
|
|
38cad77c1e | ||
|
|
9daee2c6cc | ||
|
|
4a30cc705b | ||
|
|
93ca8b49c3 | ||
|
|
c30ca2dba4 | ||
|
|
e7aed0e765 | ||
|
|
400fc272eb | ||
|
|
91ba706b55 | ||
|
|
abbd1f5aaa | ||
|
|
16baab39ef | ||
|
|
a1624ca69c | ||
|
|
41bae638fc | ||
|
|
b7166b6283 | ||
|
|
6abca7db24 | ||
|
|
e935bad9ac | ||
|
|
9617b568ed | ||
|
|
146b79cf74 | ||
|
|
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 | ||
|
|
ff7a55a804 | ||
|
|
f680263631 | ||
|
|
704937f2b9 | ||
|
|
3b016e2fa4 | ||
|
|
1e946b4e48 | ||
|
|
21b13b56c0 | ||
|
|
7b9f0ec026 | ||
|
|
cf8ea2b875 | ||
|
|
20bd7b379d | ||
|
|
64a8f7f712 | ||
|
|
1a24cd2343 | ||
|
|
27eb860c93 | ||
|
|
deb787096f | ||
|
|
b6d712ec1c | ||
|
|
0b1341a5a3 | ||
|
|
2b01701d0a | ||
|
|
7ecf35c76d | ||
|
|
5f7c3d8a10 | ||
|
|
cc58814e07 |
@@ -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
|
||||
|
||||
|
||||
@@ -160,3 +160,4 @@ cython_debug/
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
/temp/
|
||||
|
||||
@@ -1,3 +1,130 @@
|
||||
# ComfyUI-IDL
|
||||
# Comfy-Pack: Package and Deploy ComfyUI Workflows
|
||||
> ⚠️ **WARNING**: This project is under active development.
|
||||
|
||||
ComfyUI custom nodes for input and output specification.
|
||||
<img width="952" alt="banner" src="https://github.com/user-attachments/assets/1ab2c7a7-55da-4fa4-a821-db7ba720fbc9" />
|
||||
|
||||
A comprehensive toolkit for reliably packing and unpacking environments for ComfyUI workflows.
|
||||
|
||||
|
||||
- 📦 **Pack workflow environments as artifacts:** Saves the workflow environment in a `.cpack.zip` artifact with Python package versions, ComfyUI and custom node revisions, and model hashes.
|
||||
- ✨ **Unpack artifacts to recreate workflow environments:** Unpacks the `.cpack.zip` artifact to recreate the same environment with the exact Python package versions, ComfyUI and custom node revisions, and model weights.
|
||||
- 🚀 **Deploy workflows as APIs:** Deploys the workflow as a RESTful API with customizable input and output parameters.
|
||||
|
||||
## Motivations
|
||||
|
||||
When sharing ComfyUI workflows to others, your audiences or team member, you've likely heard these responses:
|
||||
- "Custom Node not found"
|
||||
- "Cannot find the model file"
|
||||
- "Missing Python dependencies"
|
||||
|
||||
These are fundamental challenges in workflow sharing – every component must match exactly: custom nodes, model files, and Python dependencies.
|
||||
|
||||
We learned it from our communit, and developed Comfy-Pack to address this directly. With a single click, it captures and locks your entire workflow environment into a `.cpack.zip` file, including Python packages, custom nodes, model hashes, and required assets.
|
||||
|
||||
Users can recreate the exact environment with one command:
|
||||
```bash
|
||||
comfy-pack unpack workflow.cpack.zip
|
||||
```
|
||||
|
||||
Focus on creating. Let Comfy-Pack handle the rest.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Installation
|
||||
|
||||
Search `comfy-pack` in ComfyUI Manager (Recommended)
|
||||
|
||||

|
||||
|
||||
or install from Git:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/bentoml/comfy-pack.git
|
||||
```
|
||||
|
||||
|
||||
### Pack a ComfyUI workflow
|
||||
1. Click "Package" button to create `.cpack.zip`
|
||||
2. (Optional) select the models that you want to include (only model hash will be recorded, so you wont get a 100GB zip file)
|
||||

|
||||
|
||||
|
||||
|
||||
### Unpack a ComfyUI workflow
|
||||
```bash
|
||||
# Restore the full ComfyUI directory from cpack files.
|
||||
comfy-pack unpack workflow.cpack.zip
|
||||
```
|
||||
|
||||
|
||||
### Deploy a workflow as an API
|
||||
<details>
|
||||
<summary> 1. annotate input & output </summary>
|
||||
|
||||

|
||||
|
||||

|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary> 2. serve and test locally </summary>
|
||||
|
||||

|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary> 3. (Optional) pack & run anywhere </summary>
|
||||
|
||||
```bash
|
||||
# Get the workflow input spec
|
||||
comfy-pack run workflow.cpack.zip --help
|
||||
|
||||
# Run
|
||||
comfy-pack run workflow.cpack.zip --src-image image.png --video video.mp4
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary> 4. (Optional) deploy to cloud * </summary>
|
||||
|
||||

|
||||
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
## Custom Node List
|
||||
|
||||
ComfyPack provides custom nodes for standardizing inputs:
|
||||
- ImageInput: provides `image` type input, similar to official `LoadImage` node
|
||||
- StringInput: provides `string` type input, nice for prompts
|
||||
- IntInput: provides `int` type input, suitable for size or seeds
|
||||
- AnyInput: provides `combo` type and more input, suitable for custom nodes
|
||||
- ImageOutput: takes `image` type inputs, similar to official `SaveImage` node, take an image of a bunch of images
|
||||
- FileOutput: takes file path as `string` type, save and output the file under that path
|
||||
- ...
|
||||
|
||||
These nodes help define clear interfaces for your workflow.
|
||||
|
||||
## 🚀 Roadmap
|
||||
- Docker Support
|
||||
- local cpack manager and Version Control
|
||||
- Enhanced service capabilities
|
||||
|
||||
|
||||
## Examples
|
||||
|
||||
Check our [examples folder](examples/) for:
|
||||
- Basic workflow packaging
|
||||
- Parameter configuration
|
||||
- API integration
|
||||
- Docker deployment
|
||||
|
||||
## License
|
||||
MIT License
|
||||
|
||||
## Community
|
||||
- Issues & Feature Requests: GitHub Issues
|
||||
- Questions & Discussion: Discord Server
|
||||
|
||||
Detailed documentation: under development
|
||||
|
||||
+15
-1
@@ -1,3 +1,17 @@
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from .nodes import api # noqa
|
||||
from .nodes.nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|
||||
WEB_DIRECTORY = "./web"
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
|
||||
|
||||
print("Comfy-Pack => Installing Python dependencies")
|
||||
subprocess.check_call(
|
||||
[sys.executable, "-m", "pip", "install", "-r", "requirements.txt"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
cwd=os.path.dirname(__file__),
|
||||
)
|
||||
|
||||
Binary file not shown.
@@ -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))
|
||||
|
||||
+562
@@ -0,0 +1,562 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
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
|
||||
from comfy_pack.utils import get_self_git_commit
|
||||
|
||||
ZPath = Union[Path, zipfile.Path]
|
||||
TEMP_FOLDER = Path(__file__).parent.parent / "temp"
|
||||
COMFY_PACK_DIR = Path(__file__).parent.parent / "src" / "comfy_pack"
|
||||
EXCLUDE_PACKAGES = ["bentoml", "onnxruntime"] # TODO: standardize this
|
||||
|
||||
|
||||
async def _write_requirements(path: ZPath, extras: list[str] | None = None) -> None:
|
||||
print("Package => Writing requirements.txt")
|
||||
with path.joinpath("requirements.txt").open("w") as f:
|
||||
proc = await asyncio.subprocess.create_subprocess_exec(
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pip",
|
||||
"list",
|
||||
"--format",
|
||||
"freeze",
|
||||
"--exclude-editable",
|
||||
*[f"--exclude={p}" for p in EXCLUDE_PACKAGES],
|
||||
stdout=subprocess.PIPE,
|
||||
)
|
||||
stdout, _ = await proc.communicate()
|
||||
f.write(stdout.decode().rstrip("\n") + "\n")
|
||||
if extras:
|
||||
f.write("\n".join(extras) + "\n")
|
||||
|
||||
|
||||
async def _write_snapshot(path: ZPath, data: dict, models: list | None = None) -> None:
|
||||
proc = await asyncio.subprocess.create_subprocess_exec(
|
||||
"git", "rev-parse", "HEAD", stdout=subprocess.PIPE, cwd=folder_paths.base_path
|
||||
)
|
||||
stdout, _ = await proc.communicate()
|
||||
if models is None:
|
||||
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": models,
|
||||
"custom_nodes": await _get_custom_nodes(),
|
||||
}
|
||||
f.write(json.dumps(data, indent=2))
|
||||
|
||||
|
||||
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",
|
||||
"--others",
|
||||
folder_paths.models_dir,
|
||||
stdout=subprocess.PIPE,
|
||||
)
|
||||
stdout, _ = await proc.communicate()
|
||||
|
||||
models = []
|
||||
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)
|
||||
|
||||
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,
|
||||
)
|
||||
# should_store = store_models and (
|
||||
# model_data["source"].get("source") != "huggingface"
|
||||
# or model_data["source"].get("repo", "").startswith("datasets/")
|
||||
# ) # TODO: sort this out
|
||||
should_store = store_models
|
||||
|
||||
if should_store:
|
||||
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
|
||||
|
||||
|
||||
async def _get_custom_nodes() -> list:
|
||||
print("Package => Writing custom nodes")
|
||||
custom_nodes = os.path.join(folder_paths.base_path, "custom_nodes")
|
||||
coros = []
|
||||
|
||||
async def get_node_info(subdir: Path) -> dict:
|
||||
proc = await asyncio.subprocess.create_subprocess_exec(
|
||||
"git",
|
||||
"config",
|
||||
"--get",
|
||||
"remote.origin.url",
|
||||
cwd=subdir,
|
||||
stdout=subprocess.PIPE,
|
||||
)
|
||||
stdout, _ = await proc.communicate()
|
||||
url = stdout.decode().strip()
|
||||
|
||||
proc = await asyncio.subprocess.create_subprocess_exec(
|
||||
"git",
|
||||
"rev-parse",
|
||||
"HEAD",
|
||||
cwd=subdir,
|
||||
stdout=subprocess.PIPE,
|
||||
)
|
||||
stdout, _ = await proc.communicate()
|
||||
commit_hash = stdout.decode().strip()
|
||||
return {
|
||||
"url": url,
|
||||
"commit_hash": commit_hash,
|
||||
"disabled": subdir.name.endswith(".disabled"),
|
||||
}
|
||||
|
||||
for subdir in Path(custom_nodes).iterdir():
|
||||
if not subdir.is_dir() or not subdir.joinpath(".git").exists():
|
||||
continue
|
||||
coros.append(get_node_info(subdir))
|
||||
|
||||
return await asyncio.gather(*coros)
|
||||
|
||||
|
||||
async def _write_workflow(path: ZPath, data: dict) -> None:
|
||||
print("Package => Writing workflow")
|
||||
with path.joinpath("workflow_api.json").open("w") as f:
|
||||
f.write(json.dumps(data["workflow_api"], indent=2))
|
||||
with path.joinpath("workflow.json").open("w") as f:
|
||||
f.write(json.dumps(data["workflow"], indent=2))
|
||||
|
||||
|
||||
async def _write_inputs(path: ZPath, data: dict) -> None:
|
||||
print("Package => Writing inputs")
|
||||
if isinstance(path, Path):
|
||||
path.joinpath("input").mkdir(exist_ok=True)
|
||||
|
||||
input_dir = folder_paths.get_input_directory()
|
||||
|
||||
used_inputs = set()
|
||||
for node in data["workflow_api"].values():
|
||||
for _, v in node["inputs"].items():
|
||||
if isinstance(v, str):
|
||||
used_inputs.add(v)
|
||||
|
||||
src_root = Path(input_dir).absolute()
|
||||
for src in src_root.glob("**/*"):
|
||||
rel = src.relative_to(src_root)
|
||||
if src.is_dir():
|
||||
if isinstance(path, Path):
|
||||
path.joinpath("input").joinpath(rel).mkdir(parents=True, exist_ok=True)
|
||||
if src.is_file():
|
||||
with path.joinpath("input").joinpath(rel).open("wb") as f:
|
||||
with open(src, "rb") as input_file:
|
||||
shutil.copyfileobj(input_file, f)
|
||||
|
||||
|
||||
@PromptServer.instance.routes.post("/bentoml/pack")
|
||||
async def pack_workspace(request):
|
||||
data = await request.json()
|
||||
TEMP_FOLDER.mkdir(exist_ok=True)
|
||||
older_than_1h = time.time() - 60 * 60
|
||||
for file in TEMP_FOLDER.iterdir():
|
||||
if file.is_file() and file.stat().st_ctime < older_than_1h:
|
||||
file.unlink()
|
||||
|
||||
zip_filename = f"{uuid.uuid4()}.zip"
|
||||
|
||||
with zipfile.ZipFile(TEMP_FOLDER / zip_filename, "w") as zf:
|
||||
path = zipfile.Path(zf)
|
||||
await _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,
|
||||
labels={"comfy-pack-version": get_self_git_commit() or "unknown"},
|
||||
models=[m["model_tag"] for m in models if "model_tag" in m],
|
||||
docker={
|
||||
"python_version": f"{sys.version_info.major}.{sys.version_info.minor}",
|
||||
"system_packages": [
|
||||
"git",
|
||||
"libglib2.0-0",
|
||||
"libsm6",
|
||||
"libxrender1",
|
||||
"libxext6",
|
||||
"ffmpeg",
|
||||
"libstdc++-12-dev",
|
||||
*data.get("system_packages", []),
|
||||
],
|
||||
},
|
||||
python={"requirements_txt": "requirements.txt", "lock_packages": True},
|
||||
)
|
||||
except bentoml.exceptions.BentoMLException as e:
|
||||
return web.json_response(
|
||||
{
|
||||
"result": "error",
|
||||
"error": f"Build failed: {e.__class__.__name__}: {e}",
|
||||
},
|
||||
)
|
||||
|
||||
if data.get("push", False):
|
||||
credentials = {}
|
||||
if api_key := data.get("api_key"):
|
||||
credentials["api_key"] = api_key
|
||||
if endpoint := data.get("endpoint"):
|
||||
credentials["endpoint"] = endpoint
|
||||
client = bentoml.cloud.BentoCloudClient(**credentials)
|
||||
try:
|
||||
client.bento.push(bento)
|
||||
except bentoml.exceptions.BentoMLException as e:
|
||||
return web.json_response(
|
||||
{
|
||||
"result": "error",
|
||||
"error": f"Push failed: {e.__class__.__name__}: {e}",
|
||||
}
|
||||
)
|
||||
|
||||
return web.json_response({"result": "success", "bento": str(bento.tag)})
|
||||
@@ -2,8 +2,8 @@ from threading import Lock
|
||||
|
||||
import execution
|
||||
|
||||
BENTOML_LAST_ID = None
|
||||
BENTOML_OUTPUT_CACHE = {}
|
||||
CPACK_LAST_ID = None
|
||||
CPACK_OUTPUT_CACHE = {}
|
||||
|
||||
_lock = Lock()
|
||||
|
||||
@@ -12,12 +12,12 @@ def store_bentoml_value(func):
|
||||
def wrapped(
|
||||
inputs, class_def, unique_id, outputs=None, dynprompt=None, extra_data={}
|
||||
):
|
||||
global BENTOML_LAST_ID
|
||||
if getattr(class_def, "BENTOML_NODE", False):
|
||||
global CPACK_LAST_ID
|
||||
if getattr(class_def, "CPACK_NODE", False):
|
||||
with _lock:
|
||||
BENTOML_LAST_ID = unique_id
|
||||
CPACK_LAST_ID = unique_id
|
||||
if outputs is None:
|
||||
outputs = BENTOML_OUTPUT_CACHE
|
||||
outputs = CPACK_OUTPUT_CACHE
|
||||
return func(inputs, class_def, unique_id, outputs, dynprompt, extra_data)
|
||||
|
||||
return wrapped
|
||||
@@ -28,4 +28,4 @@ execution.get_input_data = store_bentoml_value(execution.get_input_data)
|
||||
|
||||
def set_bentoml_output(output):
|
||||
with _lock:
|
||||
BENTOML_OUTPUT_CACHE[BENTOML_LAST_ID] = output
|
||||
CPACK_OUTPUT_CACHE[CPACK_LAST_ID] = output
|
||||
|
||||
+114
-136
@@ -1,6 +1,7 @@
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import shutil
|
||||
|
||||
import folder_paths
|
||||
@@ -22,7 +23,7 @@ class AnyType(str):
|
||||
anytype = AnyType("*") # when a != b is called, it will always return False
|
||||
|
||||
|
||||
class OutputPath:
|
||||
class OutputFile:
|
||||
COLOR = (142, 36, 170)
|
||||
OUTPUT_NODE = True
|
||||
|
||||
@@ -31,15 +32,15 @@ class OutputPath:
|
||||
return {
|
||||
"required": {
|
||||
"filename": ("STRING", {"default": "", "forceInput": True}),
|
||||
"filename_prefix": ("STRING", {"default": "ComfyUI_IDL_"}),
|
||||
"filename_prefix": ("STRING", {"default": "cpack_output_"}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
CATEGORY = "ComfyUI-IDL/output"
|
||||
BENTOML_NODE = True
|
||||
CATEGORY = "ComfyPack/output"
|
||||
CPACK_NODE = True
|
||||
FUNCTION = "save"
|
||||
DESCRIPTION = "Save the input data for IDL output"
|
||||
DESCRIPTION = "Save the input data for comfy-pack output"
|
||||
|
||||
def save(self, filename, filename_prefix):
|
||||
if not filename_prefix:
|
||||
@@ -109,7 +110,7 @@ class OutputImage:
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE", {"tooltip": "The images to save."}),
|
||||
"filename_prefix": ("STRING", {"default": "ComfyUI_IDL_"}),
|
||||
"filename_prefix": ("STRING", {"default": "cpack_output_"}),
|
||||
},
|
||||
"hidden": {
|
||||
"prompt": "PROMPT",
|
||||
@@ -119,14 +120,14 @@ class OutputImage:
|
||||
|
||||
RETURN_TYPES = ()
|
||||
FUNCTION = "save_images"
|
||||
BENTOML_NODE = True
|
||||
CPACK_NODE = True
|
||||
OUTPUT_NODE = True
|
||||
|
||||
CATEGORY = "ComfyUI-IDL/output"
|
||||
CATEGORY = "ComfyPack/output"
|
||||
DESCRIPTION = "Saves the input images to your ComfyUI output directory."
|
||||
|
||||
def save_images(
|
||||
self, images, filename_prefix="ComfyUI_IDL_", prompt=None, extra_pnginfo=None
|
||||
self, images, filename_prefix="cpack_output_", prompt=None, extra_pnginfo=None
|
||||
):
|
||||
filename_prefix += self.prefix_append
|
||||
full_output_folder, filename, counter, subfolder, filename_prefix = (
|
||||
@@ -177,10 +178,9 @@ class ImageInput:
|
||||
"required": {"image": (sorted(files), {"image_upload": True})},
|
||||
}
|
||||
|
||||
CATEGORY = "ComfyUI-IDL/input"
|
||||
BENTOML_NODE = True
|
||||
CATEGORY = "ComfyPack/input"
|
||||
CPACK_NODE = True
|
||||
RETURN_TYPES = ("IMAGE", "MASK")
|
||||
RETUEN_NAMES = ("image", "mask")
|
||||
FUNCTION = "load_image"
|
||||
|
||||
def load_image(self, image):
|
||||
@@ -243,111 +243,7 @@ class ImageInput:
|
||||
return True
|
||||
|
||||
|
||||
class StringInput:
|
||||
COLOR = (142, 36, 170)
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"value": ("STRING", {"default": ""}),
|
||||
}
|
||||
}
|
||||
|
||||
RETUEN_NAMES = ("string",)
|
||||
RETURN_TYPES = (anytype,)
|
||||
BENTOML_NODE = True
|
||||
FUNCTION = "string_input"
|
||||
CATEGORY = "ComfyUI-IDL/input"
|
||||
|
||||
def string_input(self, value):
|
||||
return (value,)
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(s, value):
|
||||
set_bentoml_output([(value,)])
|
||||
return True
|
||||
|
||||
|
||||
class IntegerInput:
|
||||
COLOR = (142, 36, 170)
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"value": ("INT", {"default": 0}),
|
||||
}
|
||||
}
|
||||
|
||||
RETUEN_NAMES = ("int",)
|
||||
RETURN_TYPES = (anytype,)
|
||||
FUNCTION = "identity"
|
||||
BENTOML_NODE = True
|
||||
CATEGORY = "ComfyUI-IDL/input"
|
||||
|
||||
def identity(self, value):
|
||||
return (value,)
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(s, value):
|
||||
set_bentoml_output([(value,)])
|
||||
return True
|
||||
|
||||
|
||||
class FloatInput:
|
||||
COLOR = (142, 36, 170)
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"value": ("FLOAT", {"default": 0.0}),
|
||||
}
|
||||
}
|
||||
|
||||
RETUEN_NAMES = ("float",)
|
||||
RETURN_TYPES = (anytype,)
|
||||
FUNCTION = "identity"
|
||||
BENTOML_NODE = True
|
||||
CATEGORY = "ComfyUI-IDL/input"
|
||||
|
||||
def identity(self, value):
|
||||
return (value,)
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(s, value):
|
||||
set_bentoml_output([(value,)])
|
||||
return True
|
||||
|
||||
|
||||
class BooleanInput:
|
||||
COLOR = (142, 36, 170)
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"value": ("BOOLEAN", {"default": False}),
|
||||
}
|
||||
}
|
||||
|
||||
RETUEN_NAMES = ("bool",)
|
||||
RETURN_TYPES = (anytype,)
|
||||
FUNCTION = "identity"
|
||||
CATEGORY = "ComfyUI-IDL/input"
|
||||
BENTOML_NODE = True
|
||||
|
||||
def identity(self, value):
|
||||
return (value,)
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(s, value):
|
||||
set_bentoml_output([(value,)])
|
||||
return True
|
||||
|
||||
|
||||
class PathInput:
|
||||
class FileInput:
|
||||
COLOR = (142, 36, 170)
|
||||
|
||||
@classmethod
|
||||
@@ -359,10 +255,10 @@ class PathInput:
|
||||
}
|
||||
|
||||
RETURN_TYPES = (anytype,)
|
||||
RETUEN_NAMES = ("path",)
|
||||
RETURN_NAMES = ("path",)
|
||||
FUNCTION = "identity"
|
||||
BENTOML_NODE = True
|
||||
CATEGORY = "ComfyUI-IDL/input"
|
||||
CPACK_NODE = True
|
||||
CATEGORY = "ComfyPack/input"
|
||||
|
||||
def identity(self, path):
|
||||
return (path,)
|
||||
@@ -373,24 +269,106 @@ class PathInput:
|
||||
return True
|
||||
|
||||
|
||||
class StringInput:
|
||||
COLOR = (142, 36, 170)
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"value": ("STRING", {"default": ""}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("value",)
|
||||
FUNCTION = "identity"
|
||||
CPACK_NODE = True
|
||||
CATEGORY = "ComfyPack/input"
|
||||
|
||||
def identity(self, value):
|
||||
return (value,)
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(s, value):
|
||||
set_bentoml_output([(value,)])
|
||||
return True
|
||||
|
||||
|
||||
class IntInput:
|
||||
COLOR = (142, 36, 170)
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"value": ("INT", {"default": 0}),
|
||||
},
|
||||
"optional": {
|
||||
"min": ("INT", {"default": -sys.maxsize}),
|
||||
"max": ("INT", {"default": sys.maxsize}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("INT",)
|
||||
RETURN_NAMES = ("value",)
|
||||
FUNCTION = "identity"
|
||||
CPACK_NODE = True
|
||||
CATEGORY = "ComfyPack/input"
|
||||
|
||||
def identity(self, value, min=None, max=None):
|
||||
return (value,)
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(s, value, min=None, max=None):
|
||||
if min is not None and max is not None and min > max:
|
||||
return f"Value must be greater than or equal to {min}"
|
||||
set_bentoml_output([(value,)])
|
||||
return True
|
||||
|
||||
|
||||
class AnyInput:
|
||||
COLOR = (142, 36, 170)
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"input": ("*", {"default": ""}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = (anytype,)
|
||||
RENAME = ("value",)
|
||||
FUNCTION = "identity"
|
||||
CPACK_NODE = True
|
||||
CATEGORY = "ComfyPack/input"
|
||||
|
||||
def identity(self, input):
|
||||
return (input,)
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(s, input):
|
||||
set_bentoml_output([(input,)])
|
||||
return True
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"BentoOutputPath": OutputPath,
|
||||
"BentoOutputImage": OutputImage,
|
||||
"BentoInputImage": ImageInput,
|
||||
"BentoInputString": StringInput,
|
||||
"BentoInputInteger": IntegerInput,
|
||||
"BentoInputFloat": FloatInput,
|
||||
"BentoInputBoolean": BooleanInput,
|
||||
"BentoInputPath": PathInput,
|
||||
"CPackOutputFile": OutputFile,
|
||||
"CPackOutputImage": OutputImage,
|
||||
"CPackInputImage": ImageInput,
|
||||
"CPackInputString": StringInput,
|
||||
"CPackInputInt": IntInput,
|
||||
"CPackInputFile": FileInput,
|
||||
"CPackInputAny": AnyInput,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"BentoInputImage": "Image Input",
|
||||
"BentoInputPath": "Path Input",
|
||||
"BentoInputInteger": "Integer Input",
|
||||
"BentoInputFloat": "Float Input",
|
||||
"BentoInputBoolean": "Boolean Input",
|
||||
"BentoInputString": "String Input",
|
||||
"BentoOutputImage": "Image Output",
|
||||
"BentoOutputPath": "Path Output",
|
||||
"CPackInputImage": "Image Input",
|
||||
"CPackInputString": "String Input",
|
||||
"CPackInputInt": "Int Input",
|
||||
"CPackInputFile": "File Input",
|
||||
"CPackInputAny": "Any Input",
|
||||
"CPackOutputImage": "Image Output",
|
||||
"CPackOutputFile": "File Output",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
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
|
||||
from bentoml.models import HuggingFaceModel
|
||||
|
||||
import comfy_pack
|
||||
import comfy_pack.run
|
||||
|
||||
REQUEST_TIMEOUT = 3600
|
||||
BASE_DIR = Path(__file__).parent
|
||||
COPY_THRESHOLD = 10 * 1024 * 1024
|
||||
INPUT_DIR = BASE_DIR / "input"
|
||||
logger = logging.getLogger("bentoml.service")
|
||||
|
||||
|
||||
EXISTING_COMFYUI_SERVER = os.environ.get("COMFYUI_SERVER")
|
||||
|
||||
with BASE_DIR.joinpath("workflow_api.json").open() 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
|
||||
|
||||
|
||||
if not EXISTING_COMFYUI_SERVER:
|
||||
# register models
|
||||
with BASE_DIR.joinpath("snapshot.json").open("rb") as f:
|
||||
snapshot = json.load(f)
|
||||
else:
|
||||
snapshot = {}
|
||||
|
||||
|
||||
@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()
|
||||
|
||||
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_path = comfy_workspace / cast(str, model["filename"])
|
||||
if model_tag := model.get("model_tag"):
|
||||
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)
|
||||
elif (source := model["source"]).get("source") == "huggingface":
|
||||
matched = next(
|
||||
(
|
||||
m
|
||||
for m in ComfyService.models
|
||||
if isinstance(m, HuggingFaceModel)
|
||||
and m.model_id.lower() == source["repo"].lower()
|
||||
and source["commit"].lower() == m.revision.lower()
|
||||
),
|
||||
None,
|
||||
)
|
||||
if matched is not None:
|
||||
model_file = os.path.join(matched.resolve(), source["path"])
|
||||
model_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
logger.info("Copying %s to %s", model_file, model_path)
|
||||
model_path.symlink_to(model_file)
|
||||
else:
|
||||
logger.warning(
|
||||
"Unrecognized model source: %s, the model may be missing",
|
||||
source,
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
|
||||
if not EXISTING_COMFYUI_SERVER:
|
||||
for model in snapshot["models"]:
|
||||
if True:
|
||||
continue
|
||||
if model.get("disabled"):
|
||||
continue
|
||||
source = model["source"]
|
||||
if source.get("source") != "huggingface" or source["repo"].startswith(
|
||||
"datasets/"
|
||||
):
|
||||
continue
|
||||
ComfyService.models.append(HuggingFaceModel(source["repo"], source["commit"]))
|
||||
+11
-3
@@ -1,10 +1,15 @@
|
||||
[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",
|
||||
]
|
||||
@@ -17,7 +22,10 @@ classifiers = [
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/bentoml/ComfyUI-IDL"
|
||||
Homepage = "https://github.com/bentoml/comfy-pack"
|
||||
|
||||
[project.scripts]
|
||||
comfy-pack = "comfy_pack.cli:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["pdm-backend"]
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
bentoml
|
||||
fastapi
|
||||
comfy-cli
|
||||
duckduckgo-search
|
||||
uv
|
||||
@@ -1,14 +1,14 @@
|
||||
from comfyui_idl.utils import (
|
||||
parse_workflow,
|
||||
from .run import ComfyUIServer, run_workflow
|
||||
from .utils import (
|
||||
generate_input_model,
|
||||
parse_workflow,
|
||||
populate_workflow,
|
||||
retrieve_workflow_outputs,
|
||||
)
|
||||
|
||||
from comfyui_idl.run import WorkflowRunner
|
||||
|
||||
__all__ = [
|
||||
"WorkflowRunner",
|
||||
"ComfyUIServer",
|
||||
"run_workflow",
|
||||
"parse_workflow",
|
||||
"generate_input_model",
|
||||
"populate_workflow",
|
||||
@@ -0,0 +1,413 @@
|
||||
import click
|
||||
import functools
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from .const import WORKSPACE_DIR, COMFYUI_REPO, COMFY_PACK_REPO, COMFYUI_MANAGER_REPO
|
||||
from .hash import get_sha256
|
||||
from .utils import get_self_git_commit
|
||||
|
||||
|
||||
def _ensure_uv() -> None:
|
||||
"""Ensure uv is installed, raise error if not."""
|
||||
try:
|
||||
subprocess.run(
|
||||
["uv", "--version"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
except (subprocess.SubprocessError, FileNotFoundError):
|
||||
raise RuntimeError(
|
||||
"uv is not installed. Please install it first:\n"
|
||||
"curl -LsSf https://astral.sh/uv/install.sh | sh"
|
||||
)
|
||||
|
||||
|
||||
@click.group()
|
||||
def main():
|
||||
"""comfy-pack CLI"""
|
||||
pass
|
||||
|
||||
|
||||
@main.command(
|
||||
name="init",
|
||||
help="Install latest ComfyUI and comfy-pack custom nodes and create a virtual environment",
|
||||
)
|
||||
@click.option(
|
||||
"--dir",
|
||||
"-d",
|
||||
default="ComfyUI",
|
||||
help="Target directory to install ComfyUI",
|
||||
type=click.Path(file_okay=False),
|
||||
)
|
||||
@click.option(
|
||||
"--verbose",
|
||||
"-v",
|
||||
count=True,
|
||||
help="Increase verbosity level",
|
||||
)
|
||||
def init(dir: str, verbose: int):
|
||||
from rich.console import Console
|
||||
import os
|
||||
|
||||
console = Console()
|
||||
|
||||
# Check if directory path is valid
|
||||
try:
|
||||
install_dir = Path(dir).absolute()
|
||||
if install_dir.exists() and not install_dir.is_dir():
|
||||
console.print(f"[red]Error: {dir} exists but is not a directory[/red]")
|
||||
return 1
|
||||
|
||||
# Check if directory is empty or contains ComfyUI
|
||||
if install_dir.exists():
|
||||
contents = list(install_dir.iterdir())
|
||||
if contents and not (install_dir / ".git").exists():
|
||||
console.print(
|
||||
f"[red]Error: Directory {dir} is not empty and doesn't appear to be a ComfyUI installation[/red]"
|
||||
)
|
||||
return 1
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error: Invalid directory path - {str(e)}[/red]")
|
||||
return 1
|
||||
|
||||
# Check git installation
|
||||
try:
|
||||
subprocess.run(
|
||||
["git", "--version"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
except (subprocess.SubprocessError, FileNotFoundError):
|
||||
console.print("[red]Error: git is not installed or not in PATH[/red]")
|
||||
return 1
|
||||
|
||||
# Check if we have write permissions
|
||||
try:
|
||||
if not install_dir.exists():
|
||||
install_dir.mkdir(parents=True)
|
||||
test_file = install_dir / ".write_test"
|
||||
test_file.touch()
|
||||
test_file.unlink()
|
||||
except (OSError, PermissionError) as e:
|
||||
console.print(f"[red]Error: No write permission in {dir} - {str(e)}[/red]")
|
||||
return 1
|
||||
|
||||
# Check if Python version is compatible
|
||||
if sys.version_info < (3, 8):
|
||||
console.print("[red]Error: Python 3.8 or higher is required[/red]")
|
||||
return 1
|
||||
|
||||
# Check if uv is installed
|
||||
try:
|
||||
_ensure_uv()
|
||||
except RuntimeError as e:
|
||||
console.print(f"[red]Error: {str(e)}[/red]")
|
||||
return 1
|
||||
|
||||
# Check if enough disk space is available (rough estimate: 2GB)
|
||||
try:
|
||||
free_space = shutil.disk_usage(install_dir).free
|
||||
if free_space < 2 * 1024 * 1024 * 1024: # 2GB in bytes
|
||||
console.print(
|
||||
"[yellow]Warning: Less than 2GB free disk space available[/yellow]"
|
||||
)
|
||||
except Exception as e:
|
||||
console.print(
|
||||
f"[yellow]Warning: Could not check free disk space - {str(e)}[/yellow]"
|
||||
)
|
||||
|
||||
# Clone ComfyUI if not exists
|
||||
if not (install_dir / ".git").exists():
|
||||
console.print("[green]Cloning ComfyUI...[/green]")
|
||||
subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"clone",
|
||||
COMFYUI_REPO,
|
||||
str(install_dir),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
# Update ComfyUI
|
||||
console.print("[green]Updating ComfyUI...[/green]")
|
||||
subprocess.run(
|
||||
["git", "pull"],
|
||||
cwd=install_dir,
|
||||
check=True,
|
||||
)
|
||||
|
||||
# Create and activate venv
|
||||
venv_dir = install_dir / ".venv"
|
||||
console.print("[green]Creating virtual environment with uv...[/green]")
|
||||
if venv_dir.exists():
|
||||
shutil.rmtree(venv_dir)
|
||||
subprocess.run(
|
||||
["uv", "venv", str(venv_dir)],
|
||||
check=True,
|
||||
)
|
||||
|
||||
# Get python path for future use
|
||||
if sys.platform == "win32":
|
||||
python = str(venv_dir / "Scripts" / "python.exe")
|
||||
|
||||
else:
|
||||
python = str(venv_dir / "bin" / "python")
|
||||
|
||||
# Install requirements with uv
|
||||
console.print("[green]Installing ComfyUI requirements with uv...[/green]")
|
||||
subprocess.run(
|
||||
["uv", "pip", "install", "pip", "--upgrade"],
|
||||
env={
|
||||
"VIRTUAL_ENV": str(venv_dir),
|
||||
"PATH": str(venv_dir / "bin") + os.pathsep + os.environ["PATH"],
|
||||
},
|
||||
check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["uv", "pip", "install", "-r", str(install_dir / "requirements.txt")],
|
||||
env={
|
||||
"VIRTUAL_ENV": str(venv_dir),
|
||||
"PATH": str(venv_dir / "bin") + os.pathsep + os.environ["PATH"],
|
||||
},
|
||||
check=True,
|
||||
)
|
||||
|
||||
# Install comfy-pack as custom node
|
||||
console.print("[green]Installing comfy-pack custom nodes...[/green]")
|
||||
custom_nodes_dir = install_dir / "custom_nodes"
|
||||
custom_nodes_dir.mkdir(exist_ok=True)
|
||||
|
||||
comfyui_manager_dir = custom_nodes_dir / "ComfyUI-Manager"
|
||||
if not (comfyui_manager_dir / ".git").exists():
|
||||
# Clone ComfyUI-Manager
|
||||
subprocess.run(
|
||||
["git", "clone", COMFYUI_MANAGER_REPO, str(comfyui_manager_dir)],
|
||||
check=True,
|
||||
)
|
||||
|
||||
comfy_pack_dir = custom_nodes_dir / "comfy-pack"
|
||||
if not (comfy_pack_dir / ".git").exists():
|
||||
# Clone comfy-pack
|
||||
subprocess.run(
|
||||
["git", "clone", COMFY_PACK_REPO, str(comfy_pack_dir)],
|
||||
check=True,
|
||||
)
|
||||
|
||||
# Update comfy-pack
|
||||
subprocess.run(
|
||||
["git", "pull"],
|
||||
cwd=comfy_pack_dir,
|
||||
check=True,
|
||||
)
|
||||
|
||||
# Install comfy-pack requirements
|
||||
if (comfy_pack_dir / "requirements.txt").exists():
|
||||
subprocess.run(
|
||||
[
|
||||
python,
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"-r",
|
||||
str(comfy_pack_dir / "requirements.txt"),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
version = get_self_git_commit() or "unknown"
|
||||
console.print(
|
||||
f"\n[green]✓ Installation completed! (comfy-pack version: {version})[/green]"
|
||||
)
|
||||
console.print(f"ComfyUI directory: {install_dir}")
|
||||
|
||||
console.print(
|
||||
"\n[green]Next steps:[/green]\n"
|
||||
f"1. cd {dir}\n"
|
||||
"2. source .venv/bin/activate # On Windows: .venv\\Scripts\\activate\n"
|
||||
"3. python main.py"
|
||||
)
|
||||
|
||||
|
||||
@main.command(
|
||||
name="unpack",
|
||||
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.option(
|
||||
"--include-disabled-models",
|
||||
default=False,
|
||||
type=click.BOOL,
|
||||
is_flag=True,
|
||||
)
|
||||
@click.option(
|
||||
"--verbose",
|
||||
"-v",
|
||||
count=True,
|
||||
help="Increase verbosity level (use multiple times for more verbosity)",
|
||||
)
|
||||
def unpack_cmd(cpack: str, dir: str, include_disabled_models: bool, verbose: int):
|
||||
from .package import install
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
|
||||
install(cpack, dir, verbose=verbose, all_models=include_disabled_models)
|
||||
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)
|
||||
|
||||
|
||||
@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",
|
||||
add_help_option=False,
|
||||
)
|
||||
@click.argument("cpack", type=click.Path(exists=True, dir_okay=False))
|
||||
@click.option("--output-dir", "-o", type=click.Path(), default=".")
|
||||
@click.option("--help", "-h", is_flag=True, help="Show this message and input schema")
|
||||
@click.option(
|
||||
"--verbose",
|
||||
"-v",
|
||||
count=True,
|
||||
help="Increase verbosity level (use multiple times for more verbosity)",
|
||||
)
|
||||
@click.pass_context
|
||||
def run(ctx, cpack: str, output_dir: str, help: bool, verbose: int):
|
||||
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)
|
||||
|
||||
# If help is requested, show command help and input schema
|
||||
if help:
|
||||
console.print(
|
||||
'Usage: comfy-pack run [OPTIONS] CPACK --input1 "value1" --input2 "value2" ...'
|
||||
)
|
||||
console.print("Run a ComfyUI package with the given inputs:")
|
||||
_print_schema(input_model.model_json_schema(), verbose)
|
||||
return 0
|
||||
|
||||
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']}")
|
||||
|
||||
console.print("\n[yellow]Expected inputs:[/yellow]")
|
||||
_print_schema(input_model.model_json_schema(), verbose)
|
||||
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, verbose=verbose)
|
||||
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=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=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,20 @@
|
||||
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"
|
||||
COMFY_PACK_REPO = "https://github.com/bentoml/comfy-pack.git"
|
||||
COMFYUI_MANAGER_REPO = "https://github.com/ltdrdata/ComfyUI-Manager.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,170 @@
|
||||
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 duckduckgo_search import DDGS
|
||||
import aiohttp
|
||||
|
||||
query = f"site:huggingface.co blob {model_sha}"
|
||||
|
||||
try:
|
||||
with DDGS() as ddgs:
|
||||
search_results = ddgs.text(query, max_results=5)
|
||||
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
for result in search_results:
|
||||
url = result['link']
|
||||
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
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
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,408 @@
|
||||
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():
|
||||
env = os.environ.copy()
|
||||
venv = workspace / ".venv"
|
||||
if venv.exists():
|
||||
python = (
|
||||
venv / "Scripts" / "python.exe"
|
||||
if os.name == "nt"
|
||||
else venv / "bin" / "python"
|
||||
)
|
||||
if "PATH" in env:
|
||||
env["PATH"] = f"{str(python.parent)}:{env['PATH']}"
|
||||
else:
|
||||
env["PATH"] = str(python.parent)
|
||||
env["VIRTUAL_ENV"] = str(venv)
|
||||
else:
|
||||
python = Path(sys.executable)
|
||||
|
||||
if verbose > 0:
|
||||
print(f"Installing {directory} custom node")
|
||||
print(f"$ {python.absolute()} install.py")
|
||||
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,
|
||||
):
|
||||
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,
|
||||
)
|
||||
if verbose > 0:
|
||||
print(f"Installing dependencies from {req_file}")
|
||||
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_search_url(sha: str) -> str:
|
||||
"""Generate custom search URLs for model on HuggingFace and CivitAI"""
|
||||
base_url = "https://duckduckgo.com"
|
||||
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,
|
||||
all_models: bool = False,
|
||||
verbose: int = 0,
|
||||
):
|
||||
"""Retrieve models from user downloads"""
|
||||
print("Retrieving models")
|
||||
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 and not all_models:
|
||||
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
|
||||
|
||||
search_url = get_search_url(sha)
|
||||
print(f"Search URL: {search_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,
|
||||
all_models: bool = False,
|
||||
verbose: int = 0,
|
||||
):
|
||||
workspace = Path(workspace)
|
||||
print(f"Installing package {cpack} to {workspace} (verbose={verbose})")
|
||||
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,
|
||||
venv=str(workspace / ".venv"),
|
||||
) as _:
|
||||
pass
|
||||
|
||||
retrive_models(snapshot, workspace, verbose=verbose, all_models=all_models)
|
||||
@@ -0,0 +1,299 @@
|
||||
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,
|
||||
venv: str | 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").absolute()
|
||||
self.temp_dir = run_dir / "temp"
|
||||
self.output_dir = run_dir / "output"
|
||||
|
||||
self.temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
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
|
||||
self.venv = venv
|
||||
|
||||
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)
|
||||
|
||||
env = os.environ.copy()
|
||||
if self.venv:
|
||||
env["VIRTUAL_ENV"] = self.venv
|
||||
if os.name == "nt":
|
||||
env["PATH"] = f"{self.venv}\\Scripts;{env.get('PATH', '')}"
|
||||
else:
|
||||
env["PATH"] = f"{self.venv}/bin:{env.get('PATH', '')}"
|
||||
|
||||
stdout = None if self.verbose > 0 else subprocess.DEVNULL
|
||||
command = ["comfy", "--skip-prompt", "tracking", "disable"]
|
||||
subprocess.run(command, check=True, stdout=stdout, env=env)
|
||||
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),
|
||||
]
|
||||
if self.input_dir:
|
||||
command.extend(["--input-directory", self.input_dir])
|
||||
|
||||
if self.host != "localhost":
|
||||
command.extend(["--listen", self.host])
|
||||
|
||||
def preexec_fn():
|
||||
os.setpgrp()
|
||||
|
||||
self.server_proc = subprocess.Popen(
|
||||
command,
|
||||
stdout=stdout,
|
||||
stderr=None,
|
||||
preexec_fn=preexec_fn,
|
||||
env=env,
|
||||
)
|
||||
|
||||
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,29 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Union
|
||||
import re
|
||||
import sys
|
||||
from typing import TYPE_CHECKING, Any, Literal, Union
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pydantic import BaseModel
|
||||
|
||||
CLASS_TYPES = {
|
||||
"BentoInputString": str,
|
||||
"BentoInputBoolean": bool,
|
||||
"BentoInputInteger": int,
|
||||
"BentoInputFloat": float,
|
||||
"BentoInputPath": Path,
|
||||
"BentoInputImage": Path,
|
||||
CPACK_OUTPUT_NODES = {
|
||||
"CPackOutputFile",
|
||||
"CPackOutputImage",
|
||||
}
|
||||
|
||||
BENTO_OUTPUT_NODES = {
|
||||
"BentoOutputPath",
|
||||
"BentoOutputImage",
|
||||
}
|
||||
|
||||
BENTO_PATH_INPUT_NODES = {
|
||||
"BentoInputPath",
|
||||
"BentoInputImage",
|
||||
CPACK_PATH_INPUT_NODES = {
|
||||
"CPackInputFile",
|
||||
"CPackInputImage",
|
||||
}
|
||||
|
||||
|
||||
@@ -57,17 +50,33 @@ def _get_node_identifier(node, dep_map=None) -> str:
|
||||
"""
|
||||
Get the input name from the node
|
||||
"""
|
||||
title = node["_meta"]["title"]
|
||||
if "_meta" in node and "title" in node["_meta"]:
|
||||
title = node["_meta"]["title"]
|
||||
else:
|
||||
title = ""
|
||||
if title.isidentifier():
|
||||
return title
|
||||
|
||||
nid = node["id"]
|
||||
if dep_map and (nid, 0) in dep_map:
|
||||
_, input_name = dep_map[(nid, 0)]
|
||||
return _normalize_to_identifier(input_name)
|
||||
|
||||
if not title:
|
||||
klass = node.get("class_type", "cpack_input")
|
||||
name = klass.lstrip("CPack").lstrip("Input")
|
||||
return _normalize_to_identifier(name)
|
||||
|
||||
return _normalize_to_identifier(title)
|
||||
|
||||
|
||||
API_WORKFLOW_MESSAGE = """
|
||||
It seems you are trying to parse an ordinary Workflow json file.
|
||||
Please save the Workflow with the Save (API Format).
|
||||
If you don't have this button, you must enable the "Dev mode Options" by clicking the Settings button on the top right (gear icon). Check the setting option "Enable Dev Mode options". After that, the Button Save (API Format) should appear.
|
||||
"""
|
||||
|
||||
|
||||
def _parse_workflow(workflow: dict) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
"""
|
||||
Parse the workflow template and return the input and output definition
|
||||
@@ -76,6 +85,9 @@ def _parse_workflow(workflow: dict) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
outputs = {}
|
||||
dep_map = {}
|
||||
|
||||
if "last_node_id" in workflow:
|
||||
raise ValueError(API_WORKFLOW_MESSAGE)
|
||||
|
||||
for id, node in workflow.items():
|
||||
for input_name, v in node["inputs"].items():
|
||||
if isinstance(v, list) and len(v) == 2: # is a link
|
||||
@@ -83,12 +95,16 @@ def _parse_workflow(workflow: dict) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
|
||||
for id, node in workflow.items():
|
||||
node["id"] = id
|
||||
if node["class_type"].startswith("BentoInput"):
|
||||
if node["class_type"].startswith("CPackInput"):
|
||||
if not node.get("inputs"):
|
||||
continue
|
||||
name = _get_node_identifier(node, dep_map)
|
||||
if name in inputs:
|
||||
name = f"{name}_{id}"
|
||||
inputs[name] = node
|
||||
elif node["class_type"].startswith("BentoOutput"):
|
||||
elif node["class_type"].startswith("CPackOutput"):
|
||||
if not node.get("inputs"):
|
||||
continue
|
||||
name = _get_node_identifier(node)
|
||||
if name in inputs:
|
||||
name = f"{name}_{id}"
|
||||
@@ -118,25 +134,59 @@ def generate_input_model(workflow: dict) -> type[BaseModel]:
|
||||
ValueError: If an unsupported class type is encountered in the workflow.
|
||||
"""
|
||||
from pydantic import Field, create_model
|
||||
from pydantic_core import PydanticUndefined
|
||||
|
||||
inputs, _ = _parse_workflow(workflow)
|
||||
|
||||
input_fields = {}
|
||||
for name, node in inputs.items():
|
||||
class_type = node["class_type"]
|
||||
if class_type in CLASS_TYPES:
|
||||
ann = CLASS_TYPES[class_type]
|
||||
if class_type in BENTO_PATH_INPUT_NODES:
|
||||
field = (ann, Field())
|
||||
if class_type in CPACK_PATH_INPUT_NODES:
|
||||
field = (Path, Field())
|
||||
elif class_type == "CPackInputString":
|
||||
value = _get_node_value(node)
|
||||
field = (str, Field(default=value))
|
||||
elif class_type == "CPackInputInt":
|
||||
value, min, max = tuple(node["inputs"].values())
|
||||
if min == -sys.maxsize:
|
||||
min = PydanticUndefined
|
||||
if max == sys.maxsize:
|
||||
max = PydanticUndefined
|
||||
field = (int, Field(default=value, ge=min, le=max))
|
||||
elif class_type == "CPackInputAny":
|
||||
options = node.get("_meta", {}).get("options")
|
||||
value = _get_node_value(node)
|
||||
if not options:
|
||||
field = (type(value), Field(default=value))
|
||||
else:
|
||||
field = (ann, Field(default=_get_node_value(node)))
|
||||
input_fields[name] = field
|
||||
if values := options.get("values"): # combo type
|
||||
field = (Literal[tuple(values)], Field(default=value))
|
||||
elif any(
|
||||
f in options for f in ("min", "max", "round", "precision", "step")
|
||||
): # must be number types
|
||||
type_ = float if options.get("round", 1) < 1 else int
|
||||
min_value = options.get("min", PydanticUndefined)
|
||||
max_value = options.get("max", PydanticUndefined)
|
||||
if type_ is int:
|
||||
if min_value < -sys.maxsize:
|
||||
min_value = PydanticUndefined
|
||||
if max_value > sys.maxsize:
|
||||
max_value = PydanticUndefined
|
||||
field = (type_, Field(default=value, ge=min_value, le=max_value))
|
||||
else:
|
||||
field = (type(value), Field(default=value))
|
||||
else:
|
||||
raise ValueError(f"Unsupported class type: {class_type}")
|
||||
input_fields[name] = field
|
||||
return create_model("ParsedWorkflowTemplate", **input_fields)
|
||||
|
||||
|
||||
def populate_workflow(workflow: dict, output_path: Path, **inputs) -> dict:
|
||||
def populate_workflow(
|
||||
workflow: dict,
|
||||
output_path: Path,
|
||||
session_id: str = "",
|
||||
**inputs,
|
||||
) -> dict:
|
||||
"""
|
||||
Fills the input values and output path into the workflow.
|
||||
|
||||
@@ -154,15 +204,15 @@ def populate_workflow(workflow: dict, output_path: Path, **inputs) -> dict:
|
||||
input_spec, output_spec = _parse_workflow(workflow)
|
||||
for k, v in inputs.items():
|
||||
node = input_spec[k]
|
||||
if not node["class_type"].startswith("BentoInput"):
|
||||
if not node["class_type"].startswith("CPackInput"):
|
||||
raise ValueError(f"Node {k} is not an input node")
|
||||
_set_node_value(workflow[node["id"]], v)
|
||||
|
||||
for _, node in output_spec.items():
|
||||
node_id = node["id"]
|
||||
if node["class_type"] in BENTO_OUTPUT_NODES:
|
||||
if node["class_type"] in CPACK_OUTPUT_NODES:
|
||||
workflow[node_id]["inputs"]["filename_prefix"] = (
|
||||
output_path / f"{node_id}_"
|
||||
output_path / f"{session_id}{node_id}_"
|
||||
).as_posix()
|
||||
return workflow
|
||||
|
||||
@@ -170,6 +220,7 @@ def populate_workflow(workflow: dict, output_path: Path, **inputs) -> dict:
|
||||
def retrieve_workflow_outputs(
|
||||
workflow: dict,
|
||||
output_path: Path,
|
||||
session_id: str = "",
|
||||
) -> Union[Path, list[Path], dict[str, Path], dict[str, list[Path]]]:
|
||||
"""
|
||||
Gets the output file(s) from the workflow.
|
||||
@@ -200,11 +251,52 @@ 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
|
||||
|
||||
|
||||
def get_self_git_commit() -> str | None:
|
||||
"""Get current git commit of the repository.
|
||||
|
||||
Returns:
|
||||
str | None: Git commit hash in format "{hash}[-dirty]" or None if not in a git repo
|
||||
"""
|
||||
try:
|
||||
repo_root = Path(__file__).parent.parent.parent
|
||||
|
||||
# Check if we're in a git repo
|
||||
subprocess.run(
|
||||
["git", "rev-parse", "--git-dir"],
|
||||
cwd=repo_root,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
# Get current commit hash
|
||||
commit_hash = subprocess.run(
|
||||
["git", "rev-parse", "--short", "HEAD"],
|
||||
cwd=repo_root,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout.strip()
|
||||
|
||||
# Check if working directory is clean
|
||||
is_dirty = (
|
||||
subprocess.run(
|
||||
["git", "diff", "--quiet"],
|
||||
cwd=repo_root,
|
||||
check=False,
|
||||
).returncode
|
||||
!= 0
|
||||
)
|
||||
|
||||
return f"{commit_hash}-dirty" if is_dirty else commit_hash
|
||||
except (subprocess.SubprocessError, FileNotFoundError):
|
||||
return None
|
||||
@@ -1,202 +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__)
|
||||
|
||||
|
||||
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
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1,90 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
|
||||
function mimicNode(node, target, slot) {
|
||||
const getWidget = (widget) => {
|
||||
if (typeof widget.origType === "undefined") return widget;
|
||||
const newWidget = new Proxy(widget, {
|
||||
has(target, prop) {
|
||||
return prop in target && prop !== "origType" && prop !== "computeSize";
|
||||
},
|
||||
get(target, prop) {
|
||||
if (prop.startsWith("orig") || prop === "computeSize") {
|
||||
return undefined;
|
||||
} else if (prop === "type") {
|
||||
return target.origType;
|
||||
} else if (prop === "name") {
|
||||
return inputName;
|
||||
} else {
|
||||
return target[prop];
|
||||
}
|
||||
},
|
||||
set(target, prop, value) {
|
||||
target[prop] = value;
|
||||
return true;
|
||||
}
|
||||
});
|
||||
return newWidget;
|
||||
}
|
||||
const input = target.inputs[slot];
|
||||
const inputName = node.inputs.length > 0 ? node.inputs[0].name : node.widgets[0].name;
|
||||
if (typeof input.widget === "undefined") { // This is an input
|
||||
node.inputs = [{ ...input, name: inputName }];
|
||||
node.widgets = [];
|
||||
} else { // This is a widget
|
||||
node.widgets = [getWidget(target.widgets.find(w => w.name === input.name))];
|
||||
node.inputs = [];
|
||||
node.size = node.computeSize();
|
||||
requestAnimationFrame(() => {
|
||||
if (node.onResize) {
|
||||
node.onResize(node.size);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "Comfy.DynamicInput",
|
||||
dynamicNodes: ["CPackInputAny", "CPackInputFile"],
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (!this.dynamicNodes.includes(nodeData.name)) return;
|
||||
|
||||
nodeType.prototype.onConnectOutput = function () {
|
||||
if (this.outputs[0].links?.length > 0) return false;
|
||||
const target = arguments[3];
|
||||
if (this.type === "CPackInputFile" && !["COMBO", "STRING"].includes(target.inputs[arguments[4]].type))
|
||||
return false;
|
||||
mimicNode(this, target, arguments[4]);
|
||||
this.title = target.inputs[arguments[4]].name;
|
||||
}
|
||||
},
|
||||
|
||||
async setup(app) {
|
||||
app.graph.nodes.forEach((node) => {
|
||||
if (!this.dynamicNodes.includes(node.type)) return;
|
||||
if (node.outputs.length > 0 && node.outputs[0].links.length > 0) {
|
||||
const link = node.graph.links[node.outputs[0].links[0]];
|
||||
mimicNode(node, app.graph.getNodeById(link.target_id), link.target_slot);
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
async init(app) {
|
||||
const originalToPrompt = app.graphToPrompt;
|
||||
const self = this;
|
||||
app.graphToPrompt = async function(graph = app.graph, clean = true) {
|
||||
const { workflow, output } = await originalToPrompt(graph, clean);
|
||||
Object.entries(output).forEach(([id, nodeData]) => {
|
||||
if (!nodeData.class_type.startsWith("CPackInput")) return;
|
||||
const node = graph.getNodeById(parseInt(id));
|
||||
if (!nodeData["_meta"]) {
|
||||
nodeData["_meta"] = { title: node.title };
|
||||
}
|
||||
if (node.widgets.length === 0) return;
|
||||
const widget = node.widgets[0];
|
||||
nodeData["_meta"] = { ...nodeData["_meta"], options: widget.options };
|
||||
});
|
||||
return { workflow, output };
|
||||
};
|
||||
}
|
||||
});
|
||||
+1020
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user