Compare commits

...
165 Commits
Author SHA1 Message Date
bojiang 62029e4a30 chore: Update README.md 2024-12-18 20:35:20 +08:00
bojiang ca97c40d9f doc: Update README.md 2024-12-18 19:10:22 +08:00
bojiang 230e25dbf0 doc: Update banner 2024-12-18 17:29:29 +08:00
bojiang 9347df2159 doc: add banner 2024-12-18 17:08:58 +08:00
bojiang 0d582b6458 doc: Update README.md 2024-12-18 17:01:17 +08:00
Sean Sheng 2878a6fcdf doc: update README.md 2024-12-18 00:55:28 -08:00
bojiang 367ea64dcd chore: add examples 2024-12-18 14:52:28 +08:00
bojiang a6c05d3b48 chore: add init subcommand 2024-12-18 14:51:17 +08:00
bojiang 4bfd08cead chore: revert hf models 2024-12-18 13:17:30 +08:00
bojiang c75539c167 Update README.md 2024-12-18 11:22:21 +08:00
bojiang 962aa5b573 Revert "Revert "fix: don't handle hugging face models specially""
This reverts commit 8a12a9b5fa.
2024-12-18 09:34:32 +08:00
bojiang 068e055501 chore: label comfy-pack version 2024-12-17 19:40:35 +08:00
bojiang feb9c12bd2 make package primary 2024-12-17 19:00:13 +08:00
bojiang 5b10d3dcfb fix: serve 2024-12-17 18:57:56 +08:00
bojiang a58907ed7f chore: enlarge default timeout 2024-12-17 17:04:31 +08:00
bojiang 8a12a9b5fa Revert "fix: don't handle hugging face models specially"
This reverts commit 9daee2c6cc.
2024-12-17 16:34:18 +08:00
bojiang 38cad77c1e fix: model matching 2024-12-17 16:33:16 +08:00
Frost Ming 9daee2c6cc fix: don't handle hugging face models specially
Signed-off-by: Frost Ming <me@frostming.com>
2024-12-17 15:43:16 +08:00
bojiang 4a30cc705b fix: listen address 2024-12-17 15:22:16 +08:00
bojiang 93ca8b49c3 fix: --include-disabled-models 2024-12-16 23:52:21 +08:00
bojiang c30ca2dba4 fix: mkdir if not exist 2024-12-16 23:45:13 +08:00
bojiang e7aed0e765 fix: install custom nodes in venv 2024-12-16 23:43:26 +08:00
bojiang 400fc272eb fix: init custom nodes in venv 2024-12-16 22:39:57 +08:00
bojiang 91ba706b55 fix: verbose 2024-12-16 20:48:51 +08:00
bojiang abbd1f5aaa chore: print verbose level 2024-12-16 20:44:38 +08:00
bojiang 16baab39ef fix: verbose install 2024-12-16 20:38:27 +08:00
bojiang a1624ca69c chore: more log 2024-12-16 20:37:11 +08:00
bojiang 41bae638fc chore: UX 2024-12-16 20:37:11 +08:00
bojiang b7166b6283 Update README.md 2024-12-16 15:51:54 +08:00
bojiang 6abca7db24 add unpack button 2024-12-16 15:49:05 +08:00
Zhao Shenyang e935bad9ac chore: use duckduckgo instead of google for less rate limit (#7) 2024-12-16 08:55:20 +08:00
bojiang 9617b568ed Update README.md 2024-12-14 15:11:56 +08:00
Frost Ming 146b79cf74 feat: register huggingface models
Signed-off-by: Frost Ming <me@frostming.com>
2024-12-13 10:16:44 +08:00
Frost Ming 174863596f chore: fix release workflow
Signed-off-by: Frost Ming <me@frostming.com>
2024-12-12 18:02:24 +08:00
bojiang 45cb9e3ce6 Revert "fix: get model info concurrently" (#6)
This reverts commit 5abbce1512.
2024-12-12 14:58:42 +08:00
Frost Ming 938be883ad fix:trust proxies from env
Signed-off-by: Frost Ming <me@frostming.com>
2024-12-12 12:37:50 +08:00
Frost Ming 5abbce1512 fix: get model info concurrently
Signed-off-by: Frost Ming <me@frostming.com>
2024-12-12 11:56:04 +08:00
Frost Ming 3264df7836 fix: always use ctime
Signed-off-by: Frost Ming <me@frostming.com>
2024-12-12 10:24:47 +08:00
Zhao Shenyang 62e368f812 fix: handle linux files creation time (#5) 2024-12-12 10:13:43 +08:00
Frost Ming f5242b0488 fix: terminate child procs recursively
Signed-off-by: Frost Ming <me@frostming.com>
2024-12-12 10:04:36 +08:00
Frost Ming 9e50767bf9 fix: don't send duplicate signals
Signed-off-by: Frost Ming <me@frostming.com>
2024-12-12 08:47:16 +08:00
bojiang b1a5a3739e fix: cannot shutdown 2024-12-11 19:17:55 +08:00
Frost Ming 8c770b0313 Merge branch 'main' of github.com:bentoml/ComfyUI-IDL 2024-12-11 17:29:33 +08:00
Frost Ming 27175ceb09 fix:dont print writing models
Signed-off-by: Frost Ming <me@frostming.com>
2024-12-11 17:29:28 +08:00
bojiang bfdc3ef4dd fix: stop thread 2024-12-11 15:06:27 +08:00
bojiang 2f9d3df5aa fix: pass signal; output error 2024-12-11 14:46:20 +08:00
bojiang 01eb014aeb chore: more log 2024-12-11 14:06:02 +08:00
Frost Ming 06aa2c6d68 Merge branch 'main' of github.com:bentoml/ComfyUI-IDL
Signed-off-by: Frost Ming <me@frostming.com>
2024-12-11 11:34:32 +08:00
Frost Ming fc5dd9e2df fix: skip disabled models
Signed-off-by: Frost Ming <me@frostming.com>
2024-12-11 11:33:59 +08:00
bojiang 3ac04864e8 fix: model not found 2024-12-11 11:28:44 +08:00
Frost Ming 0e9f7dc860 Merge branch 'main' of github.com:bentoml/ComfyUI-IDL 2024-12-11 11:26:09 +08:00
Frost Ming c6459d8448 fix: correct the status url
Signed-off-by: Frost Ming <me@frostming.com>
2024-12-11 11:26:04 +08:00
bojiang 54198b9679 chore: fix model query 2024-12-11 11:02:35 +08:00
bojiang d5b527a0eb chore(bento): terminate itself once comfy server down 2024-12-11 10:57:26 +08:00
Frost Ming 8677e0446f fix: improve the UI
- Don't store bento name nor give a default value
- Make bento name field mandatory
- Disable push/pack button when model list is loading

Signed-off-by: Frost Ming <me@frostming.com>
2024-12-11 10:07:06 +08:00
Frost Ming 5f72b80651 chore: update pyproject and readme
Signed-off-by: Frost Ming <me@frostming.com>
2024-12-11 08:27:20 +08:00
bojiang c41cf14996 chore: include path for hf source 2024-12-10 21:40:49 +08:00
bojiang b335f98143 chore: fix download logic 2024-12-10 10:55:14 +08:00
bojiang 09803955b2 retrive models 2024-12-10 03:34:10 +08:00
bojiang 61026459c9 chore: opt UI 2024-12-10 03:24:21 +08:00
bojiang 0079d8700f chore: return cached values 2024-12-10 02:43:24 +08:00
bojiang 803aa1d88d chore: minor fix 2024-12-10 02:29:27 +08:00
bojiang dc343856ba fix(model helper): support space and datasets 2024-12-10 02:21:10 +08:00
bojiang 94f24ce096 feat: lookup models 2024-12-10 02:05:20 +08:00
bojiang f6c3eb6e89 fix: avoid comfy-cli prompt 2024-12-09 19:37:05 +08:00
bojiang 95f3dd41e1 fix: add comfy-cli to requirements 2024-12-09 19:20:07 +08:00
bojiang 2356537a5d fix: clean breakpoint 2024-12-09 17:10:16 +08:00
bojiang e0ceef7b8d fix: model sha 2024-12-09 17:10:16 +08:00
bojiang fe9132bd76 refactor: sha 2024-12-09 17:10:16 +08:00
bojiang 5bccfb9adc doc: add serve git to README.md 2024-12-08 17:31:46 +08:00
bojiang 052c9d5eb6 chore: UX optimization 2024-12-08 16:06:19 +08:00
bojiang 30b3e11325 chore: badge no-wrap 2024-12-08 03:05:44 +08:00
bojiang a0f5bbf1d4 fix: model sha cache 2024-12-08 02:58:02 +08:00
bojiang 181781247f chore: validate input/output nodes 2024-12-08 02:39:38 +08:00
bojiang 9e9b4b3392 chore: fix UX 2024-12-08 01:17:33 +08:00
bojiang 0f97cfc01e chore: add model selector for deploy 2024-12-08 01:02:20 +08:00
bojiang dca5255f70 chore: enhance model selector 2024-12-07 22:49:22 +08:00
bojiang e3555bab49 chore: pick models 2024-12-07 21:47:15 +08:00
bojiang e757c308b6 feat: model selector 2024-12-07 15:09:38 +08:00
bojiang d325fe2a98 chore: monir fix 2024-12-07 11:49:18 +08:00
bojiang 73e663c18a chore: terminate server 2024-12-07 11:04:34 +08:00
bojiang 2ab442fd2e chore: polish UI 2024-12-07 10:32:36 +08:00
bojiang e76578beb9 chore: unify cpack and bento 2024-12-06 23:10:43 +08:00
bojiang 74e5c65afc chore: polish text 2024-12-06 22:46:32 +08:00
bojiang 06bd72103b feat: finish dev server 2024-12-06 22:27:22 +08:00
bojiang d16fc5bc96 chore: add dev server 2024-12-06 19:26:11 +08:00
bojiang 44ace4c9f5 chore: allow run with exist server 2024-12-06 15:25:41 +08:00
Frost Ming f90d348217 fix: add placeholder
Signed-off-by: Frost Ming <me@frostming.com>
2024-12-05 09:59:08 +08:00
Frost Ming 79bc15cf7c fix: give default gpu=1
Signed-off-by: Frost Ming <me@frostming.com>
2024-12-04 13:59:54 +08:00
Frost Ming d4718c9e50 Merge branch 'main' of github.com:bentoml/ComfyUI-IDL 2024-12-04 10:19:36 +08:00
Frost Ming 50d4675884 fix: use pip list to work with conda env
Signed-off-by: Frost Ming <me@frostming.com>
2024-12-04 10:19:31 +08:00
bojiang fa850833a5 fix: ensure temp folder exists before using it 2024-12-03 09:47:30 +00:00
bojiang 7254cbf641 Update README.md 2024-11-26 10:51:01 +08:00
bojiang cb56e5330b Update README.md 2024-11-26 10:44:49 +08:00
Frost Ming 0fe6323086 fix: build package button ui
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-25 18:52:51 +08:00
Frost Ming 9d1406e421 fix: add meta title
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-25 18:26:03 +08:00
Frost Ming e9772858ec fix: class_type match
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-25 18:22:51 +08:00
Frost Ming 251b2602ba Merge branch 'main' of github.com:bentoml/ComfyUI-IDL 2024-11-25 18:17:54 +08:00
Frost Ming d1723004d2 fix: add meta title
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-25 18:17:48 +08:00
bojiang 7565758b9d chore: model hash cache 2024-11-25 17:56:21 +08:00
bojiang bf4414bacf fix: copy inputs 2024-11-22 14:37:15 +00:00
Frost Ming d89731c1a1 Merge branch 'main' of github.com:bentoml/comfy-pack
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-22 18:54:09 +08:00
Frost Ming 660a8e6ba2 fix: use proxy instead of copy
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-22 18:53:05 +08:00
bojiang e3bd863ae7 fix: copy dir 2024-11-22 18:36:01 +08:00
bojiang bed78a3a41 fix: include dir 2024-11-22 18:31:52 +08:00
bojiang ff818e29a6 chore: skip exists during restore 2024-11-22 17:34:29 +08:00
bojiang 9c0197be7f fix: python path 2024-11-22 17:19:46 +08:00
bojiang bad78c37e8 fix: probe 2024-11-22 16:24:38 +08:00
bojiang 360a365963 fix: int node 2024-11-22 16:17:16 +08:00
bojiang 6179409369 fix: int input 2024-11-22 16:08:17 +08:00
bojiang 8ccc45168b fix: String input 2024-11-22 16:05:20 +08:00
bojiang 978b8b9ff0 chore: add IntInput 2024-11-22 16:03:47 +08:00
bojiang d7785478e5 feat: ramdom port; TextInput 2024-11-22 15:53:08 +08:00
bojiang bd14213f46 fix: filter out nodes without inputs 2024-11-22 10:22:21 +08:00
bojiang 2b0da4086a fix: remove model module 2024-11-22 09:55:08 +08:00
bojiang 1ea3be0749 fix: identifier 2024-11-22 09:38:53 +08:00
Frost Ming e4522362cb fix: get workspace location by snapshot checksum
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-22 08:21:45 +08:00
Frost Ming a8ec38e6fe fix: fix the file name of stored model
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-21 20:34:26 +08:00
bojiang f1d3af75eb fix: clean code 2024-11-21 20:25:40 +08:00
bojiang 9dd2ab639e fix(custom_node): call install.py 2024-11-21 20:24:20 +08:00
Frost Ming 016423c9f5 fix: invoke install.py if exists
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-21 20:03:51 +08:00
Frost Ming 6e3bd0d282 fix: add submodules
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-21 18:22:26 +08:00
Frost Ming d9e4d3b9f0 fix: get workspace
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-21 17:56:58 +08:00
Frost Ming 5259505cb6 fix: comfy workspace path
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-21 17:50:17 +08:00
Frost Ming 9628377e2c fix: don't recreate if exists
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-21 17:30:37 +08:00
Frost Ming 9d03466734 fix: install workspace
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-21 17:20:10 +08:00
Frost Ming e66465d572 fix: serialization value issue
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-21 17:05:08 +08:00
Frost Ming 4c9a6c2bbb fix: don't show duplicate modals
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-21 15:33:19 +08:00
Frost Ming 3a5fc6dfa1 Merge branch 'main' of github.com:bentoml/ComfyUI-IDL 2024-11-21 15:06:10 +08:00
Frost Ming f638a9ddcc fix: copyable bento name
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-21 15:06:04 +08:00
bojiang f05c5912a1 fix: use same model repo 2024-11-21 12:06:45 +08:00
Frost Ming ad2f6a67be fix: write snapshot
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-21 12:00:16 +08:00
Frost Ming 2c79b7bb69 fix: for new UI
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-21 11:50:49 +08:00
bojiang 20ce3fa584 fix: copy 2024-11-21 11:33:40 +08:00
Frost Ming c7b48c033b Feat/build (#3)
* feat: build bento

Signed-off-by: Frost Ming <me@frostming.com>

* fix bugs

Signed-off-by: Frost Ming <me@frostming.com>

* fix: refactor main.js

Signed-off-by: Frost Ming <me@frostming.com>

* feat: build bento button

Signed-off-by: Frost Ming <me@frostming.com>
2024-11-21 11:12:15 +08:00
bojiang 1f8ed12a56 chore: add model size 2024-11-20 21:01:29 +08:00
bojiang b7f49d6152 chore: add explicit flag to models 2024-11-20 17:15:44 +08:00
Frost Ming 7b9d37580b fix: use relative imports
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-20 15:52:47 +08:00
bojiang 3c02c51333 fix: comfy-pack restore 2024-11-20 15:24:47 +08:00
bojiang dbca147dbf chore: rename to comfy-pack 2024-11-20 15:03:59 +08:00
bojiang f4f1d2ce4b chore: rename to comfy-pack 2024-11-20 14:50:48 +08:00
bojiang 50165f80d6 chore: rename bento to cpack 2024-11-20 14:42:12 +08:00
bojiang 463d15656c chore: pretty output 2024-11-20 14:35:21 +08:00
bojiang e01c6bac12 chore: use verbose 2024-11-20 13:51:56 +08:00
bojiang 017c49f1af simplify temp dir 2024-11-20 12:25:48 +08:00
bojiang da6dfc5fd5 feat: more cli command 2024-11-20 12:01:04 +08:00
Frost Ming ff7a55a804 feat: add cli entrypoint
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-15 08:49:45 +08:00
bojiang f680263631 chore: install package 2024-11-15 05:59:12 +08:00
bojiang 704937f2b9 chore: filter used inputs only 2024-11-14 20:49:26 +08:00
bojiang 3b016e2fa4 chore: clean 2024-11-14 20:34:33 +08:00
bojiang 1e946b4e48 chore: input filename 2024-11-14 19:59:36 +08:00
agent 21b13b56c0 chore: add packaging progress bar 2024-11-14 17:06:17 +08:00
Frost Ming 7b9f0ec026 fix whitespaces
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-14 10:38:02 +08:00
Frost Ming cf8ea2b875 feat: collect comfyui hash, models, custom nodes into snapshot.json (#2)
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-14 10:36:03 +08:00
Frost Ming 20bd7b379d fix: disable button when processing
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-13 20:00:36 +08:00
Frost Ming 64a8f7f712 fix: get node info
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-13 19:10:03 +08:00
Frost Ming 1a24cd2343 fix: clean older files
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-13 19:02:13 +08:00
Frost Ming 27eb860c93 feat: package workspace
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-13 18:18:47 +08:00
Frost Ming deb787096f fix: rename input node names
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-13 11:38:24 +08:00
Frost Ming b6d712ec1c fix: get widget metadata and construct correct input model
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-13 09:59:48 +08:00
Frost Ming 0b1341a5a3 Merge branch 'main' of github.com:bentoml/ComfyUI-IDL
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-12 18:19:27 +08:00
Frost Ming 2b01701d0a feat: dynamic node to retrieve the metadata
Signed-off-by: Frost Ming <me@frostming.com>
2024-11-12 18:05:43 +08:00
bojiang 7ecf35c76d chore: prompt users to save api format 2024-11-11 18:52:22 +08:00
Zhao Shenyang 5f7c3d8a10 fix: use urllib instead of requests 2024-11-11 17:33:06 +08:00
Zhao Shenyang cc58814e07 fix: workflow hanging 2024-11-11 15:40:00 +08:00
24 changed files with 5153 additions and 392 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
id-token: write
environment:
name: pypi
url: https://pypi.org/project/comfyui-idl/${{ github.ref_name }}
url: https://pypi.org/project/comfy-pack/${{ github.ref_name }}
runs-on: ubuntu-latest
needs: build-package
+1
View File
@@ -160,3 +160,4 @@ cython_debug/
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
/temp/
+129 -2
View File
@@ -1,3 +1,130 @@
# ComfyUI-IDL
# Comfy-Pack: Package and Deploy ComfyUI Workflows
> ⚠️ **WARNING**: This project is under active development.
ComfyUI custom nodes for input and output specification.
<img width="952" alt="banner" src="https://github.com/user-attachments/assets/1ab2c7a7-55da-4fa4-a821-db7ba720fbc9" />
A comprehensive toolkit for reliably packing and unpacking environments for ComfyUI workflows.
- 📦 **Pack workflow environments as artifacts:** Saves the workflow environment in a `.cpack.zip` artifact with Python package versions, ComfyUI and custom node revisions, and model hashes.
- ✨ **Unpack artifacts to recreate workflow environments:** Unpacks the `.cpack.zip` artifact to recreate the same environment with the exact Python package versions, ComfyUI and custom node revisions, and model weights.
- 🚀 **Deploy workflows as APIs:** Deploys the workflow as a RESTful API with customizable input and output parameters.
## Motivations
When sharing ComfyUI workflows to others, your audiences or team member, you've likely heard these responses:
- "Custom Node not found"
- "Cannot find the model file"
- "Missing Python dependencies"
These are fundamental challenges in workflow sharing – every component must match exactly: custom nodes, model files, and Python dependencies.
We learned it from our communit, and developed Comfy-Pack to address this directly. With a single click, it captures and locks your entire workflow environment into a `.cpack.zip` file, including Python packages, custom nodes, model hashes, and required assets.
Users can recreate the exact environment with one command:
```bash
comfy-pack unpack workflow.cpack.zip
```
Focus on creating. Let Comfy-Pack handle the rest.
## Quick Start
### Installation
Search `comfy-pack` in ComfyUI Manager (Recommended)
![install_node](https://github.com/user-attachments/assets/dbfb730d-edff-4a52-b6c4-695e3ec70368)
or install from Git:
```bash
git clone https://github.com/bentoml/comfy-pack.git
```
### Pack a ComfyUI workflow
1. Click "Package" button to create `.cpack.zip`
2. (Optional) select the models that you want to include (only model hash will be recorded, so you wont get a 100GB zip file)
![pack](https://github.com/user-attachments/assets/e08bbed2-84dc-474e-a701-6c6db16edf76)
### Unpack a ComfyUI workflow
```bash
# Restore the full ComfyUI directory from cpack files.
comfy-pack unpack workflow.cpack.zip
```
### Deploy a workflow as an API
<details>
<summary> 1. annotate input & output </summary>
![input](https://github.com/user-attachments/assets/44264007-0ac8-4e23-8dc0-e60aa0ebcea2)
![output](https://github.com/user-attachments/assets/a4526661-8930-4575-bacc-33b6887f6271)
</details>
<details>
<summary> 2. serve and test locally </summary>
![serve](https://github.com/user-attachments/assets/8d4c92c5-d6d7-485e-bc71-e4fc0fe8bf35)
</details>
<details>
<summary> 3. (Optional) pack & run anywhere </summary>
```bash
# Get the workflow input spec
comfy-pack run workflow.cpack.zip --help
# Run
comfy-pack run workflow.cpack.zip --src-image image.png --video video.mp4
```
</details>
<details>
<summary> 4. (Optional) deploy to cloud * </summary>
![image](https://github.com/user-attachments/assets/1ffa31fc-1f50-4ea7-a47e-7dae3b874273)
</details>
## Custom Node List
ComfyPack provides custom nodes for standardizing inputs:
- ImageInput: provides `image` type input, similar to official `LoadImage` node
- StringInput: provides `string` type input, nice for prompts
- IntInput: provides `int` type input, suitable for size or seeds
- AnyInput: provides `combo` type and more input, suitable for custom nodes
- ImageOutput: takes `image` type inputs, similar to official `SaveImage` node, take an image of a bunch of images
- FileOutput: takes file path as `string` type, save and output the file under that path
- ...
These nodes help define clear interfaces for your workflow.
## 🚀 Roadmap
- Docker Support
- local cpack manager and Version Control
- Enhanced service capabilities
## Examples
Check our [examples folder](examples/) for:
- Basic workflow packaging
- Parameter configuration
- API integration
- Docker deployment
## License
MIT License
## Community
- Issues & Feature Requests: GitHub Issues
- Questions & Discussion: Discord Server
Detailed documentation: under development
+15 -1
View File
@@ -1,3 +1,17 @@
import os
import subprocess
import sys
from .nodes import api # noqa
from .nodes.nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
WEB_DIRECTORY = "./web"
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
print("Comfy-Pack => Installing Python dependencies")
subprocess.check_call(
[sys.executable, "-m", "pip", "install", "-r", "requirements.txt"],
stdout=subprocess.DEVNULL,
cwd=os.path.dirname(__file__),
)
Binary file not shown.
+8
View File
@@ -0,0 +1,8 @@
import sys
import pathlib
SRC_DIR = pathlib.Path(__file__).parent.parent / "src"
if str(SRC_DIR) not in sys.path:
sys.path.append(str(SRC_DIR))
+562
View File
@@ -0,0 +1,562 @@
from __future__ import annotations
import asyncio
import json
import os
import shutil
import socket
import subprocess
import sys
import tempfile
import time
import uuid
import zipfile
from pathlib import Path
from typing import Any, Union
import folder_paths
from aiohttp import web
from server import PromptServer
from comfy_pack.hash import async_batch_get_sha256
from comfy_pack.model_helper import alookup_model_source
from comfy_pack.utils import get_self_git_commit
ZPath = Union[Path, zipfile.Path]
TEMP_FOLDER = Path(__file__).parent.parent / "temp"
COMFY_PACK_DIR = Path(__file__).parent.parent / "src" / "comfy_pack"
EXCLUDE_PACKAGES = ["bentoml", "onnxruntime"] # TODO: standardize this
async def _write_requirements(path: ZPath, extras: list[str] | None = None) -> None:
print("Package => Writing requirements.txt")
with path.joinpath("requirements.txt").open("w") as f:
proc = await asyncio.subprocess.create_subprocess_exec(
sys.executable,
"-m",
"pip",
"list",
"--format",
"freeze",
"--exclude-editable",
*[f"--exclude={p}" for p in EXCLUDE_PACKAGES],
stdout=subprocess.PIPE,
)
stdout, _ = await proc.communicate()
f.write(stdout.decode().rstrip("\n") + "\n")
if extras:
f.write("\n".join(extras) + "\n")
async def _write_snapshot(path: ZPath, data: dict, models: list | None = None) -> None:
proc = await asyncio.subprocess.create_subprocess_exec(
"git", "rev-parse", "HEAD", stdout=subprocess.PIPE, cwd=folder_paths.base_path
)
stdout, _ = await proc.communicate()
if models is None:
print("Package => Writing models")
models = await _get_models()
with path.joinpath("snapshot.json").open("w") as f:
data = {
"python": f"{sys.version_info.major}.{sys.version_info.minor}",
"comfyui": stdout.decode().strip(),
"models": models,
"custom_nodes": await _get_custom_nodes(),
}
f.write(json.dumps(data, indent=2))
def _is_port_in_use(port: int | str, host="localhost"):
if isinstance(port, str):
port = int(port)
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
try:
s.connect((host, port))
return True
except ConnectionRefusedError:
return False
except Exception:
return True
def _is_file_refered(file_path: Path, workflow_api: dict) -> bool:
""" """
used_inputs = set()
for node in workflow_api.values():
for _, v in node["inputs"].items():
if isinstance(v, str):
used_inputs.add(v)
all_inputs = "\n".join(used_inputs)
file_path = file_path.absolute().relative_to(folder_paths.base_path)
relpath = Path(*file_path.parts[2:])
return str(relpath) in all_inputs
async def _get_models(
store_models: bool = False,
workflow_api: dict | None = None,
model_filter: set[str] | None = None,
ensure_sha=True,
ensure_source=True,
) -> list:
proc = await asyncio.subprocess.create_subprocess_exec(
"git",
"ls-files",
"--others",
folder_paths.models_dir,
stdout=subprocess.PIPE,
)
stdout, _ = await proc.communicate()
models = []
model_filenames = [
os.path.abspath(line)
for line in stdout.decode().splitlines()
if not os.path.basename(line).startswith(".")
]
model_hashes = await async_batch_get_sha256(
model_filenames,
cache_only=not (ensure_sha or store_models),
)
for filename in model_filenames:
relpath = os.path.relpath(filename, folder_paths.base_path)
model_data = {
"filename": relpath,
"size": os.path.getsize(filename),
"atime": os.path.getatime(filename),
"ctime": os.path.getctime(filename),
"disabled": relpath not in model_filter
if model_filter is not None
else False,
"sha256": model_hashes.get(filename),
}
model_data["source"] = await alookup_model_source(
model_data["sha256"],
cache_only=not ensure_source,
)
# should_store = store_models and (
# model_data["source"].get("source") != "huggingface"
# or model_data["source"].get("repo", "").startswith("datasets/")
# ) # TODO: sort this out
should_store = store_models
if should_store:
import bentoml
model_tag = f'cpack-model:{model_data["sha256"][:16]}'
try:
model = bentoml.models.get(model_tag)
except bentoml.exceptions.NotFound:
with bentoml.models.create(
model_tag, labels={"filename": relpath}
) as model:
shutil.copy(filename, model.path_of("model.bin"))
model_data["model_tag"] = model_tag
models.append(model_data)
if workflow_api:
for model in models:
model["refered"] = _is_file_refered(Path(model["filename"]), workflow_api)
return models
async def _get_custom_nodes() -> list:
print("Package => Writing custom nodes")
custom_nodes = os.path.join(folder_paths.base_path, "custom_nodes")
coros = []
async def get_node_info(subdir: Path) -> dict:
proc = await asyncio.subprocess.create_subprocess_exec(
"git",
"config",
"--get",
"remote.origin.url",
cwd=subdir,
stdout=subprocess.PIPE,
)
stdout, _ = await proc.communicate()
url = stdout.decode().strip()
proc = await asyncio.subprocess.create_subprocess_exec(
"git",
"rev-parse",
"HEAD",
cwd=subdir,
stdout=subprocess.PIPE,
)
stdout, _ = await proc.communicate()
commit_hash = stdout.decode().strip()
return {
"url": url,
"commit_hash": commit_hash,
"disabled": subdir.name.endswith(".disabled"),
}
for subdir in Path(custom_nodes).iterdir():
if not subdir.is_dir() or not subdir.joinpath(".git").exists():
continue
coros.append(get_node_info(subdir))
return await asyncio.gather(*coros)
async def _write_workflow(path: ZPath, data: dict) -> None:
print("Package => Writing workflow")
with path.joinpath("workflow_api.json").open("w") as f:
f.write(json.dumps(data["workflow_api"], indent=2))
with path.joinpath("workflow.json").open("w") as f:
f.write(json.dumps(data["workflow"], indent=2))
async def _write_inputs(path: ZPath, data: dict) -> None:
print("Package => Writing inputs")
if isinstance(path, Path):
path.joinpath("input").mkdir(exist_ok=True)
input_dir = folder_paths.get_input_directory()
used_inputs = set()
for node in data["workflow_api"].values():
for _, v in node["inputs"].items():
if isinstance(v, str):
used_inputs.add(v)
src_root = Path(input_dir).absolute()
for src in src_root.glob("**/*"):
rel = src.relative_to(src_root)
if src.is_dir():
if isinstance(path, Path):
path.joinpath("input").joinpath(rel).mkdir(parents=True, exist_ok=True)
if src.is_file():
with path.joinpath("input").joinpath(rel).open("wb") as f:
with open(src, "rb") as input_file:
shutil.copyfileobj(input_file, f)
@PromptServer.instance.routes.post("/bentoml/pack")
async def pack_workspace(request):
data = await request.json()
TEMP_FOLDER.mkdir(exist_ok=True)
older_than_1h = time.time() - 60 * 60
for file in TEMP_FOLDER.iterdir():
if file.is_file() and file.stat().st_ctime < older_than_1h:
file.unlink()
zip_filename = f"{uuid.uuid4()}.zip"
with zipfile.ZipFile(TEMP_FOLDER / zip_filename, "w") as zf:
path = zipfile.Path(zf)
await _prepare_bento_project(
path,
data,
)
return web.json_response({"download_url": f"/bentoml/download/{zip_filename}"})
class DevServer:
TIMEOUT = 3600 * 24
proc: Union[None, subprocess.Popen] = None
watch_dog_task: asyncio.Task | None = None
last_feed = 0
run_dir: Path | None = None
port = 0
@classmethod
def start(cls, workflow_api: dict, port: int = 3000):
cls.stop()
cls.port = port
# prepare a temporary directory
cls.run_dir = Path(tempfile.mkdtemp(suffix="-bento", prefix="comfy-pack-"))
with cls.run_dir.joinpath("workflow_api.json").open("w") as f:
f.write(json.dumps(workflow_api, indent=2))
shutil.copy(
Path(__file__).with_name("service.py"),
cls.run_dir / "service.py",
)
shutil.copytree(COMFY_PACK_DIR, cls.run_dir / COMFY_PACK_DIR.name)
# find a free port
self_port = 8188
for i, arg in enumerate(sys.argv):
if arg == "--port" or arg == "-p":
self_port = int(sys.argv[i + 1])
break
print(f"Starting dev server at port {port}, comfyui at port {self_port}")
cls.proc = subprocess.Popen(
[
sys.executable,
"-m",
"bentoml",
"serve",
"service:ComfyService",
"--port",
str(port),
],
cwd=str(cls.run_dir.absolute()),
env={
**os.environ,
"COMFYUI_SERVER": f"localhost:{self_port}",
},
)
cls.watch_dog_task = asyncio.create_task(cls.watch_dog())
cls.last_feed = time.time()
@classmethod
async def watch_dog(cls):
while True:
await asyncio.sleep(0.1)
if cls.last_feed + cls.TIMEOUT < time.time():
cls.stop()
break
@classmethod
def feed_watch_dog(cls):
if cls.proc:
if cls.proc.poll() is None:
cls.last_feed = time.time()
return True
else:
cls.stop()
return False
return False
@classmethod
def stop(cls):
if cls.proc:
cls.proc.terminate()
cls.proc.wait()
cls.proc = None
time.sleep(1)
print("Dev server stopped")
if cls.watch_dog_task:
cls.watch_dog_task.cancel()
cls.watch_dog_task = None
cls.last_feed = 0
if cls.run_dir:
shutil.rmtree(cls.run_dir)
cls.run_dir = None
def _parse_workflow(workflow: dict) -> tuple[dict[str, Any], dict[str, Any]]:
inputs = {}
outputs = {}
dep_map = {}
for id, node in workflow.items():
for input_name, v in node["inputs"].items():
if isinstance(v, list) and len(v) == 2: # is a link
dep_map[tuple(v)] = node, input_name
for id, node in workflow.items():
node["id"] = id
if node["class_type"].startswith("CPackInput"):
if not node.get("inputs"):
continue
inputs[id] = node
elif node["class_type"].startswith("CPackOutput"):
if not node.get("inputs"):
continue
outputs[id] = node
return inputs, outputs
def _validate_workflow(data: dict):
workflow = data.get("workflow_api", {})
if not workflow:
return web.json_response(
{
"result": "error",
"error": "empty workflow",
},
)
input_spec, output_spec = _parse_workflow(workflow)
if not input_spec:
return web.json_response(
{
"result": "error",
"error": "At least one ComfyPack input node is required",
},
)
if not output_spec:
return web.json_response(
{
"result": "error",
"error": "At least one ComfyPack output node is required",
},
)
@PromptServer.instance.routes.post("/bentoml/serve")
async def serve(request):
data = await request.json()
if (error := _validate_workflow(data)) is not None:
return error
DevServer.stop()
if _is_port_in_use(data.get("port", 3000), host=data.get("host", "localhost")):
return web.json_response(
{
"result": "error",
"error": "Port is already in use",
},
)
try:
DevServer.start(workflow_api=data["workflow_api"], port=data.get("port", 3000))
return web.json_response(
{
"result": "success",
"url": f"http://{data.get('host', 'localhost')}:{data.get('port', 3000)}",
},
)
except Exception as e:
return web.json_response(
{
"result": "error",
"error": f"Build failed: {e.__class__.__name__}: {e}",
},
)
@PromptServer.instance.routes.post("/bentoml/serve/heartbeat")
async def heartbeat(_):
running = DevServer.feed_watch_dog()
if running:
return web.json_response({"ready": True})
else:
return web.json_response({"error": "Server is not running"})
@PromptServer.instance.routes.post("/bentoml/serve/terminate")
async def terminate(_):
DevServer.stop()
return web.json_response({"result": "success"})
@PromptServer.instance.routes.get("/bentoml/download/{zip_filename}")
async def download_workspace(request):
zip_filename = request.match_info["zip_filename"]
return web.FileResponse(TEMP_FOLDER / zip_filename)
async def _prepare_bento_project(
working_dir: ZPath,
data: dict,
store_models: bool = False,
):
model_filter = set(data.get("models", []))
models = await _get_models(
store_models=store_models,
model_filter=model_filter,
)
await _write_requirements(working_dir, ["comfy-cli", "fastapi"])
await _write_snapshot(working_dir, data, models)
await _write_workflow(working_dir, data)
await _write_inputs(working_dir, data)
with working_dir.joinpath("service.py").open("w") as f:
f.write(Path(__file__).with_name("service.py").read_text())
# Copy comfy_pack directory
if isinstance(working_dir, Path):
shutil.copytree(COMFY_PACK_DIR, working_dir / COMFY_PACK_DIR.name)
else: # zipfile.Path
for src in COMFY_PACK_DIR.rglob("*"):
if src.is_file():
rel_path = src.relative_to(COMFY_PACK_DIR.parent)
with working_dir.joinpath(rel_path).open("wb") as f:
f.write(src.read_bytes())
models = [m for m in models if not m["disabled"]] # filter out disabled models
return models
@PromptServer.instance.routes.post("/bentoml/model/query")
async def get_models(request):
data = await request.json()
models = await _get_models(
workflow_api=data.get("workflow_api"),
ensure_sha=False,
ensure_source=False,
)
return web.json_response({"models": models})
@PromptServer.instance.routes.post("/bentoml/build")
async def build_bento(request):
"""Request body: {
workflow_api: dict,
workflow: dict,
bento_name: str,
push?: bool,
api_key?: str,
endpoint?: str,
system_packages?: list[str]
}"""
import bentoml
data = await request.json()
if (error := _validate_workflow(data)) is not None:
return error
with tempfile.TemporaryDirectory(suffix="-bento", prefix="comfy-pack-") as temp_dir:
temp_dir_path = Path(temp_dir)
models = await _prepare_bento_project(temp_dir_path, data, store_models=True)
# create a bento
try:
bento = bentoml.build(
"service:ComfyService",
name=data["bento_name"],
build_ctx=temp_dir,
labels={"comfy-pack-version": get_self_git_commit() or "unknown"},
models=[m["model_tag"] for m in models if "model_tag" in m],
docker={
"python_version": f"{sys.version_info.major}.{sys.version_info.minor}",
"system_packages": [
"git",
"libglib2.0-0",
"libsm6",
"libxrender1",
"libxext6",
"ffmpeg",
"libstdc++-12-dev",
*data.get("system_packages", []),
],
},
python={"requirements_txt": "requirements.txt", "lock_packages": True},
)
except bentoml.exceptions.BentoMLException as e:
return web.json_response(
{
"result": "error",
"error": f"Build failed: {e.__class__.__name__}: {e}",
},
)
if data.get("push", False):
credentials = {}
if api_key := data.get("api_key"):
credentials["api_key"] = api_key
if endpoint := data.get("endpoint"):
credentials["endpoint"] = endpoint
client = bentoml.cloud.BentoCloudClient(**credentials)
try:
client.bento.push(bento)
except bentoml.exceptions.BentoMLException as e:
return web.json_response(
{
"result": "error",
"error": f"Push failed: {e.__class__.__name__}: {e}",
}
)
return web.json_response({"result": "success", "bento": str(bento.tag)})
+7 -7
View File
@@ -2,8 +2,8 @@ from threading import Lock
import execution
BENTOML_LAST_ID = None
BENTOML_OUTPUT_CACHE = {}
CPACK_LAST_ID = None
CPACK_OUTPUT_CACHE = {}
_lock = Lock()
@@ -12,12 +12,12 @@ def store_bentoml_value(func):
def wrapped(
inputs, class_def, unique_id, outputs=None, dynprompt=None, extra_data={}
):
global BENTOML_LAST_ID
if getattr(class_def, "BENTOML_NODE", False):
global CPACK_LAST_ID
if getattr(class_def, "CPACK_NODE", False):
with _lock:
BENTOML_LAST_ID = unique_id
CPACK_LAST_ID = unique_id
if outputs is None:
outputs = BENTOML_OUTPUT_CACHE
outputs = CPACK_OUTPUT_CACHE
return func(inputs, class_def, unique_id, outputs, dynprompt, extra_data)
return wrapped
@@ -28,4 +28,4 @@ execution.get_input_data = store_bentoml_value(execution.get_input_data)
def set_bentoml_output(output):
with _lock:
BENTOML_OUTPUT_CACHE[BENTOML_LAST_ID] = output
CPACK_OUTPUT_CACHE[CPACK_LAST_ID] = output
+114 -136
View File
@@ -1,6 +1,7 @@
import hashlib
import json
import os
import sys
import shutil
import folder_paths
@@ -22,7 +23,7 @@ class AnyType(str):
anytype = AnyType("*") # when a != b is called, it will always return False
class OutputPath:
class OutputFile:
COLOR = (142, 36, 170)
OUTPUT_NODE = True
@@ -31,15 +32,15 @@ class OutputPath:
return {
"required": {
"filename": ("STRING", {"default": "", "forceInput": True}),
"filename_prefix": ("STRING", {"default": "ComfyUI_IDL_"}),
"filename_prefix": ("STRING", {"default": "cpack_output_"}),
},
}
RETURN_TYPES = ()
CATEGORY = "ComfyUI-IDL/output"
BENTOML_NODE = True
CATEGORY = "ComfyPack/output"
CPACK_NODE = True
FUNCTION = "save"
DESCRIPTION = "Save the input data for IDL output"
DESCRIPTION = "Save the input data for comfy-pack output"
def save(self, filename, filename_prefix):
if not filename_prefix:
@@ -109,7 +110,7 @@ class OutputImage:
return {
"required": {
"images": ("IMAGE", {"tooltip": "The images to save."}),
"filename_prefix": ("STRING", {"default": "ComfyUI_IDL_"}),
"filename_prefix": ("STRING", {"default": "cpack_output_"}),
},
"hidden": {
"prompt": "PROMPT",
@@ -119,14 +120,14 @@ class OutputImage:
RETURN_TYPES = ()
FUNCTION = "save_images"
BENTOML_NODE = True
CPACK_NODE = True
OUTPUT_NODE = True
CATEGORY = "ComfyUI-IDL/output"
CATEGORY = "ComfyPack/output"
DESCRIPTION = "Saves the input images to your ComfyUI output directory."
def save_images(
self, images, filename_prefix="ComfyUI_IDL_", prompt=None, extra_pnginfo=None
self, images, filename_prefix="cpack_output_", prompt=None, extra_pnginfo=None
):
filename_prefix += self.prefix_append
full_output_folder, filename, counter, subfolder, filename_prefix = (
@@ -177,10 +178,9 @@ class ImageInput:
"required": {"image": (sorted(files), {"image_upload": True})},
}
CATEGORY = "ComfyUI-IDL/input"
BENTOML_NODE = True
CATEGORY = "ComfyPack/input"
CPACK_NODE = True
RETURN_TYPES = ("IMAGE", "MASK")
RETUEN_NAMES = ("image", "mask")
FUNCTION = "load_image"
def load_image(self, image):
@@ -243,111 +243,7 @@ class ImageInput:
return True
class StringInput:
COLOR = (142, 36, 170)
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"value": ("STRING", {"default": ""}),
}
}
RETUEN_NAMES = ("string",)
RETURN_TYPES = (anytype,)
BENTOML_NODE = True
FUNCTION = "string_input"
CATEGORY = "ComfyUI-IDL/input"
def string_input(self, value):
return (value,)
@classmethod
def VALIDATE_INPUTS(s, value):
set_bentoml_output([(value,)])
return True
class IntegerInput:
COLOR = (142, 36, 170)
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"value": ("INT", {"default": 0}),
}
}
RETUEN_NAMES = ("int",)
RETURN_TYPES = (anytype,)
FUNCTION = "identity"
BENTOML_NODE = True
CATEGORY = "ComfyUI-IDL/input"
def identity(self, value):
return (value,)
@classmethod
def VALIDATE_INPUTS(s, value):
set_bentoml_output([(value,)])
return True
class FloatInput:
COLOR = (142, 36, 170)
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"value": ("FLOAT", {"default": 0.0}),
}
}
RETUEN_NAMES = ("float",)
RETURN_TYPES = (anytype,)
FUNCTION = "identity"
BENTOML_NODE = True
CATEGORY = "ComfyUI-IDL/input"
def identity(self, value):
return (value,)
@classmethod
def VALIDATE_INPUTS(s, value):
set_bentoml_output([(value,)])
return True
class BooleanInput:
COLOR = (142, 36, 170)
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"value": ("BOOLEAN", {"default": False}),
}
}
RETUEN_NAMES = ("bool",)
RETURN_TYPES = (anytype,)
FUNCTION = "identity"
CATEGORY = "ComfyUI-IDL/input"
BENTOML_NODE = True
def identity(self, value):
return (value,)
@classmethod
def VALIDATE_INPUTS(s, value):
set_bentoml_output([(value,)])
return True
class PathInput:
class FileInput:
COLOR = (142, 36, 170)
@classmethod
@@ -359,10 +255,10 @@ class PathInput:
}
RETURN_TYPES = (anytype,)
RETUEN_NAMES = ("path",)
RETURN_NAMES = ("path",)
FUNCTION = "identity"
BENTOML_NODE = True
CATEGORY = "ComfyUI-IDL/input"
CPACK_NODE = True
CATEGORY = "ComfyPack/input"
def identity(self, path):
return (path,)
@@ -373,24 +269,106 @@ class PathInput:
return True
class StringInput:
COLOR = (142, 36, 170)
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"value": ("STRING", {"default": ""}),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("value",)
FUNCTION = "identity"
CPACK_NODE = True
CATEGORY = "ComfyPack/input"
def identity(self, value):
return (value,)
@classmethod
def VALIDATE_INPUTS(s, value):
set_bentoml_output([(value,)])
return True
class IntInput:
COLOR = (142, 36, 170)
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"value": ("INT", {"default": 0}),
},
"optional": {
"min": ("INT", {"default": -sys.maxsize}),
"max": ("INT", {"default": sys.maxsize}),
},
}
RETURN_TYPES = ("INT",)
RETURN_NAMES = ("value",)
FUNCTION = "identity"
CPACK_NODE = True
CATEGORY = "ComfyPack/input"
def identity(self, value, min=None, max=None):
return (value,)
@classmethod
def VALIDATE_INPUTS(s, value, min=None, max=None):
if min is not None and max is not None and min > max:
return f"Value must be greater than or equal to {min}"
set_bentoml_output([(value,)])
return True
class AnyInput:
COLOR = (142, 36, 170)
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"input": ("*", {"default": ""}),
}
}
RETURN_TYPES = (anytype,)
RENAME = ("value",)
FUNCTION = "identity"
CPACK_NODE = True
CATEGORY = "ComfyPack/input"
def identity(self, input):
return (input,)
@classmethod
def VALIDATE_INPUTS(s, input):
set_bentoml_output([(input,)])
return True
NODE_CLASS_MAPPINGS = {
"BentoOutputPath": OutputPath,
"BentoOutputImage": OutputImage,
"BentoInputImage": ImageInput,
"BentoInputString": StringInput,
"BentoInputInteger": IntegerInput,
"BentoInputFloat": FloatInput,
"BentoInputBoolean": BooleanInput,
"BentoInputPath": PathInput,
"CPackOutputFile": OutputFile,
"CPackOutputImage": OutputImage,
"CPackInputImage": ImageInput,
"CPackInputString": StringInput,
"CPackInputInt": IntInput,
"CPackInputFile": FileInput,
"CPackInputAny": AnyInput,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"BentoInputImage": "Image Input",
"BentoInputPath": "Path Input",
"BentoInputInteger": "Integer Input",
"BentoInputFloat": "Float Input",
"BentoInputBoolean": "Boolean Input",
"BentoInputString": "String Input",
"BentoOutputImage": "Image Output",
"BentoOutputPath": "Path Output",
"CPackInputImage": "Image Input",
"CPackInputString": "String Input",
"CPackInputInt": "Int Input",
"CPackInputFile": "File Input",
"CPackInputAny": "Any Input",
"CPackOutputImage": "Image Output",
"CPackOutputFile": "File Output",
}
+211
View File
@@ -0,0 +1,211 @@
from __future__ import annotations
import json
import logging
import os
import shutil
import signal
import threading
import time
from functools import lru_cache
from pathlib import Path
from typing import Any, cast
import bentoml
import fastapi
from bentoml.models import HuggingFaceModel
import comfy_pack
import comfy_pack.run
REQUEST_TIMEOUT = 3600
BASE_DIR = Path(__file__).parent
COPY_THRESHOLD = 10 * 1024 * 1024
INPUT_DIR = BASE_DIR / "input"
logger = logging.getLogger("bentoml.service")
EXISTING_COMFYUI_SERVER = os.environ.get("COMFYUI_SERVER")
with BASE_DIR.joinpath("workflow_api.json").open() as f:
workflow = json.load(f)
InputModel = comfy_pack.generate_input_model(workflow)
app = fastapi.FastAPI()
@lru_cache
def _get_workspace() -> Path:
import hashlib
from bentoml._internal.configuration.containers import BentoMLContainer
snapshot = BASE_DIR / "snapshot.json"
checksum = hashlib.md5(snapshot.read_bytes()).hexdigest()
wp = (
Path(BentoMLContainer.bentoml_home.get()) / "run" / "comfy_workspace" / checksum
)
wp.parent.mkdir(parents=True, exist_ok=True)
return wp
@app.get("/workflow.json")
def workflow_json():
return workflow
def _watch_server(server: comfy_pack.run.ComfyUIServer):
while True:
time.sleep(1)
if not server.is_running():
if server.server_proc is not None:
logger.warning(
"Server exited with code %s", server.server_proc.returncode
)
os.kill(os.getpid(), signal.SIGTERM)
break
if not EXISTING_COMFYUI_SERVER:
# register models
with BASE_DIR.joinpath("snapshot.json").open("rb") as f:
snapshot = json.load(f)
else:
snapshot = {}
@bentoml.mount_asgi_app(app, path="/comfy")
@bentoml.service(traffic={"timeout": REQUEST_TIMEOUT * 2}, resources={"gpu": 1})
class ComfyService:
def __init__(self):
logger = logging.getLogger("comfy_pack")
logger.setLevel(logging.INFO)
if not EXISTING_COMFYUI_SERVER:
self.server = comfy_pack.run.ComfyUIServer(
str(_get_workspace()),
str(INPUT_DIR),
verbose=int("BENTOML_DEBUG" in os.environ),
)
self.server.start()
logger.info(
"ComfyUI Server started at %s:%s", self.server.host, self.server.port
)
self.host = self.server.host
self.port = self.server.port
self.watch_thread = threading.Thread(
target=_watch_server,
args=(self.server,),
daemon=True,
)
self.watch_thread.start()
logger.info("Watch thread started")
else:
logger.info("Attaching to ComfyUI server: %s", EXISTING_COMFYUI_SERVER)
if ":" in EXISTING_COMFYUI_SERVER:
self.host, port = EXISTING_COMFYUI_SERVER.split(":")
self.port = int(port)
else:
self.host = EXISTING_COMFYUI_SERVER
self.port = 80
@bentoml.api(input_spec=InputModel)
def generate(
self,
*,
ctx: bentoml.Context,
**kwargs: Any,
) -> Path:
verbose = int("BENTOML_DEBUG" in os.environ)
ret = comfy_pack.run_workflow(
self.host,
self.port,
workflow,
output_dir=ctx.temp_dir,
timeout=REQUEST_TIMEOUT,
verbose=verbose,
**kwargs,
)
if isinstance(ret, list):
ret = ret[-1]
return ret
@bentoml.on_shutdown
def on_shutdown(self):
logger.info("Shutting down")
if not EXISTING_COMFYUI_SERVER:
self.server.stop()
logger.info("server stopped")
self.watch_thread.join()
logger.info("Watch thread finished")
@bentoml.on_deployment
@staticmethod
def prepare_comfy_workspace():
if EXISTING_COMFYUI_SERVER:
return
from comfy_pack.package import install_comfyui, install_custom_modules
verbose = int("BENTOML_DEBUG" in os.environ)
comfy_workspace = _get_workspace()
if not comfy_workspace.joinpath(".DONE").exists():
if comfy_workspace.exists():
logger.info("Removing existing workspace")
shutil.rmtree(comfy_workspace, ignore_errors=True)
install_comfyui(snapshot, comfy_workspace, verbose=verbose)
for model in snapshot["models"]:
if model.get("disabled", False):
continue
model_path = comfy_workspace / cast(str, model["filename"])
if model_tag := model.get("model_tag"):
model_path.parent.mkdir(parents=True, exist_ok=True)
bento_model = bentoml.models.get(model_tag)
model_file = bento_model.path_of("model.bin")
logger.info("Copying %s to %s", model_file, model_path)
model_path.symlink_to(model_file)
elif (source := model["source"]).get("source") == "huggingface":
matched = next(
(
m
for m in ComfyService.models
if isinstance(m, HuggingFaceModel)
and m.model_id.lower() == source["repo"].lower()
and source["commit"].lower() == m.revision.lower()
),
None,
)
if matched is not None:
model_file = os.path.join(matched.resolve(), source["path"])
model_path.parent.mkdir(parents=True, exist_ok=True)
logger.info("Copying %s to %s", model_file, model_path)
model_path.symlink_to(model_file)
else:
logger.warning(
"Unrecognized model source: %s, the model may be missing",
source,
)
for f in INPUT_DIR.glob("*"):
if f.is_file():
shutil.copy(f, comfy_workspace / "input" / f.name)
elif f.is_dir():
shutil.copytree(f, comfy_workspace / "input" / f.name)
install_custom_modules(snapshot, comfy_workspace, verbose=verbose)
comfy_workspace.joinpath(".DONE").touch()
if not EXISTING_COMFYUI_SERVER:
for model in snapshot["models"]:
if True:
continue
if model.get("disabled"):
continue
source = model["source"]
if source.get("source") != "huggingface" or source["repo"].startswith(
"datasets/"
):
continue
ComfyService.models.append(HuggingFaceModel(source["repo"], source["commit"]))
+11 -3
View File
@@ -1,10 +1,15 @@
[project]
name = "comfyui-idl"
description = "ComfyUI Interface Definition Language"
name = "comfy-pack"
description = """\
A comprehensive toolkit for standardizing, packaging and deploying ComfyUI workflows \
as reproducible environments and production-ready REST services\
"""
authors = [{ name = "Frost Ming", email = "frost@bentoml.com" }]
readme = "README.md"
requires-python = ">=3.9"
dependencies = [
"bentoml>=1.3.13",
"click>=8.1.7",
"comfy-cli>=1.2.8",
"pydantic>=2.9",
]
@@ -17,7 +22,10 @@ classifiers = [
]
[project.urls]
Homepage = "https://github.com/bentoml/ComfyUI-IDL"
Homepage = "https://github.com/bentoml/comfy-pack"
[project.scripts]
comfy-pack = "comfy_pack.cli:main"
[build-system]
requires = ["pdm-backend"]
+5
View File
@@ -0,0 +1,5 @@
bentoml
fastapi
comfy-cli
duckduckgo-search
uv
@@ -1,14 +1,14 @@
from comfyui_idl.utils import (
parse_workflow,
from .run import ComfyUIServer, run_workflow
from .utils import (
generate_input_model,
parse_workflow,
populate_workflow,
retrieve_workflow_outputs,
)
from comfyui_idl.run import WorkflowRunner
__all__ = [
"WorkflowRunner",
"ComfyUIServer",
"run_workflow",
"parse_workflow",
"generate_input_model",
"populate_workflow",
+413
View File
@@ -0,0 +1,413 @@
import click
import functools
import json
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
from .const import WORKSPACE_DIR, COMFYUI_REPO, COMFY_PACK_REPO, COMFYUI_MANAGER_REPO
from .hash import get_sha256
from .utils import get_self_git_commit
def _ensure_uv() -> None:
"""Ensure uv is installed, raise error if not."""
try:
subprocess.run(
["uv", "--version"],
check=True,
capture_output=True,
)
except (subprocess.SubprocessError, FileNotFoundError):
raise RuntimeError(
"uv is not installed. Please install it first:\n"
"curl -LsSf https://astral.sh/uv/install.sh | sh"
)
@click.group()
def main():
"""comfy-pack CLI"""
pass
@main.command(
name="init",
help="Install latest ComfyUI and comfy-pack custom nodes and create a virtual environment",
)
@click.option(
"--dir",
"-d",
default="ComfyUI",
help="Target directory to install ComfyUI",
type=click.Path(file_okay=False),
)
@click.option(
"--verbose",
"-v",
count=True,
help="Increase verbosity level",
)
def init(dir: str, verbose: int):
from rich.console import Console
import os
console = Console()
# Check if directory path is valid
try:
install_dir = Path(dir).absolute()
if install_dir.exists() and not install_dir.is_dir():
console.print(f"[red]Error: {dir} exists but is not a directory[/red]")
return 1
# Check if directory is empty or contains ComfyUI
if install_dir.exists():
contents = list(install_dir.iterdir())
if contents and not (install_dir / ".git").exists():
console.print(
f"[red]Error: Directory {dir} is not empty and doesn't appear to be a ComfyUI installation[/red]"
)
return 1
except Exception as e:
console.print(f"[red]Error: Invalid directory path - {str(e)}[/red]")
return 1
# Check git installation
try:
subprocess.run(
["git", "--version"],
check=True,
capture_output=True,
)
except (subprocess.SubprocessError, FileNotFoundError):
console.print("[red]Error: git is not installed or not in PATH[/red]")
return 1
# Check if we have write permissions
try:
if not install_dir.exists():
install_dir.mkdir(parents=True)
test_file = install_dir / ".write_test"
test_file.touch()
test_file.unlink()
except (OSError, PermissionError) as e:
console.print(f"[red]Error: No write permission in {dir} - {str(e)}[/red]")
return 1
# Check if Python version is compatible
if sys.version_info < (3, 8):
console.print("[red]Error: Python 3.8 or higher is required[/red]")
return 1
# Check if uv is installed
try:
_ensure_uv()
except RuntimeError as e:
console.print(f"[red]Error: {str(e)}[/red]")
return 1
# Check if enough disk space is available (rough estimate: 2GB)
try:
free_space = shutil.disk_usage(install_dir).free
if free_space < 2 * 1024 * 1024 * 1024: # 2GB in bytes
console.print(
"[yellow]Warning: Less than 2GB free disk space available[/yellow]"
)
except Exception as e:
console.print(
f"[yellow]Warning: Could not check free disk space - {str(e)}[/yellow]"
)
# Clone ComfyUI if not exists
if not (install_dir / ".git").exists():
console.print("[green]Cloning ComfyUI...[/green]")
subprocess.run(
[
"git",
"clone",
COMFYUI_REPO,
str(install_dir),
],
check=True,
)
# Update ComfyUI
console.print("[green]Updating ComfyUI...[/green]")
subprocess.run(
["git", "pull"],
cwd=install_dir,
check=True,
)
# Create and activate venv
venv_dir = install_dir / ".venv"
console.print("[green]Creating virtual environment with uv...[/green]")
if venv_dir.exists():
shutil.rmtree(venv_dir)
subprocess.run(
["uv", "venv", str(venv_dir)],
check=True,
)
# Get python path for future use
if sys.platform == "win32":
python = str(venv_dir / "Scripts" / "python.exe")
else:
python = str(venv_dir / "bin" / "python")
# Install requirements with uv
console.print("[green]Installing ComfyUI requirements with uv...[/green]")
subprocess.run(
["uv", "pip", "install", "pip", "--upgrade"],
env={
"VIRTUAL_ENV": str(venv_dir),
"PATH": str(venv_dir / "bin") + os.pathsep + os.environ["PATH"],
},
check=True,
)
subprocess.run(
["uv", "pip", "install", "-r", str(install_dir / "requirements.txt")],
env={
"VIRTUAL_ENV": str(venv_dir),
"PATH": str(venv_dir / "bin") + os.pathsep + os.environ["PATH"],
},
check=True,
)
# Install comfy-pack as custom node
console.print("[green]Installing comfy-pack custom nodes...[/green]")
custom_nodes_dir = install_dir / "custom_nodes"
custom_nodes_dir.mkdir(exist_ok=True)
comfyui_manager_dir = custom_nodes_dir / "ComfyUI-Manager"
if not (comfyui_manager_dir / ".git").exists():
# Clone ComfyUI-Manager
subprocess.run(
["git", "clone", COMFYUI_MANAGER_REPO, str(comfyui_manager_dir)],
check=True,
)
comfy_pack_dir = custom_nodes_dir / "comfy-pack"
if not (comfy_pack_dir / ".git").exists():
# Clone comfy-pack
subprocess.run(
["git", "clone", COMFY_PACK_REPO, str(comfy_pack_dir)],
check=True,
)
# Update comfy-pack
subprocess.run(
["git", "pull"],
cwd=comfy_pack_dir,
check=True,
)
# Install comfy-pack requirements
if (comfy_pack_dir / "requirements.txt").exists():
subprocess.run(
[
python,
"-m",
"pip",
"install",
"-r",
str(comfy_pack_dir / "requirements.txt"),
],
check=True,
)
version = get_self_git_commit() or "unknown"
console.print(
f"\n[green]✓ Installation completed! (comfy-pack version: {version})[/green]"
)
console.print(f"ComfyUI directory: {install_dir}")
console.print(
"\n[green]Next steps:[/green]\n"
f"1. cd {dir}\n"
"2. source .venv/bin/activate # On Windows: .venv\\Scripts\\activate\n"
"3. python main.py"
)
@main.command(
name="unpack",
help="Restore the ComfyUI workspace to specified directory",
)
@click.argument("cpack", type=click.Path(exists=True, dir_okay=False))
@click.option(
"--dir",
"-d",
default="ComfyUI",
help="target directory to restore the ComfyUI project",
type=click.Path(file_okay=False),
)
@click.option(
"--include-disabled-models",
default=False,
type=click.BOOL,
is_flag=True,
)
@click.option(
"--verbose",
"-v",
count=True,
help="Increase verbosity level (use multiple times for more verbosity)",
)
def unpack_cmd(cpack: str, dir: str, include_disabled_models: bool, verbose: int):
from .package import install
from rich.console import Console
console = Console()
install(cpack, dir, verbose=verbose, all_models=include_disabled_models)
console.print("\n[green]✓ ComfyUI Workspace is restored![/green]")
console.print(f"{dir}")
console.print(
"\n[green] Next steps: [/green]\n"
"1. Change directory to the restored workspace\n"
"2. Source the virtual environment by running `source .venv/bin/activate`\n"
"3. Run the ComfyUI project by running `python main.py`"
)
def _print_schema(schema, verbose: int = 0):
from rich.table import Table
from rich.console import Console
table = Table(title="")
# Add columns
table.add_column("Input", style="cyan")
table.add_column("Type", style="green")
table.add_column("Required", style="yellow")
table.add_column("Default", style="blue")
table.add_column("Range", style="magenta")
# Get required fields
required = schema.get("required", [])
# Add rows
for field, info in schema["properties"].items():
range_str = ""
if "minimum" in info or "maximum" in info:
min_val = info.get("minimum", "")
max_val = info.get("maximum", "")
range_str = f"{min_val} to {max_val}"
table.add_row(
field,
info.get("format", "") or info.get("type", ""),
"✓" if field in required else "",
str(info.get("default", "")),
range_str,
)
Console().print(table)
@functools.lru_cache
def _get_cache_workspace(cpack: str):
sha = get_sha256(cpack)
return WORKSPACE_DIR / sha[0:8]
@main.command(
context_settings={
"ignore_unknown_options": True,
"allow_extra_args": True,
},
help="Run a ComfyUI package with the given inputs",
add_help_option=False,
)
@click.argument("cpack", type=click.Path(exists=True, dir_okay=False))
@click.option("--output-dir", "-o", type=click.Path(), default=".")
@click.option("--help", "-h", is_flag=True, help="Show this message and input schema")
@click.option(
"--verbose",
"-v",
count=True,
help="Increase verbosity level (use multiple times for more verbosity)",
)
@click.pass_context
def run(ctx, cpack: str, output_dir: str, help: bool, verbose: int):
from .utils import generate_input_model
from pydantic import ValidationError
from rich.console import Console
inputs = dict(
zip([k.lstrip("-").replace("-", "_") for k in ctx.args[::2]], ctx.args[1::2])
)
console = Console()
with tempfile.TemporaryDirectory() as temp_dir:
pack_dir = Path(temp_dir) / ".cpack"
shutil.unpack_archive(cpack, pack_dir)
workflow = json.loads((pack_dir / "workflow_api.json").read_text())
input_model = generate_input_model(workflow)
# If help is requested, show command help and input schema
if help:
console.print(
'Usage: comfy-pack run [OPTIONS] CPACK --input1 "value1" --input2 "value2" ...'
)
console.print("Run a ComfyUI package with the given inputs:")
_print_schema(input_model.model_json_schema(), verbose)
return 0
try:
validated_data = input_model(**inputs)
console.print("[green]✓ Input is valid![/green]")
for field, value in validated_data.model_dump().items():
console.print(f"{field}: {value}")
except ValidationError as e:
console.print("[red]✗ Validation failed![/red]")
for error in e.errors():
console.print(f"- {error['loc'][0]}: {error['msg']}")
console.print("\n[yellow]Expected inputs:[/yellow]")
_print_schema(input_model.model_json_schema(), verbose)
return 1
from .package import install
workspace = _get_cache_workspace(cpack)
if not (workspace / "DONE").exists():
console.print("\n[green]✓ Restoring ComfyUI Workspace...[/green]")
if workspace.exists():
shutil.rmtree(workspace)
install(cpack, workspace, verbose=verbose)
with open(workspace / "DONE", "w") as f:
f.write("DONE")
console.print("\n[green]✓ ComfyUI Workspace is restored![/green]")
console.print(f"{workspace}")
from .run import ComfyUIServer, run_workflow
with ComfyUIServer(str(workspace.absolute()), verbose=verbose) as server:
console.print("\n[green]✓ ComfyUI is launched in the background![/green]")
results = run_workflow(
server.host,
server.port,
workflow,
Path(output_dir).absolute(),
verbose=verbose,
**validated_data.model_dump(),
)
console.print("\n[green]✓ Workflow is executed successfully![/green]")
if results:
console.print("\n[green]✓ Retrieved outputs:[/green]")
if isinstance(results, dict):
for field, value in results.items():
console.print(f"{field}: {value}")
elif isinstance(results, list):
for i, value in enumerate(results):
console.print(f"{i}: {value}")
else:
console.print(results)
+20
View File
@@ -0,0 +1,20 @@
import pathlib
import os
CPACK_HOME = (
pathlib.Path.home() / ".comfypack"
if not os.environ.get("CPACK_HOME", "")
else pathlib.Path(os.environ.get("CPACK_HOME", ""))
)
if not CPACK_HOME.exists():
CPACK_HOME.mkdir()
MODEL_DIR = CPACK_HOME / "models"
WORKSPACE_DIR = CPACK_HOME / "workspace"
SHA_CACHE_FILE = CPACK_HOME / "sha_cache.json"
MODEL_SOURCE_CACHE_FILE = CPACK_HOME / "model_source_cache.json"
COMFYUI_REPO = "https://github.com/comfyanonymous/ComfyUI.git"
COMFY_PACK_REPO = "https://github.com/bentoml/comfy-pack.git"
COMFYUI_MANAGER_REPO = "https://github.com/ltdrdata/ComfyUI-Manager.git"
+122
View File
@@ -0,0 +1,122 @@
import asyncio
import json
import os
import subprocess
import sys
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from functools import partial
from typing import Dict, List
from .const import SHA_CACHE_FILE
CALC_CMD = """
import hashlib
import sys
filepath = sys.argv[1]
chunk_size = int(sys.argv[2])
sha256 = hashlib.sha256()
with open(filepath, "rb") as f:
for chunk in iter(lambda: f.read(chunk_size), b""):
sha256.update(chunk)
print(sha256.hexdigest())
"""
def calculate_sha256_worker(filepath: str, chunk_size: int = 4 * 1024 * 1024) -> str:
"""Calculate SHA-256 in a separate process"""
result = subprocess.run(
[sys.executable, "-c", CALC_CMD, filepath, str(chunk_size)],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
assert result.returncode == 0, result.stderr
return result.stdout.strip()
def get_sha256(filepath: str) -> str:
return batch_get_sha256([filepath])[filepath]
def async_get_sha256(filepath: str) -> str:
return asyncio.run(async_batch_get_sha256([filepath]))[filepath]
def batch_get_sha256(filepaths: List[str], cache_only: bool = False) -> Dict[str, str]:
return asyncio.run(async_batch_get_sha256(filepaths, cache_only=cache_only))
async def async_batch_get_sha256(
filepaths: List[str],
cache_only: bool = False,
) -> Dict[str, str]:
# Load cache
cache = {}
if SHA_CACHE_FILE.exists():
try:
with SHA_CACHE_FILE.open("r") as f:
cache = json.load(f)
except (json.JSONDecodeError, IOError):
pass
# Initialize process pool
max_workers = max(1, (os.cpu_count() or 1))
# Process files
results = {}
new_cache = {}
async with asyncio.Lock():
with ThreadPoolExecutor(max_workers=max_workers) as pool:
loop = asyncio.get_event_loop()
for filepath in filepaths:
if not os.path.exists(filepath):
results[filepath] = None
continue
# Get file info
stat = os.stat(filepath)
current_size = stat.st_size
current_time = stat.st_ctime
# Check cache
cache_entry = cache.get(filepath)
if cache_entry:
if (
cache_entry["size"] == current_size
and cache_entry["birthtime"] == current_time
):
results[filepath] = cache_entry["sha256"]
continue
if cache_only:
results[filepath] = ""
continue
# Calculate new SHA
calc_func = partial(calculate_sha256_worker, filepath)
sha256 = await loop.run_in_executor(pool, calc_func)
# Update cache and results
new_cache[filepath] = {
"sha256": sha256,
"size": current_size,
"birthtime": current_time,
"last_verified": datetime.now().isoformat(),
}
results[filepath] = sha256
# Save cache
try:
with SHA_CACHE_FILE.open("r") as f:
cache = json.load(f)
cache.update(new_cache)
with SHA_CACHE_FILE.open("w") as f:
json.dump(cache, f, indent=2)
except (IOError, OSError):
pass
return results
+170
View File
@@ -0,0 +1,170 @@
from .const import MODEL_SOURCE_CACHE_FILE
import asyncio
import json
import re
# MODEL_NAME = r"[a-zA-Z0-9-._]+"
# COMMIT = r"[a-f0-9]+"
COMMIT_PATTERN = re.compile(r'href="/([a-zA-Z0-9-._/]+)/commit/([a-f0-9]+)"')
PATH_PATTERN = re.compile(
r'data-target="CopyButton" data-props="{&quot;value&quot;:&quot;([^&]+)&quot;'
)
async def _lookup_huggingface_model(model_sha: str) -> dict:
from duckduckgo_search import DDGS
import aiohttp
query = f"site:huggingface.co blob {model_sha}"
try:
with DDGS() as ddgs:
search_results = ddgs.text(query, max_results=5)
async with aiohttp.ClientSession(trust_env=True) as session:
for result in search_results:
url = result['link']
if "blob" not in url:
continue
try:
async with session.get(url) as resp:
if resp.status != 200:
continue
text = await resp.text()
if commit_match := COMMIT_PATTERN.search(text):
repo, commit = commit_match.groups()
if path_match := PATH_PATTERN.search(text):
path = path_match.group(1)
download_url = f"https://huggingface.co/{repo}/resolve/{commit}/{path}?download=true"
url = f"https://huggingface.co/{repo}/blob/{commit}/{path}"
info = {
"download_url": download_url,
"url": url,
"repo": repo,
"commit": commit,
"path": path,
"source": "huggingface",
}
return info
except aiohttp.ClientError:
continue
except Exception:
pass
return {}
async def _loopup_civitai_model(model_sha: str) -> dict:
import aiohttp
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.post(
"https://meilisearch-v1-9.civitai.com/multi-search",
headers={
"accept": "*/*",
"accept-language": "en,zh;q=0.9,zh-CN;q=0.8",
"cache-control": "no-cache",
"content-type": "application/json",
"origin": "https://civitai.com",
"pragma": "no-cache",
"priority": "u=1, i",
"referer": "https://civitai.com/",
"sec-ch-ua": '"Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"macOS"',
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-site",
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
"x-meilisearch-client": "Meilisearch instant-meilisearch (v0.13.5) ; Meilisearch JavaScript (v0.34.0)",
},
json={
"queries": [
{
"q": model_sha,
"indexUid": "models_v9",
"facets": [
"category.name",
"checkpointType",
"fileFormats",
"lastVersionAtUnix",
"tags.name",
"type",
"user.username",
"version.baseModel",
],
"attributesToHighlight": [],
"highlightPreTag": "__ais-highlight__",
"highlightPostTag": "__/ais-highlight__",
"limit": 51,
"offset": 0,
"filter": ["nsfwLevel=1"],
}
]
},
) as resp:
if resp.status != 200:
return {}
data = await resp.json()
if len(data.get("results", [])) == 0:
return {}
if len(data["results"][0]["hits"]) == 0:
return {}
hit = data["results"][0]["hits"][0]
repo_id = hit["id"]
repo_name = hit["name"]
versions = hit["versions"]
for version in versions:
if model_sha.upper() in version["hashes"]:
break
else:
return {}
version_id = version["id"]
version_name = version["name"]
return {
"download_url": f"https://civitai.com/api/download/models/{version_id}",
"url": f"https://civitai.com/models/{repo_id}?modelVersionId={version_id}",
"repo": repo_id,
"commit": version_id,
"source": "civitai",
"repo_name": repo_name,
"version_name": version_name,
}
async def alookup_model_source(model_sha: str, cache_only=False) -> dict:
if not model_sha:
return {}
try:
model_source_cache = json.loads(MODEL_SOURCE_CACHE_FILE.read_text())
except Exception:
with open(MODEL_SOURCE_CACHE_FILE, "w") as f:
f.write("{}")
model_source_cache = {}
if model_source_cache.get(model_sha):
return model_source_cache[model_sha]
if cache_only:
return {}
info = await _lookup_huggingface_model(model_sha)
if not info:
info = await _loopup_civitai_model(model_sha)
# elemental read and write
model_source_cache = json.loads(MODEL_SOURCE_CACHE_FILE.read_text())
model_source_cache[model_sha] = info
with open(MODEL_SOURCE_CACHE_FILE, "w") as f:
json.dump(model_source_cache, f)
return info
def lookup_model_source(model_sha: str, cache_only=False) -> dict:
return asyncio.run(alookup_model_source(model_sha, cache_only=cache_only))
+408
View File
@@ -0,0 +1,408 @@
from __future__ import annotations
import json
import os
import shutil
import urllib.parse
import subprocess
import sys
import tempfile
import threading
import urllib.request
from .hash import get_sha256
from pathlib import Path
from .const import MODEL_DIR, COMFYUI_REPO
def _clone_commit(url: str, commit: str, dir: Path, verbose: int = 0):
stdout = None if verbose > 0 else subprocess.DEVNULL
stderr = None if verbose > 1 else subprocess.DEVNULL
subprocess.check_call(
["git", "clone", "--recurse-submodules", "--filter=blob:none", url, dir],
stdout=stdout,
stderr=stderr,
)
subprocess.check_call(
["git", "fetch", "-q", url, commit],
cwd=dir,
stdout=stdout,
stderr=stderr,
)
subprocess.check_call(
["git", "checkout", "FETCH_HEAD"],
cwd=dir,
stdout=stdout,
stderr=stderr,
)
subprocess.check_call(
["git", "submodule", "update", "--init", "--recursive"],
cwd=dir,
stdout=stdout,
stderr=stderr,
)
def install_comfyui(snapshot, workspace: Path, verbose: int = 0):
print("Installing ComfyUI")
comfyui_commit = snapshot["comfyui"]
if workspace.exists():
if workspace.joinpath(".DONE").exists():
commit = (workspace / ".DONE").read_text()
if commit.strip() == comfyui_commit:
print("ComfyUI is already installed")
return
shutil.rmtree(workspace)
_clone_commit(COMFYUI_REPO, comfyui_commit, workspace, verbose=verbose)
with open(workspace / ".DONE", "w") as f:
f.write(comfyui_commit)
def install_custom_modules(snapshot, workspace: Path, verbose: int = 0):
print("Installing custom nodes")
for module in snapshot["custom_nodes"]:
url = module["url"]
directory = url.split("/")[-1].split(".")[0]
module_dir = workspace / "custom_nodes" / directory
if module_dir.exists():
if module_dir.joinpath(".DONE").exists():
commit = (module_dir / ".DONE").read_text()
if commit.strip() == module["commit_hash"]:
print(f"{directory} is already installed")
continue
shutil.rmtree(module_dir)
commit_hash = module["commit_hash"]
_clone_commit(url, commit_hash, module_dir, verbose=verbose)
if module_dir.joinpath("install.py").exists():
env = os.environ.copy()
venv = workspace / ".venv"
if venv.exists():
python = (
venv / "Scripts" / "python.exe"
if os.name == "nt"
else venv / "bin" / "python"
)
if "PATH" in env:
env["PATH"] = f"{str(python.parent)}:{env['PATH']}"
else:
env["PATH"] = str(python.parent)
env["VIRTUAL_ENV"] = str(venv)
else:
python = Path(sys.executable)
if verbose > 0:
print(f"Installing {directory} custom node")
print(f"$ {python.absolute()} install.py")
subprocess.check_call(
[str(python.absolute()), "install.py"],
cwd=module_dir,
stdout=subprocess.DEVNULL if verbose == 0 else None,
)
with open(module_dir / ".DONE", "w") as f:
f.write(commit_hash)
def install_dependencies(
snapshot: dict,
req_file: str,
workspace: Path,
verbose: int = 0,
):
print("Installing Python dependencies")
python_version = snapshot["python"]
stdout = None if verbose > 0 else subprocess.DEVNULL
stderr = None if verbose > 1 else subprocess.DEVNULL
subprocess.check_call(
["uv", "python", "install", python_version],
cwd=workspace,
stdout=stdout,
stderr=stderr,
)
venv = workspace / ".venv"
if (venv / "DONE").exists():
return
venv_py = (
venv / "Scripts" / "python.exe" if os.name == "nt" else venv / "bin" / "python"
)
subprocess.check_call(
[
"uv",
"venv",
"--python",
python_version,
venv,
],
stdout=stdout,
stderr=stderr,
)
subprocess.check_call(
[
"uv",
"pip",
"install",
"-p",
str(venv_py),
"pip",
],
stdout=stdout,
stderr=stderr,
)
if verbose > 0:
print(f"Installing dependencies from {req_file}")
subprocess.check_call(
[
"uv",
"pip",
"install",
"-p",
str(venv_py),
"-r",
req_file,
"--no-deps",
],
stdout=stdout,
stderr=stderr,
)
with open(venv / "DONE", "w") as f:
f.write("DONE")
return venv_py
def get_search_url(sha: str) -> str:
"""Generate custom search URLs for model on HuggingFace and CivitAI"""
base_url = "https://duckduckgo.com"
sha = sha.upper()
hf_query = f"{sha} OR {sha[:10]}"
hf_query = urllib.parse.quote(hf_query)
return f"{base_url}?q={hf_query}"
def download_file(url: str, dest_path: Path, progress_callback=None):
"""Download file with progress tracking"""
if subprocess.call(["curl", "--version"], stdout=subprocess.DEVNULL) == 0:
subprocess.check_call(
["curl", "-L", url, "-o", str(dest_path)],
)
return True
try:
with urllib.request.urlopen(url) as response:
total_size = int(response.headers.get("content-length", 0))
block_size = 8192
downloaded = 0
with open(dest_path, "wb") as f:
while True:
buffer = response.read(block_size)
if not buffer:
break
downloaded += len(buffer)
f.write(buffer)
if progress_callback:
progress = (
(downloaded / total_size) * 100 if total_size > 0 else 0
)
progress_callback(progress)
return True
except Exception as e:
print(f"Download failed: {e}")
if dest_path.exists():
dest_path.unlink()
return False
def show_progress(filename: str):
"""Progress callback function"""
def callback(progress):
print(f"\rDownloading {filename}: {progress:.1f}%", end="")
return callback
def create_model_symlink(global_path: Path, sha: str, target_path: Path, filename: str):
"""Create symlink from global storage to workspace"""
source = global_path / sha
target = target_path / filename
if target.exists():
if target.is_symlink():
target.unlink()
else:
raise RuntimeError(f"File {target} already exists and is not a symlink")
target.parent.mkdir(parents=True, exist_ok=True)
os.symlink(source, target)
def retrive_models(
snapshot: dict,
workspace: Path,
download: bool = True,
all_models: bool = False,
verbose: int = 0,
):
"""Retrieve models from user downloads"""
print("Retrieving models")
models = snapshot.get("models", [])
if not models:
return
MODEL_DIR.mkdir(parents=True, exist_ok=True)
for model in models:
sha = model["sha256"]
filename = model["filename"]
disabled = model.get("disabled", False)
if (workspace / filename).exists():
if not (MODEL_DIR / sha).exists() and (workspace / filename).is_file():
shutil.move(workspace / filename, MODEL_DIR / sha)
create_model_symlink(MODEL_DIR, sha, workspace, filename)
continue
if (MODEL_DIR / sha).exists():
print(f"Model {filename} already exists in cache")
create_model_symlink(MODEL_DIR, sha, workspace, filename)
continue
if disabled and not all_models:
continue
if not download:
continue
print(f"\nModel {filename} is never downloaded before")
if source := model.get("source"):
url = source["download_url"]
target_path = MODEL_DIR / sha
download_thread = threading.Thread(
target=download_file,
args=(url, target_path, show_progress(filename)),
)
download_thread.start()
download_thread.join()
if not target_path.exists():
print("\nDownload failed!")
continue
print("\nDownload completed! Verifying SHA256...")
terget_sha = get_sha256(str(target_path))
if terget_sha != sha:
print("SHA256 verification failed! File may be corrupted or incorrect.")
target_path.unlink()
else:
continue
search_url = get_search_url(sha)
print(f"Search URL: {search_url}")
print(f"Path: {workspace / filename}")
while True:
path = input("Enter path to downloaded file (or 'skip' to skip): ")
if path.lower() == "skip":
break
try:
# Check if input is a URL
if path.startswith(("http://", "https://")):
url = path
target_path = MODEL_DIR / sha
# Start download in a separate thread
download_thread = threading.Thread(
target=download_file,
args=(url, target_path, show_progress(filename)),
)
download_thread.start()
download_thread.join()
if not target_path.exists():
print("\nDownload failed!")
continue
print("\nDownload completed! Verifying SHA256...")
terget_sha = get_sha256(str(target_path))
if terget_sha != sha:
print(
"SHA256 verification failed! File may be corrupted or incorrect."
)
target_path.unlink()
continue
print("SHA256 verification successful!")
else:
# Handle local file
downloaded_path = Path(path)
if not downloaded_path.exists():
print("File does not exist!")
continue
# Verify SHA256 before copying
print("Verifying SHA256...")
target_sha = get_sha256(str(downloaded_path))
if target_sha != sha:
print(
f"Downloaded file SHA256 does not match expected SHA256: {target_sha} != {sha}"
)
continue
print("SHA256 verification successful!")
# Copy to global storage
shutil.copy2(downloaded_path, MODEL_DIR / sha)
# Create symlink
create_model_symlink(MODEL_DIR, sha, workspace, filename)
print(f"Model {filename} installed successfully")
break
except Exception as e:
print(f"Error processing file: {e}")
continue
def install(
cpack: str | Path,
workspace: str | Path = "workspace",
preheat: bool = True,
all_models: bool = False,
verbose: int = 0,
):
workspace = Path(workspace)
print(f"Installing package {cpack} to {workspace} (verbose={verbose})")
with tempfile.TemporaryDirectory() as temp_dir:
pack_dir = Path(temp_dir) / ".cpack"
shutil.unpack_archive(cpack, pack_dir)
snapshot = json.loads((pack_dir / "snapshot.json").read_text())
req_txt_file = pack_dir / "requirements.txt"
install_comfyui(snapshot, workspace, verbose=verbose)
install_dependencies(snapshot, str(req_txt_file), workspace, verbose=verbose)
for f in (pack_dir / "input").glob("*"):
if f.is_file():
shutil.copy(f, workspace / "input" / f.name)
elif f.is_dir():
shutil.copytree(f, workspace / "input" / f.name, dirs_exist_ok=True)
retrive_models(
snapshot,
workspace,
verbose=verbose,
download=False,
)
install_custom_modules(snapshot, workspace, verbose=verbose)
if preheat:
from .run import ComfyUIServer
with ComfyUIServer(
str(workspace),
verbose=verbose,
venv=str(workspace / ".venv"),
) as _:
pass
retrive_models(snapshot, workspace, verbose=verbose, all_models=all_models)
+299
View File
@@ -0,0 +1,299 @@
from __future__ import annotations
import copy
import json
import logging
import os
import random
import shutil
import socket
import subprocess
import time
import uuid
from pathlib import Path
from typing import Any, Union
import psutil
from .utils import populate_workflow, retrieve_workflow_outputs
logger = logging.getLogger(__name__)
def _probe_comfyui_server(port: int) -> None:
from urllib import parse, request
url = f"http://127.0.0.1:{port}/api/customnode/getmappings"
params = {"mode": "nickname"}
full_url = f"{url}?{parse.urlencode(params)}"
req = request.Request(full_url)
_ = request.urlopen(req)
full_url = f"http://127.0.0.1:{port}/api/object_info"
req = request.Request(full_url)
_ = request.urlopen(req)
def _is_port_in_use(port: int | str, host="localhost"):
if isinstance(port, str):
port = int(port)
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
try:
s.connect((host, port))
return True
except ConnectionRefusedError:
return False
except Exception:
return True
class ComfyUIServer:
def __init__(
self,
workspace: str,
input_dir: str | None = None,
host: str = "localhost",
port: int | None = None,
venv: str | None = None,
verbose: int = 0,
) -> None:
"""
Args:
workspace (str, optional): The workspace path for ComfyUI. If not specified, runner will try to connect to an existing ComfyUI server.
input_dir (str, optional): The input directory for ComfyUI. Defaults to None.
port (int, optional): The port number for ComfyUI. Defaults to None. If 8188 is in use, a random port will be chosen.
"""
self.workspace = workspace
self.input_dir = input_dir
self.verbose = verbose
self.host = host
self.server_proc: subprocess.Popen | None = None
self._children_procs: list[psutil.Process] = []
run_dir = (Path(workspace) / "cli_run").absolute()
self.temp_dir = run_dir / "temp"
self.output_dir = run_dir / "output"
self.temp_dir.mkdir(parents=True, exist_ok=True)
self.output_dir.mkdir(parents=True, exist_ok=True)
if port is None:
if _is_port_in_use(8188):
self.port = port if port else random.randint(58000, 58999)
else:
self.port = 8188
else:
self.port = port
self.venv = venv
def start(self) -> None:
"""
Start the ComfyUI process.
This method starts ComfyUI in the background, sets up necessary directories,
and disables tracking for workaround purposes.
Args:
verbose (int, optional): Verbosity level. If 0, suppress stdout. Defaults to 0.
Raises:
RuntimeError: If ComfyUI is already running.
"""
logger.info(
"Disable tracking from Comfy CLI, not for privacy concerns, but to workaround a bug"
)
self.temp_dir.mkdir(parents=True, exist_ok=True)
self.output_dir.mkdir(parents=True, exist_ok=True)
env = os.environ.copy()
if self.venv:
env["VIRTUAL_ENV"] = self.venv
if os.name == "nt":
env["PATH"] = f"{self.venv}\\Scripts;{env.get('PATH', '')}"
else:
env["PATH"] = f"{self.venv}/bin:{env.get('PATH', '')}"
stdout = None if self.verbose > 0 else subprocess.DEVNULL
command = ["comfy", "--skip-prompt", "tracking", "disable"]
subprocess.run(command, check=True, stdout=stdout, env=env)
logger.info("Successfully disabled Comfy CLI tracking")
logger.info("Preparing directories required by ComfyUI...")
logger.info("Starting ComfyUI in the background...")
command = [
"comfy",
"--workspace",
self.workspace,
"launch",
"--",
"--output-directory",
self.output_dir,
"--temp-directory",
self.temp_dir,
"--port",
str(self.port),
]
if self.input_dir:
command.extend(["--input-directory", self.input_dir])
if self.host != "localhost":
command.extend(["--listen", self.host])
def preexec_fn():
os.setpgrp()
self.server_proc = subprocess.Popen(
command,
stdout=stdout,
stderr=None,
preexec_fn=preexec_fn,
env=env,
)
if _wait_for_startup(self.host, self.port):
_probe_comfyui_server(self.port)
logger.info("Successfully started ComfyUI in the background")
else:
logger.error("Failed to start ComfyUI in the background")
self._children_procs = psutil.Process(self.server_proc.pid).children(
recursive=True
)
def is_running(self) -> bool:
if self.server_proc is None:
return False
if self.server_proc.poll() is not None:
return False
for child in self._children_procs:
if not child.is_running():
return False
return True
def stop(self) -> None:
"""
Stop the ComfyUI process.
This method stops the running ComfyUI process and cleans up the temporary directory if necessary.
Raises:
RuntimeError: If ComfyUI is not currently running.
"""
if self.server_proc is None:
raise RuntimeError("ComfyUI server is not started yet")
proc = self.server_proc
self.server_proc = None
logger.info("Stopping ComfyUI...")
for child in self._children_procs:
child.terminate()
child.wait()
proc.terminate()
proc.wait()
self._children_procs.clear()
logger.info("Successfully stopped ComfyUI")
logger.info("Cleaning up temporary directory...")
shutil.rmtree(self.temp_dir, ignore_errors=True)
shutil.rmtree(self.output_dir, ignore_errors=True)
logger.info("Successfully cleaned up temporary directory")
def __enter__(self):
self.start()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.stop()
def _wait_for_startup(host: str, port: int, timeout: int = 1800) -> bool:
start_time = time.time()
while time.time() - start_time < timeout:
if _is_port_in_use(port, host):
return True
time.sleep(1)
return False
def run_workflow(
host: str,
port: int,
workflow: dict,
output_dir: Union[str, Path, None] = None,
timeout: int = 300,
verbose: int = 0,
**kwargs: Any,
) -> Any:
"""
Run a ComfyUI workflow.
This method executes a given workflow, populates it with input data,
and retrieves the output.
Args:
workflow (dict): The workflow to run.
output_dir (Union[str, Path, None], optional): Temporary directory for the workflow. Defaults to None.
timeout (int, optional): Timeout for the workflow execution in seconds. Defaults to 300.
**kwargs: Additional keyword arguments for workflow population.
Returns:
Any: The output of the workflow.
Raises:
RuntimeError: If ComfyUI is not started.
"""
run_id = uuid.uuid4().hex[:8]
workflow_copy = copy.deepcopy(workflow)
if output_dir is None:
output_dir = Path(".")
if isinstance(output_dir, str):
output_dir = Path(output_dir)
run_id = os.urandom(8).hex()
populate_workflow(
workflow_copy,
output_dir,
session_id=run_id,
**kwargs,
)
workflow_file_path = output_dir / f"workflow_{run_id}.json"
with open(workflow_file_path, "w") as file:
json.dump(workflow_copy, file)
extra_args = []
if verbose > 0:
extra_args.append("--verbose")
stdout = None if verbose > 0 else subprocess.DEVNULL
command = ["comfy", "--skip-prompt", "tracking", "disable"]
subprocess.run(command, check=True, stdout=stdout)
# Execute the workflow
command = [
"comfy",
"--skip-prompt",
"run",
"--workflow",
workflow_file_path.as_posix(),
"--port",
str(port),
"--host",
host,
"--timeout",
str(timeout),
"--wait",
*extra_args,
]
env = os.environ.copy()
env["NO_COLOR"] = "1"
subprocess.run(command, check=True, env=env)
workflow_file_path.unlink()
# retrieve the output
return retrieve_workflow_outputs(
workflow_copy,
output_dir,
session_id=run_id,
)
@@ -1,29 +1,22 @@
from __future__ import annotations
import re
import subprocess
from pathlib import Path
from typing import TYPE_CHECKING, Any, Union
import re
import sys
from typing import TYPE_CHECKING, Any, Literal, Union
if TYPE_CHECKING:
from pydantic import BaseModel
CLASS_TYPES = {
"BentoInputString": str,
"BentoInputBoolean": bool,
"BentoInputInteger": int,
"BentoInputFloat": float,
"BentoInputPath": Path,
"BentoInputImage": Path,
CPACK_OUTPUT_NODES = {
"CPackOutputFile",
"CPackOutputImage",
}
BENTO_OUTPUT_NODES = {
"BentoOutputPath",
"BentoOutputImage",
}
BENTO_PATH_INPUT_NODES = {
"BentoInputPath",
"BentoInputImage",
CPACK_PATH_INPUT_NODES = {
"CPackInputFile",
"CPackInputImage",
}
@@ -57,17 +50,33 @@ def _get_node_identifier(node, dep_map=None) -> str:
"""
Get the input name from the node
"""
title = node["_meta"]["title"]
if "_meta" in node and "title" in node["_meta"]:
title = node["_meta"]["title"]
else:
title = ""
if title.isidentifier():
return title
nid = node["id"]
if dep_map and (nid, 0) in dep_map:
_, input_name = dep_map[(nid, 0)]
return _normalize_to_identifier(input_name)
if not title:
klass = node.get("class_type", "cpack_input")
name = klass.lstrip("CPack").lstrip("Input")
return _normalize_to_identifier(name)
return _normalize_to_identifier(title)
API_WORKFLOW_MESSAGE = """
It seems you are trying to parse an ordinary Workflow json file.
Please save the Workflow with the Save (API Format).
If you don't have this button, you must enable the "Dev mode Options" by clicking the Settings button on the top right (gear icon). Check the setting option "Enable Dev Mode options". After that, the Button Save (API Format) should appear.
"""
def _parse_workflow(workflow: dict) -> tuple[dict[str, Any], dict[str, Any]]:
"""
Parse the workflow template and return the input and output definition
@@ -76,6 +85,9 @@ def _parse_workflow(workflow: dict) -> tuple[dict[str, Any], dict[str, Any]]:
outputs = {}
dep_map = {}
if "last_node_id" in workflow:
raise ValueError(API_WORKFLOW_MESSAGE)
for id, node in workflow.items():
for input_name, v in node["inputs"].items():
if isinstance(v, list) and len(v) == 2: # is a link
@@ -83,12 +95,16 @@ def _parse_workflow(workflow: dict) -> tuple[dict[str, Any], dict[str, Any]]:
for id, node in workflow.items():
node["id"] = id
if node["class_type"].startswith("BentoInput"):
if node["class_type"].startswith("CPackInput"):
if not node.get("inputs"):
continue
name = _get_node_identifier(node, dep_map)
if name in inputs:
name = f"{name}_{id}"
inputs[name] = node
elif node["class_type"].startswith("BentoOutput"):
elif node["class_type"].startswith("CPackOutput"):
if not node.get("inputs"):
continue
name = _get_node_identifier(node)
if name in inputs:
name = f"{name}_{id}"
@@ -118,25 +134,59 @@ def generate_input_model(workflow: dict) -> type[BaseModel]:
ValueError: If an unsupported class type is encountered in the workflow.
"""
from pydantic import Field, create_model
from pydantic_core import PydanticUndefined
inputs, _ = _parse_workflow(workflow)
input_fields = {}
for name, node in inputs.items():
class_type = node["class_type"]
if class_type in CLASS_TYPES:
ann = CLASS_TYPES[class_type]
if class_type in BENTO_PATH_INPUT_NODES:
field = (ann, Field())
if class_type in CPACK_PATH_INPUT_NODES:
field = (Path, Field())
elif class_type == "CPackInputString":
value = _get_node_value(node)
field = (str, Field(default=value))
elif class_type == "CPackInputInt":
value, min, max = tuple(node["inputs"].values())
if min == -sys.maxsize:
min = PydanticUndefined
if max == sys.maxsize:
max = PydanticUndefined
field = (int, Field(default=value, ge=min, le=max))
elif class_type == "CPackInputAny":
options = node.get("_meta", {}).get("options")
value = _get_node_value(node)
if not options:
field = (type(value), Field(default=value))
else:
field = (ann, Field(default=_get_node_value(node)))
input_fields[name] = field
if values := options.get("values"): # combo type
field = (Literal[tuple(values)], Field(default=value))
elif any(
f in options for f in ("min", "max", "round", "precision", "step")
): # must be number types
type_ = float if options.get("round", 1) < 1 else int
min_value = options.get("min", PydanticUndefined)
max_value = options.get("max", PydanticUndefined)
if type_ is int:
if min_value < -sys.maxsize:
min_value = PydanticUndefined
if max_value > sys.maxsize:
max_value = PydanticUndefined
field = (type_, Field(default=value, ge=min_value, le=max_value))
else:
field = (type(value), Field(default=value))
else:
raise ValueError(f"Unsupported class type: {class_type}")
input_fields[name] = field
return create_model("ParsedWorkflowTemplate", **input_fields)
def populate_workflow(workflow: dict, output_path: Path, **inputs) -> dict:
def populate_workflow(
workflow: dict,
output_path: Path,
session_id: str = "",
**inputs,
) -> dict:
"""
Fills the input values and output path into the workflow.
@@ -154,15 +204,15 @@ def populate_workflow(workflow: dict, output_path: Path, **inputs) -> dict:
input_spec, output_spec = _parse_workflow(workflow)
for k, v in inputs.items():
node = input_spec[k]
if not node["class_type"].startswith("BentoInput"):
if not node["class_type"].startswith("CPackInput"):
raise ValueError(f"Node {k} is not an input node")
_set_node_value(workflow[node["id"]], v)
for _, node in output_spec.items():
node_id = node["id"]
if node["class_type"] in BENTO_OUTPUT_NODES:
if node["class_type"] in CPACK_OUTPUT_NODES:
workflow[node_id]["inputs"]["filename_prefix"] = (
output_path / f"{node_id}_"
output_path / f"{session_id}{node_id}_"
).as_posix()
return workflow
@@ -170,6 +220,7 @@ def populate_workflow(workflow: dict, output_path: Path, **inputs) -> dict:
def retrieve_workflow_outputs(
workflow: dict,
output_path: Path,
session_id: str = "",
) -> Union[Path, list[Path], dict[str, Path], dict[str, list[Path]]]:
"""
Gets the output file(s) from the workflow.
@@ -200,11 +251,52 @@ def retrieve_workflow_outputs(
return value_map
name, node = next(iter(outputs.items()))
if node["class_type"] not in BENTO_OUTPUT_NODES:
raise ValueError(f"Output node {name} is not of type {BENTO_OUTPUT_NODES}")
if node["class_type"] not in CPACK_OUTPUT_NODES:
raise ValueError(f"Output node {name} is not of type {CPACK_OUTPUT_NODES}")
node_id = node["id"]
outs = list(output_path.glob(f"{node_id}_*"))
outs = list(output_path.glob(f"{session_id}{node_id}_*"))
if len(outs) == 1:
return outs[0]
return outs
def get_self_git_commit() -> str | None:
"""Get current git commit of the repository.
Returns:
str | None: Git commit hash in format "{hash}[-dirty]" or None if not in a git repo
"""
try:
repo_root = Path(__file__).parent.parent.parent
# Check if we're in a git repo
subprocess.run(
["git", "rev-parse", "--git-dir"],
cwd=repo_root,
check=True,
capture_output=True,
)
# Get current commit hash
commit_hash = subprocess.run(
["git", "rev-parse", "--short", "HEAD"],
cwd=repo_root,
check=True,
capture_output=True,
text=True,
).stdout.strip()
# Check if working directory is clean
is_dirty = (
subprocess.run(
["git", "diff", "--quiet"],
cwd=repo_root,
check=False,
).returncode
!= 0
)
return f"{commit_hash}-dirty" if is_dirty else commit_hash
except (subprocess.SubprocessError, FileNotFoundError):
return None
-202
View File
@@ -1,202 +0,0 @@
from __future__ import annotations
import copy
import json
import logging
import os
import shutil
import subprocess
import tempfile
from pathlib import Path
from typing import Any, Union
from comfyui_idl.utils import (
populate_workflow,
retrieve_workflow_outputs,
)
logger = logging.getLogger(__name__)
class WorkflowRunner:
"""
A class to manage and run ComfyUI workflows.
This class handles the initialization, starting, stopping, and execution of ComfyUI workflows.
It manages temporary and output directories, and provides methods to run workflows with specified parameters.
Attributes:
temp_dir (Path): The temporary directory for ComfyUI operations.
output_dir (Path): The output directory for ComfyUI results.
workspace (str): The workspace path for ComfyUI.
is_running (bool): Flag indicating whether ComfyUI is currently running.
"""
def __init__(
self,
workspace: str,
temp_dir: Union[str, Path, None] = None,
output_dir: Union[str, Path, None] = None,
) -> None:
"""
Initialize the WorkflowRunner.
Args:
workspace (str): The workspace path for ComfyUI.
temp_dir (Union[str, Path, None], optional): The temporary directory. Defaults to None.
output_dir (Union[str, Path, None], optional): The output directory. Defaults to None.
"""
if temp_dir is None:
self.temp_dir = Path(tempfile.mkdtemp())
self._cleanup_temp_dir = True
elif isinstance(temp_dir, str):
self.temp_dir = Path(temp_dir)
self._cleanup_temp_dir = False
else:
self.temp_dir = temp_dir
self._cleanup_temp_dir = False
if output_dir is None:
self.output_dir = self.temp_dir
elif isinstance(output_dir, str):
self.output_dir = Path(output_dir)
else:
self.output_dir = output_dir
self.workspace = workspace
# The ComfyUI process
self.is_running = False
def start(self) -> None:
"""
Start the ComfyUI process.
This method starts ComfyUI in the background, sets up necessary directories,
and disables tracking for workaround purposes.
Raises:
RuntimeError: If ComfyUI is already running.
"""
if self.is_running:
raise RuntimeError("ComfyUI Runner is already started")
logger.info(
"Disable tracking from Comfy CLI, not for privacy concerns, but to workaround a bug"
)
command = ["comfy", "--skip-prompt", "tracking", "disable"]
subprocess.run(command, check=True)
logger.info("Successfully disabled Comfy CLI tracking")
logger.info("Preparing directories required by ComfyUI...")
os.makedirs(self.output_dir, exist_ok=True)
os.makedirs(self.temp_dir, exist_ok=True)
print("Comfy Output Path:", self.output_dir)
print("Comfy Temp Path:", self.temp_dir)
logger.info("Starting ComfyUI in the background...")
command = [
"comfy",
"--workspace",
self.workspace,
"launch",
"--background",
"--",
"--output-directory",
self.output_dir,
"--temp-directory",
self.temp_dir,
]
if subprocess.run(command, check=True):
self.is_running = True
logger.info("Successfully started ComfyUI in the background")
else:
logger.error("Failed to start ComfyUI in the background")
def stop(self) -> None:
"""
Stop the ComfyUI process.
This method stops the running ComfyUI process and cleans up the temporary directory if necessary.
Raises:
RuntimeError: If ComfyUI is not currently running.
"""
if not self.is_running:
raise RuntimeError("ComfyUI Runner is not started yet")
logger.info("Stopping ComfyUI...")
command = ["comfy", "stop"]
subprocess.run(command, check=True)
logger.info("Successfully stopped ComfyUI")
if self._cleanup_temp_dir:
logger.info("Cleaning up temporary directory...")
shutil.rmtree(self.temp_dir)
logger.info("Successfully cleaned up temporary directory")
self.is_running = False
def run_workflow(
self,
workflow: dict,
temp_dir: Union[str, Path, None] = None,
timeout: int = 300,
**kwargs: Any,
) -> Any:
"""
Run a ComfyUI workflow.
This method executes a given workflow, populates it with input data,
and retrieves the output.
Args:
workflow (dict): The workflow to run.
temp_dir (Union[str, Path, None], optional): Temporary directory for the workflow. Defaults to None.
timeout (int, optional): Timeout for the workflow execution in seconds. Defaults to 300.
**kwargs: Additional keyword arguments for workflow population.
Returns:
Any: The output of the workflow.
Raises:
RuntimeError: If ComfyUI is not started.
"""
if not self.is_running:
raise RuntimeError("ComfyUI Runner is not started yet")
workflow_copy = copy.deepcopy(workflow)
if temp_dir is None:
temp_dir = self.temp_dir
if isinstance(temp_dir, str):
temp_dir = Path(temp_dir)
populate_workflow(
workflow_copy,
temp_dir,
**kwargs,
)
workflow_file_path = temp_dir / "workflow.json"
with open(workflow_file_path, "w") as file:
json.dump(workflow_copy, file)
extra_args = []
if "BENTOML_DEBUG" in os.environ:
extra_args.append("--verbose")
# Execute the workflow
command = [
"comfy",
"run",
"--workflow",
workflow_file_path.as_posix(),
"--timeout",
str(timeout),
"--wait",
*extra_args,
]
subprocess.run(command, check=True)
# retrieve the output
return retrieve_workflow_outputs(
workflow_copy,
temp_dir,
)
Generated
+1417 -2
View File
File diff suppressed because it is too large Load Diff
+90
View File
@@ -0,0 +1,90 @@
import { app } from "../../scripts/app.js";
function mimicNode(node, target, slot) {
const getWidget = (widget) => {
if (typeof widget.origType === "undefined") return widget;
const newWidget = new Proxy(widget, {
has(target, prop) {
return prop in target && prop !== "origType" && prop !== "computeSize";
},
get(target, prop) {
if (prop.startsWith("orig") || prop === "computeSize") {
return undefined;
} else if (prop === "type") {
return target.origType;
} else if (prop === "name") {
return inputName;
} else {
return target[prop];
}
},
set(target, prop, value) {
target[prop] = value;
return true;
}
});
return newWidget;
}
const input = target.inputs[slot];
const inputName = node.inputs.length > 0 ? node.inputs[0].name : node.widgets[0].name;
if (typeof input.widget === "undefined") { // This is an input
node.inputs = [{ ...input, name: inputName }];
node.widgets = [];
} else { // This is a widget
node.widgets = [getWidget(target.widgets.find(w => w.name === input.name))];
node.inputs = [];
node.size = node.computeSize();
requestAnimationFrame(() => {
if (node.onResize) {
node.onResize(node.size);
}
});
}
}
app.registerExtension({
name: "Comfy.DynamicInput",
dynamicNodes: ["CPackInputAny", "CPackInputFile"],
async beforeRegisterNodeDef(nodeType, nodeData) {
if (!this.dynamicNodes.includes(nodeData.name)) return;
nodeType.prototype.onConnectOutput = function () {
if (this.outputs[0].links?.length > 0) return false;
const target = arguments[3];
if (this.type === "CPackInputFile" && !["COMBO", "STRING"].includes(target.inputs[arguments[4]].type))
return false;
mimicNode(this, target, arguments[4]);
this.title = target.inputs[arguments[4]].name;
}
},
async setup(app) {
app.graph.nodes.forEach((node) => {
if (!this.dynamicNodes.includes(node.type)) return;
if (node.outputs.length > 0 && node.outputs[0].links.length > 0) {
const link = node.graph.links[node.outputs[0].links[0]];
mimicNode(node, app.graph.getNodeById(link.target_id), link.target_slot);
}
})
},
async init(app) {
const originalToPrompt = app.graphToPrompt;
const self = this;
app.graphToPrompt = async function(graph = app.graph, clean = true) {
const { workflow, output } = await originalToPrompt(graph, clean);
Object.entries(output).forEach(([id, nodeData]) => {
if (!nodeData.class_type.startsWith("CPackInput")) return;
const node = graph.getNodeById(parseInt(id));
if (!nodeData["_meta"]) {
nodeData["_meta"] = { title: node.title };
}
if (node.widgets.length === 0) return;
const widget = node.widgets[0];
nodeData["_meta"] = { ...nodeData["_meta"], options: widget.options };
});
return { workflow, output };
};
}
});
+1020
View File
File diff suppressed because it is too large Load Diff