Compare commits

...

222 Commits

Author SHA1 Message Date
6fd636da20 更新 builder/modal-builder/src/template/app2.py 2025-02-11 10:50:34 -05:00
02b4488f1b 更新 builder/modal-builder/src/template/app1.py 2025-02-11 10:46:52 -05:00
946acc1c86 更新 builder/modal-builder/src/template/app.py 2025-02-11 10:12:44 -05:00
9a23d814c2 更新 builder/modal-builder/src/main1.py 2025-02-11 09:49:31 -05:00
d8197398ab 更新 builder/modal-builder/src/main.py 2025-02-11 07:50:14 -05:00
bennykok
4073a43d3d use torch audio 2025-02-07 23:14:16 +08:00
bennykok
3d6a554f7f feat: add external audio node based on VHS node 2025-02-07 21:42:44 +08:00
KarrixLee
ce939fbe1b
add: gpu in info (#78) 2025-02-06 15:41:56 +08:00
bennykok
48f5ce15d7 fix: fallback to default api runs 2025-02-05 17:58:57 +08:00
karrix
9512437573 feat: send back event if the graph is loading properly 2025-02-05 14:41:35 +08:00
bennykok
649e431227 feat: configure_menu_buttons 2025-01-23 13:44:31 +08:00
EmmanuelMr18
411db66d81 Revert "chore: refresh models when getting object_info"
This reverts commit 67f25b2353cde0c318f0450d5c3222091a988625.
2025-01-20 01:52:52 -05:00
Emmanuel Morales
67f25b2353
chore: refresh models when getting object_info
This is a WIP that will be used to refresh the models when execution comfyUI without having to stop the server and start a new one
2025-01-19 17:26:41 -06:00
bennykok
ce3b0dbe84 chore: log prompt_id on start 2025-01-19 12:39:24 +08:00
bennykok
fc36a8ad0f feat: add output image node 2025-01-19 12:39:06 +08:00
Robin Huang
638e625d72
chore(licence-update): Update PyProject Toml - License (#77)
Co-authored-by: snomiao <snomiao+comfy-pr@gmail.com>
2025-01-10 15:44:27 +08:00
EmmanuelMr18
230cee40d2 fix: add container to the buttons injected into the right menu 2025-01-10 01:08:42 -06:00
EmmanuelMr18
73853a60ff feat: inject buttons in the right position of the comfyui menu 2025-01-07 23:49:03 -06:00
bennykok
413115571b chore: add event for updating widget 2025-01-07 21:36:12 +08:00
bennykok
bf00580562 feat: update external image node to have default value 2025-01-07 21:03:52 +08:00
bennykok
6ed468d7d4 feat: drag drop proxy + inject button to toolbar 2025-01-06 13:01:39 +08:00
bennykok
5423b4ee6f fix: simply js import 2025-01-05 14:00:42 +08:00
Emmanuel Morales
2c1656756d
fix(updates): make updates async to avoid blocking execution (#75)
I tracked the time and takes ~200ms everytime that we send the "Executing <NODE NAME> n%".
So this means that if you have 10 custom nodes we are adding 2 extra seconds to the execution.
200 * 10 = 2,000.
Some workflows are more complext and have more custom nodes, so this only keeps increasing.
2025-01-03 16:25:04 +08:00
bennykok
ac843527d9 fix: turn perf meta into array 2024-12-09 18:42:15 +08:00
bennykok
f39d216326 fix: ordered dict 2024-12-09 18:13:36 +08:00
bennykok
40ec37e58f fix 2024-12-09 16:48:11 +08:00
bennykok
1d63b21643 fix: move update run 2024-12-09 16:31:36 +08:00
bennykok
0e3baf22df fix: also send timing pref 2024-12-09 16:18:04 +08:00
bennykok
1837065ed2 fix: log printing 2024-12-09 09:34:39 +08:00
bennykok
9a8f4795d1 fix log 2024-12-09 00:35:12 +08:00
bennykok
c0c617c5d2 Merge branch 'combine-text' into public-main 2024-12-09 00:24:36 +08:00
bennykok
1e33435ae5 feat: add perf counter 2024-12-09 00:11:51 +08:00
karrix
04161071f2 test 2024-12-06 18:54:56 +08:00
bennykok
32d574475c fix: backward comp with old ui 2024-11-13 18:14:36 +09:00
bennykok
1a017ee6a3 make sure link reconnect works 2024-11-13 17:59:54 +09:00
bennykok
603223741a feat: tweak ui styles 2024-11-13 17:24:11 +09:00
bennykok
2bd8b23c60 feat: convert external input 2024-11-13 14:20:24 +08:00
bennykok
a82e315d6c fix: when file endpoint is null, skip uploading 2024-10-25 19:56:36 +08:00
nick
7fdfba6b6e external lora 2024-10-24 22:46:31 +08:00
BennyKok
0779136134
Update pyproject.toml 2024-10-22 11:28:59 +08:00
nick
fe116a4655 clean logs 2024-10-12 23:59:58 -07:00
nick
7dd8a7e67e gpu eveent 2024-10-12 16:57:37 -07:00
nick
778e6fefe6 Merge branch 'main' into nickkao/gpu_event 2024-10-12 12:56:11 -07:00
nick
fd310e8478 globals 2024-10-11 21:46:48 -07:00
karrix
3a3b93d564 tweak: modify the local storage of the dock 2024-10-11 17:15:28 +08:00
nick
82c564228d None gpu event 2024-10-10 17:58:11 -07:00
nick
ad0a23434b merge 2024-10-10 17:23:19 -07:00
karrix
292f77f06b fix: default queue button position to dock 2024-10-11 02:21:32 +08:00
bennykok
a139424b91 fix: output node status 2024-10-10 11:20:12 -07:00
bennykok
44a91d2093 fix: default new ui for comfyui 2024-10-09 17:20:08 -07:00
bennykok
7cff930861 fix: token will be fetched everytime to make sure it is the latest 2024-10-09 16:54:34 -07:00
nick
ce464c6ce4 Merge branch 'main' into nickkao/gpu_event 2024-10-07 15:50:47 -07:00
bennykok
1c7998c554 feat: attach gpu event 2024-10-07 15:48:14 -07:00
nick
66d1e42409 lopgs 2024-10-07 14:16:55 -07:00
nick
8882f4983c fix: pydantic type simpleprompt 2024-10-04 19:14:37 -07:00
nick
492b81c340 print 2024-10-04 19:02:25 -07:00
nick
8b05ed26c9 merge 2024-10-04 18:49:30 -07:00
nick
ce67604926 stuff 2024-10-04 17:34:50 -07:00
bennykok
c115c22a91 fix: send ws after cd logic 2024-10-04 16:16:19 -07:00
bennykok
2f33bcf497 chore: return item on upload 2024-10-04 15:27:31 -07:00
nick
bcf466c472 merge 2024-10-04 12:10:01 -07:00
bennykok
f812d9d698 Merge branch 'workspace-v3' into public-main 2024-10-02 16:38:55 -07:00
nick
101b6cca57 merge 2024-09-29 12:02:46 -07:00
EdwinWong
ae68aae011 fix: add workflow data to extra data 2024-09-27 18:48:51 -07:00
EmmanuelMr18
07926158f0 feat: model_list node to display all the models available 2024-09-27 18:19:56 -07:00
EmmanuelMr18
ce92dd0570 refactor: remove ExternalTextList node, was for lora traning 2024-09-27 15:13:27 -07:00
bennykok
e2fcf67aec fix: graph load 2024-09-25 12:59:00 -07:00
nick
79650f48d0 merge 2024-09-24 23:16:49 -07:00
bennykok
69f63f4869 Merge branch 'jeff/fix-workflow-in-extra-data' into workspace-v3 2024-09-24 19:58:16 -07:00
bennykok
50860cd500 test 2024-09-24 19:45:53 -07:00
bennykok
2eb02fc92e fi 2024-09-24 19:36:57 -07:00
EdwinWong
5c6defbe62 fix: add workflow data to extra data 2024-09-24 15:35:48 -07:00
bennykok
d1c54b2b6d fix: state 2024-09-23 19:01:47 -07:00
bennykok
3a6c3b1ae9 feat: add native run proxy 2024-09-23 15:31:13 -07:00
bennykok
aea456cba9 fix face loader extenal load 2024-09-21 10:51:51 -07:00
bennykok
8c5e5c4277 feat: add ComfyUIDeployExternalTextAny 2024-09-21 10:39:34 -07:00
bennykok
02430ee62d remove some logs 2024-09-20 18:10:04 -07:00
Fawaz Kadem
764a8fee82
Add new external deploy node for face models (#66) 2024-09-18 17:00:51 -07:00
bennykok
61acffd355 fix 2024-09-18 08:20:35 -07:00
bennykok
aa47f3523f fix 2024-09-17 23:36:24 -07:00
bennykok
7ed4284a6f fix 2024-09-17 23:25:19 -07:00
bennykok
a403daa314 fix 2024-09-17 23:09:42 -07:00
bennykok
ba9b187dcc fix 2024-09-17 22:59:27 -07:00
bennykok
1243fa4e58 fix 2024-09-17 22:55:08 -07:00
bennykok
0d1537963c fix 2024-09-17 21:48:42 -07:00
bennykok
0083b38dcc chore: log image size 2024-09-17 20:44:44 -07:00
bennykok
b8dded1535 Revert "fix: roll back to unique session per request"
This reverts commit 5a78ca97bd1eeeb8de6d96a18aa9f1a2d51869b6.
2024-09-17 20:26:39 -07:00
bennykok
4927d81e73 chore: accept cd_token 2024-09-17 18:57:15 -07:00
nick
0e70db4013 merge 2024-09-17 16:37:39 -07:00
nick
06805e310d merge 2024-09-17 14:32:52 -07:00
bennykok
fb6bb2357a Reapply "fix: back to sequential file upload"
This reverts commit 1f5a88b88805f8f01ba1803b0ecec2e796937417.
2024-09-17 14:28:56 -07:00
bennykok
086d642360 Merge branch 'benny/log-sync' into public-main 2024-09-17 14:27:59 -07:00
bennykok
212daa838c Revert "feat: experiment with await + asyncio.gather for multi file in same node"
This reverts commit c08b68c41f8f0bae587675f7592dcdde28d09627.
2024-09-17 14:25:13 -07:00
bennykok
c08b68c41f feat: experiment with await + asyncio.gather for multi file in same node 2024-09-17 12:56:42 -07:00
bennykok
5a78ca97bd fix: roll back to unique session per request 2024-09-16 23:57:40 -07:00
bennykok
1f5a88b888 Revert "fix: back to sequential file upload"
This reverts commit 3d099f88ea8799a80be9b860793e9c64a7cf4843.
2024-09-16 23:55:16 -07:00
bennykok
946571e32e fix: await 2024-09-16 18:54:05 -07:00
bennykok
e692beb009 feat: realtime log sync 2024-09-16 15:34:20 -07:00
bennykok
3d099f88ea fix: back to sequential file upload 2024-09-16 13:55:02 -07:00
karrix
65f7576748 fix: non type error when upload output 2024-09-16 12:45:53 -07:00
bennykok
2d72cd8175 fix: batch zip image input 2024-09-14 21:49:17 -07:00
bennykok
5554c95f44 Merge branch 'benny/auth_token' into public-main 2024-09-12 14:14:16 -07:00
bennykok
c1003f7e31 Merge branch 'benny/zip-batch-image' into public-main 2024-09-12 14:14:08 -07:00
EdwinWong
71d60a5dd1 fix: comfydeploy node backward compatible in every comfyui 2024-09-10 01:03:50 -07:00
bennykok
e011711600 feat: zip batch image support 2024-09-09 17:49:39 -07:00
nick
4cd7d7a8f9 gpu event 2024-09-08 09:55:47 -07:00
bennykok
4df9d38e56 feat: embed file public status into image output 2024-09-03 23:07:48 -07:00
bennykok
9cd626e1f6 feat: send token for cd update api 2024-09-03 21:58:39 -07:00
bennykok
503dca8fb6 chore: add log 2024-08-30 12:16:41 -07:00
bennykok
73c149b4cb fix node meta 2024-08-30 12:16:41 -07:00
bennykok
65b5b0b8c7 fix: remove content length 2024-08-30 12:16:41 -07:00
bennykok
9d6ee85402 fix: upload file acl 2024-08-30 12:16:41 -07:00
bennykok
cdaed8a571 fix: include upload time 2024-08-30 12:16:41 -07:00
bennykok
3129e89cce fix: log file error log 2024-08-30 12:16:41 -07:00
bennykok
7a693eabc8 fix: size 2024-08-30 12:16:41 -07:00
bennykok
8f677e520d chore: log more test for upload file debug 2024-08-30 12:16:41 -07:00
bennykok
4c8d32c5b0 fix 2024-08-30 12:16:41 -07:00
nick
a99d2568e0 video and lora node fix 2024-08-28 13:08:15 -07:00
nick
649b61c580 default vid 2024-08-26 13:46:01 -07:00
nick
edff5685f9 fix: random seed 2024-08-22 17:39:03 -07:00
bennykok
9fc0c2b4a2 chore: upload node data 2024-08-21 16:34:25 -07:00
bennykok
d34e2e99b1 fix: external lora for new comfyui 2024-08-21 09:46:13 -07:00
bennykok
f85043db07 fix: remove default value 2024-08-20 19:14:43 -07:00
bennykok
894d8e1503 Merge branch 'benny/async-upload-file' into public-main 2024-08-20 18:02:57 -07:00
bennykok
08d631d1eb feat: async file upload for the same node 2024-08-20 17:07:50 -07:00
karrix
a1031487e1 add: all node support name and description 2024-08-20 20:15:29 +08:00
bennykok
ca41207192 feat: max min int for all number inputs to enable negative number input 2024-08-19 13:27:46 -07:00
bennykok
507d5ef631 feat: add a init timeout of 10 seconds for retry logic 2024-08-18 17:31:48 -07:00
bennykok
dd1d9df23f fix: resolve false possible error 2024-08-18 15:38:16 -07:00
bennykok
3a14e49ca5 fix: refresh workflows list 2024-08-17 16:04:14 -07:00
nick
8147c4bfb7 video node' 2024-08-15 12:50:29 -07:00
bennykok
10268825d9 feat: support new frontend! 2024-08-14 11:09:58 -07:00
bennykok
f6ea252652 fix: log when random seed is applied 2024-08-10 10:35:48 -07:00
bennykok
98cd5ef79c fix: randomize noise RandomNoise, KSamplerAdvanced, SamplerCustom 2024-08-10 10:02:01 -07:00
Emmanuel Morales
4bce5cadfb
fix(text): return correctly the text in external_text_list node 2024-08-10 09:44:37 -06:00
Nick Kao
f362671041
Merge pull request #61 from BennyKok/node-error-no-throw
block on bad prompt
2024-08-08 10:01:33 -07:00
nick
0582d1d869 merge 2024-08-07 20:43:38 -07:00
nick
ce073a86c7 block on bad prompt 2024-08-07 20:42:12 -07:00
Emmanuel Morales
3a85a1edf2
feat(text): create node for external text list (#60)
* feat(text): create node for external text list 

This is to send a list of texts to other nodes

* refactor: remove prints and rename variable

* style: update comment

* refactor: remove unused optional inputs
2024-08-06 21:35:46 -06:00
karrix
369c1456a9 add: node focusing function 2024-08-05 00:59:52 +08:00
bennykok
01e323b7e2 fix: excessive log 2024-08-03 22:22:06 -07:00
bennykok
db684d044a fix: not yield 2024-08-03 21:56:16 -07:00
BennyKok
8e12803ea1
Retry logic when calling api (#57)
* fix: retry logic, bypass logfire, clean up log

* fix: max_retries and retry_delay_multiplier, do not throw when pass the retry failed
2024-08-01 20:43:21 -07:00
Nick Kao
7585d5049a
Merge pull request #58 from GwonHyeok/main
fix: ExternalLoRA node Make downloaded files reusable
2024-08-01 19:50:59 -07:00
GwonHyeok
772bb09240 fix: ExternalLoRA node Make downloaded files reusable 2024-08-02 10:29:24 +09:00
bennykok
9a7e18e651 fix: fe communication 2024-08-01 10:50:08 -07:00
Hmily
a02c8d237f
fix: Fix request deploy service interface error (#56) 2024-08-01 10:47:45 -07:00
nick
2ba5a0ff3d external lora 2024-08-01 10:43:24 -07:00
bennykok
e0eae1068b fix: make external lora and checkpoint wildcard 2024-07-26 17:39:40 -07:00
bennykok
4f1a80fb64 fix: log issues with websocket 2024-07-22 13:36:39 -07:00
Hmily
b4273b1907
fix: update next version and routing parameter errors (#55) 2024-07-22 09:40:23 -07:00
nick
10ba00e3dd update: external video node 2024-07-20 00:16:39 -07:00
nick
eb40fddb76 Merge branch 'main' of https://github.com/bennykok/comfyui-deploy 2024-07-20 00:16:27 -07:00
nick
3c9d1865ca video node 2024-07-20 00:15:41 -07:00
bennykok
6fa38e9bb8 fix 2024-07-13 19:17:30 -07:00
bennykok
6e4532078f feat: update plugin js 2024-07-12 12:24:10 -07:00
nick
48d21f8d52 feat: audio output from external video node 2024-07-12 11:20:18 -07:00
BennyKok
a2ac1adf01
Streaming support (#52)
* feat: add streaming endpoint

* fix: run issues

* feat(plugin): add dispatchAPIEventData

* fix(plugin): event

* fix: streaming event format

* fix: prompt error

* fix: node_error proxy

* chore(plugin): add log

* custom route

---------

Co-authored-by: nick <kobenkao@gmail.com>
2024-07-11 20:03:41 -07:00
Emmanuel Morales
716790e344
fix(media upload): skip when using the CD_BYPASS_UPLOAD env var (#51)
* fix(image upload): skip when using the CD_BYPASS_UPLOAD env var

* Revert "fix(image upload): skip when using the CD_BYPASS_UPLOAD env var"

This reverts commit 384eda63e6fec6977db3f9e9ba655e0db0719578.

* fix(upload outputs): skip images/gifs/files/mesh when env var is true

The env var is `CD_BYPASS_UPLOAD`.
When that variables is `True`, we don't upload the media to our comfy
deploy s3 bucket.

There are 2 steps.
1. save the file into our s3 bucket
2. save the saving into our database.

When `CD_BYPASS_UPLOAD` is True:
1. Skip the save file into our s3 bucket
2. Skip the save into our database

Previously we were skipping the step 1, but not the step 2. So that is
the reason of why we keep seeing the comfy deploy URL when fetching the
run details:

```
outputs: [
  {
    data:{
      gifs: [
        {
          url: "https://comfy-deploy-output.s3.amazonaws.com/video.mp4"
        }
      ],
      text: [
        "A text that you displayed with show text node"
      ]
    }
  }
]
```

With the new changes we don't save that into our database, and fetching
the details of a run will look like this:
```
outputs: [
  {
    data:{
      text: [
        "A text that you displayed with show text node"
      ]
    }
  }
]
```
2024-07-07 22:04:00 -07:00
nick
c6fe88bf66 new route 2024-06-15 17:29:51 -07:00
bennykok
9b24b12006 fix: file upload issues with cloudflare 2024-06-11 17:42:52 -07:00
bennykok
ff70bbdcec fix: correctly set the file content type for images, webp, jepg, png 2024-05-29 08:59:53 -07:00
haohaocreates
840bea79e8
chore(publish): Add Github Action for Publishing to Comfy Registry (#48) 2024-05-26 23:25:15 +08:00
BennyKok
0f423ce1c3
Update pyproject.toml 2024-05-26 23:21:13 +08:00
haohaocreates
2aa1a446e5
chore(pyproject): Add pyproject.toml for Custom Node Registry (#47) 2024-05-26 23:20:50 +08:00
karrix
07a7feb6ac add: slider number support 2024-05-11 14:50:46 +08:00
bennykok
c5ac1b5f94 perf: turn back on async file upload 2024-05-10 13:08:37 +09:00
bennykok
00d827e232 feat: CD_BYPASS_UPLOAD 2024-05-10 11:36:00 +09:00
karrix
697fd52349 add: bool custom node 2024-05-09 14:26:43 +08:00
karrix
6b9c431df8 add: boolean input and 3d mesh support 2024-05-09 14:25:22 +08:00
bennykok
3c508c7eec feat: redirect queue prompt to iframe event in workspace mode 2024-05-07 00:42:36 +08:00
Nick Kao
409ca6f1dd
Merge pull request #45 from NicholasKao1029/main
video node
2024-05-04 10:19:07 -07:00
nick
df391e867e video node 2024-05-04 10:14:33 -07:00
Nick Kao
c37b8be00a
Merge pull request #44 from NicholasKao1029/main
Video node
2024-04-30 12:56:30 -07:00
nick
a5a73e4209 clean up 2024-04-30 12:55:04 -07:00
nick
c7841deea2 vid node 2024-04-30 12:19:41 -07:00
nick
b0b1d64b6b external video 2024-04-27 13:32:50 -07:00
bennykok
c8dc189f99 fix: external number input 2024-04-25 18:36:24 +08:00
bennykok
cd5e4a5d01 fix: duplicated file upload 2024-04-25 16:14:14 +08:00
bennykok
95c15f095d chore: add file upload time log 2024-04-25 15:55:34 +08:00
nick
b4c27bbbea fix: external lora 2024-04-24 23:27:01 -07:00
bennykok
810aec5135 fix: empty inputs causing run issues 2024-04-25 13:15:55 +08:00
nick
c843926d6e fix: external lora takes in value outside of default 2024-04-24 17:35:09 -07:00
bennykok
797180b5c7 feat(plugin): add external image batch 2024-04-24 21:48:56 +08:00
bennykok
d00ca375a2 chore: bump comfyui json version 2024-04-23 18:44:29 +08:00
bennykok
be5d5d2b54 feat: update deploy method 2024-04-23 14:11:11 +08:00
bennykok
d592a6ba12 feat: refactor deployment code 2024-04-22 00:07:26 +08:00
bennykok
35fed9aa4d fix: failed case marked as success 2024-04-20 01:38:31 +08:00
bennykok
3b6a753472 feat: workspace_mode and window event 2024-04-19 16:01:47 +08:00
bennykok
7d2c521645 chore: clean up custom node log 2024-04-14 15:59:33 +08:00
bennykok
f363b7e871 fix: make sure to skip the temp file. 2024-04-14 00:24:21 +08:00
bennykok
1b25cfdd6c feat: add file hash cache, workflow deployment will be faster
# Conflicts:
#	.gitignore
2024-04-12 19:53:03 +08:00
bennykok
5da56b5507 chore: tweak log 2024-04-12 18:43:24 +08:00
bennykok
03d12e4099 fix!: skipping preview image as save node 2024-04-12 13:34:27 +08:00
bennykok
e66712425d fix: bump comfydeploy deps 2024-04-12 12:28:41 +08:00
bennykok
81f315e14d fix: clashes with ComfyUI manager restart 2024-03-27 13:14:57 -07:00
bennykok
7189f13263 fix: added queue_prompt from event, now input and image will not trigger queue prompt 2024-03-18 14:31:43 -07:00
bennykok
e73392ba8b fix(plugin): external checkpoint fixes 2024-03-08 14:35:44 -08:00
bennykok
1bfbd91708 feat(plugin): add models endpoints for listing out all folder paths for debug usecase 2024-03-03 16:17:43 -08:00
bennykok
a640e1eb79 fix(plugin): kill pending prompt if new streaming prompts comes in 2024-03-02 12:44:42 -08:00
bennykok
011d36edce fix(plugin): default_value to be optional in streaming image input 2024-03-02 12:22:25 -08:00
bennykok
3df549c25c feat: add ws streaming input 2024-03-02 00:47:28 -08:00
bennykok
619a9728c0 fix(plugin): prompt expansion node seed generation error 2024-02-29 19:09:33 -08:00
bennykok
410d03cd2b fix(plugin): output_id is also included in the binary data back 2024-02-29 11:40:36 -08:00
bennykok
32c6d1215b feat(plugin): streaming file type support, webp and jepg, quality settings 2024-02-28 14:28:39 -08:00
bennykok
9e79c434a9 fix(plugin): make sure number input nodes takes down to 0.01 steps and its casted to float 2024-02-28 12:09:18 -08:00
bennykok
19511e55ba fix(plugin): make sure number input nodes takes down to 0.01 steps 2024-02-28 11:59:26 -08:00
bennykok
2d59fd2b1b feat(plugin): update run status for ws request 2024-02-27 19:45:10 -08:00
bennykok
542b72bde5 fix(plugin): deploy login button 2024-02-26 13:09:48 -08:00
bennykok
7b653201ae fix(plugin): update prompt metadata status properly with realtime prompt 2024-02-26 00:09:56 -08:00
bennykok
1c9c32e9e4 fix(plugin): client id wrongly set causing not sending out ws event 2024-02-25 23:52:20 -08:00
bennykok
97096a9035 feat(plugin): send live_status and elapsed_time 2024-02-25 22:48:22 -08:00
bennykok
e87bb63c6f fix(plugin): is_realtime check failed causing everything to not upload 2024-02-25 22:48:22 -08:00
bennykok
a643fa0999 fix(plugin): remove file upload + status update from is_realtime prompt 2024-02-25 17:25:41 -08:00
bennykok
cc31840d41 fix(plugin): comfy_deploy_check_ws_status 2024-02-25 00:18:07 -08:00
bennykok
25e62af24c refactor(plugin): add prompt_metadata types and refactor from dict to data model 2024-02-24 23:57:32 -08:00
bennykok
9d0ded7ecc feat(plugin): display workflow name on deploy
- remove 2 seconds delay
- use comfy deploy for dependency viewer
- display user / org label
- when login with comfy deploy, ensure save and re load the current url
2024-02-24 23:57:32 -08:00
bennykok
ec620dbc53 feat(plugin): load workflow from ws url params 2024-02-24 13:29:56 -08:00
bennykok
45d37879c2 fix: not returning images in websocket output node 2024-02-23 15:09:31 -08:00
bennykok
ddbf6848a7 feat(plugin): add output ws image node 2024-02-23 14:03:12 -08:00
nick
4ce2c98ae9 Merge branch 'license-update-agpl' 2024-02-19 08:52:29 -08:00
bennykok
6e068590a0 chore: bump comfyui-json version 2024-02-19 18:53:50 +08:00
38 changed files with 5789 additions and 1283 deletions

21
.github/workflows/publish.yml vendored Normal file
View File

@ -0,0 +1,21 @@
name: Publish to Comfy registry
on:
workflow_dispatch:
push:
branches:
- main
paths:
- "pyproject.toml"
jobs:
publish-node:
name: Publish Custom Node to registry
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Publish Custom Node
uses: Comfy-Org/publish-node-action@main
with:
## Add your own personal access token to your Github Repository secrets and reference it here.
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}

1
.gitignore vendored
View File

@ -1,2 +1,3 @@
__pycache__
.DS_Store
file-hash-cache.json

View File

@ -1,504 +0,0 @@
from typing import Union, Optional, Dict, List
from pydantic import BaseModel, Field, field_validator
from fastapi import FastAPI, HTTPException, WebSocket, BackgroundTasks, WebSocketDisconnect
from fastapi.responses import JSONResponse
from fastapi.logger import logger as fastapi_logger
import os
from enum import Enum
import json
import subprocess
import time
from contextlib import asynccontextmanager
import asyncio
import threading
import signal
import logging
from fastapi.logger import logger as fastapi_logger
import requests
from urllib.parse import parse_qs
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.types import ASGIApp, Scope, Receive, Send
from concurrent.futures import ThreadPoolExecutor
# executor = ThreadPoolExecutor(max_workers=5)
gunicorn_error_logger = logging.getLogger("gunicorn.error")
gunicorn_logger = logging.getLogger("gunicorn")
uvicorn_access_logger = logging.getLogger("uvicorn.access")
uvicorn_access_logger.handlers = gunicorn_error_logger.handlers
fastapi_logger.handlers = gunicorn_error_logger.handlers
if __name__ != "__main__":
fastapi_logger.setLevel(gunicorn_logger.level)
else:
fastapi_logger.setLevel(logging.DEBUG)
logger = logging.getLogger("uvicorn")
logger.setLevel(logging.INFO)
last_activity_time = time.time()
global_timeout = 60 * 4
machine_id_websocket_dict = {}
machine_id_status = {}
fly_instance_id = os.environ.get('FLY_ALLOC_ID', 'local').split('-')[0]
class FlyReplayMiddleware(BaseHTTPMiddleware):
"""
If the wrong instance was picked by the fly.io load balancer we use the fly-replay header
to repeat the request again on the right instance.
This only works if the right instance is provided as a query_string parameter.
"""
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
query_string = scope.get('query_string', b'').decode()
query_params = parse_qs(query_string)
target_instance = query_params.get(
'fly_instance_id', [fly_instance_id])[0]
async def send_wrapper(message):
if target_instance != fly_instance_id:
if message['type'] == 'websocket.close' and 'Invalid session' in message['reason']:
# fly.io only seems to look at the fly-replay header if websocket is accepted
message = {'type': 'websocket.accept'}
if 'headers' not in message:
message['headers'] = []
message['headers'].append(
[b'fly-replay', f'instance={target_instance}'.encode()])
await send(message)
await self.app(scope, receive, send_wrapper)
async def check_inactivity():
global last_activity_time
while True:
# logger.info("Checking inactivity...")
if time.time() - last_activity_time > global_timeout:
if len(machine_id_status) == 0:
# The application has been inactive for more than 60 seconds.
# Scale it down to zero here.
logger.info(
f"No activity for {global_timeout} seconds, exiting...")
# os._exit(0)
os.kill(os.getpid(), signal.SIGINT)
break
else:
pass
# logger.info(f"Timeout but still in progress")
await asyncio.sleep(1) # Check every second
@asynccontextmanager
async def lifespan(app: FastAPI):
thread = run_in_new_thread(check_inactivity())
yield
logger.info("Cancelling")
#
app = FastAPI(lifespan=lifespan)
app.add_middleware(FlyReplayMiddleware)
# MODAL_ORG = os.environ.get("MODAL_ORG")
@app.get("/")
def read_root():
global last_activity_time
last_activity_time = time.time()
logger.info(f"Extended inactivity time to {global_timeout}")
return {"Hello": "World"}
# create a post route called /create takes in a json of example
# {
# name: "my first image",
# deps: {
# "comfyui": "d0165d819afe76bd4e6bdd710eb5f3e571b6a804",
# "git_custom_nodes": {
# "https://github.com/cubiq/ComfyUI_IPAdapter_plus": {
# "hash": "2ca0c6dd0b2ad64b1c480828638914a564331dcd",
# "disabled": true
# },
# "https://github.com/ltdrdata/ComfyUI-Manager.git": {
# "hash": "9c86f62b912f4625fe2b929c7fc61deb9d16f6d3",
# "disabled": false
# },
# },
# "file_custom_nodes": []
# }
# }
class GitCustomNodes(BaseModel):
hash: str
disabled: bool
class FileCustomNodes(BaseModel):
filename: str
disabled: bool
class Snapshot(BaseModel):
comfyui: str
git_custom_nodes: Dict[str, GitCustomNodes]
file_custom_nodes: List[FileCustomNodes]
class Model(BaseModel):
name: str
type: str
base: str
save_path: str
description: str
reference: str
filename: str
url: str
class GPUType(str, Enum):
T4 = "T4"
A10G = "A10G"
A100 = "A100"
L4 = "L4"
class Item(BaseModel):
machine_id: str
name: str
snapshot: Snapshot
models: List[Model]
callback_url: str
gpu: GPUType = Field(default=GPUType.T4)
@field_validator('gpu')
@classmethod
def check_gpu(cls, value):
if value not in GPUType.__members__:
raise ValueError(
f"Invalid GPU option. Choose from: {', '.join(GPUType.__members__.keys())}")
return GPUType(value)
@app.websocket("/ws/{machine_id}")
async def websocket_endpoint(websocket: WebSocket, machine_id: str):
await websocket.accept()
machine_id_websocket_dict[machine_id] = websocket
# Send existing logs
if machine_id in machine_logs_cache:
combined_logs = "\n".join(
log_entry['logs'] for log_entry in machine_logs_cache[machine_id])
await websocket.send_text(json.dumps({"event": "LOGS", "data": {
"machine_id": machine_id,
"logs": combined_logs,
"timestamp": time.time()
}}))
try:
while True:
data = await websocket.receive_text()
global last_activity_time
last_activity_time = time.time()
logger.info(f"Extended inactivity time to {global_timeout}")
# You can handle received messages here if needed
except WebSocketDisconnect:
if machine_id in machine_id_websocket_dict:
machine_id_websocket_dict.pop(machine_id)
# @app.get("/test")
# async def test():
# machine_id_status["123"] = True
# global last_activity_time
# last_activity_time = time.time()
# logger.info(f"Extended inactivity time to {global_timeout}")
# await asyncio.sleep(10)
# machine_id_status["123"] = False
# machine_id_status.pop("123")
# return {"Hello": "World"}
@app.post("/create")
async def create_machine(item: Item):
global last_activity_time
last_activity_time = time.time()
logger.info(f"Extended inactivity time to {global_timeout}")
if item.machine_id in machine_id_status and machine_id_status[item.machine_id]:
return JSONResponse(status_code=400, content={"error": "Build already in progress."})
# Run the building logic in a separate thread
# future = executor.submit(build_logic, item)
task = asyncio.create_task(build_logic(item))
return JSONResponse(status_code=200, content={"message": "Build Queued", "build_machine_instance_id": fly_instance_id})
class StopAppItem(BaseModel):
machine_id: str
def find_app_id(app_list, app_name):
for app in app_list:
if app['Name'] == app_name:
return app['App ID']
return None
@app.post("/stop-app")
async def stop_app(item: StopAppItem):
# cmd = f"modal app list | grep {item.machine_id} | awk -F '│' '{{print $2}}'"
cmd = f"modal app list --json"
env = os.environ.copy()
env["COLUMNS"] = "10000" # Set the width to a large value
find_id_process = await asyncio.subprocess.create_subprocess_shell(cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env)
await find_id_process.wait()
stdout, stderr = await find_id_process.communicate()
if stdout:
app_id = stdout.decode().strip()
app_list = json.loads(app_id)
app_id = find_app_id(app_list, item.machine_id)
logger.info(f"cp_process stdout: {app_id}")
if stderr:
logger.info(f"cp_process stderr: {stderr.decode()}")
cp_process = await asyncio.subprocess.create_subprocess_exec("modal", "app", "stop", app_id,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,)
await cp_process.wait()
logger.info(f"Stopping app {item.machine_id}")
stdout, stderr = await cp_process.communicate()
if stdout:
logger.info(f"cp_process stdout: {stdout.decode()}")
if stderr:
logger.info(f"cp_process stderr: {stderr.decode()}")
if cp_process.returncode == 0:
return JSONResponse(status_code=200, content={"status": "success"})
else:
return JSONResponse(status_code=500, content={"status": "error", "error": stderr.decode()})
# Initialize the logs cache
machine_logs_cache = {}
async def build_logic(item: Item):
# Deploy to modal
folder_path = f"/app/builds/{item.machine_id}"
machine_id_status[item.machine_id] = True
# Ensure the os path is same as the current directory
# os.chdir(os.path.dirname(os.path.realpath(__file__)))
# print(
# f"builder - Current working directory: {os.getcwd()}"
# )
# Copy the app template
# os.system(f"cp -r template {folder_path}")
cp_process = await asyncio.subprocess.create_subprocess_exec("cp", "-r", "/app/src/template", folder_path)
await cp_process.wait()
# Write the config file
config = {
"name": item.name,
"deploy_test": os.environ.get("DEPLOY_TEST_FLAG", "False"),
"gpu": item.gpu,
"civitai_token": os.environ.get("CIVITAI_TOKEN", "")
}
with open(f"{folder_path}/config.py", "w") as f:
f.write("config = " + json.dumps(config))
with open(f"{folder_path}/data/snapshot.json", "w") as f:
f.write(item.snapshot.json())
with open(f"{folder_path}/data/models.json", "w") as f:
models_json_list = [model.dict() for model in item.models]
models_json_string = json.dumps(models_json_list)
f.write(models_json_string)
# os.chdir(folder_path)
# process = subprocess.Popen(f"modal deploy {folder_path}/app.py", stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=True)
process = await asyncio.subprocess.create_subprocess_shell(
f"modal deploy app.py",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=folder_path,
env={**os.environ, "COLUMNS": "10000"}
)
url = None
if item.machine_id not in machine_logs_cache:
machine_logs_cache[item.machine_id] = []
machine_logs = machine_logs_cache[item.machine_id]
url_queue = asyncio.Queue()
async def read_stream(stream, isStderr, url_queue: asyncio.Queue):
while True:
line = await stream.readline()
if line:
l = line.decode('utf-8').strip()
if l == "":
continue
if not isStderr:
logger.info(l)
machine_logs.append({
"logs": l,
"timestamp": time.time()
})
if item.machine_id in machine_id_websocket_dict:
await machine_id_websocket_dict[item.machine_id].send_text(json.dumps({"event": "LOGS", "data": {
"machine_id": item.machine_id,
"logs": l,
"timestamp": time.time()
}}))
if "Created comfyui_api =>" in l or ((l.startswith("https://") or l.startswith("")) and l.endswith(".modal.run")):
if "Created comfyui_api =>" in l:
url = l.split("=>")[1].strip()
# making sure it is a url
elif "comfyui-api" in l:
# Some case it only prints the url on a blank line
if l.startswith(""):
url = l.split("")[1].strip()
else:
url = l
if url:
machine_logs.append({
"logs": f"App image built, url: {url}",
"timestamp": time.time()
})
await url_queue.put(url)
if item.machine_id in machine_id_websocket_dict:
await machine_id_websocket_dict[item.machine_id].send_text(json.dumps({"event": "LOGS", "data": {
"machine_id": item.machine_id,
"logs": f"App image built, url: {url}",
"timestamp": time.time()
}}))
await machine_id_websocket_dict[item.machine_id].send_text(json.dumps({"event": "FINISHED", "data": {
"status": "succuss",
}}))
else:
# is error
logger.error(l)
machine_logs.append({
"logs": l,
"timestamp": time.time()
})
if item.machine_id in machine_id_websocket_dict:
await machine_id_websocket_dict[item.machine_id].send_text(json.dumps({"event": "LOGS", "data": {
"machine_id": item.machine_id,
"logs": l,
"timestamp": time.time()
}}))
await machine_id_websocket_dict[item.machine_id].send_text(json.dumps({"event": "FINISHED", "data": {
"status": "failed",
}}))
else:
break
stdout_task = asyncio.create_task(
read_stream(process.stdout, False, url_queue))
stderr_task = asyncio.create_task(
read_stream(process.stderr, True, url_queue))
await asyncio.wait([stdout_task, stderr_task])
# Wait for the subprocess to finish
await process.wait()
if not url_queue.empty():
# The queue is not empty, you can get an item
url = await url_queue.get()
# Close the ws connection and also pop the item
if item.machine_id in machine_id_websocket_dict and machine_id_websocket_dict[item.machine_id] is not None:
await machine_id_websocket_dict[item.machine_id].close()
if item.machine_id in machine_id_websocket_dict:
machine_id_websocket_dict.pop(item.machine_id)
if item.machine_id in machine_id_status:
machine_id_status[item.machine_id] = False
# Check for errors
if process.returncode != 0:
logger.info("An error occurred.")
# Send a post request with the json body machine_id to the callback url
machine_logs.append({
"logs": "Unable to build the app image.",
"timestamp": time.time()
})
requests.post(item.callback_url, json={
"machine_id": item.machine_id, "build_log": json.dumps(machine_logs)})
if item.machine_id in machine_logs_cache:
del machine_logs_cache[item.machine_id]
return
# return JSONResponse(status_code=400, content={"error": "Unable to build the app image."})
# app_suffix = "comfyui-app"
if url is None:
machine_logs.append({
"logs": "App image built, but url is None, unable to parse the url.",
"timestamp": time.time()
})
requests.post(item.callback_url, json={
"machine_id": item.machine_id, "build_log": json.dumps(machine_logs)})
if item.machine_id in machine_logs_cache:
del machine_logs_cache[item.machine_id]
return
# return JSONResponse(status_code=400, content={"error": "App image built, but url is None, unable to parse the url."})
# example https://bennykok--my-app-comfyui-app.modal.run/
# my_url = f"https://{MODAL_ORG}--{item.container_id}-{app_suffix}.modal.run"
requests.post(item.callback_url, json={
"machine_id": item.machine_id, "endpoint": url, "build_log": json.dumps(machine_logs)})
if item.machine_id in machine_logs_cache:
del machine_logs_cache[item.machine_id]
logger.info("done")
logger.info(url)
def start_loop(loop):
asyncio.set_event_loop(loop)
loop.run_forever()
def run_in_new_thread(coroutine):
new_loop = asyncio.new_event_loop()
t = threading.Thread(target=start_loop, args=(new_loop,), daemon=True)
t.start()
asyncio.run_coroutine_threadsafe(coroutine, new_loop)
return t
if __name__ == "__main__":
import uvicorn
# , log_level="debug"
uvicorn.run("main:app", host="0.0.0.0", port=8080, lifespan="on")

View File

@ -0,0 +1,448 @@
import modal
from typing import Union, Optional, Dict, List
from pydantic import BaseModel, Field, field_validator
from fastapi import FastAPI, HTTPException, WebSocket, BackgroundTasks, WebSocketDisconnect
from fastapi.responses import JSONResponse
from fastapi.logger import logger as fastapi_logger
import os
from enum import Enum
import json
import subprocess
import time
from contextlib import asynccontextmanager
import asyncio
import threading
import signal
import logging
from fastapi.logger import logger as fastapi_logger
import requests
from urllib.parse import parse_qs
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.types import ASGIApp, Scope, Receive, Send
# Modal应用实例
modal_app = modal.App(name="comfyui-deploy")
gunicorn_error_logger = logging.getLogger("gunicorn.error")
gunicorn_logger = logging.getLogger("gunicorn")
uvicorn_access_logger = logging.getLogger("uvicorn.access")
uvicorn_access_logger.handlers = gunicorn_error_logger.handlers
fastapi_logger.handlers = gunicorn_error_logger.handlers
if __name__ != "__main__":
fastapi_logger.setLevel(gunicorn_logger.level)
else:
fastapi_logger.setLevel(logging.DEBUG)
logger = logging.getLogger("uvicorn")
logger.setLevel(logging.INFO)
last_activity_time = time.time()
global_timeout = 60 * 4
machine_id_websocket_dict = {}
machine_id_status = {}
machine_logs_cache = {}
fly_instance_id = os.environ.get('FLY_ALLOC_ID', 'local').split('-')[0]
class FlyReplayMiddleware(BaseHTTPMiddleware):
def __init__(self, app: ASGIApp) -> None:
super().__init__(app)
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
query_string = scope.get('query_string', b'').decode()
query_params = parse_qs(query_string)
target_instance = query_params.get('fly_instance_id', [fly_instance_id])[0]
async def send_wrapper(message):
if target_instance != fly_instance_id:
if message['type'] == 'websocket.close' and 'Invalid session' in message.get('reason', ''):
message = {'type': 'websocket.accept'}
if 'headers' not in message:
message['headers'] = []
message['headers'].append([b'fly-replay', f'instance={target_instance}'.encode()])
await send(message)
await self.app(scope, receive, send_wrapper)
async def check_inactivity():
global last_activity_time
while True:
if time.time() - last_activity_time > global_timeout:
if len(machine_id_status) == 0:
logger.info(f"No activity for {global_timeout} seconds, exiting...")
os.kill(os.getpid(), signal.SIGINT)
break
await asyncio.sleep(1)
@asynccontextmanager
async def lifespan(app: FastAPI):
thread = run_in_new_thread(check_inactivity())
yield
logger.info("Cancelling")
# FastAPI实例
fastapi_app = FastAPI(lifespan=lifespan)
fastapi_app.add_middleware(FlyReplayMiddleware)
class GitCustomNodes(BaseModel):
hash: str
disabled: bool
class FileCustomNodes(BaseModel):
filename: str
disabled: bool
class Snapshot(BaseModel):
comfyui: str
git_custom_nodes: Dict[str, GitCustomNodes]
file_custom_nodes: List[FileCustomNodes]
class Model(BaseModel):
name: str
type: str
base: str
save_path: str
description: str
reference: str
filename: str
url: str
class GPUType(str, Enum):
T4 = "T4"
A10G = "A10G"
A100 = "A100"
L4 = "L4"
class Item(BaseModel):
machine_id: str
name: str
snapshot: Snapshot
models: List[Model]
callback_url: str
gpu: GPUType = Field(default=GPUType.T4)
@field_validator('gpu')
@classmethod
def check_gpu(cls, value):
if value not in GPUType.__members__:
raise ValueError(f"Invalid GPU option. Choose from: {', '.join(GPUType.__members__.keys())}")
return GPUType(value)
class StopAppItem(BaseModel):
machine_id: str
@fastapi_app.get("/")
def read_root():
global last_activity_time
last_activity_time = time.time()
logger.info(f"Extended inactivity time to {global_timeout}")
return {"Hello": "World"}
@fastapi_app.websocket("/ws/{machine_id}")
async def websocket_endpoint(websocket: WebSocket, machine_id: str):
await websocket.accept()
machine_id_websocket_dict[machine_id] = websocket
if machine_id in machine_logs_cache:
combined_logs = "\n".join(log_entry['logs'] for log_entry in machine_logs_cache[machine_id])
await websocket.send_text(json.dumps({
"event": "LOGS",
"data": {
"machine_id": machine_id,
"logs": combined_logs,
"timestamp": time.time()
}
}))
try:
while True:
data = await websocket.receive_text()
global last_activity_time
last_activity_time = time.time()
logger.info(f"Extended inactivity time to {global_timeout}")
except WebSocketDisconnect:
if machine_id in machine_id_websocket_dict:
del machine_id_websocket_dict[machine_id]
@fastapi_app.post("/create")
async def create_machine(item: Item):
global last_activity_time
last_activity_time = time.time()
logger.info(f"Extended inactivity time to {global_timeout}")
if item.machine_id in machine_id_status and machine_id_status[item.machine_id]:
return JSONResponse(status_code=400, content={"error": "Build already in progress."})
task = asyncio.create_task(build_logic(item))
return JSONResponse(
status_code=200,
content={
"message": "Build Queued",
"build_machine_instance_id": fly_instance_id
}
)
def find_app_id(app_list, app_name):
for app in app_list:
if app['Name'] == app_name:
return app['App ID']
return None
@fastapi_app.post("/stop-app")
async def stop_app(item: StopAppItem):
cmd = f"modal app list --json"
env = os.environ.copy()
env["COLUMNS"] = "10000"
find_id_process = await asyncio.subprocess.create_subprocess_shell(
cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env
)
stdout, stderr = await find_id_process.communicate()
if stdout:
app_list = json.loads(stdout.decode().strip())
app_id = find_app_id(app_list, item.machine_id)
logger.info(f"cp_process stdout: {app_id}")
if stderr:
logger.info(f"cp_process stderr: {stderr.decode()}")
cp_process = await asyncio.subprocess.create_subprocess_exec(
"modal", "app", "stop", app_id,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
await cp_process.wait()
stdout, stderr = await cp_process.communicate()
if stdout:
logger.info(f"cp_process stdout: {stdout.decode()}")
if stderr:
logger.info(f"cp_process stderr: {stderr.decode()}")
if cp_process.returncode == 0:
return JSONResponse(status_code=200, content={"status": "success"})
else:
return JSONResponse(
status_code=500,
content={"status": "error", "error": stderr.decode()}
)
async def build_logic(item: Item):
folder_path = f"/app/builds/{item.machine_id}"
machine_id_status[item.machine_id] = True
cp_process = await asyncio.subprocess.create_subprocess_exec(
"cp", "-r", "/app/src/template", folder_path
)
await cp_process.wait()
config = {
"name": item.name,
"deploy_test": os.environ.get("DEPLOY_TEST_FLAG", "False"),
"gpu": item.gpu,
"civitai_token": os.environ.get("CIVITAI_TOKEN", "833b4ded5c7757a06a803763500bab58")
}
with open(f"{folder_path}/config.py", "w") as f:
f.write("config = " + json.dumps(config))
with open(f"{folder_path}/data/snapshot.json", "w") as f:
f.write(item.snapshot.json())
with open(f"{folder_path}/data/models.json", "w") as f:
models_json_list = [model.dict() for model in item.models]
f.write(json.dumps(models_json_list))
process = await asyncio.subprocess.create_subprocess_shell(
f"modal deploy app.py",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=folder_path,
env={**os.environ, "COLUMNS": "10000"}
)
if item.machine_id not in machine_logs_cache:
machine_logs_cache[item.machine_id] = []
machine_logs = machine_logs_cache[item.machine_id]
url_queue = asyncio.Queue()
async def read_stream(stream, isStderr, url_queue: asyncio.Queue):
while True:
line = await stream.readline()
if not line:
break
l = line.decode('utf-8').strip()
if not l:
continue
if not isStderr:
logger.info(l)
machine_logs.append({
"logs": l,
"timestamp": time.time()
})
if item.machine_id in machine_id_websocket_dict:
await machine_id_websocket_dict[item.machine_id].send_text(
json.dumps({
"event": "LOGS",
"data": {
"machine_id": item.machine_id,
"logs": l,
"timestamp": time.time()
}
})
)
if "Created comfyui_api =>" in l or ((l.startswith("https://") or l.startswith("")) and l.endswith(".modal.run")):
if "Created comfyui_api =>" in l:
url = l.split("=>")[1].strip()
elif "comfyui-api" in l:
url = l.split("")[1].strip() if l.startswith("") else l
if url:
machine_logs.append({
"logs": f"App image built, url: {url}",
"timestamp": time.time()
})
await url_queue.put(url)
if item.machine_id in machine_id_websocket_dict:
await machine_id_websocket_dict[item.machine_id].send_text(
json.dumps({
"event": "LOGS",
"data": {
"machine_id": item.machine_id,
"logs": f"App image built, url: {url}",
"timestamp": time.time()
}
})
)
await machine_id_websocket_dict[item.machine_id].send_text(
json.dumps({
"event": "FINISHED",
"data": {
"status": "success",
}
})
)
else:
logger.error(l)
machine_logs.append({
"logs": l,
"timestamp": time.time()
})
if item.machine_id in machine_id_websocket_dict:
await machine_id_websocket_dict[item.machine_id].send_text(
json.dumps({
"event": "LOGS",
"data": {
"machine_id": item.machine_id,
"logs": l,
"timestamp": time.time()
}
})
)
await machine_id_websocket_dict[item.machine_id].send_text(
json.dumps({
"event": "FINISHED",
"data": {
"status": "failed",
}
})
)
stdout_task = asyncio.create_task(read_stream(process.stdout, False, url_queue))
stderr_task = asyncio.create_task(read_stream(process.stderr, True, url_queue))
await asyncio.wait([stdout_task, stderr_task])
await process.wait()
url = await url_queue.get() if not url_queue.empty() else None
if item.machine_id in machine_id_websocket_dict and machine_id_websocket_dict[item.machine_id] is not None:
await machine_id_websocket_dict[item.machine_id].close()
if item.machine_id in machine_id_websocket_dict:
del machine_id_websocket_dict[item.machine_id]
if item.machine_id in machine_id_status:
machine_id_status[item.machine_id] = False
if process.returncode != 0:
logger.info("An error occurred.")
machine_logs.append({
"logs": "Unable to build the app image.",
"timestamp": time.time()
})
requests.post(
item.callback_url,
json={
"machine_id": item.machine_id,
"build_log": json.dumps(machine_logs)
}
)
if item.machine_id in machine_logs_cache:
del machine_logs_cache[item.machine_id]
return
if url is None:
machine_logs.append({
"logs": "App image built, but url is None, unable to parse the url.",
"timestamp": time.time()
})
requests.post(
item.callback_url,
json={
"machine_id": item.machine_id,
"build_log": json.dumps(machine_logs)
}
)
if item.machine_id in machine_logs_cache:
del machine_logs_cache[item.machine_id]
return
requests.post(
item.callback_url,
json={
"machine_id": item.machine_id,
"endpoint": url,
"build_log": json.dumps(machine_logs)
}
)
if item.machine_id in machine_logs_cache:
del machine_logs_cache[item.machine_id]
logger.info("done")
logger.info(url)
def start_loop(loop):
asyncio.set_event_loop(loop)
loop.run_forever()
def run_in_new_thread(coroutine):
new_loop = asyncio.new_event_loop()
t = threading.Thread(target=start_loop, args=(new_loop,), daemon=True)
t.start()
asyncio.run_coroutine_threadsafe(coroutine, new_loop)
return t
# Modal endpoint
@modal_app.function()
@modal.asgi_app()
def app():
return fastapi_app
if __name__ == "__main__":
import uvicorn
uvicorn.run(fastapi_app, host="0.0.0.0", port=8080, lifespan="on")

View File

@ -307,4 +307,5 @@ def comfyui_app():
},
)()
return make_simple_proxy_app(ProxyContext(config))
proxy_app = make_simple_proxy_app(ProxyContext(config)) # Assign to variable
return proxy_app # Return the variable

View File

@ -0,0 +1,57 @@
import os
import io
import torchaudio
from folder_paths import get_annotated_filepath
class ComfyUIDeployExternalAudio:
RETURN_TYPES = ("AUDIO",)
RETURN_NAMES = ("audio",)
FUNCTION = "load_audio"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"input_id": (
"STRING",
{"multiline": False, "default": "input_audio"},
),
"audio_file": ("STRING", {"default": ""}),
},
"optional": {
"default_value": ("AUDIO",),
"display_name": (
"STRING",
{"multiline": False, "default": ""},
),
"description": (
"STRING",
{"multiline": False, "default": ""},
),
}
}
@classmethod
def VALIDATE_INPUTS(s, audio_file, **kwargs):
return True
def load_audio(self, input_id, audio_file, default_value=None, display_name=None, description=None):
if audio_file and audio_file != "":
if audio_file.startswith(('http://', 'https://')):
# Handle URL input
import requests
response = requests.get(audio_file)
audio_data = io.BytesIO(response.content)
waveform, sample_rate = torchaudio.load(audio_data)
else:
# Handle local file
audio_path = get_annotated_filepath(audio_file)
waveform, sample_rate = torchaudio.load(audio_path)
audio = {"waveform": waveform.unsqueeze(0), "sample_rate": sample_rate}
return (audio,)
else:
return (default_value,)
NODE_CLASS_MAPPINGS = {"ComfyUIDeployExternalAudio": ComfyUIDeployExternalAudio}
NODE_DISPLAY_NAME_MAPPINGS = {"ComfyUIDeployExternalAudio": "External Audio (ComfyUI Deploy)"}

View File

@ -0,0 +1,35 @@
class ComfyUIDeployExternalBoolean:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"input_id": (
"STRING",
{"multiline": False, "default": "input_bool"},
),
"default_value": ("BOOLEAN", {"default": False})
},
"optional": {
"display_name": (
"STRING",
{"multiline": False, "default": ""},
),
"description": (
"STRING",
{"multiline": True, "default": ""},
),
}
}
RETURN_TYPES = ("BOOLEAN",)
RETURN_NAMES = ("bool_value",)
FUNCTION = "run"
def run(self, input_id, default_value=None, display_name=None, description=None):
print(f"Node '{input_id}' processing with switch set to {default_value}")
return [default_value]
NODE_CLASS_MAPPINGS = {"ComfyUIDeployExternalBoolean": ComfyUIDeployExternalBoolean}
NODE_DISPLAY_NAME_MAPPINGS = {"ComfyUIDeployExternalBoolean": "External Boolean (ComfyUI Deploy)"}

View File

@ -5,6 +5,12 @@ import torch
import folder_paths
from tqdm import tqdm
class AnyType(str):
def __ne__(self, __value: object) -> bool:
return False
WILDCARD = AnyType("*")
class ComfyUIDeployExternalCheckpoint:
@classmethod
def INPUT_TYPES(s):
@ -16,23 +22,31 @@ class ComfyUIDeployExternalCheckpoint:
),
},
"optional": {
"default_checkpoint_name": (folder_paths.get_filename_list("checkpoints"), ),
"default_value": (folder_paths.get_filename_list("checkpoints"), ),
"display_name": (
"STRING",
{"multiline": False, "default": ""},
),
"description": (
"STRING",
{"multiline": True, "default": ""},
),
}
}
RETURN_TYPES = (folder_paths.get_filename_list("checkpoints"),)
RETURN_TYPES = (WILDCARD,)
RETURN_NAMES = ("path",)
FUNCTION = "run"
CATEGORY = "deploy"
def run(self, input_id, default_checkpoint_name=None):
def run(self, input_id, default_value=None, display_name=None, description=None):
import requests
import os
import uuid
if input_id and input_id.startswith('http'):
if default_value.startswith('http'):
unique_filename = str(uuid.uuid4()) + ".safetensors"
print(unique_filename)
print(folder_paths.folder_names_and_paths["checkpoints"][0][0])
@ -59,7 +73,7 @@ class ComfyUIDeployExternalCheckpoint:
out_file.write(chunk)
return (unique_filename,)
else:
return (default_checkpoints_name,)
return (default_value,)
NODE_CLASS_MAPPINGS = {

View File

@ -0,0 +1,108 @@
from PIL import Image, ImageOps
import numpy as np
import torch
import folder_paths
class AnyType(str):
def __ne__(self, __value: object) -> bool:
return False
WILDCARD = AnyType("*")
class ComfyUIDeployExternalFaceModel:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"input_id": (
"STRING",
{"multiline": False, "default": "input_reactor_face_model"},
),
},
"optional": {
"default_face_model_name": (
"STRING",
{"multiline": False, "default": ""},
),
"face_model_save_name": ( # if `default_face_model_name` is a link to download a file, we will attempt to save it with this name
"STRING",
{"multiline": False, "default": ""},
),
"display_name": (
"STRING",
{"multiline": False, "default": ""},
),
"description": (
"STRING",
{"multiline": True, "default": ""},
),
"face_model_url": (
"STRING",
{"multiline": False, "default": ""},
),
},
}
RETURN_TYPES = (WILDCARD,)
RETURN_NAMES = ("path",)
FUNCTION = "run"
CATEGORY = "deploy"
def run(
self,
input_id,
default_face_model_name=None,
face_model_save_name=None,
display_name=None,
description=None,
face_model_url=None,
):
import requests
import os
import uuid
if face_model_url and face_model_url.startswith("http"):
if face_model_save_name:
existing_face_models = folder_paths.get_filename_list("reactor/faces")
# Check if face_model_save_name exists in the list
if face_model_save_name in existing_face_models:
print(f"using face model: {face_model_save_name}")
return (face_model_save_name,)
else:
face_model_save_name = str(uuid.uuid4()) + ".safetensors"
print(face_model_save_name)
print(folder_paths.folder_names_and_paths["reactor/faces"][0][0])
destination_path = os.path.join(
folder_paths.folder_names_and_paths["reactor/faces"][0][0],
face_model_save_name,
)
print(destination_path)
print(
"Downloading external face model - "
+ face_model_url
+ " to "
+ destination_path
)
response = requests.get(
face_model_url,
headers={"User-Agent": "Mozilla/5.0"},
allow_redirects=True,
)
with open(destination_path, "wb") as out_file:
out_file.write(response.content)
return (face_model_save_name,)
else:
print(f"using face model: {default_face_model_name}")
return (default_face_model_name,)
NODE_CLASS_MAPPINGS = {"ComfyUIDeployExternalFaceModel": ComfyUIDeployExternalFaceModel}
NODE_DISPLAY_NAME_MAPPINGS = {
"ComfyUIDeployExternalFaceModel": "External Face Model (ComfyUI Deploy)"
}

View File

@ -15,6 +15,15 @@ class ComfyUIDeployExternalImage:
},
"optional": {
"default_value": ("IMAGE",),
"display_name": (
"STRING",
{"multiline": False, "default": ""},
),
"description": (
"STRING",
{"multiline": False, "default": ""},
),
"default_value_url": ("STRING", {"image_preview": True, "default": ""}),
}
}
@ -25,32 +34,44 @@ class ComfyUIDeployExternalImage:
CATEGORY = "image"
def run(self, input_id, default_value=None):
def run(self, input_id, default_value=None, display_name=None, description=None, default_value_url=None):
image = default_value
try:
if input_id.startswith('http'):
import requests
from io import BytesIO
print("Fetching image from url: ", input_id)
response = requests.get(input_id)
image = Image.open(BytesIO(response.content))
elif input_id.startswith('data:image/png;base64,') or input_id.startswith('data:image/jpeg;base64,') or input_id.startswith('data:image/jpg;base64,'):
import base64
from io import BytesIO
print("Decoding base64 image")
base64_image = input_id[input_id.find(",")+1:]
decoded_image = base64.b64decode(base64_image)
image = Image.open(BytesIO(decoded_image))
else:
raise ValueError("Invalid image url provided.")
image = ImageOps.exif_transpose(image)
image = image.convert("RGB")
image = np.array(image).astype(np.float32) / 255.0
image = torch.from_numpy(image)[None,]
return [image]
except:
return [image]
# Try both input_id and default_value_url
urls_to_try = [url for url in [input_id, default_value_url] if url]
print(default_value_url)
for url in urls_to_try:
try:
if url.startswith('http'):
import requests
from io import BytesIO
print(f"Fetching image from url: {url}")
response = requests.get(url)
image = Image.open(BytesIO(response.content))
break
elif url.startswith(('data:image/png;base64,', 'data:image/jpeg;base64,', 'data:image/jpg;base64,')):
import base64
from io import BytesIO
print("Decoding base64 image")
base64_image = url[url.find(",")+1:]
decoded_image = base64.b64decode(base64_image)
image = Image.open(BytesIO(decoded_image))
break
except:
continue
if image is not None:
try:
image = ImageOps.exif_transpose(image)
image = image.convert("RGB")
image = np.array(image).astype(np.float32) / 255.0
image = torch.from_numpy(image)[None,]
except:
pass
return [image]
NODE_CLASS_MAPPINGS = {"ComfyUIDeployExternalImage": ComfyUIDeployExternalImage}

View File

@ -15,6 +15,14 @@ class ComfyUIDeployExternalImageAlpha:
},
"optional": {
"default_value": ("IMAGE",),
"display_name": (
"STRING",
{"multiline": False, "default": ""},
),
"description": (
"STRING",
{"multiline": True, "default": ""},
),
}
}
@ -25,7 +33,7 @@ class ComfyUIDeployExternalImageAlpha:
CATEGORY = "image"
def run(self, input_id, default_value=None):
def run(self, input_id, default_value=None, display_name=None, description=None):
image = default_value
try:
if input_id.startswith('http'):

View File

@ -0,0 +1,113 @@
import folder_paths
from PIL import Image, ImageOps
import numpy as np
import torch
import json
import comfy
class ComfyUIDeployExternalImageBatch:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"input_id": (
"STRING",
{"multiline": False, "default": "input_images"},
),
"images": (
"STRING",
{"multiline": False, "default": "[]"},
),
},
"optional": {
"default_value": ("IMAGE",),
"display_name": (
"STRING",
{"multiline": False, "default": ""},
),
"description": (
"STRING",
{"multiline": True, "default": ""},
),
}
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "run"
CATEGORY = "image"
def process_image(self, image):
image = ImageOps.exif_transpose(image)
image = image.convert("RGB")
image = np.array(image).astype(np.float32) / 255.0
image_tensor = torch.from_numpy(image)[None,]
return image_tensor
def run(self, input_id, images=None, default_value=None, display_name=None, description=None):
import requests
import zipfile
import io
processed_images = []
try:
images_list = json.loads(images) # Assuming images is a JSON array string
print(images_list)
for img_input in images_list:
if img_input.startswith('http') and img_input.endswith('.zip'):
print("Fetching zip file from url: ", img_input)
response = requests.get(img_input)
zip_file = zipfile.ZipFile(io.BytesIO(response.content))
for file_name in zip_file.namelist():
if file_name.lower().endswith(('.png', '.jpg', '.jpeg')):
with zip_file.open(file_name) as file:
image = Image.open(file)
image = self.process_image(image)
processed_images.append(image)
elif img_input.startswith('http'):
from io import BytesIO
print("Fetching image from url: ", img_input)
response = requests.get(img_input)
image = Image.open(BytesIO(response.content))
elif img_input.startswith('data:image/png;base64,') or img_input.startswith('data:image/jpeg;base64,') or img_input.startswith('data:image/jpg;base64,'):
import base64
from io import BytesIO
print("Decoding base64 image")
base64_image = img_input[img_input.find(",")+1:]
decoded_image = base64.b64decode(base64_image)
image = Image.open(BytesIO(decoded_image))
else:
raise ValueError("Invalid image url or base64 data provided.")
image = ImageOps.exif_transpose(image)
image = image.convert("RGB")
image = np.array(image).astype(np.float32) / 255.0
image_tensor = torch.from_numpy(image)[None,]
processed_images.append(image_tensor)
except Exception as e:
print(f"Error processing images: {e}")
pass
if default_value is not None and len(images_list) == 0:
processed_images.append(default_value) # Assuming default_value is a pre-processed image tensor
# Resize images if necessary and concatenate from MakeImageBatch in ImpactPack
if processed_images:
base_shape = processed_images[0].shape[1:] # Get the shape of the first image for comparison
batch_tensor = processed_images[0]
for i in range(1, len(processed_images)):
if processed_images[i].shape[1:] != base_shape:
# Resize to match the first image's dimensions
processed_images[i] = comfy.utils.common_upscale(processed_images[i].movedim(-1, 1), base_shape[1], base_shape[0], "lanczos", "center").movedim(1, -1)
batch_tensor = torch.cat((batch_tensor, processed_images[i]), dim=0)
# Concatenate using torch.cat
else:
batch_tensor = None # or handle the empty case as needed
return (batch_tensor, )
NODE_CLASS_MAPPINGS = {"ComfyUIDeployExternalImageBatch": ComfyUIDeployExternalImageBatch}
NODE_DISPLAY_NAME_MAPPINGS = {"ComfyUIDeployExternalImageBatch": "External Image Batch (ComfyUI Deploy)"}

View File

@ -5,6 +5,14 @@ import torch
import folder_paths
class AnyType(str):
def __ne__(self, __value: object) -> bool:
return False
WILDCARD = AnyType("*")
class ComfyUIDeployExternalLora:
@classmethod
def INPUT_TYPES(s):
@ -16,36 +24,86 @@ class ComfyUIDeployExternalLora:
),
},
"optional": {
"default_lora_name": (folder_paths.get_filename_list("loras"), ),
}
"default_lora_name": (folder_paths.get_filename_list("loras"),),
"lora_save_name": ( # if `default_lora_name` is a link to download a file, we will attempt to save it with this name
"STRING",
{"multiline": False, "default": ""},
),
"display_name": (
"STRING",
{"multiline": False, "default": ""},
),
"description": (
"STRING",
{"multiline": True, "default": ""},
),
"lora_url": (
"STRING",
{"multiline": False, "default": ""},
),
},
}
RETURN_TYPES = (folder_paths.get_filename_list("loras"),)
RETURN_TYPES = (WILDCARD,)
RETURN_NAMES = ("path",)
FUNCTION = "run"
CATEGORY = "deploy"
def run(self, input_id, default_lora_name=None):
def run(
self,
input_id,
default_lora_name=None,
lora_save_name=None,
display_name=None,
description=None,
lora_url=None,
):
import requests
import os
import uuid
if input_id and input_id.startswith('http'):
unique_filename = str(uuid.uuid4()) + ".safetensors"
print(unique_filename)
print(folder_paths.folder_names_and_paths["loras"][0][0])
destination_path = os.path.join(folder_paths.folder_names_and_paths["loras"][0][0], unique_filename)
print(destination_path)
print("Downloading external lora - " + input_id + " to " + destination_path)
response = requests.get(input_id, headers={'User-Agent': 'Mozilla/5.0'}, allow_redirects=True)
with open(destination_path, 'wb') as out_file:
out_file.write(response.content)
return (unique_filename,)
if lora_url:
if lora_url.startswith("http"):
if lora_save_name:
existing_loras = folder_paths.get_filename_list("loras")
# Check if lora_save_name exists in the list
if lora_save_name in existing_loras:
print(f"using lora: {lora_save_name}")
return (lora_save_name,)
else:
lora_save_name = str(uuid.uuid4()) + ".safetensors"
print(lora_save_name)
print(folder_paths.folder_names_and_paths["loras"][0][0])
destination_path = os.path.join(
folder_paths.folder_names_and_paths["loras"][0][0], lora_save_name
)
print(destination_path)
print(
"Downloading external lora - "
+ lora_url
+ " to "
+ destination_path
)
response = requests.get(
lora_url,
headers={"User-Agent": "Mozilla/5.0"},
allow_redirects=True,
)
with open(destination_path, "wb") as out_file:
out_file.write(response.content)
print(f"Ext Lora loading: {lora_url} to {lora_save_name}")
return (lora_save_name,)
else:
print(f"Ext Lora loading: {lora_url}")
return (lora_url,)
else:
print(f"Ext Lora loading: {default_lora_name}")
return (default_lora_name,)
NODE_CLASS_MAPPINGS = {"ComfyUIDeployExternalLora": ComfyUIDeployExternalLora}
NODE_DISPLAY_NAME_MAPPINGS = {"ComfyUIDeployExternalLora": "External Lora (ComfyUI Deploy)"}
NODE_DISPLAY_NAME_MAPPINGS = {
"ComfyUIDeployExternalLora": "External Lora (ComfyUI Deploy)"
}

View File

@ -16,7 +16,15 @@ class ComfyUIDeployExternalNumber:
"optional": {
"default_value": (
"FLOAT",
{"multiline": True, "display": "number", "default": 0},
{"multiline": True, "display": "number", "default": 0, "min": -2147483647, "max": 2147483647, "step": 0.01},
),
"display_name": (
"STRING",
{"multiline": False, "default": ""},
),
"description": (
"STRING",
{"multiline": True, "default": ""},
),
}
}
@ -28,10 +36,13 @@ class ComfyUIDeployExternalNumber:
CATEGORY = "number"
def run(self, input_id, default_value=None):
if not input_id or not input_id.strip().isdigit():
def run(self, input_id, default_value=None, display_name=None, description=None):
try:
float_value = float(input_id)
print("my number", float_value)
return [float_value]
except ValueError:
return [default_value]
return [int(input_id)]
NODE_CLASS_MAPPINGS = {"ComfyUIDeployExternalNumber": ComfyUIDeployExternalNumber}

View File

@ -16,7 +16,15 @@ class ComfyUIDeployExternalNumberInt:
"optional": {
"default_value": (
"INT",
{"multiline": True, "display": "number", "default": 0},
{"multiline": True, "display": "number", "min": -2147483647, "max": 2147483647, "default": 0},
),
"display_name": (
"STRING",
{"multiline": False, "default": ""},
),
"description": (
"STRING",
{"multiline": True, "default": ""},
),
}
}
@ -28,8 +36,8 @@ class ComfyUIDeployExternalNumberInt:
CATEGORY = "number"
def run(self, input_id, default_value=None):
if not input_id or not input_id.strip().isdigit():
def run(self, input_id, default_value=None, display_name=None, description=None):
if not input_id or (isinstance(input_id, str) and not input_id.strip().isdigit()):
return [default_value]
return [int(input_id)]

View File

@ -0,0 +1,56 @@
class ComfyUIDeployExternalNumberSlider:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"input_id": (
"STRING",
{"multiline": False, "default": "input_number_slider"},
),
},
"optional": {
"default_value": (
"FLOAT",
{"multiline": True, "display": "number", "min": -2147483647, "max": 2147483647, "default": 0.5, "step": 0.01},
),
"min_value": (
"FLOAT",
{"multiline": True, "display": "number", "min": -2147483647, "max": 2147483647, "default": 0, "step": 0.01},
),
"max_value": (
"FLOAT",
{"multiline": True, "display": "number", "min": -2147483647, "max": 2147483647, "default": 1, "step": 0.01},
),
"display_name": (
"STRING",
{"multiline": False, "default": ""},
),
"description": (
"STRING",
{"multiline": True, "default": ""},
),
}
}
RETURN_TYPES = ("FLOAT",)
RETURN_NAMES = ("value",)
FUNCTION = "run"
CATEGORY = "number"
def run(self, input_id, default_value=None, min_value=0, max_value=1, display_name=None, description=None):
try:
float_value = float(input_id)
if min_value <= float_value <= max_value:
print("my number", float_value)
return [float_value]
else:
print("Number out of range. Returning default value:", default_value)
return [default_value]
except ValueError:
print("Invalid input. Returning default value:", default_value)
return [default_value]
NODE_CLASS_MAPPINGS = {"ComfyUIDeployExternalNumberSlider": ComfyUIDeployExternalNumberSlider}
NODE_DISPLAY_NAME_MAPPINGS = {"ComfyUIDeployExternalNumberSlider": "External Number Slider (ComfyUI Deploy)"}

View File

@ -0,0 +1,53 @@
import re
class StringFunction:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"action": (["append", "replace"], {}),
"tidy_tags": (["yes", "no"], {}),
},
"optional": {
"text_a": ("STRING", {"multiline": True, "dynamicPrompts": False}),
"text_b": ("STRING", {"multiline": True, "dynamicPrompts": False}),
"text_c": ("STRING", {"multiline": True, "dynamicPrompts": False}),
},
}
RETURN_TYPES = ("STRING",)
FUNCTION = "exec"
CATEGORY = "utils"
OUTPUT_NODE = True
def exec(self, action, tidy_tags, text_a="", text_b="", text_c=""):
tidy_tags = tidy_tags == "yes"
out = ""
if action == "append":
out = (", " if tidy_tags else "").join(
filter(None, [text_a, text_b, text_c])
)
else:
if text_c is None:
text_c = ""
if text_b.startswith("/") and text_b.endswith("/"):
regex = text_b[1:-1]
out = re.sub(regex, text_c, text_a)
else:
out = text_a.replace(text_b, text_c)
if tidy_tags:
out = re.sub(r"\s{2,}", " ", out)
out = out.replace(" ,", ",")
out = re.sub(r",{2,}", ",", out)
out = out.strip()
return {"ui": {"text": (out,)}, "result": (out,)}
NODE_CLASS_MAPPINGS = {
"ComfyUIDeployStringCombine": StringFunction,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"ComfyUIDeployStringCombine": "String Combine (ComfyUI Deploy)",
}

View File

@ -18,6 +18,14 @@ class ComfyUIDeployExternalText:
"STRING",
{"multiline": True, "default": ""},
),
"display_name": (
"STRING",
{"multiline": False, "default": ""},
),
"description": (
"STRING",
{"multiline": True, "default": ""},
),
}
}
@ -28,7 +36,7 @@ class ComfyUIDeployExternalText:
CATEGORY = "text"
def run(self, input_id, default_value=None):
def run(self, input_id, default_value=None, display_name=None, description=None):
return [default_value]

View File

@ -0,0 +1,46 @@
class AnyType(str):
def __ne__(self, __value: object) -> bool:
return False
WILDCARD = AnyType("*")
class ComfyUIDeployExternalTextAny:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"input_id": (
"STRING",
{"multiline": False, "default": "input_text"},
),
},
"optional": {
"default_value": (
"STRING",
{"multiline": True, "default": ""},
),
"display_name": (
"STRING",
{"multiline": False, "default": ""},
),
"description": (
"STRING",
{"multiline": True, "default": ""},
),
}
}
RETURN_TYPES = (WILDCARD,)
RETURN_NAMES = ("text",)
FUNCTION = "run"
CATEGORY = "text"
def run(self, input_id, default_value=None, display_name=None, description=None):
return [default_value]
NODE_CLASS_MAPPINGS = {"ComfyUIDeployExternalTextAny": ComfyUIDeployExternalTextAny}
NODE_DISPLAY_NAME_MAPPINGS = {"ComfyUIDeployExternalTextAny": "External Text Any (ComfyUI Deploy)"}

View File

@ -0,0 +1,78 @@
import os
import folder_paths
import uuid
from tqdm import tqdm
video_extensions = ["webm", "mp4", "mkv", "gif"]
class ComfyUIDeployExternalVideo:
@classmethod
def INPUT_TYPES(s):
input_dir = folder_paths.get_input_directory()
files = []
for f in os.listdir(input_dir):
if os.path.isfile(os.path.join(input_dir, f)):
file_parts = f.split(".")
if len(file_parts) > 1 and (file_parts[-1] in video_extensions):
files.append(f)
return {
"required": {
"input_id": (
"STRING",
{"multiline": False, "default": "input_video"},
),
},
"optional": {
"meta_batch": ("VHS_BatchManager",),
"default_value": (sorted(files),),
},
}
CATEGORY = "Video Helper Suite 🎥🅥🅗🅢"
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("video")
FUNCTION = "load_video"
def load_video(self, input_id, default_value):
input_dir = folder_paths.get_input_directory()
if input_id.startswith("http"):
import requests
print("Fetching video from URL: ", input_id)
response = requests.get(input_id, stream=True)
file_size = int(response.headers.get("Content-Length", 0))
file_extension = input_id.split(".")[-1].split("?")[
0
] # Extract extension and handle URLs with parameters
if file_extension not in video_extensions:
file_extension = ".mp4"
unique_filename = str(uuid.uuid4()) + "." + file_extension
video_path = os.path.join(input_dir, unique_filename)
chunk_size = 1024 # 1 Kibibyte
num_bars = int(file_size / chunk_size)
with open(video_path, "wb") as out_file:
for chunk in tqdm(
response.iter_content(chunk_size=chunk_size),
total=num_bars,
unit="KB",
desc="Downloading",
leave=True,
):
out_file.write(chunk)
else:
video_path = os.path.abspath(os.path.join(input_dir, default_value))
return (video_path,)
NODE_CLASS_MAPPINGS = {"ComfyUIDeployExternalVid": ComfyUIDeployExternalVideo}
NODE_DISPLAY_NAME_MAPPINGS = {
"ComfyUIDeployExternalVid": "External Video (ComfyUI Deploy) path"
}

View File

@ -0,0 +1,864 @@
# credit goes to https://github.com/Kosinkadink/ComfyUI-VideoHelperSuite
# Intended to work with https://github.com/NicholasKao1029/ComfyUI-VideoHelperSuite/tree/main
import os
import itertools
import numpy as np
import torch
from typing import Union
from torch import Tensor
import cv2
import psutil
from collections.abc import Mapping
import folder_paths
from comfy.utils import common_upscale
### Utils
import hashlib
from typing import Iterable
import shutil
import subprocess
import re
import uuid
import server
from tqdm import tqdm
BIGMIN = -(2**53 - 1)
BIGMAX = 2**53 - 1
DIMMAX = 8192
def ffmpeg_suitability(path):
try:
version = subprocess.run(
[path, "-version"], check=True, capture_output=True
).stdout.decode("utf-8")
except:
return 0
score = 0
# rough layout of the importance of various features
simple_criterion = [
("libvpx", 20),
("264", 10),
("265", 3),
("svtav1", 5),
("libopus", 1),
]
for criterion in simple_criterion:
if version.find(criterion[0]) >= 0:
score += criterion[1]
# obtain rough compile year from copyright information
copyright_index = version.find("2000-2")
if copyright_index >= 0:
copyright_year = version[copyright_index + 6 : copyright_index + 9]
if copyright_year.isnumeric():
score += int(copyright_year)
return score
if "VHS_FORCE_FFMPEG_PATH" in os.environ:
ffmpeg_path = os.environ.get("VHS_FORCE_FFMPEG_PATH")
else:
ffmpeg_paths = []
try:
from imageio_ffmpeg import get_ffmpeg_exe
imageio_ffmpeg_path = get_ffmpeg_exe()
ffmpeg_paths.append(imageio_ffmpeg_path)
except:
if "VHS_USE_IMAGEIO_FFMPEG" in os.environ:
raise
if "VHS_USE_IMAGEIO_FFMPEG" in os.environ:
ffmpeg_path = imageio_ffmpeg_path
else:
system_ffmpeg = shutil.which("ffmpeg")
if system_ffmpeg is not None:
ffmpeg_paths.append(system_ffmpeg)
if os.path.isfile("ffmpeg"):
ffmpeg_paths.append(os.path.abspath("ffmpeg"))
if os.path.isfile("ffmpeg.exe"):
ffmpeg_paths.append(os.path.abspath("ffmpeg.exe"))
if len(ffmpeg_paths) == 0:
ffmpeg_path = None
elif len(ffmpeg_paths) == 1:
# Evaluation of suitability isn't required, can take sole option
# to reduce startup time
ffmpeg_path = ffmpeg_paths[0]
else:
ffmpeg_path = max(ffmpeg_paths, key=ffmpeg_suitability)
gifski_path = os.environ.get("VHS_GIFSKI", None)
if gifski_path is None:
gifski_path = os.environ.get("JOV_GIFSKI", None)
if gifski_path is None:
gifski_path = shutil.which("gifski")
def is_safe_path(path):
if "VHS_STRICT_PATHS" not in os.environ:
return True
basedir = os.path.abspath(".")
try:
common_path = os.path.commonpath([basedir, path])
except:
# Different drive on windows
return False
return common_path == basedir
def get_sorted_dir_files_from_directory(
directory: str,
skip_first_images: int = 0,
select_every_nth: int = 1,
extensions: Iterable = None,
):
directory = strip_path(directory)
dir_files = os.listdir(directory)
dir_files = sorted(dir_files)
dir_files = [os.path.join(directory, x) for x in dir_files]
dir_files = list(filter(lambda filepath: os.path.isfile(filepath), dir_files))
# filter by extension, if needed
if extensions is not None:
extensions = list(extensions)
new_dir_files = []
for filepath in dir_files:
ext = "." + filepath.split(".")[-1]
if ext.lower() in extensions:
new_dir_files.append(filepath)
dir_files = new_dir_files
# start at skip_first_images
dir_files = dir_files[skip_first_images:]
dir_files = dir_files[0::select_every_nth]
return dir_files
# modified from https://stackoverflow.com/questions/22058048/hashing-a-file-in-python
def calculate_file_hash(filename: str, hash_every_n: int = 1):
# Larger video files were taking >.5 seconds to hash even when cached,
# so instead the modified time from the filesystem is used as a hash
h = hashlib.sha256()
h.update(filename.encode())
h.update(str(os.path.getmtime(filename)).encode())
return h.hexdigest()
prompt_queue = server.PromptServer.instance.prompt_queue
def requeue_workflow_unchecked():
"""Requeues the current workflow without checking for multiple requeues"""
currently_running = prompt_queue.currently_running
(_, _, prompt, extra_data, outputs_to_execute) = next(
iter(currently_running.values())
)
# Ensure batch_managers are marked stale
prompt = prompt.copy()
for uid in prompt:
if prompt[uid]["class_type"] == "VHS_BatchManager":
prompt[uid]["inputs"]["requeue"] = (
prompt[uid]["inputs"].get("requeue", 0) + 1
)
# execution.py has guards for concurrency, but server doesn't.
# TODO: Check that this won't be an issue
number = -server.PromptServer.instance.number
server.PromptServer.instance.number += 1
prompt_id = str(server.uuid.uuid4())
prompt_queue.put((number, prompt_id, prompt, extra_data, outputs_to_execute))
requeue_guard = [None, 0, 0, {}]
def requeue_workflow(requeue_required=(-1, True)):
assert len(prompt_queue.currently_running) == 1
global requeue_guard
(run_number, _, prompt, _, _) = next(iter(prompt_queue.currently_running.values()))
if requeue_guard[0] != run_number:
# Calculate a count of how many outputs are managed by a batch manager
managed_outputs = 0
for bm_uid in prompt:
if prompt[bm_uid]["class_type"] == "VHS_BatchManager":
for output_uid in prompt:
if prompt[output_uid]["class_type"] in ["VHS_VideoCombine"]:
for inp in prompt[output_uid]["inputs"].values():
if inp == [bm_uid, 0]:
managed_outputs += 1
requeue_guard = [run_number, 0, managed_outputs, {}]
requeue_guard[1] = requeue_guard[1] + 1
requeue_guard[3][requeue_required[0]] = requeue_required[1]
if requeue_guard[1] == requeue_guard[2] and max(requeue_guard[3].values()):
requeue_workflow_unchecked()
def get_audio(file, start_time=0, duration=0):
args = [ffmpeg_path, "-i", file]
if start_time > 0:
args += ["-ss", str(start_time)]
if duration > 0:
args += ["-t", str(duration)]
try:
# TODO: scan for sample rate and maintain
res = subprocess.run(
args + ["-f", "f32le", "-"], capture_output=True, check=True
)
audio = torch.frombuffer(bytearray(res.stdout), dtype=torch.float32)
match = re.search(", (\\d+) Hz, (\\w+), ", res.stderr.decode("utf-8"))
except subprocess.CalledProcessError as e:
raise Exception(
f"VHS failed to extract audio from {file}:\n" + e.stderr.decode("utf-8")
)
if match:
ar = int(match.group(1))
# NOTE: Just throwing an error for other channel types right now
# Will deal with issues if they come
ac = {"mono": 1, "stereo": 2}[match.group(2)]
else:
ar = 44100
ac = 2
audio = audio.reshape((-1, ac)).transpose(0, 1).unsqueeze(0)
return {"waveform": audio, "sample_rate": ar}
class LazyAudioMap(Mapping):
def __init__(self, file, start_time, duration):
self.file = file
self.start_time = start_time
self.duration = duration
self._dict = None
def __getitem__(self, key):
if self._dict is None:
self._dict = get_audio(self.file, self.start_time, self.duration)
return self._dict[key]
def __iter__(self):
if self._dict is None:
self._dict = get_audio(self.file, self.start_time, self.duration)
return iter(self._dict)
def __len__(self):
if self._dict is None:
self._dict = get_audio(self.file, self.start_time, self.duration)
return len(self._dict)
def lazy_get_audio(file, start_time=0, duration=0):
return LazyAudioMap(file, start_time, duration)
def lazy_eval(func):
class Cache:
def __init__(self, func):
self.res = None
self.func = func
def get(self):
if self.res is None:
self.res = self.func()
return self.res
cache = Cache(func)
return lambda: cache.get()
def is_url(url):
return url.split("://")[0] in ["http", "https"]
def validate_sequence(path):
# Check if path is a valid ffmpeg sequence that points to at least one file
(path, file) = os.path.split(path)
if not os.path.isdir(path):
return False
match = re.search("%0?\d+d", file)
if not match:
return False
seq = match.group()
if seq == "%d":
seq = "\\\\d+"
else:
seq = "\\\\d{%s}" % seq[1:-1]
file_matcher = re.compile(re.sub("%0?\d+d", seq, file))
for file in os.listdir(path):
if file_matcher.fullmatch(file):
return True
return False
def strip_path(path):
# This leaves whitespace inside quotes and only a single "
# thus ' ""test"' -> '"test'
# consider path.strip(string.whitespace+"\"")
# or weightier re.fullmatch("[\\s\"]*(.+?)[\\s\"]*", path).group(1)
path = path.strip()
if path.startswith('"'):
path = path[1:]
if path.endswith('"'):
path = path[:-1]
return path
def hash_path(path):
if path is None:
return "input"
if is_url(path):
return "url"
return calculate_file_hash(path.strip('"'))
def validate_path(path, allow_none=False, allow_url=True):
if path is None:
return allow_none
if is_url(path):
# Probably not feasible to check if url resolves here
return True if allow_url else "URLs are unsupported for this path"
if not os.path.isfile(path.strip('"')):
return "Invalid file path: {}".format(path)
return True
### Utils
video_extensions = ["webm", "mp4", "mkv", "gif"]
def is_gif(filename) -> bool:
file_parts = filename.split(".")
return len(file_parts) > 1 and file_parts[-1] == "gif"
def target_size(
width, height, force_size, custom_width, custom_height
) -> tuple[int, int]:
if force_size == "Custom":
return (custom_width, custom_height)
elif force_size == "Custom Height":
force_size = "?x" + str(custom_height)
elif force_size == "Custom Width":
force_size = str(custom_width) + "x?"
if force_size != "Disabled":
force_size = force_size.split("x")
if force_size[0] == "?":
width = (width * int(force_size[1])) // height
# Limit to a multple of 8 for latent conversion
width = int(width) + 4 & ~7
height = int(force_size[1])
elif force_size[1] == "?":
height = (height * int(force_size[0])) // width
height = int(height) + 4 & ~7
width = int(force_size[0])
else:
width = int(force_size[0])
height = int(force_size[1])
return (width, height)
def validate_index(
index: int,
length: int = 0,
is_range: bool = False,
allow_negative=False,
allow_missing=False,
) -> int:
# if part of range, do nothing
if is_range:
return index
# otherwise, validate index
# validate not out of range - only when latent_count is passed in
if length > 0 and index > length - 1 and not allow_missing:
raise IndexError(f"Index '{index}' out of range for {length} item(s).")
# if negative, validate not out of range
if index < 0:
if not allow_negative:
raise IndexError(f"Negative indeces not allowed, but was '{index}'.")
conv_index = length + index
if conv_index < 0 and not allow_missing:
raise IndexError(
f"Index '{index}', converted to '{conv_index}' out of range for {length} item(s)."
)
index = conv_index
return index
def convert_to_index_int(
raw_index: str,
length: int = 0,
is_range: bool = False,
allow_negative=False,
allow_missing=False,
) -> int:
try:
return validate_index(
int(raw_index),
length=length,
is_range=is_range,
allow_negative=allow_negative,
allow_missing=allow_missing,
)
except ValueError as e:
raise ValueError(f"Index '{raw_index}' must be an integer.", e)
def convert_str_to_indexes(
indexes_str: str, length: int = 0, allow_missing=False
) -> list[int]:
if not indexes_str:
return []
int_indexes = list(range(0, length))
allow_negative = length > 0
chosen_indexes = []
# parse string - allow positive ints, negative ints, and ranges separated by ':'
groups = indexes_str.split(",")
groups = [g.strip() for g in groups]
for g in groups:
# parse range of indeces (e.g. 2:16)
if ":" in g:
index_range = g.split(":", 2)
index_range = [r.strip() for r in index_range]
start_index = index_range[0]
if len(start_index) > 0:
start_index = convert_to_index_int(
start_index,
length=length,
is_range=True,
allow_negative=allow_negative,
allow_missing=allow_missing,
)
else:
start_index = 0
end_index = index_range[1]
if len(end_index) > 0:
end_index = convert_to_index_int(
end_index,
length=length,
is_range=True,
allow_negative=allow_negative,
allow_missing=allow_missing,
)
else:
end_index = length
# support step as well, to allow things like reversing, every-other, etc.
step = 1
if len(index_range) > 2:
step = index_range[2]
if len(step) > 0:
step = convert_to_index_int(
step,
length=length,
is_range=True,
allow_negative=True,
allow_missing=True,
)
else:
step = 1
# if latents were passed in, base indeces on known latent count
if len(int_indexes) > 0:
chosen_indexes.extend(int_indexes[start_index:end_index][::step])
# otherwise, assume indeces are valid
else:
chosen_indexes.extend(list(range(start_index, end_index, step)))
# parse individual indeces
else:
chosen_indexes.append(
convert_to_index_int(
g,
length=length,
allow_negative=allow_negative,
allow_missing=allow_missing,
)
)
return chosen_indexes
def select_indexes(input_obj: Union[Tensor, list], idxs: list):
if type(input_obj) == Tensor:
return input_obj[idxs]
else:
return [input_obj[i] for i in idxs]
def select_indexes_from_str(
input_obj: Union[Tensor, list], indexes: str, err_if_missing=True, err_if_empty=True
):
real_idxs = convert_str_to_indexes(
indexes, len(input_obj), allow_missing=not err_if_missing
)
if err_if_empty and len(real_idxs) == 0:
raise Exception(f"Nothing was selected based on indexes found in '{indexes}'.")
return select_indexes(input_obj, real_idxs)
###
def cv_frame_generator(
video,
force_rate,
frame_load_cap,
skip_first_frames,
select_every_nth,
meta_batch=None,
unique_id=None,
):
video_cap = cv2.VideoCapture(strip_path(video))
if not video_cap.isOpened():
raise ValueError(f"{video} could not be loaded with cv.")
pbar = None
# extract video metadata
fps = video_cap.get(cv2.CAP_PROP_FPS)
width = int(video_cap.get(cv2.CAP_PROP_FRAME_WIDTH))
height = int(video_cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
total_frames = int(video_cap.get(cv2.CAP_PROP_FRAME_COUNT))
duration = total_frames / fps
# set video_cap to look at start_index frame
total_frame_count = 0
total_frames_evaluated = -1
frames_added = 0
base_frame_time = 1 / fps
prev_frame = None
if force_rate == 0:
target_frame_time = base_frame_time
else:
target_frame_time = 1 / force_rate
yield (width, height, fps, duration, total_frames, target_frame_time)
if meta_batch is not None:
yield min(frame_load_cap, total_frames)
time_offset = target_frame_time - base_frame_time
while video_cap.isOpened():
if time_offset < target_frame_time:
is_returned = video_cap.grab()
# if didn't return frame, video has ended
if not is_returned:
break
time_offset += base_frame_time
if time_offset < target_frame_time:
continue
time_offset -= target_frame_time
# if not at start_index, skip doing anything with frame
total_frame_count += 1
if total_frame_count <= skip_first_frames:
continue
else:
total_frames_evaluated += 1
# if should not be selected, skip doing anything with frame
if total_frames_evaluated % select_every_nth != 0:
continue
# opencv loads images in BGR format (yuck), so need to convert to RGB for ComfyUI use
# follow up: can videos ever have an alpha channel?
# To my testing: No. opencv has no support for alpha
unused, frame = video_cap.retrieve()
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
# convert frame to comfyui's expected format
# TODO: frame contains no exif information. Check if opencv2 has already applied
frame = np.array(frame, dtype=np.float32)
torch.from_numpy(frame).div_(255)
if prev_frame is not None:
inp = yield prev_frame
if inp is not None:
# ensure the finally block is called
return
prev_frame = frame
frames_added += 1
if pbar is not None:
pbar.update_absolute(frames_added, frame_load_cap)
# if cap exists and we've reached it, stop processing frames
if frame_load_cap > 0 and frames_added >= frame_load_cap:
break
if meta_batch is not None:
meta_batch.inputs.pop(unique_id)
meta_batch.has_closed_inputs = True
if prev_frame is not None:
yield prev_frame
def batched(it, n):
while batch := tuple(itertools.islice(it, n)):
yield batch
def batched_vae_encode(images, vae, frames_per_batch):
for batch in batched(images, frames_per_batch):
image_batch = torch.from_numpy(np.array(batch))
yield from vae.encode(image_batch).numpy()
def load_video_cv(
video: str,
force_rate: int,
force_size: str,
custom_width: int,
custom_height: int,
frame_load_cap: int,
skip_first_frames: int,
select_every_nth: int,
meta_batch=None,
unique_id=None,
memory_limit_mb=None,
vae=None,
):
if meta_batch is None or unique_id not in meta_batch.inputs:
gen = cv_frame_generator(
video,
force_rate,
frame_load_cap,
skip_first_frames,
select_every_nth,
meta_batch,
unique_id,
)
(width, height, fps, duration, total_frames, target_frame_time) = next(gen)
if meta_batch is not None:
meta_batch.inputs[unique_id] = (
gen,
width,
height,
fps,
duration,
total_frames,
target_frame_time,
)
meta_batch.total_frames = min(meta_batch.total_frames, next(gen))
else:
(gen, width, height, fps, duration, total_frames, target_frame_time) = (
meta_batch.inputs[unique_id]
)
memory_limit = None
if memory_limit_mb is not None:
memory_limit *= 2**20
else:
# TODO: verify if garbage collection should be performed here.
# leaves ~128 MB unreserved for safety
try:
memory_limit = (
psutil.virtual_memory().available + psutil.swap_memory().free
) - 2**27
except:
print(
"Failed to calculate available memory. Memory load limit has been disabled"
)
if memory_limit is not None:
if vae is not None:
# space required to load as f32, exist as latent with wiggle room, decode to f32
max_loadable_frames = int(
memory_limit // (width * height * 3 * (4 + 4 + 1 / 10))
)
else:
# TODO: use better estimate for when vae is not None
# Consider completely ignoring for load_latent case?
max_loadable_frames = int(memory_limit // (width * height * 3 * (0.1)))
if meta_batch is not None:
if meta_batch.frames_per_batch > max_loadable_frames:
raise RuntimeError(
f"Meta Batch set to {meta_batch.frames_per_batch} frames but only {max_loadable_frames} can fit in memory"
)
gen = itertools.islice(gen, meta_batch.frames_per_batch)
else:
original_gen = gen
gen = itertools.islice(gen, max_loadable_frames)
downscale_ratio = getattr(vae, "downscale_ratio", 8)
frames_per_batch = (1920 * 1080 * 16) // (width * height) or 1
if force_size != "Disabled" or vae is not None:
new_size = target_size(
width, height, force_size, custom_width, custom_height, downscale_ratio
)
if new_size[0] != width or new_size[1] != height:
def rescale(frame):
s = torch.from_numpy(
np.fromiter(frame, np.dtype((np.float32, (height, width, 3))))
)
s = s.movedim(-1, 1)
s = common_upscale(s, new_size[0], new_size[1], "lanczos", "center")
return s.movedim(1, -1).numpy()
gen = itertools.chain.from_iterable(
map(rescale, batched(gen, frames_per_batch))
)
else:
new_size = width, height
if vae is not None:
gen = batched_vae_encode(gen, vae, frames_per_batch)
vw, vh = new_size[0] // downscale_ratio, new_size[1] // downscale_ratio
images = torch.from_numpy(np.fromiter(gen, np.dtype((np.float32, (4, vh, vw)))))
else:
# Some minor wizardry to eliminate a copy and reduce max memory by a factor of ~2
images = torch.from_numpy(
np.fromiter(gen, np.dtype((np.float32, (new_size[1], new_size[0], 3))))
)
if meta_batch is None and memory_limit is not None:
try:
next(original_gen)
raise RuntimeError(
f"Memory limit hit after loading {len(images)} frames. Stopping execution."
)
except StopIteration:
pass
if len(images) == 0:
raise RuntimeError("No frames generated")
# Setup lambda for lazy audio capture
audio = lazy_get_audio(
video,
skip_first_frames * target_frame_time,
frame_load_cap * target_frame_time * select_every_nth,
)
# Adjust target_frame_time for select_every_nth
target_frame_time *= select_every_nth
video_info = {
"source_fps": fps,
"source_frame_count": total_frames,
"source_duration": duration,
"source_width": width,
"source_height": height,
"loaded_fps": 1 / target_frame_time,
"loaded_frame_count": len(images),
"loaded_duration": len(images) * target_frame_time,
"loaded_width": new_size[0],
"loaded_height": new_size[1],
}
if vae is None:
return (images, len(images), audio, video_info, None)
else:
return (None, len(images), audio, video_info, {"samples": images})
# modeled after Video upload node
class ComfyUIDeployExternalVideo:
@classmethod
def INPUT_TYPES(s):
input_dir = folder_paths.get_input_directory()
files = []
for f in os.listdir(input_dir):
if os.path.isfile(os.path.join(input_dir, f)):
file_parts = f.split(".")
if len(file_parts) > 1 and (file_parts[-1] in video_extensions):
files.append(f)
return {"required": {
"input_id": (
"STRING",
{"multiline": False, "default": "input_video"},
),
"force_rate": ("INT", {"default": 0, "min": 0, "max": 60, "step": 1}),
"force_size": (["Disabled", "Custom Height", "Custom Width", "Custom", "256x?", "?x256", "256x256", "512x?", "?x512", "512x512"],),
"custom_width": ("INT", {"default": 512, "min": 0, "max": DIMMAX, "step": 8}),
"custom_height": ("INT", {"default": 512, "min": 0, "max": DIMMAX, "step": 8}),
"frame_load_cap": ("INT", {"default": 0, "min": 0, "max": BIGMAX, "step": 1}),
"skip_first_frames": ("INT", {"default": 0, "min": 0, "max": BIGMAX, "step": 1}),
"select_every_nth": ("INT", {"default": 1, "min": 1, "max": BIGMAX, "step": 1}),
},
"optional": {
"meta_batch": ("VHS_BatchManager",),
"vae": ("VAE",),
"default_video": (sorted(files),),
"display_name": (
"STRING",
{"multiline": False, "default": ""},
),
"description": (
"STRING",
{"multiline": True, "default": ""},
),
},
"hidden": {
"unique_id": "UNIQUE_ID"
},
}
CATEGORY = "Video Helper Suite 🎥🅥🅗🅢"
RETURN_TYPES = ("IMAGE", "INT", "AUDIO", "VHS_VIDEOINFO", "LATENT")
RETURN_NAMES = (
"IMAGE",
"frame_count",
"audio",
"video_info",
"LATENT",
)
FUNCTION = "load_video"
def load_video(self, **kwargs):
input_id = kwargs.get("input_id")
force_rate = kwargs.get("force_rate")
force_size = kwargs.get("force_size", "Disabled")
custom_width = kwargs.get("custom_width")
custom_height = kwargs.get("custom_height")
frame_load_cap = kwargs.get("frame_load_cap")
skip_first_frames = kwargs.get("skip_first_frames")
select_every_nth = kwargs.get("select_every_nth")
meta_batch = kwargs.get("meta_batch")
unique_id = kwargs.get("unique_id")
input_dir = folder_paths.get_input_directory()
if input_id.startswith("http"):
import requests
print("Fetching video from URL: ", input_id)
response = requests.get(input_id, stream=True)
file_size = int(response.headers.get("Content-Length", 0))
file_extension = input_id.split(".")[-1].split("?")[
0
] # Extract extension and handle URLs with parameters
if file_extension not in video_extensions:
file_extension = ".mp4"
unique_filename = str(uuid.uuid4()) + "." + file_extension
video_path = os.path.join(input_dir, unique_filename)
chunk_size = 1024 # 1 Kibibyte
num_bars = int(file_size / chunk_size)
with open(video_path, "wb") as out_file:
for chunk in tqdm(
response.iter_content(chunk_size=chunk_size),
total=num_bars,
unit="KB",
desc="Downloading",
leave=True,
):
out_file.write(chunk)
else:
video = kwargs.get("default_video", None)
if video is None:
raise "No default video given and no external video provided"
video_path = folder_paths.get_annotated_filepath(video.strip('"'))
return load_video_cv(
video=video_path,
force_rate=force_rate,
force_size=force_size,
custom_width=custom_width,
custom_height=custom_height,
frame_load_cap=frame_load_cap,
skip_first_frames=skip_first_frames,
select_every_nth=select_every_nth,
meta_batch=meta_batch,
unique_id=unique_id,
)
@classmethod
def IS_CHANGED(s, video, **kwargs):
image_path = folder_paths.get_annotated_filepath(video)
return calculate_file_hash(image_path)
NODE_CLASS_MAPPINGS = {"ComfyUIDeployExternalVideo": ComfyUIDeployExternalVideo}
NODE_DISPLAY_NAME_MAPPINGS = {
"ComfyUIDeployExternalVideo": "External Video (ComfyUI Deploy x VHS)"
}

View File

@ -0,0 +1,66 @@
import folder_paths
from PIL import Image, ImageOps
import numpy as np
import torch
from server import PromptServer, BinaryEventTypes
import asyncio
from globals import streaming_prompt_metadata, max_output_id_length
class ComfyDeployWebscoketImageInput:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"input_id": (
"STRING",
{"multiline": False, "default": "input_id"},
),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}),
},
"optional": {
"default_value": ("IMAGE", ),
"client_id": (
"STRING",
{"multiline": False, "default": ""},
),
}
}
OUTPUT_NODE = True
RETURN_TYPES = ("IMAGE", )
RETURN_NAMES = ("images",)
FUNCTION = "run"
@classmethod
def VALIDATE_INPUTS(s, input_id):
try:
if len(input_id.encode('ascii')) > max_output_id_length:
raise ValueError(f"input_id size is greater than {max_output_id_length} bytes")
except UnicodeEncodeError:
raise ValueError("input_id is not ASCII encodable")
return True
def run(self, input_id, seed, default_value=None ,client_id=None):
# print(streaming_prompt_metadata[client_id].inputs)
if client_id in streaming_prompt_metadata and input_id in streaming_prompt_metadata[client_id].inputs:
if isinstance(streaming_prompt_metadata[client_id].inputs[input_id], Image.Image):
print("Returning image from websocket input")
image = streaming_prompt_metadata[client_id].inputs[input_id]
image = ImageOps.exif_transpose(image)
image = image.convert("RGB")
image = np.array(image).astype(np.float32) / 255.0
image = torch.from_numpy(image)[None,]
return [image]
print("Returning default value")
return [default_value]
NODE_CLASS_MAPPINGS = {"ComfyDeployWebscoketImageInput": ComfyDeployWebscoketImageInput}
NODE_DISPLAY_NAME_MAPPINGS = {"ComfyDeployWebscoketImageInput": "Image Websocket Input (ComfyDeploy)"}

60
comfy-nodes/model_list.py Normal file
View File

@ -0,0 +1,60 @@
import folder_paths
class AnyType(str):
def __ne__(self, __value: object) -> bool:
return False
from os import walk
WILDCARD = AnyType("*")
MODEL_EXTENSIONS = {
"safetensors": "SafeTensors file format",
"ckpt": "Checkpoint file",
"pth": "PyTorch serialized file",
"pkl": "Pickle file",
"onnx": "ONNX file",
}
def fetch_files(path):
for (dirpath, dirnames, filenames) in walk(path):
fs = []
if len(dirnames) > 0:
for dirname in dirnames:
fs.extend(fetch_files(f"{dirpath}/{dirname}"))
for filename in filenames:
# Remove "./models/" from the beginning of dirpath
relative_dirpath = dirpath.replace("./models/", "", 1)
file_path = f"{relative_dirpath}/{filename}"
# Only add files that are known model extensions
file_extension = filename.split('.')[-1].lower()
if file_extension in MODEL_EXTENSIONS:
fs.append(file_path)
return fs
allModels = fetch_files("./models")
class ComfyUIDeployModalList:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"model": (allModels, ),
}
}
RETURN_TYPES = (WILDCARD,)
RETURN_NAMES = ("model",)
FUNCTION = "run"
CATEGORY = "model"
def run(self, model=""):
# Split the model path by '/' and select the last item
model_name = model.split('/')[-1]
return [model_name]
NODE_CLASS_MAPPINGS = {"ComfyUIDeployModelList": ComfyUIDeployModalList}
NODE_DISPLAY_NAME_MAPPINGS = {"ComfyUIDeployModelList": "Model List (ComfyUI Deploy)"}

View File

@ -0,0 +1,92 @@
import os
import json
import numpy as np
from PIL import Image
from PIL.PngImagePlugin import PngInfo
import folder_paths
class ComfyDeployOutputImage:
def __init__(self):
self.output_dir = folder_paths.get_output_directory()
self.type = "output"
self.prefix_append = ""
self.compress_level = 4
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"images": ("IMAGE", {"tooltip": "The images to save."}),
"filename_prefix": (
"STRING",
{
"default": "ComfyUI",
"tooltip": "The prefix for the file to save. This may include formatting information such as %date:yyyy-MM-dd% or %Empty Latent Image.width% to include values from nodes.",
},
),
"file_type": (["png", "jpg", "webp"], {"default": "webp"}),
"quality": ("INT", {"default": 80, "min": 1, "max": 100, "step": 1}),
},
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
}
RETURN_TYPES = ()
FUNCTION = "run"
OUTPUT_NODE = True
CATEGORY = "output"
DESCRIPTION = "Saves the input images to your ComfyUI output directory."
def run(
self,
images,
filename_prefix="ComfyUI",
file_type="png",
quality=80,
prompt=None,
extra_pnginfo=None,
):
filename_prefix += self.prefix_append
full_output_folder, filename, counter, subfolder, filename_prefix = (
folder_paths.get_save_image_path(
filename_prefix, self.output_dir, images[0].shape[1], images[0].shape[0]
)
)
results = list()
for batch_number, image in enumerate(images):
i = 255.0 * image.cpu().numpy()
img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8))
metadata = PngInfo()
if prompt is not None:
metadata.add_text("prompt", json.dumps(prompt))
if extra_pnginfo is not None:
for x in extra_pnginfo:
metadata.add_text(x, json.dumps(extra_pnginfo[x]))
filename_with_batch_num = filename.replace("%batch_num%", str(batch_number))
file = f"{filename_with_batch_num}_{counter:05}_.{file_type}"
file_path = os.path.join(full_output_folder, file)
if file_type == "png":
img.save(
file_path, pnginfo=metadata, compress_level=self.compress_level
)
elif file_type == "jpg":
img.save(file_path, quality=quality, optimize=True)
elif file_type == "webp":
img.save(file_path, quality=quality)
results.append(
{"filename": file, "subfolder": subfolder, "type": self.type}
)
counter += 1
return {"ui": {"images": results}}
NODE_CLASS_MAPPINGS = {"ComfyDeployOutputImage": ComfyDeployOutputImage}
NODE_DISPLAY_NAME_MAPPINGS = {
"ComfyDeployOutputImage": "Image Output (ComfyDeploy)"
}

View File

@ -0,0 +1,71 @@
import folder_paths
from PIL import Image, ImageOps
import numpy as np
import torch
from server import PromptServer, BinaryEventTypes
import asyncio
from globals import send_image, max_output_id_length
class ComfyDeployWebscoketImageOutput:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"output_id": (
"STRING",
{"multiline": False, "default": "output_id"},
),
"images": ("IMAGE", ),
"file_type": (["WEBP", "PNG", "JPEG"], ),
"quality": ("INT", {"default": 80, "min": 1, "max": 100, "step": 1}),
},
"optional": {
"client_id": (
"STRING",
{"multiline": False, "default": ""},
),
}
# "hidden": {"client_id": "CLIENT_ID"},
}
OUTPUT_NODE = True
RETURN_TYPES = ()
RETURN_NAMES = ("text",)
FUNCTION = "run"
CATEGORY = "output"
@classmethod
def VALIDATE_INPUTS(s, output_id):
try:
if len(output_id.encode('ascii')) > max_output_id_length:
raise ValueError(f"output_id size is greater than {max_output_id_length} bytes")
except UnicodeEncodeError:
raise ValueError("output_id is not ASCII encodable")
return True
def run(self, output_id, images, file_type, quality, client_id):
prompt_server = PromptServer.instance
loop = prompt_server.loop
def schedule_coroutine_blocking(target, *args):
future = asyncio.run_coroutine_threadsafe(target(*args), loop)
return future.result() # This makes the call blocking
for tensor in images:
array = 255.0 * tensor.cpu().numpy()
image = Image.fromarray(np.clip(array, 0, 255).astype(np.uint8))
schedule_coroutine_blocking(send_image, [file_type, image, None, quality], client_id, output_id)
print("Image sent")
return {"ui": {}}
NODE_CLASS_MAPPINGS = {"ComfyDeployWebscoketImageOutput": ComfyDeployWebscoketImageOutput}
NODE_DISPLAY_NAME_MAPPINGS = {"ComfyDeployWebscoketImageOutput": "Image Websocket Output (ComfyDeploy)"}

File diff suppressed because it is too large Load Diff

136
globals.py Normal file
View File

@ -0,0 +1,136 @@
import struct
from enum import Enum
import aiohttp
from typing import List, Union, Any, Optional
from PIL import Image, ImageOps
from io import BytesIO
from pydantic import BaseModel as PydanticBaseModel
class BaseModel(PydanticBaseModel):
class Config:
arbitrary_types_allowed = True
class Status(Enum):
NOT_STARTED = "not-started"
RUNNING = "running"
SUCCESS = "success"
FAILED = "failed"
UPLOADING = "uploading"
class StreamingPrompt(BaseModel):
workflow_api: Any
auth_token: str
inputs: dict[str, Union[str, bytes, Image.Image]]
running_prompt_ids: set[str] = set()
status_endpoint: Optional[str]
file_upload_endpoint: Optional[str]
workflow: Any
gpu_event_id: Optional[str] = None
class SimplePrompt(BaseModel):
status_endpoint: Optional[str]
file_upload_endpoint: Optional[str]
token: Optional[str]
workflow_api: dict
status: Status = Status.NOT_STARTED
progress: set = set()
last_updated_node: Optional[str] = None
uploading_nodes: set = set()
done: bool = False
is_realtime: bool = False
start_time: Optional[float] = None
gpu_event_id: Optional[str] = None
sockets = dict()
prompt_metadata: dict[str, SimplePrompt] = {}
streaming_prompt_metadata: dict[str, StreamingPrompt] = {}
class BinaryEventTypes:
PREVIEW_IMAGE = 1
UNENCODED_PREVIEW_IMAGE = 2
max_output_id_length = 24
async def send_image(image_data, sid=None, output_id: str = None):
max_length = max_output_id_length
output_id = output_id[:max_length]
padded_output_id = output_id.ljust(max_length, "\x00")
encoded_output_id = padded_output_id.encode("ascii", "replace")
image_type = image_data[0]
image = image_data[1]
max_size = image_data[2]
quality = image_data[3]
if max_size is not None:
if hasattr(Image, "Resampling"):
resampling = Image.Resampling.BILINEAR
else:
resampling = Image.ANTIALIAS
image = ImageOps.contain(image, (max_size, max_size), resampling)
type_num = 1
if image_type == "JPEG":
type_num = 1
elif image_type == "PNG":
type_num = 2
elif image_type == "WEBP":
type_num = 3
bytesIO = BytesIO()
header = struct.pack(">I", type_num)
# 4 bytes for the type
bytesIO.write(header)
# 10 bytes for the output_id
position_before = bytesIO.tell()
bytesIO.write(encoded_output_id)
position_after = bytesIO.tell()
bytes_written = position_after - position_before
print(f"Bytes written: {bytes_written}")
image.save(bytesIO, format=image_type, quality=quality, compress_level=1)
preview_bytes = bytesIO.getvalue()
await send_bytes(BinaryEventTypes.PREVIEW_IMAGE, preview_bytes, sid=sid)
async def send_socket_catch_exception(function, message):
try:
await function(message)
except (
aiohttp.ClientError,
aiohttp.ClientPayloadError,
ConnectionResetError,
) as err:
print("send error:", err)
def encode_bytes(event, data):
if not isinstance(event, int):
raise RuntimeError(f"Binary event types must be integers, got {event}")
packed = struct.pack(">I", event)
message = bytearray(packed)
message.extend(data)
return message
async def send_bytes(event, data, sid=None):
message = encode_bytes(event, data)
print("sending image to ", event, sid)
if sid is None:
_sockets = list(sockets.values())
for ws in _sockets:
await send_socket_catch_exception(ws.send_bytes, message)
elif sid in sockets:
await send_socket_catch_exception(sockets[sid].send_bytes, message)

View File

@ -58,6 +58,9 @@ if cd_enable_log:
print("** Comfy Deploy logging enabled")
setup()
# Store the original working directory
original_cwd = os.getcwd()
try:
# Get the absolute path of the script's directory
script_dir = os.path.dirname(os.path.abspath(__file__))
@ -67,3 +70,6 @@ try:
print(f"** Comfy Deploy Revision: {current_git_commit}")
except Exception as e:
print(f"** Comfy Deploy failed to get current git commit: {str(e)}")
finally:
# Change back to the original directory
os.chdir(original_cwd)

15
pyproject.toml Normal file
View File

@ -0,0 +1,15 @@
[project]
name = "comfyui-deploy"
description = "Open source comfyui deployment platform, a vercel for generative workflow infra."
version = "1.1.0"
license = { file = "LICENSE" }
dependencies = ["aiofiles", "pydantic", "opencv-python", "imageio-ffmpeg"]
[project.urls]
Repository = "https://github.com/BennyKok/comfyui-deploy"
# Used by Comfy Registry https://comfyregistry.org
[tool.comfy]
PublisherId = "comfydeploy"
DisplayName = "comfyui-deploy"
Icon = ""

View File

@ -1 +1,7 @@
aiofiles
pydantic
opencv-python
imageio-ffmpeg
brotli
tabulate
# logfire

View File

@ -1,4 +0,0 @@
/** @typedef {import('../../../web/scripts/api.js').api} API*/
import { api as _api } from '../../scripts/api.js';
/** @type {API} */
export const api = _api;

View File

@ -1,4 +0,0 @@
/** @typedef {import('../../../web/scripts/app.js').ComfyApp} ComfyApp*/
import { app as _app } from '../../scripts/app.js';
/** @type {ComfyApp} */
export const app = _app;

File diff suppressed because it is too large Load Diff

View File

@ -1,18 +0,0 @@
// /** @typedef {import('../../../web/scripts/api.js').api} API*/
// import { api as _api } from "../../scripts/api.js";
// /** @type {API} */
// export const api = _api;
/** @typedef {typeof import('../../../web/scripts/widgets.js').ComfyWidgets} Widgets*/
import { ComfyWidgets as _ComfyWidgets } from "../../scripts/widgets.js";
/**
* @type {Widgets}
*/
export const ComfyWidgets = _ComfyWidgets;
// import { LGraphNode as _LGraphNode } from "../../types/litegraph.js";
/** @typedef {typeof import('../../../web/types/litegraph.js').LGraphNode} LGraphNode*/
/** @type {LGraphNode}*/
export const LGraphNode = LiteGraph.LGraphNode;

View File

@ -74,7 +74,7 @@
"mitata": "^0.1.6",
"ms": "^2.1.3",
"nanoid": "^5.0.4",
"next": "14.1",
"next": "14.2",
"next-plausible": "^3.12.0",
"next-themes": "^0.2.1",
"next-usequerystate": "^1.13.2",

View File

@ -6,4 +6,5 @@ export const customInputNodes: Record<string, string> = {
ComfyUIDeployExternalNumberInt: "integer",
ComfyUIDeployExternalLora: "string - (public lora download url)",
ComfyUIDeployExternalCheckpoint: "string - (public checkpoints download url)",
ComfyUIDeployExternalFaceModel: "string - (public face model download url)",
};

View File

@ -51,7 +51,9 @@ const createRunRoute = createRoute({
export const registerCreateRunRoute = (app: App) => {
app.openapi(createRunRoute, async (c) => {
const data = c.req.valid("json");
const origin = new URL(c.req.url).origin;
const proto = c.req.headers.get('x-forwarded-proto') || "http";
const host = c.req.headers.get('x-forwarded-host') || c.req.headers.get('host');
const origin = `${proto}://${host}` || new URL(c.req.url).origin;
const apiKeyTokenData = c.get("apiKeyTokenData")!;
const { deployment_id, inputs } = data;

View File

@ -102,7 +102,7 @@ export const createRun = withServerPromise(
let prompt_id: string | undefined = undefined;
const shareData = {
workflow_api: workflow_api,
workflow_api_raw: workflow_api,
status_endpoint: `${origin}/api/update-run`,
file_upload_endpoint: `${origin}/api/file-upload`,
};