Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
705e82af4a | ||
|
|
521aca76ee | ||
|
|
d3fe79b996 | ||
|
|
5f9c5f5dad | ||
|
|
b44f81e4d1 | ||
|
|
22114ebfa6 | ||
|
|
062ddc0155 | ||
|
|
9e5a29a3f1 | ||
|
|
f6552c551b | ||
|
|
ef4c24eced | ||
|
|
bb9844db5b | ||
|
|
fe60c8aa2b | ||
|
|
9f73c4b29f | ||
|
|
06cab6a0a1 | ||
|
|
4a22f94071 | ||
|
|
17e2fe77f3 | ||
|
|
06c122af0d | ||
|
|
2c9b65c2c5 | ||
|
|
38a8427970 | ||
|
|
8063b7b60f | ||
|
|
9ae75d5ed7 | ||
|
|
a717e3b40f | ||
|
|
d5dbda8ce9 | ||
|
|
cbf66db398 | ||
|
|
56a9cb88ac | ||
|
|
d15c4c54ad | ||
|
|
f54ff9aa92 | ||
|
|
3f07d59b58 | ||
|
|
e181b07499 | ||
|
|
48ba44cc6c | ||
|
|
dfe00bfc21 | ||
|
|
bcb5b4b809 | ||
|
|
2cbc0ee754 | ||
|
|
b850017f39 | ||
|
|
8f7289c97e | ||
|
|
eae4e789fe | ||
|
|
0ac43cf5c3 | ||
|
|
1b553459a5 | ||
|
|
2f0cab20d2 | ||
|
|
6dffa3daff | ||
|
|
8891b27cec | ||
|
|
f373caae67 | ||
|
|
f27e0eed50 | ||
|
|
0e488f27fd | ||
|
|
06173cea47 | ||
|
|
333892725a | ||
|
|
bb8f0ec1ba | ||
|
|
4e1304df3a | ||
|
|
3d6d487b59 | ||
|
|
3cc962c3d0 | ||
|
|
a375d09884 | ||
|
|
ac9b55d7e4 | ||
|
|
92dea5889b | ||
|
|
e4fec46afc | ||
|
|
6454dca048 | ||
|
|
eed02aaddc | ||
|
|
e09f96466c | ||
|
|
52381157d9 | ||
|
|
bb83b0db05 | ||
|
|
0d4f59bf68 | ||
|
|
c1b8aeb692 | ||
|
|
6277f6d83f | ||
|
|
1247498e57 | ||
|
|
693c03a16c | ||
|
|
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 |
@@ -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
|
||||
|
||||
|
||||
@@ -161,3 +161,5 @@ cython_debug/
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
/temp/
|
||||
src/.DS_Store
|
||||
.DS_Store
|
||||
|
||||
@@ -1,3 +1,214 @@
|
||||
# ComfyUI-IDL
|
||||
# Comfy-Pack: Making ComfyUI Workflows Shareable
|
||||
|
||||
ComfyUI custom nodes for input and output specification.
|
||||

|
||||
|
||||
|
||||
comfy-pack is 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
|
||||
ComfyUI Manager is great for find missing custom nodes. But when sharing ComfyUI workflows to others(your audience or team members), you've still likely heard these responses:
|
||||
|
||||
- "Custom Node not found"
|
||||
- "Cannot find the correct model file"
|
||||
- "Missing Python dependencies"
|
||||
|
||||
These are fundamental challenges in workflow sharing – every component should match exactly: custom nodes, model files, and Python dependencies. Modern pacakge managers like npm and poetry introduced "lock" feature, which means record the exact version for every requirement. ComfyUI Manager isn't designed for that.
|
||||
|
||||
We learned it from our community and developed comfy-pack to address these problems. 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
|
||||
```
|
||||
|
||||
This means you can focus on your creative work while comfy-pack handles the rest.
|
||||
|
||||
## Usages
|
||||
|
||||
### Installation
|
||||
|
||||
We recommend you use ComfyUI Manager to install comfy-pack. Simply search for `comfy-pack` and click **Install**. Restart the server and refresh your ComfyUI interface to apply changes.
|
||||
|
||||

|
||||
|
||||
Alternatively, clone the project repository through `git`.
|
||||
|
||||
```bash
|
||||
cd ComfyUI/custom_nodes
|
||||
git clone https://github.com/bentoml/comfy-pack.git
|
||||
```
|
||||
|
||||
To install the comfy-pack CLI, run:
|
||||
|
||||
```bash
|
||||
pip install comfy-pack
|
||||
```
|
||||
|
||||
### Pack a ComfyUI workflow and its environment
|
||||
|
||||
You can package a workflow and the environment required to run the workflow into an artifact that can be unpacked elsewhere.
|
||||
|
||||
1. Click the **Package** button to create a `.cpack.zip` artifact.
|
||||
2. (Optional) Select the models that you want to include (only model hash will be recorded, so you won't get a 100GB zip file).
|
||||
|
||||

|
||||
|
||||
### Unpack the ComfyUI environments
|
||||
|
||||
Unpacking a `.cpack.zip` artifact will restore the ComfyUI environment for the workflow. During unpacking, comfy-pack will perform the following steps.
|
||||
|
||||
1. Prepare a Python virtual environment with the exact packages used to run the workflow.
|
||||
2. Clone ComfyUI and custom nodes from the exact revisions required by the workflow.
|
||||
3. Search for and download models from common registries like Hugging Face and Civitai. Unpacking workflows using the same model will not cause the model to be downloaded multiple times. Instead, model weights will be symbolically linked.
|
||||
|
||||
To unpack:
|
||||
|
||||
```bash
|
||||
comfy-pack unpack workflow.cpack.zip
|
||||
```
|
||||
|
||||
Huggingface gated models can be accessed by setting your `HF_TOKEN` as an environment variable before unpacking:
|
||||
|
||||
```bash
|
||||
export HF_TOKEN=hf_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXx
|
||||
comfy-pack unpack workflow.cpack.zip
|
||||
```
|
||||
|
||||
For example cpack files, check our [examples folder](examples/).
|
||||
|
||||
### Deploy a workflow as an API
|
||||
|
||||
You can turn a ComfyUI workflow into an API endpoint callable using any clients through HTTP.
|
||||
|
||||
<details>
|
||||
<summary> 1. Annotate input & output </summary>
|
||||
|
||||
Use custom nodes provided by comfy-pack to annotate the fields to be used as input and output parameters. To add a comfy-pack node, right-click and select **Add Node** > **ComfyPack** > **output/input** > [Select a type]
|
||||
|
||||
Input nodes:
|
||||
|
||||
- ImageInput: Accepts `image` type input, similar to the official `LoadImage` node
|
||||
- StringInput: Accepts `string` type input (e.g., prompts)
|
||||
- IntInput: Accepts `int` type input (e.g., dimensions, seeds)
|
||||
- AnyInput: Accepts `combo` type and more input (e.g., custom nodes)
|
||||
|
||||

|
||||
|
||||
Output nodes:
|
||||
|
||||
- ImageOutput: Outputs `image` type, similar to the official `SaveImage` node
|
||||
- FileOutput: Outputs file path as `string` type and saves the file under that path
|
||||
|
||||

|
||||
|
||||
More field types are under way.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary> 2. Serve the workflow </summary>
|
||||
|
||||
Start an HTTP server at `http://127.0.0.1:3000` (default) to serve the workflow under the `/generate` path.
|
||||
|
||||

|
||||
|
||||
You can call the `/generate` endpoint by specifying parameters configured through your comfy-pack nodes, such as prompt, width, height, and seed.
|
||||
|
||||
> [!NOTE]
|
||||
> The name of a comfy-pack node is the parameter name used for API calls.
|
||||
|
||||
Examples to call the endpoint:
|
||||
|
||||
CURL
|
||||
|
||||
```bash
|
||||
curl -X 'POST' \
|
||||
'http://127.0.0.1:3000/generate' \
|
||||
-H 'accept: application/octet-stream' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"prompt": "rocks in a bottle",
|
||||
"width": 512,
|
||||
"height": 512,
|
||||
"seed": 1
|
||||
}'
|
||||
```
|
||||
|
||||
BentoML client
|
||||
|
||||
Under the hood, comfy-pack leverages [BentoML](https://github.com/bentoml/BentoML), the unified model serving framework. You can invoke the endpoint using [the BentoML Python client](https://docs.bentoml.com/en/latest/build-with-bentoml/clients.html):
|
||||
|
||||
```python
|
||||
import bentoml
|
||||
|
||||
with bentoml.SyncHTTPClient("http://127.0.0.1:3000") as client:
|
||||
result = client.generate(
|
||||
prompt="rocks in a bottle",
|
||||
width=512,
|
||||
height=512,
|
||||
seed=1
|
||||
)
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary> 3. (Optional) Pack the workflow and environment </summary>
|
||||
|
||||
Pack the workflow and environment into an artifact that can be unpacked elsewhere to recreate the workflow.
|
||||
|
||||
```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 the cloud </summary>
|
||||
|
||||
Deploy to [BentoCloud](https://www.bentoml.com/) with access to a variety of GPUs and blazing fast scaling.
|
||||
|
||||
Follow [the instructions here](https://docs.bentoml.com/en/latest/scale-with-bentocloud/manage-api-tokens.html) to get your BentoCloud access token. If you don’t have a BentoCloud account, you can [sign up for free](https://bentoml.com/).
|
||||
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
## Security Guidelines
|
||||
|
||||
A cpack file only contains the metadata of the workflow environment, such as Python package versions, ComfyUI and custom node revisions, and model hashes. It does not contain any sensitive information like API keys, passwords, or user data. However, unpacking a cpack file will install custom nodes and Python dependencies. It is recommended to unpack cpack files from trusted sources.
|
||||
|
||||
comfy-pack has a strict mode for unpacking. You can enable it by setting the `CPACK_STRICT_MODE` environment variable to `true`. It will sacrifice some flexibility and compatibility for security. For now, comfy-pack will:
|
||||
|
||||
* Use more strict index strategy in Python package installation
|
||||
|
||||
More security features are under way.
|
||||
|
||||
|
||||
## Roadmap
|
||||
|
||||
This project is under active development. Currently we are working on:
|
||||
|
||||
- Enhanced user experience
|
||||
- Docker support
|
||||
- Local `.cpack` file management with version control
|
||||
- Enhanced service capabilities
|
||||
|
||||
## Community
|
||||
|
||||
comfy-pack is actively maintained by the BentoML team. Feel free to reach out 👉 [Join our Slack community!](https://l.bentoml.com/join-slack)
|
||||
|
||||
## Contributing
|
||||
|
||||
As an open-source project, we welcome contributions of all kinds, such as new features, bug fixes, and documentation. Here are some of the ways to contribute:
|
||||
|
||||
- Repost a bug by creating a [GitHub issue](https://github.com/bentoml/comfy-pack/issues).
|
||||
- Submit a [pull request](https://github.com/bentoml/comfy-pack/pulls) or help review other developers’ pull requests.
|
||||
|
||||
+11
@@ -1,6 +1,17 @@
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from .nodes import api # noqa
|
||||
from .nodes.nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
||||
|
||||
WEB_DIRECTORY = "./web"
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
|
||||
|
||||
print("Comfy-Pack => Installing Python dependencies")
|
||||
subprocess.check_call(
|
||||
[sys.executable, "-m", "pip", "install", "-r", "requirements.txt"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
cwd=os.path.dirname(__file__),
|
||||
)
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
SRC_DIR = pathlib.Path(__file__).parent.parent / "src"
|
||||
|
||||
if str(SRC_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SRC_DIR))
|
||||
|
||||
+493
-103
@@ -1,54 +1,126 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
import zipfile
|
||||
from fnmatch import fnmatch
|
||||
from pathlib import Path
|
||||
from typing import Any, Union
|
||||
|
||||
from aiohttp import web
|
||||
from comfy_pack.hash import async_batch_get_sha256
|
||||
from comfy_pack.model_helper import alookup_model_source
|
||||
from comfy_pack.package import build_bento
|
||||
|
||||
import folder_paths
|
||||
from aiohttp import web
|
||||
from server import PromptServer
|
||||
|
||||
ZPath = Union[Path, zipfile.Path]
|
||||
TEMP_FOLDER = Path(__file__).parent.parent / "temp"
|
||||
COMFY_PACK_DIR = Path(__file__).parent.parent / "src" / "comfy_pack"
|
||||
EXCLUDE_PACKAGES = ["bentoml", "onnxruntime", "conda", "nvidia-*"]
|
||||
|
||||
|
||||
async def _write_requirements(zf: zipfile.ZipFile) -> None:
|
||||
print("Package => Writing requirements.txt")
|
||||
with zf.open("requirements.txt", "w") as f:
|
||||
proc = await asyncio.subprocess.create_subprocess_exec(
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pip",
|
||||
"freeze",
|
||||
"--exclude-editable",
|
||||
stdout=subprocess.PIPE,
|
||||
)
|
||||
stdout, _ = await proc.communicate()
|
||||
f.write(stdout)
|
||||
def normalize_name(name: str) -> str:
|
||||
import re
|
||||
|
||||
return re.sub(r"[-_.]+", "-", name).lower()
|
||||
|
||||
|
||||
async def _write_snapshot(zf: zipfile.ZipFile) -> None:
|
||||
proc = await asyncio.subprocess.create_subprocess_exec(
|
||||
"git", "rev-parse", "HEAD", stdout=subprocess.PIPE, cwd=folder_paths.base_path
|
||||
def get_snapshot_path() -> Path | None:
|
||||
manager_file_path = Path(
|
||||
folder_paths.get_user_directory(), "default", "ComfyUI-Manager"
|
||||
)
|
||||
stdout, _ = await proc.communicate()
|
||||
with zf.open("snapshot.json", "w") as f:
|
||||
data = {
|
||||
"python": f"{sys.version_info.major}.{sys.version_info.minor}",
|
||||
"comfyui": stdout.decode().strip(),
|
||||
"models": await _get_models(),
|
||||
"custom_nodes": await _get_custom_nodes(),
|
||||
}
|
||||
f.write(json.dumps(data, indent=2).encode())
|
||||
return manager_file_path / "snapshots"
|
||||
|
||||
|
||||
async def _get_models() -> list:
|
||||
print("Package => Writing models")
|
||||
async def _save_snapshot() -> dict[str, Any]:
|
||||
save_snapshot_route = next(
|
||||
(
|
||||
route
|
||||
for route in PromptServer.instance.routes
|
||||
if route.path == "/snapshot/save"
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not save_snapshot_route:
|
||||
raise RuntimeError("ComfyUI-Manager must be installed to save snapshot")
|
||||
await save_snapshot_route.handler(None)
|
||||
snapshot_path = get_snapshot_path()
|
||||
if not snapshot_path.exists():
|
||||
raise RuntimeError("Snapshot save failed")
|
||||
|
||||
most_recent = max(
|
||||
snapshot_path.glob("*.json"), key=lambda x: x.stat().st_mtime, default=None
|
||||
)
|
||||
if not most_recent:
|
||||
raise RuntimeError("Snapshot save failed")
|
||||
with most_recent.open("r") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
async def _write_snapshot(path: ZPath, data: dict, models: list) -> None:
|
||||
snapshot = await _save_snapshot()
|
||||
for package in list(snapshot["pips"]):
|
||||
if any(
|
||||
fnmatch(normalize_name(package.split("==")[0]), pat)
|
||||
for pat in EXCLUDE_PACKAGES
|
||||
):
|
||||
del snapshot["pips"][package]
|
||||
with path.joinpath("snapshot.json").open("w") as f:
|
||||
snapshot.update(
|
||||
{
|
||||
"python": f"{sys.version_info.major}.{sys.version_info.minor}",
|
||||
"models": models,
|
||||
}
|
||||
)
|
||||
f.write(json.dumps(snapshot, 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)
|
||||
if file_path.parts[0] == "input":
|
||||
relpath = Path(*file_path.parts[1:])
|
||||
else: # models
|
||||
relpath = Path(*file_path.parts[2:])
|
||||
return str(relpath) in all_inputs
|
||||
|
||||
|
||||
async def _get_models(
|
||||
store_models: bool = False,
|
||||
workflow_api: dict | None = None,
|
||||
model_filter: set[str] | None = None,
|
||||
ensure_sha=True,
|
||||
ensure_source=True,
|
||||
) -> list:
|
||||
proc = await asyncio.subprocess.create_subprocess_exec(
|
||||
"git",
|
||||
"ls-files",
|
||||
@@ -57,89 +129,107 @@ async def _get_models() -> list:
|
||||
stdout=subprocess.PIPE,
|
||||
)
|
||||
stdout, _ = await proc.communicate()
|
||||
|
||||
models = []
|
||||
for line in stdout.decode().splitlines():
|
||||
if os.path.basename(line).startswith("."):
|
||||
model_filenames = [
|
||||
os.path.abspath(line.strip().strip('"').replace('\\\\', '/'))
|
||||
for line in stdout.decode().splitlines()
|
||||
if not os.path.basename(line.strip().strip('"').replace('\\\\', '/')).startswith(".")
|
||||
]
|
||||
|
||||
# Only compute hashes for referenced models
|
||||
to_include = []
|
||||
if model_filter:
|
||||
for m1 in model_filter:
|
||||
for m2 in model_filenames:
|
||||
if m1 in m2:
|
||||
to_include.append(m2)
|
||||
else:
|
||||
to_include = model_filenames
|
||||
|
||||
model_hashes = await async_batch_get_sha256(
|
||||
to_include,
|
||||
cache_only=not (ensure_sha or store_models),
|
||||
)
|
||||
|
||||
for filename in to_include:
|
||||
# Skip if file doesn't exist
|
||||
if not os.path.exists(filename):
|
||||
continue
|
||||
filename = os.path.abspath(line)
|
||||
|
||||
relpath = os.path.relpath(filename, folder_paths.base_path)
|
||||
with open(filename, "rb") as model:
|
||||
models.append(
|
||||
{
|
||||
"filename": relpath,
|
||||
"sha256": hashlib.sha256(model.read()).hexdigest(),
|
||||
}
|
||||
)
|
||||
|
||||
model_data = {
|
||||
"filename": relpath,
|
||||
"size": os.path.getsize(filename),
|
||||
"atime": os.path.getatime(filename),
|
||||
"ctime": os.path.getctime(filename),
|
||||
"disabled": relpath not in model_filter
|
||||
if model_filter is not None
|
||||
else False,
|
||||
"sha256": model_hashes.get(filename),
|
||||
}
|
||||
|
||||
model_data["source"] = await alookup_model_source(
|
||||
model_data["sha256"],
|
||||
cache_only=not ensure_source,
|
||||
)
|
||||
# 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(zf: zipfile.ZipFile, data: dict) -> None:
|
||||
async def _write_workflow(path: ZPath, data: dict) -> None:
|
||||
print("Package => Writing workflow")
|
||||
with zf.open("workflow_api.json", "w") as f:
|
||||
f.write(json.dumps(data["workflow_api"], indent=2).encode())
|
||||
with zf.open("workflow.json", "w") as f:
|
||||
f.write(json.dumps(data["workflow"], indent=2).encode())
|
||||
with path.joinpath("workflow_api.json").open("w") as f:
|
||||
f.write(json.dumps(data["workflow_api"], indent=2))
|
||||
with path.joinpath("workflow.json").open("w") as f:
|
||||
f.write(json.dumps(data["workflow"], indent=2))
|
||||
|
||||
|
||||
async def _write_inputs(zf: zipfile.ZipFile, data: dict) -> None:
|
||||
async def _write_inputs(path: ZPath, data: dict) -> None:
|
||||
print("Package => Writing inputs")
|
||||
if isinstance(path, Path):
|
||||
path.joinpath("input").mkdir(exist_ok=True)
|
||||
|
||||
input_dir = folder_paths.get_input_directory()
|
||||
|
||||
used_inputs = set()
|
||||
for node in data["workflow_api"].values():
|
||||
for k, v in node["inputs"].items():
|
||||
# if k in ("image", "path"):
|
||||
if isinstance(v, str):
|
||||
used_inputs.add(v)
|
||||
if "files" in data:
|
||||
selected = "\n".join(set(data.get("files", [])))
|
||||
else:
|
||||
selected = None
|
||||
|
||||
for root_path, _, files in os.walk(input_dir):
|
||||
for file in files:
|
||||
if file not in used_inputs:
|
||||
continue
|
||||
file_path = os.path.join(root_path, file)
|
||||
relpath = os.path.relpath(file_path, input_dir)
|
||||
with zf.open(f"inputs/{relpath}", "w") as f:
|
||||
with open(file_path, "rb") as input_file:
|
||||
src_root = Path(input_dir).absolute()
|
||||
for src in src_root.glob("**/*"):
|
||||
rel = src.relative_to(src_root)
|
||||
if selected is not None and str(rel) not in selected:
|
||||
continue
|
||||
if src.is_dir():
|
||||
if isinstance(path, Path):
|
||||
path.joinpath("input").joinpath(rel).mkdir(parents=True, exist_ok=True)
|
||||
if src.is_file():
|
||||
with path.joinpath("input").joinpath(rel).open("wb") as f:
|
||||
with open(src, "rb") as input_file:
|
||||
shutil.copyfileobj(input_file, f)
|
||||
|
||||
|
||||
@@ -155,15 +245,315 @@ async def pack_workspace(request):
|
||||
zip_filename = f"{uuid.uuid4()}.zip"
|
||||
|
||||
with zipfile.ZipFile(TEMP_FOLDER / zip_filename, "w") as zf:
|
||||
await _write_requirements(zf)
|
||||
await _write_snapshot(zf)
|
||||
await _write_workflow(zf, data)
|
||||
await _write_inputs(zf, data)
|
||||
path = zipfile.Path(zf)
|
||||
await _prepare_pack(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):
|
||||
from comfy_pack import __file__ as comfy_pack_file
|
||||
|
||||
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(comfy_pack_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.get("/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_pack(
|
||||
working_dir: ZPath,
|
||||
data: dict,
|
||||
store_models: bool = False,
|
||||
ensure_source: bool = True,
|
||||
) -> None:
|
||||
model_filter = set(data.get("models", []))
|
||||
models = await _get_models(
|
||||
store_models=store_models,
|
||||
model_filter=model_filter,
|
||||
ensure_source=ensure_source,
|
||||
)
|
||||
|
||||
await _write_snapshot(working_dir, data, models)
|
||||
await _write_workflow(working_dir, data)
|
||||
await _write_inputs(working_dir, data)
|
||||
|
||||
|
||||
@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})
|
||||
|
||||
|
||||
async def _get_inputs(workflow_api):
|
||||
input_dir = folder_paths.get_input_directory()
|
||||
inputs = []
|
||||
for src in Path(input_dir).rglob("*"):
|
||||
if src.is_file():
|
||||
rel = src.relative_to(input_dir)
|
||||
badges = []
|
||||
checked = False
|
||||
if _is_file_refered(src, workflow_api):
|
||||
badges.append({"text": "Referenced"})
|
||||
checked = True
|
||||
data = {
|
||||
"path": str(rel),
|
||||
"badges": badges,
|
||||
"checked": checked,
|
||||
}
|
||||
inputs.append(data)
|
||||
return inputs
|
||||
|
||||
|
||||
@PromptServer.instance.routes.post("/bentoml/file/query")
|
||||
async def get_inputs(request):
|
||||
data = await request.json()
|
||||
inputs = await _get_inputs(
|
||||
workflow_api=data.get("workflow_api"),
|
||||
)
|
||||
return web.json_response({"files": inputs})
|
||||
|
||||
|
||||
@PromptServer.instance.routes.post("/bentoml/build")
|
||||
async def build_bento_api(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)
|
||||
await _prepare_pack(temp_dir_path, data, store_models=True, ensure_source=False)
|
||||
|
||||
# create a bento
|
||||
try:
|
||||
bento = build_bento(
|
||||
data["bento_name"],
|
||||
temp_dir_path,
|
||||
system_packages=data.get("system_packages"),
|
||||
)
|
||||
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
|
||||
|
||||
+477
-26
@@ -1,13 +1,18 @@
|
||||
import glob
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import zipfile
|
||||
from io import BytesIO
|
||||
|
||||
import folder_paths
|
||||
import node_helpers
|
||||
import numpy as np
|
||||
import torch
|
||||
from PIL import Image, ImageOps, ImageSequence
|
||||
from PIL import Image, ImageOps, ImageSequence, PngImagePlugin
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
from .monkeypatch import set_bentoml_output
|
||||
@@ -31,15 +36,15 @@ class OutputFile:
|
||||
return {
|
||||
"required": {
|
||||
"filename": ("STRING", {"default": "", "forceInput": True}),
|
||||
"filename_prefix": ("STRING", {"default": "ComfyUI_IDL_"}),
|
||||
"filename_prefix": ("STRING", {"default": "cpack_output_"}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
CATEGORY = "ComfyUI-IDL/output"
|
||||
BENTOML_NODE = True
|
||||
CATEGORY = "ComfyPack/output"
|
||||
CPACK_NODE = True
|
||||
FUNCTION = "save"
|
||||
DESCRIPTION = "Save the input data for IDL output"
|
||||
DESCRIPTION = "Save the input data for comfy-pack output"
|
||||
|
||||
def save(self, filename, filename_prefix):
|
||||
if not filename_prefix:
|
||||
@@ -109,7 +114,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 +124,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 = (
|
||||
@@ -162,6 +167,100 @@ class OutputImage:
|
||||
return {"ui": {"images": results}}
|
||||
|
||||
|
||||
class OutputImageWithStringTxt:
|
||||
COLOR = (142, 36, 170)
|
||||
|
||||
def __init__(self):
|
||||
self.output_dir = folder_paths.get_output_directory()
|
||||
self.type = "output"
|
||||
self.prefix_append = ""
|
||||
self.compress_level = 4
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE", {"tooltip": "The images to save."}),
|
||||
"filename_prefix": ("STRING", {"default": "cpack_output_"}),
|
||||
"text": ("STRING", {"default": ""}),
|
||||
},
|
||||
"hidden": {
|
||||
"prompt": "PROMPT",
|
||||
"extra_pnginfo": "EXTRA_PNGINFO",
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
FUNCTION = "save_images"
|
||||
CPACK_NODE = True
|
||||
OUTPUT_NODE = True
|
||||
|
||||
CATEGORY = "ComfyPack/output"
|
||||
DESCRIPTION = (
|
||||
"Saves the input images (and optional text) to your ComfyUI output directory."
|
||||
)
|
||||
|
||||
def save_images(
|
||||
self,
|
||||
images,
|
||||
filename_prefix="cpack_output_",
|
||||
text="",
|
||||
prompt=None,
|
||||
extra_pnginfo=None,
|
||||
):
|
||||
filename_prefix += self.prefix_append
|
||||
full_output_folder, filename, counter, subfolder, filename_prefix = (
|
||||
get_save_image_path(
|
||||
filename_prefix, self.output_dir, images[0].shape[1], images[0].shape[0]
|
||||
)
|
||||
)
|
||||
|
||||
base_counter = counter # use for name zip
|
||||
zip_filename = f"{filename}_batch_{base_counter:05}.zip"
|
||||
zip_path = os.path.join(full_output_folder, zip_filename)
|
||||
|
||||
# create ZIP file
|
||||
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zipf:
|
||||
for batch_number, image in enumerate(images):
|
||||
# temp store img to RAM
|
||||
i = 255.0 * image.cpu().numpy()
|
||||
img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8))
|
||||
|
||||
# write meta data
|
||||
metadata = PngImagePlugin.PngInfo()
|
||||
if prompt is not None:
|
||||
metadata.add_text("prompt", json.dumps(prompt))
|
||||
if extra_pnginfo is not None:
|
||||
for x in extra_pnginfo:
|
||||
metadata.add_text(x, json.dumps(extra_pnginfo[x]))
|
||||
|
||||
# write img file to RAM buffer
|
||||
img_buffer = BytesIO()
|
||||
img.save(
|
||||
img_buffer,
|
||||
format="PNG",
|
||||
pnginfo=metadata,
|
||||
compress_level=self.compress_level,
|
||||
)
|
||||
img_buffer.seek(0)
|
||||
|
||||
# write img into ZIP file
|
||||
image_filename = f"image_{batch_number:05}.png"
|
||||
zipf.writestr(image_filename, img_buffer.read())
|
||||
|
||||
# write txt into ZIP file
|
||||
text_filename = f"text_{batch_number:05}.txt"
|
||||
zipf.writestr(text_filename, text)
|
||||
|
||||
# return zip as output
|
||||
out = [{"filename": zip_filename, "subfolder": subfolder, "type": "zip"}]
|
||||
return {
|
||||
"ui": {
|
||||
"zip": out,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class ImageInput:
|
||||
COLOR = (142, 36, 170)
|
||||
|
||||
@@ -177,8 +276,8 @@ class ImageInput:
|
||||
"required": {"image": (sorted(files), {"image_upload": True})},
|
||||
}
|
||||
|
||||
CATEGORY = "ComfyUI-IDL/input"
|
||||
BENTOML_NODE = True
|
||||
CATEGORY = "ComfyPack/input"
|
||||
CPACK_NODE = True
|
||||
RETURN_TYPES = ("IMAGE", "MASK")
|
||||
FUNCTION = "load_image"
|
||||
|
||||
@@ -256,8 +355,8 @@ class FileInput:
|
||||
RETURN_TYPES = (anytype,)
|
||||
RETURN_NAMES = ("path",)
|
||||
FUNCTION = "identity"
|
||||
BENTOML_NODE = True
|
||||
CATEGORY = "ComfyUI-IDL/input"
|
||||
CPACK_NODE = True
|
||||
CATEGORY = "ComfyPack/input"
|
||||
|
||||
def identity(self, path):
|
||||
return (path,)
|
||||
@@ -268,7 +367,65 @@ class FileInput:
|
||||
return True
|
||||
|
||||
|
||||
class ValueInput:
|
||||
class StringInput:
|
||||
COLOR = (142, 36, 170)
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"value": ("STRING", {"default": ""}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("value",)
|
||||
FUNCTION = "identity"
|
||||
CPACK_NODE = True
|
||||
CATEGORY = "ComfyPack/input"
|
||||
|
||||
def identity(self, value):
|
||||
return (value,)
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(s, value):
|
||||
set_bentoml_output([(value,)])
|
||||
return True
|
||||
|
||||
|
||||
class IntInput:
|
||||
COLOR = (142, 36, 170)
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"value": ("INT", {"default": 0}),
|
||||
},
|
||||
"optional": {
|
||||
"min": ("INT", {"default": -sys.maxsize}),
|
||||
"max": ("INT", {"default": sys.maxsize}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("INT",)
|
||||
RETURN_NAMES = ("value",)
|
||||
FUNCTION = "identity"
|
||||
CPACK_NODE = True
|
||||
CATEGORY = "ComfyPack/input"
|
||||
|
||||
def identity(self, value, min=None, max=None):
|
||||
return (value,)
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(s, value, min=None, max=None):
|
||||
if min is not None and max is not None and min > max:
|
||||
return f"Value must be greater than or equal to {min}"
|
||||
set_bentoml_output([(value,)])
|
||||
return True
|
||||
|
||||
|
||||
class AnyInput:
|
||||
COLOR = (142, 36, 170)
|
||||
|
||||
@classmethod
|
||||
@@ -282,8 +439,8 @@ class ValueInput:
|
||||
RETURN_TYPES = (anytype,)
|
||||
RENAME = ("value",)
|
||||
FUNCTION = "identity"
|
||||
BENTOML_NODE = True
|
||||
CATEGORY = "ComfyUI-IDL/input"
|
||||
CPACK_NODE = True
|
||||
CATEGORY = "ComfyPack/input"
|
||||
|
||||
def identity(self, input):
|
||||
return (input,)
|
||||
@@ -294,18 +451,312 @@ class ValueInput:
|
||||
return True
|
||||
|
||||
|
||||
class OutputZip:
|
||||
CATEGORY = "ComfyPack/output"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
FUNCTION = "null_op"
|
||||
|
||||
def null_op(self):
|
||||
return ()
|
||||
|
||||
|
||||
class OutputAudio:
|
||||
CPACK_NODE = True
|
||||
CATEGORY = "ComfyPack/output"
|
||||
RETURN_TYPES = ()
|
||||
FUNCTION = "save_audio"
|
||||
OUTPUT_NODE = True
|
||||
|
||||
def __init__(self):
|
||||
self.output_dir = folder_paths.get_output_directory()
|
||||
self.type = "output"
|
||||
self.prefix_append = ""
|
||||
|
||||
def save_audio(
|
||||
self,
|
||||
audio,
|
||||
filename_prefix,
|
||||
format="flac",
|
||||
prompt=None,
|
||||
extra_pnginfo=None,
|
||||
quality="128k",
|
||||
):
|
||||
import av
|
||||
import torchaudio
|
||||
|
||||
filename_prefix += self.prefix_append
|
||||
full_output_folder, filename, counter, subfolder, filename_prefix = (
|
||||
get_save_image_path(filename_prefix, self.output_dir)
|
||||
)
|
||||
results = []
|
||||
|
||||
# Prepare metadata dictionary
|
||||
metadata = {}
|
||||
if prompt is not None:
|
||||
metadata["prompt"] = json.dumps(prompt)
|
||||
if extra_pnginfo is not None:
|
||||
for x in extra_pnginfo:
|
||||
metadata[x] = json.dumps(extra_pnginfo[x])
|
||||
|
||||
# Opus supported sample rates
|
||||
OPUS_RATES = [8000, 12000, 16000, 24000, 48000]
|
||||
|
||||
for batch_number, waveform in enumerate(audio["waveform"].cpu()):
|
||||
filename_with_batch_num = filename.replace("%batch_num%", str(batch_number))
|
||||
file = f"{filename_with_batch_num}_{counter:05}.{format}"
|
||||
output_path = os.path.join(full_output_folder, file)
|
||||
|
||||
# Use original sample rate initially
|
||||
sample_rate = audio["sample_rate"]
|
||||
|
||||
# Handle Opus sample rate requirements
|
||||
if format == "opus":
|
||||
if sample_rate > 48000:
|
||||
sample_rate = 48000
|
||||
elif sample_rate not in OPUS_RATES:
|
||||
# Find the next highest supported rate
|
||||
for rate in sorted(OPUS_RATES):
|
||||
if rate > sample_rate:
|
||||
sample_rate = rate
|
||||
break
|
||||
if sample_rate not in OPUS_RATES: # Fallback if still not supported
|
||||
sample_rate = 48000
|
||||
|
||||
# Resample if necessary
|
||||
if sample_rate != audio["sample_rate"]:
|
||||
waveform = torchaudio.functional.resample(
|
||||
waveform, audio["sample_rate"], sample_rate
|
||||
)
|
||||
|
||||
# Create in-memory WAV buffer
|
||||
wav_buffer = io.BytesIO()
|
||||
torchaudio.save(wav_buffer, waveform, sample_rate, format="WAV")
|
||||
wav_buffer.seek(0) # Rewind for reading
|
||||
|
||||
# Use PyAV to convert and add metadata
|
||||
input_container = av.open(wav_buffer)
|
||||
|
||||
# Create output with specified format
|
||||
output_buffer = io.BytesIO()
|
||||
output_container = av.open(output_buffer, mode="w", format=format)
|
||||
|
||||
# Set metadata on the container
|
||||
for key, value in metadata.items():
|
||||
output_container.metadata[key] = value
|
||||
|
||||
# Set up the output stream with appropriate properties
|
||||
input_container.streams.audio[0]
|
||||
if format == "opus":
|
||||
out_stream = output_container.add_stream("libopus", rate=sample_rate)
|
||||
if quality == "64k":
|
||||
out_stream.bit_rate = 64000
|
||||
elif quality == "96k":
|
||||
out_stream.bit_rate = 96000
|
||||
elif quality == "128k":
|
||||
out_stream.bit_rate = 128000
|
||||
elif quality == "192k":
|
||||
out_stream.bit_rate = 192000
|
||||
elif quality == "320k":
|
||||
out_stream.bit_rate = 320000
|
||||
elif format == "mp3":
|
||||
out_stream = output_container.add_stream("libmp3lame", rate=sample_rate)
|
||||
if quality == "V0":
|
||||
# TODO i would really love to support V3 and V5 but there doesn't seem to be a way to set the qscale level, the property below is a bool
|
||||
out_stream.codec_context.qscale = 1
|
||||
elif quality == "128k":
|
||||
out_stream.bit_rate = 128000
|
||||
elif quality == "320k":
|
||||
out_stream.bit_rate = 320000
|
||||
else: # format == "flac":
|
||||
out_stream = output_container.add_stream("flac", rate=sample_rate)
|
||||
|
||||
# Copy frames from input to output
|
||||
for frame in input_container.decode(audio=0):
|
||||
frame.pts = None # Let PyAV handle timestamps
|
||||
output_container.mux(out_stream.encode(frame))
|
||||
|
||||
# Flush encoder
|
||||
output_container.mux(out_stream.encode(None))
|
||||
|
||||
# Close containers
|
||||
output_container.close()
|
||||
input_container.close()
|
||||
|
||||
# Write the output to file
|
||||
output_buffer.seek(0)
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(output_buffer.getbuffer())
|
||||
|
||||
results.append(
|
||||
{"filename": file, "subfolder": subfolder, "type": self.type}
|
||||
)
|
||||
counter += 1
|
||||
|
||||
return {"ui": {"audio": results}}
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"audio": ("AUDIO",),
|
||||
"filename_prefix": ("STRING", {"default": "audio/comfypack"}),
|
||||
},
|
||||
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
|
||||
}
|
||||
|
||||
|
||||
class OutputVideo:
|
||||
CPACK_NODE = True
|
||||
CATEGORY = "ComfyPack/output"
|
||||
|
||||
RETURN_TYPES = ()
|
||||
FUNCTION = "save_video"
|
||||
DESCRIPTION = "Saves the input video to your ComfyUI output directory."
|
||||
OUTPUT_NODE = True
|
||||
|
||||
def __init__(self):
|
||||
self.output_dir = folder_paths.get_output_directory()
|
||||
self.type = "output"
|
||||
self.prefix_append = ""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"video": ("VIDEO", {"tooltip": "The video to save."}),
|
||||
"filename_prefix": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "video/comfypack",
|
||||
"tooltip": "The prefix for the file to save. This may include formatting information such as %date:yyyy-MM-dd% or %Empty Latent Image.width% to include values from nodes.",
|
||||
},
|
||||
),
|
||||
"format": (
|
||||
["auto", "mp4"],
|
||||
{"default": "auto", "tooltip": "The format to save the video as."},
|
||||
),
|
||||
"codec": (
|
||||
["auto", "h264"],
|
||||
{"default": "auto", "tooltip": "The codec to use for the video."},
|
||||
),
|
||||
},
|
||||
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
|
||||
}
|
||||
|
||||
def save_video(
|
||||
self,
|
||||
video,
|
||||
filename_prefix,
|
||||
format,
|
||||
codec,
|
||||
prompt=None,
|
||||
extra_pnginfo=None,
|
||||
):
|
||||
filename_prefix += self.prefix_append
|
||||
width, height = video.get_dimensions()
|
||||
full_output_folder, filename, counter, subfolder, filename_prefix = (
|
||||
get_save_image_path(filename_prefix, self.output_dir, width, height)
|
||||
)
|
||||
results = list()
|
||||
saved_metadata = None
|
||||
metadata = {}
|
||||
if extra_pnginfo is not None:
|
||||
metadata.update(extra_pnginfo)
|
||||
if prompt is not None:
|
||||
metadata["prompt"] = prompt
|
||||
if len(metadata) > 0:
|
||||
saved_metadata = metadata
|
||||
file = f"{filename}_{counter:05}.mp4"
|
||||
video.save_to(
|
||||
os.path.join(full_output_folder, file),
|
||||
format=format,
|
||||
codec=codec,
|
||||
metadata=saved_metadata,
|
||||
)
|
||||
|
||||
results.append({"filename": file, "subfolder": subfolder, "type": self.type})
|
||||
counter += 1
|
||||
|
||||
return {"ui": {"images": results, "animated": (True,)}}
|
||||
|
||||
|
||||
class OutputTextFile:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"text": ("STRING", {"forceInput": True}),
|
||||
"filename_prefix": ("STRING", {"default": "cpack_output_"}),
|
||||
},
|
||||
"optional": {
|
||||
"file_extension": ("STRING", {"default": ".txt"}),
|
||||
},
|
||||
}
|
||||
|
||||
OUTPUT_NODE = True
|
||||
RETURN_TYPES = ()
|
||||
FUNCTION = "save_text_file"
|
||||
CATEGORY = "ComfyPack/output"
|
||||
CPACK_NODE = True
|
||||
|
||||
def save_text_file(
|
||||
self, text: str, filename_prefix: str, file_extension: str = ".txt"
|
||||
):
|
||||
subfolder, filename_prefix = os.path.split(os.path.normpath(filename_prefix))
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
full_output_folder = os.path.join(output_dir, subfolder)
|
||||
|
||||
full_output_filename = self.get_output_filename(
|
||||
full_output_folder, filename_prefix, file_extension
|
||||
)
|
||||
with open(full_output_filename, "w", encoding="utf-8", newline="\n") as f:
|
||||
f.write(text)
|
||||
return (text, {"ui": {"string": text}})
|
||||
|
||||
@staticmethod
|
||||
def get_output_filename(folder: str, prefix: str, extension: str) -> str:
|
||||
matched_files = [
|
||||
os.path.basename(f)[len(prefix) + 1 : -len(extension)]
|
||||
for f in glob.glob(os.path.join(folder, f"{prefix}_*{extension}"))
|
||||
]
|
||||
print("MATCHING", matched_files)
|
||||
max_count = max(
|
||||
(int(name) for name in matched_files if name.isdigit()), default=0
|
||||
)
|
||||
return os.path.join(folder, f"{prefix}_{max_count + 1:04d}{extension}")
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"BentoOutputFile": OutputFile,
|
||||
"BentoOutputImage": OutputImage,
|
||||
"BentoInputImage": ImageInput,
|
||||
"BentoInputFile": FileInput,
|
||||
"BentoInputValue": ValueInput,
|
||||
"CPackOutputFile": OutputFile,
|
||||
"CPackOutputImage": OutputImage,
|
||||
"CPackOutputAudio": OutputAudio,
|
||||
"CPackOutputVideo": OutputVideo,
|
||||
"CPackOutputZip": OutputImageWithStringTxt,
|
||||
"CPackOutputZipSwitch": OutputZip,
|
||||
"CPackInputImage": ImageInput,
|
||||
"CPackInputString": StringInput,
|
||||
"CPackInputInt": IntInput,
|
||||
"CPackInputFile": FileInput,
|
||||
"CPackInputAny": AnyInput,
|
||||
"CPackOutputTextFile": OutputTextFile,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"BentoInputImage": "Image Input",
|
||||
"BentoInputFile": "File Input",
|
||||
"BentoOutputImage": "Image Output",
|
||||
"BentoOutputFile": "File Output",
|
||||
"BentoInputValue": "Plain Value Input",
|
||||
"CPackInputImage": "Image Input",
|
||||
"CPackInputString": "String Input",
|
||||
"CPackInputInt": "Int Input",
|
||||
"CPackInputFile": "File Input",
|
||||
"CPackInputAny": "Any Input",
|
||||
"CPackOutputImage": "Image Output",
|
||||
"CPackOutputAudio": "Audio Output",
|
||||
"CPackOutputVideo": "Video Output",
|
||||
"CPackOutputFile": "File Output",
|
||||
"CPackOutputZip": "Zip Output(img + txt file)",
|
||||
"CPackOutputZipSwitch": "Enable Zip Output",
|
||||
"CPackOutputTextFile": "Output Text to File",
|
||||
}
|
||||
|
||||
+8
-5
@@ -1,13 +1,16 @@
|
||||
[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.21",
|
||||
"click>=8.1.7",
|
||||
"comfy-cli>=1.2.8",
|
||||
"pydantic>=2.9",
|
||||
]
|
||||
dynamic = ["version"]
|
||||
|
||||
@@ -18,10 +21,10 @@ classifiers = [
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/bentoml/ComfyUI-IDL"
|
||||
Homepage = "https://github.com/bentoml/comfy-pack"
|
||||
|
||||
[project.scripts]
|
||||
comfy-idl = "comfyui_idl.cli:main"
|
||||
comfy-pack = "comfy_pack.cli:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["pdm-backend"]
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
bentoml>=1.3.21
|
||||
fastapi
|
||||
comfy-cli
|
||||
duckduckgo-search
|
||||
@@ -1,5 +1,5 @@
|
||||
from comfyui_idl.run import WorkflowRunner
|
||||
from comfyui_idl.utils import (
|
||||
from .run import ComfyUIServer, run_workflow
|
||||
from .utils import (
|
||||
generate_input_model,
|
||||
parse_workflow,
|
||||
populate_workflow,
|
||||
@@ -7,7 +7,8 @@ from comfyui_idl.utils import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"WorkflowRunner",
|
||||
"ComfyUIServer",
|
||||
"run_workflow",
|
||||
"parse_workflow",
|
||||
"generate_input_model",
|
||||
"populate_workflow",
|
||||
@@ -0,0 +1,551 @@
|
||||
import functools
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from .const import COMFY_PACK_REPO, COMFYUI_MANAGER_REPO, COMFYUI_REPO, WORKSPACE_DIR
|
||||
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()
|
||||
@click.version_option()
|
||||
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):
|
||||
import os
|
||||
|
||||
import rich
|
||||
|
||||
# Check if directory path is valid
|
||||
try:
|
||||
install_dir = Path(dir).absolute()
|
||||
if install_dir.exists() and not install_dir.is_dir():
|
||||
rich.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():
|
||||
rich.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:
|
||||
rich.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):
|
||||
rich.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:
|
||||
rich.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):
|
||||
rich.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:
|
||||
rich.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
|
||||
rich.print(
|
||||
"[yellow]Warning: Less than 2GB free disk space available[/yellow]"
|
||||
)
|
||||
except Exception as e:
|
||||
rich.print(
|
||||
f"[yellow]Warning: Could not check free disk space - {str(e)}[/yellow]"
|
||||
)
|
||||
|
||||
# Clone ComfyUI if not exists
|
||||
if not (install_dir / ".git").exists():
|
||||
rich.print("[green]Cloning ComfyUI...[/green]")
|
||||
subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"clone",
|
||||
COMFYUI_REPO,
|
||||
str(install_dir),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
# Update ComfyUI
|
||||
rich.print("[green]Updating ComfyUI...[/green]")
|
||||
subprocess.run(
|
||||
["git", "pull"],
|
||||
cwd=install_dir,
|
||||
check=True,
|
||||
)
|
||||
|
||||
# Create and activate venv
|
||||
venv_dir = install_dir / ".venv"
|
||||
rich.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
|
||||
rich.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
|
||||
rich.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"
|
||||
rich.print(
|
||||
f"\n[green]✓ Installation completed! (comfy-pack version: {version})[/green]"
|
||||
)
|
||||
rich.print(f"ComfyUI directory: {install_dir}")
|
||||
|
||||
rich.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))
|
||||
@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(
|
||||
"--no-models",
|
||||
default=False,
|
||||
type=click.BOOL,
|
||||
is_flag=True,
|
||||
help="Do not install models",
|
||||
)
|
||||
@click.option(
|
||||
"--no-venv",
|
||||
is_flag=True,
|
||||
help="Do not create a virtual environment for ComfyUI",
|
||||
default=False,
|
||||
)
|
||||
@click.option(
|
||||
"--verbose",
|
||||
"-v",
|
||||
count=True,
|
||||
help="Increase verbosity level (use multiple times for more verbosity)",
|
||||
)
|
||||
@click.option(
|
||||
"--preheat",
|
||||
is_flag=True,
|
||||
help="Preheat the workspace after unpacking",
|
||||
default=False,
|
||||
)
|
||||
def unpack_cmd(
|
||||
cpack: str,
|
||||
dir: str,
|
||||
include_disabled_models: bool,
|
||||
no_models: bool,
|
||||
no_venv: bool,
|
||||
verbose: int,
|
||||
preheat: bool,
|
||||
):
|
||||
import rich
|
||||
|
||||
from .package import install
|
||||
|
||||
install(
|
||||
cpack,
|
||||
dir,
|
||||
verbose=verbose,
|
||||
all_models=include_disabled_models,
|
||||
prepare_models=not no_models,
|
||||
no_venv=no_venv,
|
||||
preheat=preheat,
|
||||
)
|
||||
rich.print("\n[green]✓ ComfyUI Workspace is restored at:[/green]")
|
||||
rich.print(os.path.abspath(dir))
|
||||
steps = [f"Change directory to the restored workspace: `cd {dir}`"]
|
||||
if not no_venv:
|
||||
steps.append(
|
||||
"Source the virtual environment by running `source .venv/bin/activate`"
|
||||
)
|
||||
steps.append("Run the ComfyUI project by running `python main.py`")
|
||||
|
||||
rich.print(f"\n[green]⏭️ Next steps: [/green]\n1. {steps[0]}\n2. {steps[1]}")
|
||||
if len(steps) > 2:
|
||||
rich.print(f"3. {steps[2]}")
|
||||
|
||||
|
||||
def _print_schema(schema, verbose: int = 0):
|
||||
import rich
|
||||
from rich.table import Table
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
rich.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):
|
||||
import rich
|
||||
from pydantic import ValidationError
|
||||
|
||||
from .utils import generate_input_model
|
||||
|
||||
inputs = dict(
|
||||
zip([k.lstrip("-").replace("-", "_") for k in ctx.args[::2]], ctx.args[1::2])
|
||||
)
|
||||
|
||||
with zipfile.ZipFile(cpack) as z:
|
||||
workflow = json.loads(z.read("workflow_api.json"))
|
||||
|
||||
input_model = generate_input_model(workflow)
|
||||
|
||||
# If help is requested, show command help and input schema
|
||||
if help:
|
||||
rich.print(
|
||||
'Usage: comfy-pack run [OPTIONS] CPACK --input1 "value1" --input2 "value2" ...'
|
||||
)
|
||||
rich.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)
|
||||
rich.print("[green]✓ Input is valid![/green]")
|
||||
for field, value in validated_data.model_dump().items():
|
||||
rich.print(f"{field}: {value}")
|
||||
except ValidationError as e:
|
||||
rich.print("[red]✗ Validation failed![/red]")
|
||||
for error in e.errors():
|
||||
rich.print(f"- {error['loc'][0]}: {error['msg']}")
|
||||
|
||||
rich.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():
|
||||
rich.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")
|
||||
rich.print("\n[green]✓ ComfyUI Workspace is restored![/green]")
|
||||
rich.print(f"{workspace}")
|
||||
|
||||
from .run import ComfyUIServer, run_workflow
|
||||
|
||||
with ComfyUIServer(str(workspace.absolute()), verbose=verbose) as server:
|
||||
rich.print("\n[green]✓ ComfyUI is launched in the background![/green]")
|
||||
results = run_workflow(
|
||||
server.host,
|
||||
server.port,
|
||||
workflow,
|
||||
Path(output_dir).absolute(),
|
||||
verbose=verbose,
|
||||
workspace=server.workspace,
|
||||
**validated_data.model_dump(),
|
||||
)
|
||||
rich.print("\n[green]✓ Workflow is executed successfully![/green]")
|
||||
if results:
|
||||
rich.print("\n[green]✓ Retrieved outputs:[/green]")
|
||||
if isinstance(results, dict):
|
||||
for field, value in results.items():
|
||||
rich.print(f"{field}: {value}")
|
||||
elif isinstance(results, list):
|
||||
for i, value in enumerate(results):
|
||||
rich.print(f"{i}: {value}")
|
||||
else:
|
||||
rich.print(results)
|
||||
|
||||
|
||||
@main.command(name="build-bento")
|
||||
@click.argument("source")
|
||||
@click.option("--name", help="Name of the bento service")
|
||||
@click.option("--version", help="Version of the bento service")
|
||||
def bento_cmd(source: str, name: str | None, version: str | None):
|
||||
"""Build a bento from the source, which can be either a .cpack.zip file or a bento tag."""
|
||||
import bentoml
|
||||
from bentoml.bentos import BentoBuildConfig
|
||||
|
||||
from .package import build_bento
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
if source.endswith(".cpack.zip"):
|
||||
name = name or os.path.basename(source).replace(".cpack.zip", "")
|
||||
shutil.unpack_archive(source, temp_dir)
|
||||
system_packages = None
|
||||
include_default_system_packages = True
|
||||
else:
|
||||
existing_bento = bentoml.get(source)
|
||||
name = name or existing_bento.tag.name
|
||||
shutil.copytree(existing_bento.path_of("src"), temp_dir, dirs_exist_ok=True)
|
||||
build_config = BentoBuildConfig.from_bento_dir(
|
||||
existing_bento.path_of("src")
|
||||
)
|
||||
requirements_txt = Path(temp_dir) / "requirements.txt"
|
||||
if (
|
||||
requirements_txt.exists()
|
||||
and "comfy-pack" not in requirements_txt.read_text()
|
||||
):
|
||||
with open(requirements_txt, "a") as f:
|
||||
f.write("\ncomfy-pack")
|
||||
system_packages = build_config.docker.system_packages
|
||||
include_default_system_packages = False
|
||||
|
||||
build_bento(
|
||||
name,
|
||||
Path(temp_dir),
|
||||
version=version,
|
||||
system_packages=system_packages,
|
||||
include_default_system_packages=include_default_system_packages,
|
||||
)
|
||||
|
||||
|
||||
def setup_cloud_client(
|
||||
ctx: click.Context, param: click.Parameter, value: str | None
|
||||
) -> str | None:
|
||||
from bentoml._internal.configuration.containers import BentoMLContainer
|
||||
|
||||
if value:
|
||||
BentoMLContainer.cloud_context.set(value)
|
||||
os.environ["BENTOML_CLOUD_CONTEXT"] = value
|
||||
return value
|
||||
|
||||
|
||||
@main.command()
|
||||
@click.argument("bento")
|
||||
@click.option(
|
||||
"-w",
|
||||
"--workspace",
|
||||
type=click.Path(file_okay=False, path_type=Path),
|
||||
default="workspace",
|
||||
help="Workspace directory, defaults to './workspace'.",
|
||||
)
|
||||
@click.option("-v", "--verbose", count=True, help="Increase verbosity level")
|
||||
@click.option(
|
||||
"--context",
|
||||
help="BentoCloud context name.",
|
||||
expose_value=False,
|
||||
callback=setup_cloud_client,
|
||||
)
|
||||
def unpack_bento(bento: str, workspace: Path, verbose: int):
|
||||
"""Restore the ComfyUI workspace from a given bento."""
|
||||
import bentoml
|
||||
|
||||
from .package import install
|
||||
|
||||
try:
|
||||
bento_obj = bentoml.get(bento)
|
||||
except bentoml.exceptions.NotFound:
|
||||
click.echo(
|
||||
f"Bento {bento} not found in the local repository, trying to pull from BentoCloud",
|
||||
err=True,
|
||||
)
|
||||
bentoml.pull(bento)
|
||||
bento_obj = bentoml.get(bento)
|
||||
|
||||
install(bento_obj.path_of("src"), workspace, verbose=verbose, prepare_models=False)
|
||||
|
||||
if os.name == "nt":
|
||||
exe = "Scripts/python.exe"
|
||||
else:
|
||||
exe = "bin/python"
|
||||
click.echo(
|
||||
f"Workspace is ready at {workspace}\n"
|
||||
f"You can start ComfyUI by running `cd {workspace} && .venv/{exe} main.py`",
|
||||
color="green",
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
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"
|
||||
|
||||
STRICT_MODE = os.environ.get("CPACK_STRICT_MODE", "0") in ["1", "true", "True"]
|
||||
@@ -0,0 +1,127 @@
|
||||
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)
|
||||
except (IOError, OSError):
|
||||
cache = {}
|
||||
|
||||
cache.update(new_cache)
|
||||
|
||||
try:
|
||||
with SHA_CACHE_FILE.open("w") as f:
|
||||
json.dump(cache, f, indent=2)
|
||||
except (IOError, OSError):
|
||||
pass
|
||||
|
||||
return results
|
||||
@@ -0,0 +1,168 @@
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
|
||||
from .const import MODEL_SOURCE_CACHE_FILE
|
||||
|
||||
# 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:
|
||||
import aiohttp
|
||||
from duckduckgo_search import DDGS
|
||||
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['href']
|
||||
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)
|
||||
info = {
|
||||
"download_url": path,
|
||||
"url": path,
|
||||
"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,549 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .const import COMFYUI_REPO, MODEL_DIR, STRICT_MODE
|
||||
from .hash import get_sha256
|
||||
from .utils import get_self_git_commit
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import bentoml
|
||||
|
||||
COMFY_PACK_DIR = Path(__file__).parent
|
||||
|
||||
|
||||
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
|
||||
env = {**os.environ, "GIT_TERMINAL_PROMPT": "0"}
|
||||
subprocess.check_call(
|
||||
["git", "clone", "--recurse-submodules", "--filter=blob:none", url, dir],
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
env=env,
|
||||
)
|
||||
subprocess.check_call(
|
||||
["git", "fetch", "-q", url, commit],
|
||||
cwd=dir,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
env=env,
|
||||
)
|
||||
subprocess.check_call(
|
||||
["git", "checkout", "FETCH_HEAD"],
|
||||
cwd=dir,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
env=env,
|
||||
)
|
||||
subprocess.check_call(
|
||||
["git", "submodule", "update", "--init", "--recursive"],
|
||||
cwd=dir,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
env=env,
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
manager_node = next(
|
||||
url for url in snapshot.get("git_custom_nodes", {}) if "ComfyUI-Manager" in url
|
||||
)
|
||||
if manager_node:
|
||||
manager_commit = snapshot["git_custom_nodes"][manager_node]["hash"].strip()
|
||||
print("Installing ComfyUI-Manager")
|
||||
_clone_commit(
|
||||
manager_node,
|
||||
manager_commit,
|
||||
workspace / "custom_nodes" / "ComfyUI-Manager",
|
||||
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"]
|
||||
if not url.strip():
|
||||
print(f"Skipping invalid custom node: {module}")
|
||||
continue
|
||||
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)
|
||||
|
||||
print(f"Installing custom node {url}")
|
||||
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(
|
||||
python_version: str,
|
||||
req_files: list[str],
|
||||
workspace: Path,
|
||||
verbose: int = 0,
|
||||
no_deps: bool = False,
|
||||
no_venv: bool = False,
|
||||
) -> Path:
|
||||
print("Installing Python dependencies")
|
||||
stdout = None if verbose > 0 else subprocess.DEVNULL
|
||||
stderr = None if verbose > 1 else subprocess.DEVNULL
|
||||
if no_venv:
|
||||
print("Using the current Python environment")
|
||||
venv_py = Path(sys.executable)
|
||||
else:
|
||||
venv = (workspace / ".venv").absolute()
|
||||
venv_py = (
|
||||
venv / "Scripts" / "python.exe"
|
||||
if os.name == "nt"
|
||||
else venv / "bin" / "python"
|
||||
)
|
||||
if (venv / "DONE").exists():
|
||||
return venv_py
|
||||
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_files}")
|
||||
install_cmd = [
|
||||
"uv",
|
||||
"pip",
|
||||
"install",
|
||||
"-p",
|
||||
str(venv_py),
|
||||
]
|
||||
for req_file in req_files:
|
||||
install_cmd.extend(["-r", str(req_file)])
|
||||
if not STRICT_MODE:
|
||||
install_cmd.extend(["--index-strategy", "unsafe-best-match"])
|
||||
if no_deps:
|
||||
install_cmd.append("--no-deps")
|
||||
subprocess.check_call(
|
||||
install_cmd,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
)
|
||||
if not no_venv:
|
||||
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"""
|
||||
|
||||
# prepare auth token from huggingface if possible
|
||||
if (token := os.getenv("HF_TOKEN")) and ("huggingface" in url):
|
||||
bearer = f"Bearer {token}"
|
||||
urllib_request = urllib.request.Request(url, headers={"Authorization": bearer})
|
||||
curl_auth = ["-H", f"Authorization: {bearer}"]
|
||||
else:
|
||||
curl_auth = []
|
||||
urllib_request = url
|
||||
|
||||
try:
|
||||
if shutil.which("curl"):
|
||||
subprocess.check_call(
|
||||
["curl", "-L", url, *curl_auth, "--fail", "-o", str(dest_path)],
|
||||
)
|
||||
return True
|
||||
with urllib.request.urlopen(urllib_request) 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 retrieve_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 = False,
|
||||
prepare_models: bool = True,
|
||||
all_models: bool = False,
|
||||
no_venv: bool = False,
|
||||
verbose: int = 0,
|
||||
):
|
||||
workspace = Path(workspace)
|
||||
cpack = Path(cpack)
|
||||
print(f"Installing package {cpack} to {workspace} (verbose={verbose})")
|
||||
with contextlib.ExitStack() as stack:
|
||||
if cpack.is_file():
|
||||
temp_dir = stack.enter_context(tempfile.TemporaryDirectory())
|
||||
pack_dir = Path(temp_dir) / ".cpack"
|
||||
shutil.unpack_archive(cpack, pack_dir)
|
||||
else:
|
||||
pack_dir = cpack
|
||||
snapshot = json.loads((pack_dir / "snapshot.json").read_text())
|
||||
if "pips" not in snapshot:
|
||||
raise RuntimeError(
|
||||
"This cpack is generated by an old version of comfy-pack, "
|
||||
"please use comfy-pack<0.4.0 to unpack it."
|
||||
)
|
||||
|
||||
install_comfyui(snapshot, workspace, verbose=verbose)
|
||||
py = install_dependencies(
|
||||
snapshot["python"],
|
||||
[
|
||||
str(workspace / "requirements.txt"),
|
||||
str(
|
||||
workspace / "custom_nodes" / "ComfyUI-Manager" / "requirements.txt"
|
||||
),
|
||||
],
|
||||
workspace,
|
||||
no_venv=no_venv,
|
||||
verbose=verbose,
|
||||
)
|
||||
cm_cli = Path("custom_nodes", "ComfyUI-Manager", "cm-cli.py")
|
||||
subprocess.check_call(
|
||||
[
|
||||
str(py),
|
||||
str(cm_cli),
|
||||
"restore-snapshot",
|
||||
"--pip-non-url",
|
||||
"--pip-non-local-url",
|
||||
str(pack_dir / "snapshot.json"),
|
||||
],
|
||||
cwd=workspace,
|
||||
)
|
||||
|
||||
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)
|
||||
if prepare_models:
|
||||
retrieve_models(
|
||||
snapshot,
|
||||
workspace,
|
||||
verbose=verbose,
|
||||
download=False,
|
||||
)
|
||||
|
||||
if preheat:
|
||||
from .run import ComfyUIServer
|
||||
|
||||
with ComfyUIServer(
|
||||
str(workspace),
|
||||
verbose=verbose,
|
||||
venv=str(workspace / ".venv") if not no_venv else None,
|
||||
) as _:
|
||||
pass
|
||||
if prepare_models:
|
||||
retrieve_models(snapshot, workspace, verbose=verbose, all_models=all_models)
|
||||
|
||||
|
||||
required_files = ["snapshot.json"]
|
||||
|
||||
|
||||
def build_bento(
|
||||
bento_name: str,
|
||||
source_dir: Path,
|
||||
*,
|
||||
version: str | None = None,
|
||||
system_packages: list[str] | None = None,
|
||||
include_default_system_packages: bool = True,
|
||||
) -> bentoml.Bento:
|
||||
import bentoml
|
||||
|
||||
for f in required_files:
|
||||
if not (source_dir / f).exists():
|
||||
raise FileNotFoundError(f"Not a valid comfy-pack package: missing `{f}`")
|
||||
|
||||
if include_default_system_packages:
|
||||
system_packages = [
|
||||
"git",
|
||||
"libglib2.0-0",
|
||||
"libsm6",
|
||||
"libxrender1",
|
||||
"libxext6",
|
||||
"ffmpeg",
|
||||
"libstdc++-12-dev",
|
||||
*(system_packages or []),
|
||||
]
|
||||
else:
|
||||
system_packages = system_packages or []
|
||||
|
||||
shutil.copy2(Path(__file__).with_name("service.py"), source_dir / "service.py")
|
||||
snapshot_text = (source_dir / "snapshot.json").read_text()
|
||||
setup_script = source_dir / "setup_workspace.sh"
|
||||
with Path(__file__).with_name("setup_workspace.sh").open() as f:
|
||||
setup_script.write_text(f.read().replace("<SNAPSHOT>", snapshot_text))
|
||||
# Make setup script executable in a cross-platform way
|
||||
if os.name in ("posix", "mac"):
|
||||
setup_script.chmod(setup_script.stat().st_mode | 0o755)
|
||||
snapshot = json.loads(snapshot_text)
|
||||
return bentoml.build(
|
||||
"service:ComfyService",
|
||||
name=bento_name,
|
||||
version=version,
|
||||
build_ctx=str(source_dir),
|
||||
labels={"comfy-pack-version": get_self_git_commit() or "unknown"},
|
||||
models=[
|
||||
m["model_tag"]
|
||||
for m in snapshot["models"]
|
||||
if "model_tag" in m and not m.get("disabled", False)
|
||||
],
|
||||
docker={
|
||||
"python_version": f"{sys.version_info.major}.{sys.version_info.minor}",
|
||||
"system_packages": system_packages,
|
||||
"setup_script": source_dir.joinpath("setup_workspace.sh").as_posix(),
|
||||
},
|
||||
python={
|
||||
"lock_packages": True,
|
||||
"packages": ["fastapi", "comfy-pack>=0.4.0a0"],
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,288 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import shlex
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Union
|
||||
|
||||
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
|
||||
|
||||
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 = os.path.abspath(venv) if venv else None
|
||||
|
||||
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("Starting ComfyUI in the background...")
|
||||
command = [
|
||||
"python",
|
||||
"main.py",
|
||||
"--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])
|
||||
if options := env.pop("COMFYUI_OPTIONS", None):
|
||||
command.extend(shlex.split(options))
|
||||
|
||||
def preexec_fn():
|
||||
os.setpgrp()
|
||||
|
||||
self.server_proc = subprocess.Popen(
|
||||
command,
|
||||
stdout=stdout,
|
||||
stderr=None,
|
||||
preexec_fn=preexec_fn,
|
||||
env=env,
|
||||
cwd=self.workspace,
|
||||
)
|
||||
|
||||
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")
|
||||
|
||||
def is_running(self) -> bool:
|
||||
if self.server_proc is None:
|
||||
return False
|
||||
if self.server_proc.poll() is not None:
|
||||
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...")
|
||||
proc.terminate()
|
||||
proc.wait()
|
||||
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,
|
||||
workspace: str = ".",
|
||||
**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",
|
||||
"--workspace",
|
||||
workspace,
|
||||
"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,
|
||||
)
|
||||
@@ -0,0 +1,184 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
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().strip()).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.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:
|
||||
workspace_override = os.environ.get("COMFYUI_PATH", None)
|
||||
self.server = comfy_pack.run.ComfyUIServer(
|
||||
workspace_override if workspace_override else 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,
|
||||
workspace=self.server.workspace if hasattr(self, "server") else ".",
|
||||
**kwargs,
|
||||
)
|
||||
if isinstance(ret, list):
|
||||
ret = ret[-1]
|
||||
return ret
|
||||
|
||||
@bentoml.on_deployment
|
||||
@staticmethod
|
||||
def prepare_models():
|
||||
if EXISTING_COMFYUI_SERVER:
|
||||
return
|
||||
comfy_workspace = _get_workspace()
|
||||
if not comfy_workspace.joinpath(".DONE").exists():
|
||||
raise RuntimeError("ComfyUI workspace is not ready")
|
||||
for model in snapshot["models"]:
|
||||
if model.get("disabled", False):
|
||||
continue
|
||||
model_path = comfy_workspace / cast(str, model["filename"])
|
||||
if model_path.exists():
|
||||
continue
|
||||
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")
|
||||
print(f"Copying {model_file} to {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)
|
||||
print(f"Copying {model_file} to {model_path}")
|
||||
model_path.symlink_to(model_file)
|
||||
else:
|
||||
print(
|
||||
f"WARN: Unrecognized model source: {source}, the model may be missing"
|
||||
)
|
||||
|
||||
|
||||
if False and not EXISTING_COMFYUI_SERVER:
|
||||
for model in snapshot["models"]:
|
||||
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"]))
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -eo pipefail
|
||||
|
||||
CPACK=/tmp/bento-cpack
|
||||
mkdir -p "$CPACK"
|
||||
|
||||
cat <<EOF | head -c -1 > "$CPACK"/snapshot.json
|
||||
<SNAPSHOT>
|
||||
EOF
|
||||
|
||||
checksum=$(md5sum "$CPACK"/snapshot.json | awk '{print $1}')
|
||||
workspace="${BENTOML_HOME:-$HOME/bentoml}/run/comfy_workspace/${checksum}"
|
||||
|
||||
if [ -n "$VIRTUAL_ENV" ]; then
|
||||
# shellcheck disable=SC1091
|
||||
source "$VIRTUAL_ENV"/bin/activate
|
||||
fi
|
||||
|
||||
set -x
|
||||
comfy-pack unpack "$CPACK" -d "$workspace" --no-models --no-venv -v
|
||||
chown -R bentoml:bentoml "$workspace"
|
||||
set +x
|
||||
@@ -1,20 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Literal, Union
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pydantic import BaseModel
|
||||
|
||||
BENTO_OUTPUT_NODES = {
|
||||
"BentoOutputPath",
|
||||
"BentoOutputImage",
|
||||
}
|
||||
|
||||
BENTO_PATH_INPUT_NODES = {
|
||||
"BentoInputPath",
|
||||
"BentoInputImage",
|
||||
CPACK_PATH_INPUT_NODES = {
|
||||
"CPackInputFile",
|
||||
"CPackInputImage",
|
||||
}
|
||||
|
||||
|
||||
@@ -48,14 +47,23 @@ def _get_node_identifier(node, dep_map=None) -> str:
|
||||
"""
|
||||
Get the input name from the node
|
||||
"""
|
||||
title = node["_meta"]["title"]
|
||||
if "_meta" in node and "title" in node["_meta"]:
|
||||
title = node["_meta"]["title"]
|
||||
else:
|
||||
title = ""
|
||||
if title.isidentifier():
|
||||
return title
|
||||
|
||||
nid = node["id"]
|
||||
if dep_map and (nid, 0) in dep_map:
|
||||
_, input_name = dep_map[(nid, 0)]
|
||||
return _normalize_to_identifier(input_name)
|
||||
|
||||
if not title:
|
||||
klass = node.get("class_type", "cpack_input")
|
||||
name = klass.lstrip("CPack").lstrip("Input")
|
||||
return _normalize_to_identifier(name)
|
||||
|
||||
return _normalize_to_identifier(title)
|
||||
|
||||
|
||||
@@ -84,12 +92,16 @@ def _parse_workflow(workflow: dict) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
|
||||
for id, node in workflow.items():
|
||||
node["id"] = id
|
||||
if node["class_type"].startswith("BentoInput"):
|
||||
if node["class_type"].startswith("CPackInput"):
|
||||
if not node.get("inputs"):
|
||||
continue
|
||||
name = _get_node_identifier(node, dep_map)
|
||||
if name in inputs:
|
||||
name = f"{name}_{id}"
|
||||
inputs[name] = node
|
||||
elif node["class_type"].startswith("BentoOutput"):
|
||||
elif node["class_type"].startswith("CPackOutput"):
|
||||
if not node.get("inputs"):
|
||||
continue
|
||||
name = _get_node_identifier(node)
|
||||
if name in inputs:
|
||||
name = f"{name}_{id}"
|
||||
@@ -126,9 +138,19 @@ def generate_input_model(workflow: dict) -> type[BaseModel]:
|
||||
input_fields = {}
|
||||
for name, node in inputs.items():
|
||||
class_type = node["class_type"]
|
||||
if class_type in BENTO_PATH_INPUT_NODES:
|
||||
if class_type in CPACK_PATH_INPUT_NODES:
|
||||
field = (Path, Field())
|
||||
elif class_type == "BentoInputValue":
|
||||
elif class_type == "CPackInputString":
|
||||
value = _get_node_value(node)
|
||||
field = (str, Field(default=value))
|
||||
elif class_type == "CPackInputInt":
|
||||
value, min, max = tuple(node["inputs"].values())
|
||||
if min == -sys.maxsize:
|
||||
min = PydanticUndefined
|
||||
if max == sys.maxsize:
|
||||
max = PydanticUndefined
|
||||
field = (int, Field(default=value, ge=min, le=max))
|
||||
elif class_type == "CPackInputAny":
|
||||
options = node.get("_meta", {}).get("options")
|
||||
value = _get_node_value(node)
|
||||
if not options:
|
||||
@@ -140,14 +162,14 @@ def generate_input_model(workflow: dict) -> type[BaseModel]:
|
||||
f in options for f in ("min", "max", "round", "precision", "step")
|
||||
): # must be number types
|
||||
type_ = float if options.get("round", 1) < 1 else int
|
||||
field = (
|
||||
type_,
|
||||
Field(
|
||||
default=value,
|
||||
ge=options.get("min", PydanticUndefined),
|
||||
le=options.get("max", PydanticUndefined),
|
||||
),
|
||||
)
|
||||
min_value = options.get("min", PydanticUndefined)
|
||||
max_value = options.get("max", PydanticUndefined)
|
||||
if type_ is int:
|
||||
if min_value < -sys.maxsize:
|
||||
min_value = PydanticUndefined
|
||||
if max_value > sys.maxsize:
|
||||
max_value = PydanticUndefined
|
||||
field = (type_, Field(default=value, ge=min_value, le=max_value))
|
||||
else:
|
||||
field = (type(value), Field(default=value))
|
||||
else:
|
||||
@@ -156,7 +178,12 @@ def generate_input_model(workflow: dict) -> type[BaseModel]:
|
||||
return create_model("ParsedWorkflowTemplate", **input_fields)
|
||||
|
||||
|
||||
def populate_workflow(workflow: dict, output_path: Path, **inputs) -> dict:
|
||||
def populate_workflow(
|
||||
workflow: dict,
|
||||
output_path: Path,
|
||||
session_id: str = "",
|
||||
**inputs,
|
||||
) -> dict:
|
||||
"""
|
||||
Fills the input values and output path into the workflow.
|
||||
|
||||
@@ -174,15 +201,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"].startswith("CPackOutput"):
|
||||
workflow[node_id]["inputs"]["filename_prefix"] = (
|
||||
output_path / f"{node_id}_"
|
||||
output_path / f"{session_id}{node_id}_"
|
||||
).as_posix()
|
||||
return workflow
|
||||
|
||||
@@ -190,7 +217,8 @@ def populate_workflow(workflow: dict, output_path: Path, **inputs) -> dict:
|
||||
def retrieve_workflow_outputs(
|
||||
workflow: dict,
|
||||
output_path: Path,
|
||||
) -> Union[Path, list[Path], dict[str, Path], dict[str, list[Path]]]:
|
||||
session_id: str = "",
|
||||
) -> Union[Path, list[Path], dict[str, Path | list[Path]]]:
|
||||
"""
|
||||
Gets the output file(s) from the workflow.
|
||||
|
||||
@@ -199,7 +227,7 @@ def retrieve_workflow_outputs(
|
||||
output_path (Path): The path where output files are saved.
|
||||
|
||||
Returns:
|
||||
Union[Path, list[Path], dict[str, Path], dict[str, list[Path]]]:
|
||||
Union[Path, list[Path], dict[str, Path | list[Path]]]:
|
||||
- A single Path if there's only one output file.
|
||||
- A list of Paths if there are multiple files for a single output.
|
||||
- A dictionary mapping output names to Paths or lists of Paths for multiple outputs.
|
||||
@@ -208,23 +236,85 @@ def retrieve_workflow_outputs(
|
||||
ValueError: If the output node is not of the expected type.
|
||||
"""
|
||||
_, outputs = _parse_workflow(workflow)
|
||||
should_zip = any(
|
||||
node["class_type"] == "CPackOutputZipSwitch" for node in workflow.values()
|
||||
)
|
||||
zip_paths: list[tuple[Path, str]] = []
|
||||
if len(outputs) != 1:
|
||||
value_map = {}
|
||||
for k, node in outputs.items():
|
||||
node_id = node["id"]
|
||||
path_strs = list(output_path.glob(f"{node_id}_*"))
|
||||
path_strs = list(output_path.glob(f"{session_id}{node_id}_*"))
|
||||
zip_paths.extend(
|
||||
(p, p.name.replace(f"{session_id}{node_id}", k)) for p in path_strs
|
||||
)
|
||||
if len(path_strs) == 1:
|
||||
value_map[k] = path_strs[0]
|
||||
else:
|
||||
value_map[k] = path_strs
|
||||
return value_map
|
||||
if not should_zip:
|
||||
return value_map
|
||||
else:
|
||||
name, node = next(iter(outputs.items()))
|
||||
if not node["class_type"].startswith("CPackOutput"):
|
||||
raise ValueError(f"Node {name} is not a comfy-pack output node")
|
||||
node_id = node["id"]
|
||||
|
||||
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}")
|
||||
node_id = node["id"]
|
||||
outs = list(output_path.glob(f"{session_id}{node_id}_*"))
|
||||
zip_paths.extend(
|
||||
(p, p.name.replace(f"{session_id}{node_id}", name)) for p in outs
|
||||
)
|
||||
if not should_zip:
|
||||
if len(outs) == 1:
|
||||
return outs[0]
|
||||
return outs
|
||||
if len(zip_paths) == 1:
|
||||
return zip_paths[0][0]
|
||||
# Make a zipball from the collected files
|
||||
output_zip = output_path / f"{session_id}_output.zip"
|
||||
print(f"Creating zip file: {output_zip}")
|
||||
with zipfile.ZipFile(output_zip, "w", zipfile.ZIP_DEFLATED) as zipf:
|
||||
for path, name in zip_paths:
|
||||
zipf.write(path, arcname=name)
|
||||
return output_zip
|
||||
|
||||
outs = list(output_path.glob(f"{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,34 +0,0 @@
|
||||
import click
|
||||
|
||||
|
||||
@click.group()
|
||||
def main():
|
||||
"""ComfyUI IDL CLI"""
|
||||
|
||||
|
||||
@main.command(name="install")
|
||||
@click.option(
|
||||
"--workspace",
|
||||
"-w",
|
||||
default="workspace",
|
||||
help="Workspace directory",
|
||||
type=click.Path(file_okay=False),
|
||||
)
|
||||
@click.argument("cpack", type=click.Path(exists=True, dir_okay=False))
|
||||
def install_cmd(cpack: str, workspace: str):
|
||||
"""
|
||||
Install ComfyUI workspace from a zipped package.
|
||||
|
||||
Example:
|
||||
|
||||
# Install to the default directory(`workspace`)
|
||||
|
||||
$ comfyui_idl install workspace.cpack.zip
|
||||
|
||||
# Install to a different directory
|
||||
|
||||
$ comfyui_idl install -w my_workspace workspace.cpack.zip
|
||||
"""
|
||||
from .package import install
|
||||
|
||||
install(cpack, workspace)
|
||||
@@ -1,103 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
COMFYUI_REPO = "https://github.com/comfyanonymous/ComfyUI.git"
|
||||
|
||||
|
||||
def _clone_commit(url: str, commit: str, dir: Path):
|
||||
subprocess.check_call(["git", "clone", "--filter=blob:none", url, dir])
|
||||
subprocess.check_call(["git", "fetch", "-q", url, commit], cwd=dir)
|
||||
subprocess.check_call(["git", "checkout", "FETCH_HEAD"], cwd=dir)
|
||||
|
||||
|
||||
def install_comfyui(snapshot, workspace: Path):
|
||||
print("Installing ComfyUI")
|
||||
comfyui_commit = snapshot["comfyui"]
|
||||
|
||||
_clone_commit(COMFYUI_REPO, comfyui_commit, workspace)
|
||||
|
||||
|
||||
def install_custom_modules(snapshot, workspace: Path):
|
||||
print("Installing custom nodes")
|
||||
for module in snapshot["custom_nodes"]:
|
||||
url = module["url"]
|
||||
directory = url.split("/")[-1].split(".")[0]
|
||||
module_dir = workspace / "custom_nodes" / directory
|
||||
|
||||
commit_hash = module["commit_hash"]
|
||||
_clone_commit(url, commit_hash, module_dir)
|
||||
|
||||
|
||||
def install_dependencies(snapshot: dict, req_file: str, workspace: Path):
|
||||
print("Installing Python dependencies")
|
||||
python_version = snapshot["python"]
|
||||
subprocess.check_call(
|
||||
["uv", "python", "install", python_version],
|
||||
cwd=workspace,
|
||||
)
|
||||
venv = workspace / ".venv"
|
||||
if (venv / "DONE").exists():
|
||||
return
|
||||
venv_py = (
|
||||
venv / "Scripts" / "python.exe" if os.name == "nt" else venv / "bin" / "python"
|
||||
)
|
||||
subprocess.check_call(
|
||||
[
|
||||
"uv",
|
||||
"venv",
|
||||
"--python",
|
||||
python_version,
|
||||
venv,
|
||||
],
|
||||
)
|
||||
subprocess.check_call(
|
||||
[
|
||||
"uv",
|
||||
"pip",
|
||||
"install",
|
||||
"-p",
|
||||
str(venv_py),
|
||||
"pip",
|
||||
],
|
||||
)
|
||||
subprocess.check_call(
|
||||
[
|
||||
"uv",
|
||||
"pip",
|
||||
"install",
|
||||
"-p",
|
||||
str(venv_py),
|
||||
"-r",
|
||||
req_file,
|
||||
"--no-deps",
|
||||
],
|
||||
)
|
||||
with open(venv / "DONE", "w") as f:
|
||||
f.write("DONE")
|
||||
|
||||
|
||||
def install(cpack: str | Path, workspace: str | Path = "workspace") -> None:
|
||||
workspace = Path(workspace)
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
pack_dir = Path(temp_dir) / ".cpack"
|
||||
shutil.unpack_archive(cpack, pack_dir)
|
||||
snapshot = json.loads((pack_dir / "snapshot.json").read_text())
|
||||
req_txt_file = pack_dir / "requirements.txt"
|
||||
|
||||
install_comfyui(snapshot, workspace)
|
||||
install_custom_modules(snapshot, workspace)
|
||||
install_dependencies(snapshot, str(req_txt_file), workspace)
|
||||
for f in (pack_dir / "inputs").glob("*"):
|
||||
shutil.copy(f, workspace / "input" / f.name)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
install(sys.argv[1])
|
||||
@@ -1,212 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Union
|
||||
|
||||
from comfyui_idl.utils import (
|
||||
populate_workflow,
|
||||
retrieve_workflow_outputs,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _probe_comfyui_server():
|
||||
from urllib import parse, request
|
||||
url = 'http://127.0.0.1:8188/api/customnode/getmappings'
|
||||
params = {'mode': 'nickname'}
|
||||
full_url = f"{url}?{parse.urlencode(params)}"
|
||||
req = request.Request(full_url)
|
||||
_ = request.urlopen(req)
|
||||
|
||||
|
||||
class WorkflowRunner:
|
||||
"""
|
||||
A class to manage and run ComfyUI workflows.
|
||||
|
||||
This class handles the initialization, starting, stopping, and execution of ComfyUI workflows.
|
||||
It manages temporary and output directories, and provides methods to run workflows with specified parameters.
|
||||
|
||||
Attributes:
|
||||
temp_dir (Path): The temporary directory for ComfyUI operations.
|
||||
output_dir (Path): The output directory for ComfyUI results.
|
||||
workspace (str): The workspace path for ComfyUI.
|
||||
is_running (bool): Flag indicating whether ComfyUI is currently running.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workspace: str,
|
||||
temp_dir: Union[str, Path, None] = None,
|
||||
output_dir: Union[str, Path, None] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the WorkflowRunner.
|
||||
|
||||
Args:
|
||||
workspace (str): The workspace path for ComfyUI.
|
||||
temp_dir (Union[str, Path, None], optional): The temporary directory. Defaults to None.
|
||||
output_dir (Union[str, Path, None], optional): The output directory. Defaults to None.
|
||||
"""
|
||||
if temp_dir is None:
|
||||
self.temp_dir = Path(tempfile.mkdtemp())
|
||||
self._cleanup_temp_dir = True
|
||||
elif isinstance(temp_dir, str):
|
||||
self.temp_dir = Path(temp_dir)
|
||||
self._cleanup_temp_dir = False
|
||||
else:
|
||||
self.temp_dir = temp_dir
|
||||
self._cleanup_temp_dir = False
|
||||
|
||||
if output_dir is None:
|
||||
self.output_dir = self.temp_dir
|
||||
elif isinstance(output_dir, str):
|
||||
self.output_dir = Path(output_dir)
|
||||
else:
|
||||
self.output_dir = output_dir
|
||||
|
||||
self.workspace = workspace
|
||||
|
||||
# The ComfyUI process
|
||||
self.is_running = False
|
||||
|
||||
def start(self) -> None:
|
||||
"""
|
||||
Start the ComfyUI process.
|
||||
|
||||
This method starts ComfyUI in the background, sets up necessary directories,
|
||||
and disables tracking for workaround purposes.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If ComfyUI is already running.
|
||||
"""
|
||||
if self.is_running:
|
||||
raise RuntimeError("ComfyUI Runner is already started")
|
||||
|
||||
logger.info(
|
||||
"Disable tracking from Comfy CLI, not for privacy concerns, but to workaround a bug"
|
||||
)
|
||||
command = ["comfy", "--skip-prompt", "tracking", "disable"]
|
||||
subprocess.run(command, check=True)
|
||||
logger.info("Successfully disabled Comfy CLI tracking")
|
||||
|
||||
logger.info("Preparing directories required by ComfyUI...")
|
||||
os.makedirs(self.output_dir, exist_ok=True)
|
||||
os.makedirs(self.temp_dir, exist_ok=True)
|
||||
print("Comfy Output Path:", self.output_dir)
|
||||
print("Comfy Temp Path:", self.temp_dir)
|
||||
|
||||
logger.info("Starting ComfyUI in the background...")
|
||||
command = [
|
||||
"comfy",
|
||||
"--workspace",
|
||||
self.workspace,
|
||||
"launch",
|
||||
"--background",
|
||||
"--",
|
||||
"--output-directory",
|
||||
self.output_dir,
|
||||
"--temp-directory",
|
||||
self.temp_dir,
|
||||
]
|
||||
if subprocess.run(command, check=True):
|
||||
self.is_running = True
|
||||
_probe_comfyui_server()
|
||||
logger.info("Successfully started ComfyUI in the background")
|
||||
else:
|
||||
logger.error("Failed to start ComfyUI in the background")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""
|
||||
Stop the ComfyUI process.
|
||||
|
||||
This method stops the running ComfyUI process and cleans up the temporary directory if necessary.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If ComfyUI is not currently running.
|
||||
"""
|
||||
if not self.is_running:
|
||||
raise RuntimeError("ComfyUI Runner is not started yet")
|
||||
|
||||
logger.info("Stopping ComfyUI...")
|
||||
command = ["comfy", "stop"]
|
||||
subprocess.run(command, check=True)
|
||||
logger.info("Successfully stopped ComfyUI")
|
||||
if self._cleanup_temp_dir:
|
||||
logger.info("Cleaning up temporary directory...")
|
||||
shutil.rmtree(self.temp_dir)
|
||||
logger.info("Successfully cleaned up temporary directory")
|
||||
self.is_running = False
|
||||
|
||||
def run_workflow(
|
||||
self,
|
||||
workflow: dict,
|
||||
temp_dir: Union[str, Path, None] = None,
|
||||
timeout: int = 300,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""
|
||||
Run a ComfyUI workflow.
|
||||
|
||||
This method executes a given workflow, populates it with input data,
|
||||
and retrieves the output.
|
||||
|
||||
Args:
|
||||
workflow (dict): The workflow to run.
|
||||
temp_dir (Union[str, Path, None], optional): Temporary directory for the workflow. Defaults to None.
|
||||
timeout (int, optional): Timeout for the workflow execution in seconds. Defaults to 300.
|
||||
**kwargs: Additional keyword arguments for workflow population.
|
||||
|
||||
Returns:
|
||||
Any: The output of the workflow.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If ComfyUI is not started.
|
||||
"""
|
||||
if not self.is_running:
|
||||
raise RuntimeError("ComfyUI Runner is not started yet")
|
||||
|
||||
workflow_copy = copy.deepcopy(workflow)
|
||||
if temp_dir is None:
|
||||
temp_dir = self.temp_dir
|
||||
if isinstance(temp_dir, str):
|
||||
temp_dir = Path(temp_dir)
|
||||
|
||||
populate_workflow(
|
||||
workflow_copy,
|
||||
temp_dir,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
workflow_file_path = temp_dir / "workflow.json"
|
||||
with open(workflow_file_path, "w") as file:
|
||||
json.dump(workflow_copy, file)
|
||||
|
||||
extra_args = []
|
||||
if "BENTOML_DEBUG" in os.environ:
|
||||
extra_args.append("--verbose")
|
||||
# Execute the workflow
|
||||
command = [
|
||||
"comfy",
|
||||
"run",
|
||||
"--workflow",
|
||||
workflow_file_path.as_posix(),
|
||||
"--timeout",
|
||||
str(timeout),
|
||||
"--wait",
|
||||
*extra_args,
|
||||
]
|
||||
subprocess.run(command, check=True)
|
||||
|
||||
# retrieve the output
|
||||
return retrieve_workflow_outputs(
|
||||
workflow_copy,
|
||||
temp_dir,
|
||||
)
|
||||
+28
-13
@@ -3,14 +3,26 @@ import { app } from "../../scripts/app.js";
|
||||
function mimicNode(node, target, slot) {
|
||||
const getWidget = (widget) => {
|
||||
if (typeof widget.origType === "undefined") return widget;
|
||||
const newWidget = Object.assign({}, widget);
|
||||
for (const key in widget) {
|
||||
if (key.startsWith("orig") || key === "computeSize") {
|
||||
delete newWidget[key];
|
||||
const newWidget = new Proxy(widget, {
|
||||
has(target, prop) {
|
||||
return prop in target && prop !== "origType" && prop !== "computeSize";
|
||||
},
|
||||
get(target, prop) {
|
||||
if (prop.startsWith("orig") || prop === "computeSize") {
|
||||
return undefined;
|
||||
} else if (prop === "type") {
|
||||
return target.origType;
|
||||
} else if (prop === "name") {
|
||||
return inputName;
|
||||
} else {
|
||||
return target[prop];
|
||||
}
|
||||
},
|
||||
set(target, prop, value) {
|
||||
target[prop] = value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
newWidget.type = widget.origType;
|
||||
newWidget.name = inputName;
|
||||
});
|
||||
return newWidget;
|
||||
}
|
||||
const input = target.inputs[slot];
|
||||
@@ -32,15 +44,15 @@ function mimicNode(node, target, slot) {
|
||||
|
||||
app.registerExtension({
|
||||
name: "Comfy.DynamicInput",
|
||||
extensionNodes: ["BentoInputValue", "BentoInputFile"],
|
||||
dynamicNodes: ["CPackInputAny", "CPackInputFile"],
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (!this.extensionNodes.includes(nodeData.name)) return;
|
||||
if (!this.dynamicNodes.includes(nodeData.name)) return;
|
||||
|
||||
nodeType.prototype.onConnectOutput = function () {
|
||||
if (this.outputs[0].links?.length > 0) return false;
|
||||
const target = arguments[3];
|
||||
if (this.type === "BentoInputFile" && !["COMBO", "STRING"].includes(target.inputs[arguments[4]].type))
|
||||
if (this.type === "CPackInputFile" && !["COMBO", "STRING"].includes(target.inputs[arguments[4]].type))
|
||||
return false;
|
||||
mimicNode(this, target, arguments[4]);
|
||||
this.title = target.inputs[arguments[4]].name;
|
||||
@@ -49,7 +61,7 @@ app.registerExtension({
|
||||
|
||||
async setup(app) {
|
||||
app.graph.nodes.forEach((node) => {
|
||||
if (!this.extensionNodes.includes(node.type)) return;
|
||||
if (!this.dynamicNodes.includes(node.type)) return;
|
||||
if (node.outputs.length > 0 && node.outputs[0].links.length > 0) {
|
||||
const link = node.graph.links[node.outputs[0].links[0]];
|
||||
mimicNode(node, app.graph.getNodeById(link.target_id), link.target_slot);
|
||||
@@ -63,11 +75,14 @@ app.registerExtension({
|
||||
app.graphToPrompt = async function(graph = app.graph, clean = true) {
|
||||
const { workflow, output } = await originalToPrompt(graph, clean);
|
||||
Object.entries(output).forEach(([id, nodeData]) => {
|
||||
if (!self.extensionNodes.includes(nodeData.class_type)) return;
|
||||
if (!nodeData.class_type.startsWith("CPackInput")) return;
|
||||
const node = graph.getNodeById(parseInt(id));
|
||||
if (!nodeData["_meta"]) {
|
||||
nodeData["_meta"] = { title: node.title };
|
||||
}
|
||||
if (node.widgets.length === 0) return;
|
||||
const widget = node.widgets[0];
|
||||
nodeData["_meta"] = Object.assign({}, nodeData["_meta"] || {}, { options: widget.options });
|
||||
nodeData["_meta"] = { ...nodeData["_meta"], options: widget.options };
|
||||
});
|
||||
return { workflow, output };
|
||||
};
|
||||
|
||||
+1355
-144
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user