SuperRealCo commited on
Commit
8242e7d
·
verified ·
1 Parent(s): bbb06b1

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .ci/update_windows/update.py +151 -0
  2. .ci/update_windows/update_comfyui.bat +8 -0
  3. .ci/update_windows/update_comfyui_stable.bat +8 -0
  4. .ci/windows_base_files/README_VERY_IMPORTANT.txt +34 -0
  5. .ci/windows_base_files/run_cpu.bat +2 -0
  6. .ci/windows_base_files/run_nvidia_gpu.bat +2 -0
  7. .ci/windows_base_files/run_nvidia_gpu_fast_fp16_accumulation.bat +2 -0
  8. .ci/windows_nightly_base_files/run_nvidia_gpu_fast.bat +2 -0
  9. .gitattributes +1 -0
  10. .github/ISSUE_TEMPLATE/bug-report.yml +56 -0
  11. .github/ISSUE_TEMPLATE/config.yml +11 -0
  12. .github/ISSUE_TEMPLATE/feature-request.yml +32 -0
  13. .github/ISSUE_TEMPLATE/user-support.yml +40 -0
  14. .github/workflows/check-line-endings.yml +40 -0
  15. .github/workflows/pullrequest-ci-run.yml +53 -0
  16. .github/workflows/release-webhook.yml +108 -0
  17. .github/workflows/ruff.yml +23 -0
  18. .github/workflows/stable-release.yml +105 -0
  19. .github/workflows/stale-issues.yml +21 -0
  20. .github/workflows/test-build.yml +31 -0
  21. .github/workflows/test-ci.yml +96 -0
  22. .github/workflows/test-launch.yml +45 -0
  23. .github/workflows/test-unit.yml +30 -0
  24. .github/workflows/update-api-stubs.yml +56 -0
  25. .github/workflows/update-version.yml +58 -0
  26. .github/workflows/windows_release_dependencies.yml +71 -0
  27. .github/workflows/windows_release_nightly_pytorch.yml +93 -0
  28. .github/workflows/windows_release_package.yml +102 -0
  29. alembic_db/README.md +4 -0
  30. alembic_db/env.py +64 -0
  31. alembic_db/script.py.mako +28 -0
  32. api_server/__init__.py +0 -0
  33. api_server/routes/__init__.py +0 -0
  34. api_server/routes/internal/README.md +3 -0
  35. api_server/routes/internal/__init__.py +0 -0
  36. api_server/routes/internal/internal_routes.py +73 -0
  37. api_server/services/__init__.py +0 -0
  38. api_server/services/terminal_service.py +60 -0
  39. api_server/utils/file_operations.py +42 -0
  40. app/__init__.py +0 -0
  41. app/app_settings.py +65 -0
  42. app/custom_node_manager.py +145 -0
  43. app/database/db.py +112 -0
  44. app/database/models.py +14 -0
  45. app/frontend_management.py +326 -0
  46. app/logger.py +98 -0
  47. app/model_manager.py +184 -0
  48. app/user_manager.py +436 -0
  49. comfy/checkpoint_pickle.py +13 -0
  50. comfy/cldm/cldm.py +433 -0
.ci/update_windows/update.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pygit2
2
+ from datetime import datetime
3
+ import sys
4
+ import os
5
+ import shutil
6
+ import filecmp
7
+
8
+ def pull(repo, remote_name='origin', branch='master'):
9
+ for remote in repo.remotes:
10
+ if remote.name == remote_name:
11
+ remote.fetch()
12
+ remote_master_id = repo.lookup_reference('refs/remotes/origin/%s' % (branch)).target
13
+ merge_result, _ = repo.merge_analysis(remote_master_id)
14
+ # Up to date, do nothing
15
+ if merge_result & pygit2.GIT_MERGE_ANALYSIS_UP_TO_DATE:
16
+ return
17
+ # We can just fastforward
18
+ elif merge_result & pygit2.GIT_MERGE_ANALYSIS_FASTFORWARD:
19
+ repo.checkout_tree(repo.get(remote_master_id))
20
+ try:
21
+ master_ref = repo.lookup_reference('refs/heads/%s' % (branch))
22
+ master_ref.set_target(remote_master_id)
23
+ except KeyError:
24
+ repo.create_branch(branch, repo.get(remote_master_id))
25
+ repo.head.set_target(remote_master_id)
26
+ elif merge_result & pygit2.GIT_MERGE_ANALYSIS_NORMAL:
27
+ repo.merge(remote_master_id)
28
+
29
+ if repo.index.conflicts is not None:
30
+ for conflict in repo.index.conflicts:
31
+ print('Conflicts found in:', conflict[0].path) # noqa: T201
32
+ raise AssertionError('Conflicts, ahhhhh!!')
33
+
34
+ user = repo.default_signature
35
+ tree = repo.index.write_tree()
36
+ repo.create_commit('HEAD',
37
+ user,
38
+ user,
39
+ 'Merge!',
40
+ tree,
41
+ [repo.head.target, remote_master_id])
42
+ # We need to do this or git CLI will think we are still merging.
43
+ repo.state_cleanup()
44
+ else:
45
+ raise AssertionError('Unknown merge analysis result')
46
+
47
+ pygit2.option(pygit2.GIT_OPT_SET_OWNER_VALIDATION, 0)
48
+ repo_path = str(sys.argv[1])
49
+ repo = pygit2.Repository(repo_path)
50
+ ident = pygit2.Signature('comfyui', 'comfy@ui')
51
+ try:
52
+ print("stashing current changes") # noqa: T201
53
+ repo.stash(ident)
54
+ except KeyError:
55
+ print("nothing to stash") # noqa: T201
56
+ backup_branch_name = 'backup_branch_{}'.format(datetime.today().strftime('%Y-%m-%d_%H_%M_%S'))
57
+ print("creating backup branch: {}".format(backup_branch_name)) # noqa: T201
58
+ try:
59
+ repo.branches.local.create(backup_branch_name, repo.head.peel())
60
+ except:
61
+ pass
62
+
63
+ print("checking out master branch") # noqa: T201
64
+ branch = repo.lookup_branch('master')
65
+ if branch is None:
66
+ try:
67
+ ref = repo.lookup_reference('refs/remotes/origin/master')
68
+ except:
69
+ print("pulling.") # noqa: T201
70
+ pull(repo)
71
+ ref = repo.lookup_reference('refs/remotes/origin/master')
72
+ repo.checkout(ref)
73
+ branch = repo.lookup_branch('master')
74
+ if branch is None:
75
+ repo.create_branch('master', repo.get(ref.target))
76
+ else:
77
+ ref = repo.lookup_reference(branch.name)
78
+ repo.checkout(ref)
79
+
80
+ print("pulling latest changes") # noqa: T201
81
+ pull(repo)
82
+
83
+ if "--stable" in sys.argv:
84
+ def latest_tag(repo):
85
+ versions = []
86
+ for k in repo.references:
87
+ try:
88
+ prefix = "refs/tags/v"
89
+ if k.startswith(prefix):
90
+ version = list(map(int, k[len(prefix):].split(".")))
91
+ versions.append((version[0] * 10000000000 + version[1] * 100000 + version[2], k))
92
+ except:
93
+ pass
94
+ versions.sort()
95
+ if len(versions) > 0:
96
+ return versions[-1][1]
97
+ return None
98
+ latest_tag = latest_tag(repo)
99
+ if latest_tag is not None:
100
+ repo.checkout(latest_tag)
101
+
102
+ print("Done!") # noqa: T201
103
+
104
+ self_update = True
105
+ if len(sys.argv) > 2:
106
+ self_update = '--skip_self_update' not in sys.argv
107
+
108
+ update_py_path = os.path.realpath(__file__)
109
+ repo_update_py_path = os.path.join(repo_path, ".ci/update_windows/update.py")
110
+
111
+ cur_path = os.path.dirname(update_py_path)
112
+
113
+
114
+ req_path = os.path.join(cur_path, "current_requirements.txt")
115
+ repo_req_path = os.path.join(repo_path, "requirements.txt")
116
+
117
+
118
+ def files_equal(file1, file2):
119
+ try:
120
+ return filecmp.cmp(file1, file2, shallow=False)
121
+ except:
122
+ return False
123
+
124
+ def file_size(f):
125
+ try:
126
+ return os.path.getsize(f)
127
+ except:
128
+ return 0
129
+
130
+
131
+ if self_update and not files_equal(update_py_path, repo_update_py_path) and file_size(repo_update_py_path) > 10:
132
+ shutil.copy(repo_update_py_path, os.path.join(cur_path, "update_new.py"))
133
+ exit()
134
+
135
+ if not os.path.exists(req_path) or not files_equal(repo_req_path, req_path):
136
+ import subprocess
137
+ try:
138
+ subprocess.check_call([sys.executable, '-s', '-m', 'pip', 'install', '-r', repo_req_path])
139
+ shutil.copy(repo_req_path, req_path)
140
+ except:
141
+ pass
142
+
143
+
144
+ stable_update_script = os.path.join(repo_path, ".ci/update_windows/update_comfyui_stable.bat")
145
+ stable_update_script_to = os.path.join(cur_path, "update_comfyui_stable.bat")
146
+
147
+ try:
148
+ if not file_size(stable_update_script_to) > 10:
149
+ shutil.copy(stable_update_script, stable_update_script_to)
150
+ except:
151
+ pass
.ci/update_windows/update_comfyui.bat ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ @echo off
2
+ ..\python_embeded\python.exe .\update.py ..\ComfyUI\
3
+ if exist update_new.py (
4
+ move /y update_new.py update.py
5
+ echo Running updater again since it got updated.
6
+ ..\python_embeded\python.exe .\update.py ..\ComfyUI\ --skip_self_update
7
+ )
8
+ if "%~1"=="" pause
.ci/update_windows/update_comfyui_stable.bat ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ @echo off
2
+ ..\python_embeded\python.exe .\update.py ..\ComfyUI\ --stable
3
+ if exist update_new.py (
4
+ move /y update_new.py update.py
5
+ echo Running updater again since it got updated.
6
+ ..\python_embeded\python.exe .\update.py ..\ComfyUI\ --skip_self_update --stable
7
+ )
8
+ if "%~1"=="" pause
.ci/windows_base_files/README_VERY_IMPORTANT.txt ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ HOW TO RUN:
2
+
3
+ if you have a NVIDIA gpu:
4
+
5
+ run_nvidia_gpu.bat
6
+
7
+ if you want to enable the fast fp16 accumulation (faster for fp16 models with slightly less quality):
8
+
9
+ run_nvidia_gpu_fast_fp16_accumulation.bat
10
+
11
+
12
+ To run it in slow CPU mode:
13
+
14
+ run_cpu.bat
15
+
16
+
17
+
18
+ IF YOU GET A RED ERROR IN THE UI MAKE SURE YOU HAVE A MODEL/CHECKPOINT IN: ComfyUI\models\checkpoints
19
+
20
+ You can download the stable diffusion 1.5 one from: https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/blob/main/v1-5-pruned-emaonly-fp16.safetensors
21
+
22
+
23
+ RECOMMENDED WAY TO UPDATE:
24
+ To update the ComfyUI code: update\update_comfyui.bat
25
+
26
+
27
+
28
+ To update ComfyUI with the python dependencies, note that you should ONLY run this if you have issues with python dependencies.
29
+ update\update_comfyui_and_python_dependencies.bat
30
+
31
+
32
+ TO SHARE MODELS BETWEEN COMFYUI AND ANOTHER UI:
33
+ In the ComfyUI directory you will find a file: extra_model_paths.yaml.example
34
+ Rename this file to: extra_model_paths.yaml and edit it with your favorite text editor.
.ci/windows_base_files/run_cpu.bat ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ .\python_embeded\python.exe -s ComfyUI\main.py --cpu --windows-standalone-build
2
+ pause
.ci/windows_base_files/run_nvidia_gpu.bat ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ .\python_embeded\python.exe -s ComfyUI\main.py --windows-standalone-build
2
+ pause
.ci/windows_base_files/run_nvidia_gpu_fast_fp16_accumulation.bat ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ .\python_embeded\python.exe -s ComfyUI\main.py --windows-standalone-build --fast fp16_accumulation
2
+ pause
.ci/windows_nightly_base_files/run_nvidia_gpu_fast.bat ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ .\python_embeded\python.exe -s ComfyUI\main.py --windows-standalone-build --fast
2
+ pause
.gitattributes CHANGED
@@ -75,3 +75,4 @@ venv/lib/python3.10/site-packages/diffusers/loaders/__pycache__/lora_pipeline.cp
75
  venv/lib/python3.10/site-packages/accelerate/__pycache__/accelerator.cpython-310.pyc filter=lfs diff=lfs merge=lfs -text
76
  venv/lib/python3.10/site-packages/deepspeed/runtime/__pycache__/engine.cpython-310.pyc filter=lfs diff=lfs merge=lfs -text
77
  venv/lib/python3.10/site-packages/hf_transfer/hf_transfer.abi3.so filter=lfs diff=lfs merge=lfs -text
 
 
75
  venv/lib/python3.10/site-packages/accelerate/__pycache__/accelerator.cpython-310.pyc filter=lfs diff=lfs merge=lfs -text
76
  venv/lib/python3.10/site-packages/deepspeed/runtime/__pycache__/engine.cpython-310.pyc filter=lfs diff=lfs merge=lfs -text
77
  venv/lib/python3.10/site-packages/hf_transfer/hf_transfer.abi3.so filter=lfs diff=lfs merge=lfs -text
78
+ venv/lib/python3.10/site-packages/flash_attn_2_cuda.cpython-310-x86_64-linux-gnu.so filter=lfs diff=lfs merge=lfs -text
.github/ISSUE_TEMPLATE/bug-report.yml ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Bug Report
2
+ description: "Something is broken inside of ComfyUI. (Do not use this if you're just having issues and need help, or if the issue relates to a custom node)"
3
+ labels: ["Potential Bug"]
4
+ body:
5
+ - type: markdown
6
+ attributes:
7
+ value: |
8
+ Before submitting a **Bug Report**, please ensure the following:
9
+
10
+ - **1:** You are running the latest version of ComfyUI.
11
+ - **2:** You have looked at the existing bug reports and made sure this isn't already reported.
12
+ - **3:** You confirmed that the bug is not caused by a custom node. You can disable all custom nodes by passing
13
+ `--disable-all-custom-nodes` command line argument.
14
+ - **4:** This is an actual bug in ComfyUI, not just a support question. A bug is when you can specify exact
15
+ steps to replicate what went wrong and others will be able to repeat your steps and see the same issue happen.
16
+
17
+ If unsure, ask on the [ComfyUI Matrix Space](https://app.element.io/#/room/%23comfyui_space%3Amatrix.org) or the [Comfy Org Discord](https://discord.gg/comfyorg) first.
18
+ - type: checkboxes
19
+ id: custom-nodes-test
20
+ attributes:
21
+ label: Custom Node Testing
22
+ description: Please confirm you have tried to reproduce the issue with all custom nodes disabled.
23
+ options:
24
+ - label: I have tried disabling custom nodes and the issue persists (see [how to disable custom nodes](https://docs.comfy.org/troubleshooting/custom-node-issues#step-1%3A-test-with-all-custom-nodes-disabled) if you need help)
25
+ required: true
26
+ - type: textarea
27
+ attributes:
28
+ label: Expected Behavior
29
+ description: "What you expected to happen."
30
+ validations:
31
+ required: true
32
+ - type: textarea
33
+ attributes:
34
+ label: Actual Behavior
35
+ description: "What actually happened. Please include a screenshot of the issue if possible."
36
+ validations:
37
+ required: true
38
+ - type: textarea
39
+ attributes:
40
+ label: Steps to Reproduce
41
+ description: "Describe how to reproduce the issue. Please be sure to attach a workflow JSON or PNG, ideally one that doesn't require custom nodes to test. If the bug open happens when certain custom nodes are used, most likely that custom node is what has the bug rather than ComfyUI, in which case it should be reported to the node's author."
42
+ validations:
43
+ required: true
44
+ - type: textarea
45
+ attributes:
46
+ label: Debug Logs
47
+ description: "Please copy the output from your terminal logs here."
48
+ render: powershell
49
+ validations:
50
+ required: true
51
+ - type: textarea
52
+ attributes:
53
+ label: Other
54
+ description: "Any other additional information you think might be helpful."
55
+ validations:
56
+ required: false
.github/ISSUE_TEMPLATE/config.yml ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ blank_issues_enabled: true
2
+ contact_links:
3
+ - name: ComfyUI Frontend Issues
4
+ url: https://github.com/Comfy-Org/ComfyUI_frontend/issues
5
+ about: Issues related to the ComfyUI frontend (display issues, user interaction bugs), please go to the frontend repo to file the issue
6
+ - name: ComfyUI Matrix Space
7
+ url: https://app.element.io/#/room/%23comfyui_space%3Amatrix.org
8
+ about: The ComfyUI Matrix Space is available for support and general discussion related to ComfyUI (Matrix is like Discord but open source).
9
+ - name: Comfy Org Discord
10
+ url: https://discord.gg/comfyorg
11
+ about: The Comfy Org Discord is available for support and general discussion related to ComfyUI.
.github/ISSUE_TEMPLATE/feature-request.yml ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Feature Request
2
+ description: "You have an idea for something new you would like to see added to ComfyUI's core."
3
+ labels: [ "Feature" ]
4
+ body:
5
+ - type: markdown
6
+ attributes:
7
+ value: |
8
+ Before submitting a **Feature Request**, please ensure the following:
9
+
10
+ **1:** You are running the latest version of ComfyUI.
11
+ **2:** You have looked to make sure there is not already a feature that does what you need, and there is not already a Feature Request listed for the same idea.
12
+ **3:** This is something that makes sense to add to ComfyUI Core, and wouldn't make more sense as a custom node.
13
+
14
+ If unsure, ask on the [ComfyUI Matrix Space](https://app.element.io/#/room/%23comfyui_space%3Amatrix.org) or the [Comfy Org Discord](https://discord.gg/comfyorg) first.
15
+ - type: textarea
16
+ attributes:
17
+ label: Feature Idea
18
+ description: "Describe the feature you want to see."
19
+ validations:
20
+ required: true
21
+ - type: textarea
22
+ attributes:
23
+ label: Existing Solutions
24
+ description: "Please search through available custom nodes / extensions to see if there are existing custom solutions for this. If so, please link the options you found here as a reference."
25
+ validations:
26
+ required: false
27
+ - type: textarea
28
+ attributes:
29
+ label: Other
30
+ description: "Any other additional information you think might be helpful."
31
+ validations:
32
+ required: false
.github/ISSUE_TEMPLATE/user-support.yml ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: User Support
2
+ description: "Use this if you need help with something, or you're experiencing an issue."
3
+ labels: [ "User Support" ]
4
+ body:
5
+ - type: markdown
6
+ attributes:
7
+ value: |
8
+ Before submitting a **User Report** issue, please ensure the following:
9
+
10
+ **1:** You are running the latest version of ComfyUI.
11
+ **2:** You have made an effort to find public answers to your question before asking here. In other words, you googled it first, and scrolled through recent help topics.
12
+
13
+ If unsure, ask on the [ComfyUI Matrix Space](https://app.element.io/#/room/%23comfyui_space%3Amatrix.org) or the [Comfy Org Discord](https://discord.gg/comfyorg) first.
14
+ - type: checkboxes
15
+ id: custom-nodes-test
16
+ attributes:
17
+ label: Custom Node Testing
18
+ description: Please confirm you have tried to reproduce the issue with all custom nodes disabled.
19
+ options:
20
+ - label: I have tried disabling custom nodes and the issue persists (see [how to disable custom nodes](https://docs.comfy.org/troubleshooting/custom-node-issues#step-1%3A-test-with-all-custom-nodes-disabled) if you need help)
21
+ required: true
22
+ - type: textarea
23
+ attributes:
24
+ label: Your question
25
+ description: "Post your question here. Please be as detailed as possible."
26
+ validations:
27
+ required: true
28
+ - type: textarea
29
+ attributes:
30
+ label: Logs
31
+ description: "If your question relates to an issue you're experiencing, please go to `Server` -> `Logs` -> potentially set `View Type` to `Debug` as well, then copypaste all the text into here."
32
+ render: powershell
33
+ validations:
34
+ required: false
35
+ - type: textarea
36
+ attributes:
37
+ label: Other
38
+ description: "Any other additional information you think might be helpful."
39
+ validations:
40
+ required: false
.github/workflows/check-line-endings.yml ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Check for Windows Line Endings
2
+
3
+ on:
4
+ pull_request:
5
+ branches: ['*'] # Trigger on all pull requests to any branch
6
+
7
+ jobs:
8
+ check-line-endings:
9
+ runs-on: ubuntu-latest
10
+
11
+ steps:
12
+ - name: Checkout code
13
+ uses: actions/checkout@v4
14
+ with:
15
+ fetch-depth: 0 # Fetch all history to compare changes
16
+
17
+ - name: Check for Windows line endings (CRLF)
18
+ run: |
19
+ # Get the list of changed files in the PR
20
+ CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}..HEAD)
21
+
22
+ # Flag to track if CRLF is found
23
+ CRLF_FOUND=false
24
+
25
+ # Loop through each changed file
26
+ for FILE in $CHANGED_FILES; do
27
+ # Check if the file exists and is a text file
28
+ if [ -f "$FILE" ] && file "$FILE" | grep -q "text"; then
29
+ # Check for CRLF line endings
30
+ if grep -UP '\r$' "$FILE"; then
31
+ echo "Error: Windows line endings (CRLF) detected in $FILE"
32
+ CRLF_FOUND=true
33
+ fi
34
+ fi
35
+ done
36
+
37
+ # Exit with error if CRLF was found
38
+ if [ "$CRLF_FOUND" = true ]; then
39
+ exit 1
40
+ fi
.github/workflows/pullrequest-ci-run.yml ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # This is the GitHub Workflow that drives full-GPU-enabled tests of pull requests to ComfyUI, when the 'Run-CI-Test' label is added
2
+ # Results are reported as checkmarks on the commits, as well as onto https://ci.comfy.org/
3
+ name: Pull Request CI Workflow Runs
4
+ on:
5
+ pull_request_target:
6
+ types: [labeled]
7
+
8
+ jobs:
9
+ pr-test-stable:
10
+ if: ${{ github.event.label.name == 'Run-CI-Test' }}
11
+ strategy:
12
+ fail-fast: false
13
+ matrix:
14
+ os: [macos, linux, windows]
15
+ python_version: ["3.9", "3.10", "3.11", "3.12"]
16
+ cuda_version: ["12.1"]
17
+ torch_version: ["stable"]
18
+ include:
19
+ - os: macos
20
+ runner_label: [self-hosted, macOS]
21
+ flags: "--use-pytorch-cross-attention"
22
+ - os: linux
23
+ runner_label: [self-hosted, Linux]
24
+ flags: ""
25
+ - os: windows
26
+ runner_label: [self-hosted, Windows]
27
+ flags: ""
28
+ runs-on: ${{ matrix.runner_label }}
29
+ steps:
30
+ - name: Test Workflows
31
+ uses: comfy-org/comfy-action@main
32
+ with:
33
+ os: ${{ matrix.os }}
34
+ python_version: ${{ matrix.python_version }}
35
+ torch_version: ${{ matrix.torch_version }}
36
+ google_credentials: ${{ secrets.GCS_SERVICE_ACCOUNT_JSON }}
37
+ comfyui_flags: ${{ matrix.flags }}
38
+ use_prior_commit: 'true'
39
+ comment:
40
+ if: ${{ github.event.label.name == 'Run-CI-Test' }}
41
+ runs-on: ubuntu-latest
42
+ permissions:
43
+ pull-requests: write
44
+ steps:
45
+ - uses: actions/github-script@v6
46
+ with:
47
+ script: |
48
+ github.rest.issues.createComment({
49
+ issue_number: context.issue.number,
50
+ owner: context.repo.owner,
51
+ repo: context.repo.repo,
52
+ body: '(Automated Bot Message) CI Tests are running, you can view the results at https://ci.comfy.org/?branch=${{ github.event.pull_request.number }}%2Fmerge'
53
+ })
.github/workflows/release-webhook.yml ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Release Webhook
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ send-webhook:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - name: Send release webhook
12
+ env:
13
+ WEBHOOK_URL: ${{ secrets.RELEASE_GITHUB_WEBHOOK_URL }}
14
+ WEBHOOK_SECRET: ${{ secrets.RELEASE_GITHUB_WEBHOOK_SECRET }}
15
+ run: |
16
+ # Generate UUID for delivery ID
17
+ DELIVERY_ID=$(uuidgen)
18
+ HOOK_ID="release-webhook-$(date +%s)"
19
+
20
+ # Create webhook payload matching GitHub release webhook format
21
+ PAYLOAD=$(cat <<EOF
22
+ {
23
+ "action": "published",
24
+ "release": {
25
+ "id": ${{ github.event.release.id }},
26
+ "node_id": "${{ github.event.release.node_id }}",
27
+ "url": "${{ github.event.release.url }}",
28
+ "html_url": "${{ github.event.release.html_url }}",
29
+ "assets_url": "${{ github.event.release.assets_url }}",
30
+ "upload_url": "${{ github.event.release.upload_url }}",
31
+ "tag_name": "${{ github.event.release.tag_name }}",
32
+ "target_commitish": "${{ github.event.release.target_commitish }}",
33
+ "name": ${{ toJSON(github.event.release.name) }},
34
+ "body": ${{ toJSON(github.event.release.body) }},
35
+ "draft": ${{ github.event.release.draft }},
36
+ "prerelease": ${{ github.event.release.prerelease }},
37
+ "created_at": "${{ github.event.release.created_at }}",
38
+ "published_at": "${{ github.event.release.published_at }}",
39
+ "author": {
40
+ "login": "${{ github.event.release.author.login }}",
41
+ "id": ${{ github.event.release.author.id }},
42
+ "node_id": "${{ github.event.release.author.node_id }}",
43
+ "avatar_url": "${{ github.event.release.author.avatar_url }}",
44
+ "url": "${{ github.event.release.author.url }}",
45
+ "html_url": "${{ github.event.release.author.html_url }}",
46
+ "type": "${{ github.event.release.author.type }}",
47
+ "site_admin": ${{ github.event.release.author.site_admin }}
48
+ },
49
+ "tarball_url": "${{ github.event.release.tarball_url }}",
50
+ "zipball_url": "${{ github.event.release.zipball_url }}",
51
+ "assets": ${{ toJSON(github.event.release.assets) }}
52
+ },
53
+ "repository": {
54
+ "id": ${{ github.event.repository.id }},
55
+ "node_id": "${{ github.event.repository.node_id }}",
56
+ "name": "${{ github.event.repository.name }}",
57
+ "full_name": "${{ github.event.repository.full_name }}",
58
+ "private": ${{ github.event.repository.private }},
59
+ "owner": {
60
+ "login": "${{ github.event.repository.owner.login }}",
61
+ "id": ${{ github.event.repository.owner.id }},
62
+ "node_id": "${{ github.event.repository.owner.node_id }}",
63
+ "avatar_url": "${{ github.event.repository.owner.avatar_url }}",
64
+ "url": "${{ github.event.repository.owner.url }}",
65
+ "html_url": "${{ github.event.repository.owner.html_url }}",
66
+ "type": "${{ github.event.repository.owner.type }}",
67
+ "site_admin": ${{ github.event.repository.owner.site_admin }}
68
+ },
69
+ "html_url": "${{ github.event.repository.html_url }}",
70
+ "clone_url": "${{ github.event.repository.clone_url }}",
71
+ "git_url": "${{ github.event.repository.git_url }}",
72
+ "ssh_url": "${{ github.event.repository.ssh_url }}",
73
+ "url": "${{ github.event.repository.url }}",
74
+ "created_at": "${{ github.event.repository.created_at }}",
75
+ "updated_at": "${{ github.event.repository.updated_at }}",
76
+ "pushed_at": "${{ github.event.repository.pushed_at }}",
77
+ "default_branch": "${{ github.event.repository.default_branch }}",
78
+ "fork": ${{ github.event.repository.fork }}
79
+ },
80
+ "sender": {
81
+ "login": "${{ github.event.sender.login }}",
82
+ "id": ${{ github.event.sender.id }},
83
+ "node_id": "${{ github.event.sender.node_id }}",
84
+ "avatar_url": "${{ github.event.sender.avatar_url }}",
85
+ "url": "${{ github.event.sender.url }}",
86
+ "html_url": "${{ github.event.sender.html_url }}",
87
+ "type": "${{ github.event.sender.type }}",
88
+ "site_admin": ${{ github.event.sender.site_admin }}
89
+ }
90
+ }
91
+ EOF
92
+ )
93
+
94
+ # Generate HMAC-SHA256 signature
95
+ SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -hex | cut -d' ' -f2)
96
+
97
+ # Send webhook with required headers
98
+ curl -X POST "$WEBHOOK_URL" \
99
+ -H "Content-Type: application/json" \
100
+ -H "X-GitHub-Event: release" \
101
+ -H "X-GitHub-Delivery: $DELIVERY_ID" \
102
+ -H "X-GitHub-Hook-ID: $HOOK_ID" \
103
+ -H "X-Hub-Signature-256: sha256=$SIGNATURE" \
104
+ -H "User-Agent: GitHub-Actions-Webhook/1.0" \
105
+ -d "$PAYLOAD" \
106
+ --fail --silent --show-error
107
+
108
+ echo "✅ Release webhook sent successfully"
.github/workflows/ruff.yml ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Python Linting
2
+
3
+ on: [push, pull_request]
4
+
5
+ jobs:
6
+ ruff:
7
+ name: Run Ruff
8
+ runs-on: ubuntu-latest
9
+
10
+ steps:
11
+ - name: Checkout repository
12
+ uses: actions/checkout@v4
13
+
14
+ - name: Set up Python
15
+ uses: actions/setup-python@v2
16
+ with:
17
+ python-version: 3.x
18
+
19
+ - name: Install Ruff
20
+ run: pip install ruff
21
+
22
+ - name: Run Ruff
23
+ run: ruff check .
.github/workflows/stable-release.yml ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ name: "Release Stable Version"
3
+
4
+ on:
5
+ workflow_dispatch:
6
+ inputs:
7
+ git_tag:
8
+ description: 'Git tag'
9
+ required: true
10
+ type: string
11
+ cu:
12
+ description: 'CUDA version'
13
+ required: true
14
+ type: string
15
+ default: "128"
16
+ python_minor:
17
+ description: 'Python minor version'
18
+ required: true
19
+ type: string
20
+ default: "12"
21
+ python_patch:
22
+ description: 'Python patch version'
23
+ required: true
24
+ type: string
25
+ default: "10"
26
+
27
+
28
+ jobs:
29
+ package_comfy_windows:
30
+ permissions:
31
+ contents: "write"
32
+ packages: "write"
33
+ pull-requests: "read"
34
+ runs-on: windows-latest
35
+ steps:
36
+ - uses: actions/checkout@v4
37
+ with:
38
+ ref: ${{ inputs.git_tag }}
39
+ fetch-depth: 150
40
+ persist-credentials: false
41
+ - uses: actions/cache/restore@v4
42
+ id: cache
43
+ with:
44
+ path: |
45
+ cu${{ inputs.cu }}_python_deps.tar
46
+ update_comfyui_and_python_dependencies.bat
47
+ key: ${{ runner.os }}-build-cu${{ inputs.cu }}-${{ inputs.python_minor }}
48
+ - shell: bash
49
+ run: |
50
+ mv cu${{ inputs.cu }}_python_deps.tar ../
51
+ mv update_comfyui_and_python_dependencies.bat ../
52
+ cd ..
53
+ tar xf cu${{ inputs.cu }}_python_deps.tar
54
+ pwd
55
+ ls
56
+
57
+ - shell: bash
58
+ run: |
59
+ cd ..
60
+ cp -r ComfyUI ComfyUI_copy
61
+ curl https://www.python.org/ftp/python/3.${{ inputs.python_minor }}.${{ inputs.python_patch }}/python-3.${{ inputs.python_minor }}.${{ inputs.python_patch }}-embed-amd64.zip -o python_embeded.zip
62
+ unzip python_embeded.zip -d python_embeded
63
+ cd python_embeded
64
+ echo ${{ env.MINOR_VERSION }}
65
+ echo 'import site' >> ./python3${{ inputs.python_minor }}._pth
66
+ curl https://bootstrap.pypa.io/get-pip.py -o get-pip.py
67
+ ./python.exe get-pip.py
68
+ ./python.exe -s -m pip install ../cu${{ inputs.cu }}_python_deps/*
69
+ sed -i '1i../ComfyUI' ./python3${{ inputs.python_minor }}._pth
70
+ cd ..
71
+
72
+ git clone --depth 1 https://github.com/comfyanonymous/taesd
73
+ cp taesd/*.safetensors ./ComfyUI_copy/models/vae_approx/
74
+
75
+ mkdir ComfyUI_windows_portable
76
+ mv python_embeded ComfyUI_windows_portable
77
+ mv ComfyUI_copy ComfyUI_windows_portable/ComfyUI
78
+
79
+ cd ComfyUI_windows_portable
80
+
81
+ mkdir update
82
+ cp -r ComfyUI/.ci/update_windows/* ./update/
83
+ cp -r ComfyUI/.ci/windows_base_files/* ./
84
+ cp ../update_comfyui_and_python_dependencies.bat ./update/
85
+
86
+ cd ..
87
+
88
+ "C:\Program Files\7-Zip\7z.exe" a -t7z -m0=lzma2 -mx=9 -mfb=128 -md=512m -ms=on -mf=BCJ2 ComfyUI_windows_portable.7z ComfyUI_windows_portable
89
+ mv ComfyUI_windows_portable.7z ComfyUI/ComfyUI_windows_portable_nvidia.7z
90
+
91
+ cd ComfyUI_windows_portable
92
+ python_embeded/python.exe -s ComfyUI/main.py --quick-test-for-ci --cpu
93
+
94
+ python_embeded/python.exe -s ./update/update.py ComfyUI/
95
+
96
+ ls
97
+
98
+ - name: Upload binaries to release
99
+ uses: svenstaro/upload-release-action@v2
100
+ with:
101
+ repo_token: ${{ secrets.GITHUB_TOKEN }}
102
+ file: ComfyUI_windows_portable_nvidia.7z
103
+ tag: ${{ inputs.git_tag }}
104
+ overwrite: true
105
+ draft: true
.github/workflows/stale-issues.yml ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: 'Close stale issues'
2
+ on:
3
+ schedule:
4
+ # Run daily at 430 am PT
5
+ - cron: '30 11 * * *'
6
+ permissions:
7
+ issues: write
8
+
9
+ jobs:
10
+ stale:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/stale@v9
14
+ with:
15
+ stale-issue-message: "This issue is being marked stale because it has not had any activity for 30 days. Reply below within 7 days if your issue still isn't solved, and it will be left open. Otherwise, the issue will be closed automatically."
16
+ days-before-stale: 30
17
+ days-before-close: 7
18
+ stale-issue-label: 'Stale'
19
+ only-labels: 'User Support'
20
+ exempt-all-assignees: true
21
+ exempt-all-milestones: true
.github/workflows/test-build.yml ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Build package
2
+
3
+ #
4
+ # This workflow is a test of the python package build.
5
+ # Install Python dependencies across different Python versions.
6
+ #
7
+
8
+ on:
9
+ push:
10
+ paths:
11
+ - "requirements.txt"
12
+ - ".github/workflows/test-build.yml"
13
+
14
+ jobs:
15
+ build:
16
+ name: Build Test
17
+ runs-on: ubuntu-latest
18
+ strategy:
19
+ fail-fast: false
20
+ matrix:
21
+ python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
22
+ steps:
23
+ - uses: actions/checkout@v4
24
+ - name: Set up Python ${{ matrix.python-version }}
25
+ uses: actions/setup-python@v4
26
+ with:
27
+ python-version: ${{ matrix.python-version }}
28
+ - name: Install dependencies
29
+ run: |
30
+ python -m pip install --upgrade pip
31
+ pip install -r requirements.txt
.github/workflows/test-ci.yml ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # This is the GitHub Workflow that drives automatic full-GPU-enabled tests of all new commits to the master branch of ComfyUI
2
+ # Results are reported as checkmarks on the commits, as well as onto https://ci.comfy.org/
3
+ name: Full Comfy CI Workflow Runs
4
+ on:
5
+ push:
6
+ branches:
7
+ - master
8
+ paths-ignore:
9
+ - 'app/**'
10
+ - 'input/**'
11
+ - 'output/**'
12
+ - 'notebooks/**'
13
+ - 'script_examples/**'
14
+ - '.github/**'
15
+ - 'web/**'
16
+ workflow_dispatch:
17
+
18
+ jobs:
19
+ test-stable:
20
+ strategy:
21
+ fail-fast: false
22
+ matrix:
23
+ # os: [macos, linux, windows]
24
+ os: [macos, linux]
25
+ python_version: ["3.9", "3.10", "3.11", "3.12"]
26
+ cuda_version: ["12.1"]
27
+ torch_version: ["stable"]
28
+ include:
29
+ - os: macos
30
+ runner_label: [self-hosted, macOS]
31
+ flags: "--use-pytorch-cross-attention"
32
+ - os: linux
33
+ runner_label: [self-hosted, Linux]
34
+ flags: ""
35
+ # - os: windows
36
+ # runner_label: [self-hosted, Windows]
37
+ # flags: ""
38
+ runs-on: ${{ matrix.runner_label }}
39
+ steps:
40
+ - name: Test Workflows
41
+ uses: comfy-org/comfy-action@main
42
+ with:
43
+ os: ${{ matrix.os }}
44
+ python_version: ${{ matrix.python_version }}
45
+ torch_version: ${{ matrix.torch_version }}
46
+ google_credentials: ${{ secrets.GCS_SERVICE_ACCOUNT_JSON }}
47
+ comfyui_flags: ${{ matrix.flags }}
48
+
49
+ # test-win-nightly:
50
+ # strategy:
51
+ # fail-fast: true
52
+ # matrix:
53
+ # os: [windows]
54
+ # python_version: ["3.9", "3.10", "3.11", "3.12"]
55
+ # cuda_version: ["12.1"]
56
+ # torch_version: ["nightly"]
57
+ # include:
58
+ # - os: windows
59
+ # runner_label: [self-hosted, Windows]
60
+ # flags: ""
61
+ # runs-on: ${{ matrix.runner_label }}
62
+ # steps:
63
+ # - name: Test Workflows
64
+ # uses: comfy-org/comfy-action@main
65
+ # with:
66
+ # os: ${{ matrix.os }}
67
+ # python_version: ${{ matrix.python_version }}
68
+ # torch_version: ${{ matrix.torch_version }}
69
+ # google_credentials: ${{ secrets.GCS_SERVICE_ACCOUNT_JSON }}
70
+ # comfyui_flags: ${{ matrix.flags }}
71
+
72
+ test-unix-nightly:
73
+ strategy:
74
+ fail-fast: false
75
+ matrix:
76
+ os: [macos, linux]
77
+ python_version: ["3.11"]
78
+ cuda_version: ["12.1"]
79
+ torch_version: ["nightly"]
80
+ include:
81
+ - os: macos
82
+ runner_label: [self-hosted, macOS]
83
+ flags: "--use-pytorch-cross-attention"
84
+ - os: linux
85
+ runner_label: [self-hosted, Linux]
86
+ flags: ""
87
+ runs-on: ${{ matrix.runner_label }}
88
+ steps:
89
+ - name: Test Workflows
90
+ uses: comfy-org/comfy-action@main
91
+ with:
92
+ os: ${{ matrix.os }}
93
+ python_version: ${{ matrix.python_version }}
94
+ torch_version: ${{ matrix.torch_version }}
95
+ google_credentials: ${{ secrets.GCS_SERVICE_ACCOUNT_JSON }}
96
+ comfyui_flags: ${{ matrix.flags }}
.github/workflows/test-launch.yml ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Test server launches without errors
2
+
3
+ on:
4
+ push:
5
+ branches: [ main, master ]
6
+ pull_request:
7
+ branches: [ main, master ]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - name: Checkout ComfyUI
14
+ uses: actions/checkout@v4
15
+ with:
16
+ repository: "comfyanonymous/ComfyUI"
17
+ path: "ComfyUI"
18
+ - uses: actions/setup-python@v4
19
+ with:
20
+ python-version: '3.10'
21
+ - name: Install requirements
22
+ run: |
23
+ python -m pip install --upgrade pip
24
+ pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu
25
+ pip install -r requirements.txt
26
+ pip install wait-for-it
27
+ working-directory: ComfyUI
28
+ - name: Start ComfyUI server
29
+ run: |
30
+ python main.py --cpu 2>&1 | tee console_output.log &
31
+ wait-for-it --service 127.0.0.1:8188 -t 30
32
+ working-directory: ComfyUI
33
+ - name: Check for unhandled exceptions in server log
34
+ run: |
35
+ if grep -qE "Exception|Error" console_output.log; then
36
+ echo "Unhandled exception/error found in server log."
37
+ exit 1
38
+ fi
39
+ working-directory: ComfyUI
40
+ - uses: actions/upload-artifact@v4
41
+ if: always()
42
+ with:
43
+ name: console-output
44
+ path: ComfyUI/console_output.log
45
+ retention-days: 30
.github/workflows/test-unit.yml ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Unit Tests
2
+
3
+ on:
4
+ push:
5
+ branches: [ main, master ]
6
+ pull_request:
7
+ branches: [ main, master ]
8
+
9
+ jobs:
10
+ test:
11
+ strategy:
12
+ matrix:
13
+ os: [ubuntu-latest, windows-latest, macos-latest]
14
+ runs-on: ${{ matrix.os }}
15
+ continue-on-error: true
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - name: Set up Python
19
+ uses: actions/setup-python@v4
20
+ with:
21
+ python-version: '3.12'
22
+ - name: Install requirements
23
+ run: |
24
+ python -m pip install --upgrade pip
25
+ pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu
26
+ pip install -r requirements.txt
27
+ - name: Run Unit Tests
28
+ run: |
29
+ pip install -r tests-unit/requirements.txt
30
+ python -m pytest tests-unit
.github/workflows/update-api-stubs.yml ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Generate Pydantic Stubs from api.comfy.org
2
+
3
+ on:
4
+ schedule:
5
+ - cron: '0 0 * * 1'
6
+ workflow_dispatch:
7
+
8
+ jobs:
9
+ generate-models:
10
+ runs-on: ubuntu-latest
11
+
12
+ steps:
13
+ - name: Checkout repository
14
+ uses: actions/checkout@v4
15
+
16
+ - name: Set up Python
17
+ uses: actions/setup-python@v4
18
+ with:
19
+ python-version: '3.10'
20
+
21
+ - name: Install dependencies
22
+ run: |
23
+ python -m pip install --upgrade pip
24
+ pip install 'datamodel-code-generator[http]'
25
+ npm install @redocly/cli
26
+
27
+ - name: Download OpenAPI spec
28
+ run: |
29
+ curl -o openapi.yaml https://api.comfy.org/openapi
30
+
31
+ - name: Filter OpenAPI spec with Redocly
32
+ run: |
33
+ npx @redocly/cli bundle openapi.yaml --output filtered-openapi.yaml --config comfy_api_nodes/redocly.yaml --remove-unused-components
34
+
35
+ - name: Generate API models
36
+ run: |
37
+ datamodel-codegen --use-subclass-enum --input filtered-openapi.yaml --output comfy_api_nodes/apis --output-model-type pydantic_v2.BaseModel
38
+
39
+ - name: Check for changes
40
+ id: git-check
41
+ run: |
42
+ git diff --exit-code comfy_api_nodes/apis || echo "changes=true" >> $GITHUB_OUTPUT
43
+
44
+ - name: Create Pull Request
45
+ if: steps.git-check.outputs.changes == 'true'
46
+ uses: peter-evans/create-pull-request@v5
47
+ with:
48
+ commit-message: 'chore: update API models from OpenAPI spec'
49
+ title: 'Update API models from api.comfy.org'
50
+ body: |
51
+ This PR updates the API models based on the latest api.comfy.org OpenAPI specification.
52
+
53
+ Generated automatically by the a Github workflow.
54
+ branch: update-api-stubs
55
+ delete-branch: true
56
+ base: master
.github/workflows/update-version.yml ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Update Version File
2
+
3
+ on:
4
+ pull_request:
5
+ paths:
6
+ - "pyproject.toml"
7
+ branches:
8
+ - master
9
+
10
+ jobs:
11
+ update-version:
12
+ runs-on: ubuntu-latest
13
+ # Don't run on fork PRs
14
+ if: github.event.pull_request.head.repo.full_name == github.repository
15
+ permissions:
16
+ pull-requests: write
17
+ contents: write
18
+
19
+ steps:
20
+ - name: Checkout repository
21
+ uses: actions/checkout@v4
22
+
23
+ - name: Set up Python
24
+ uses: actions/setup-python@v4
25
+ with:
26
+ python-version: "3.11"
27
+
28
+ - name: Install dependencies
29
+ run: |
30
+ python -m pip install --upgrade pip
31
+
32
+ - name: Update comfyui_version.py
33
+ run: |
34
+ # Read version from pyproject.toml and update comfyui_version.py
35
+ python -c '
36
+ import tomllib
37
+
38
+ # Read version from pyproject.toml
39
+ with open("pyproject.toml", "rb") as f:
40
+ config = tomllib.load(f)
41
+ version = config["project"]["version"]
42
+
43
+ # Write version to comfyui_version.py
44
+ with open("comfyui_version.py", "w") as f:
45
+ f.write("# This file is automatically generated by the build process when version is\n")
46
+ f.write("# updated in pyproject.toml.\n")
47
+ f.write(f"__version__ = \"{version}\"\n")
48
+ '
49
+
50
+ - name: Commit changes
51
+ run: |
52
+ git config --local user.name "github-actions"
53
+ git config --local user.email "github-actions@github.com"
54
+ git fetch origin ${{ github.head_ref }}
55
+ git checkout -B ${{ github.head_ref }} origin/${{ github.head_ref }}
56
+ git add comfyui_version.py
57
+ git diff --quiet && git diff --staged --quiet || git commit -m "chore: Update comfyui_version.py to match pyproject.toml"
58
+ git push origin HEAD:${{ github.head_ref }}
.github/workflows/windows_release_dependencies.yml ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: "Windows Release dependencies"
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ inputs:
6
+ xformers:
7
+ description: 'xformers version'
8
+ required: false
9
+ type: string
10
+ default: ""
11
+ extra_dependencies:
12
+ description: 'extra dependencies'
13
+ required: false
14
+ type: string
15
+ default: ""
16
+ cu:
17
+ description: 'cuda version'
18
+ required: true
19
+ type: string
20
+ default: "128"
21
+
22
+ python_minor:
23
+ description: 'python minor version'
24
+ required: true
25
+ type: string
26
+ default: "12"
27
+
28
+ python_patch:
29
+ description: 'python patch version'
30
+ required: true
31
+ type: string
32
+ default: "10"
33
+ # push:
34
+ # branches:
35
+ # - master
36
+
37
+ jobs:
38
+ build_dependencies:
39
+ runs-on: windows-latest
40
+ steps:
41
+ - uses: actions/checkout@v4
42
+ - uses: actions/setup-python@v5
43
+ with:
44
+ python-version: 3.${{ inputs.python_minor }}.${{ inputs.python_patch }}
45
+
46
+ - shell: bash
47
+ run: |
48
+ echo "@echo off
49
+ call update_comfyui.bat nopause
50
+ echo -
51
+ echo This will try to update pytorch and all python dependencies.
52
+ echo -
53
+ echo If you just want to update normally, close this and run update_comfyui.bat instead.
54
+ echo -
55
+ pause
56
+ ..\python_embeded\python.exe -s -m pip install --upgrade torch torchvision torchaudio ${{ inputs.xformers }} --extra-index-url https://download.pytorch.org/whl/cu${{ inputs.cu }} -r ../ComfyUI/requirements.txt pygit2
57
+ pause" > update_comfyui_and_python_dependencies.bat
58
+
59
+ python -m pip wheel --no-cache-dir torch torchvision torchaudio ${{ inputs.xformers }} ${{ inputs.extra_dependencies }} --extra-index-url https://download.pytorch.org/whl/cu${{ inputs.cu }} -r requirements.txt pygit2 -w ./temp_wheel_dir
60
+ python -m pip install --no-cache-dir ./temp_wheel_dir/*
61
+ echo installed basic
62
+ ls -lah temp_wheel_dir
63
+ mv temp_wheel_dir cu${{ inputs.cu }}_python_deps
64
+ tar cf cu${{ inputs.cu }}_python_deps.tar cu${{ inputs.cu }}_python_deps
65
+
66
+ - uses: actions/cache/save@v4
67
+ with:
68
+ path: |
69
+ cu${{ inputs.cu }}_python_deps.tar
70
+ update_comfyui_and_python_dependencies.bat
71
+ key: ${{ runner.os }}-build-cu${{ inputs.cu }}-${{ inputs.python_minor }}
.github/workflows/windows_release_nightly_pytorch.yml ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: "Windows Release Nightly pytorch"
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ inputs:
6
+ cu:
7
+ description: 'cuda version'
8
+ required: true
9
+ type: string
10
+ default: "129"
11
+
12
+ python_minor:
13
+ description: 'python minor version'
14
+ required: true
15
+ type: string
16
+ default: "13"
17
+
18
+ python_patch:
19
+ description: 'python patch version'
20
+ required: true
21
+ type: string
22
+ default: "5"
23
+ # push:
24
+ # branches:
25
+ # - master
26
+
27
+ jobs:
28
+ build:
29
+ permissions:
30
+ contents: "write"
31
+ packages: "write"
32
+ pull-requests: "read"
33
+ runs-on: windows-latest
34
+ steps:
35
+ - uses: actions/checkout@v4
36
+ with:
37
+ fetch-depth: 30
38
+ persist-credentials: false
39
+ - uses: actions/setup-python@v5
40
+ with:
41
+ python-version: 3.${{ inputs.python_minor }}.${{ inputs.python_patch }}
42
+ - shell: bash
43
+ run: |
44
+ cd ..
45
+ cp -r ComfyUI ComfyUI_copy
46
+ curl https://www.python.org/ftp/python/3.${{ inputs.python_minor }}.${{ inputs.python_patch }}/python-3.${{ inputs.python_minor }}.${{ inputs.python_patch }}-embed-amd64.zip -o python_embeded.zip
47
+ unzip python_embeded.zip -d python_embeded
48
+ cd python_embeded
49
+ echo 'import site' >> ./python3${{ inputs.python_minor }}._pth
50
+ curl https://bootstrap.pypa.io/get-pip.py -o get-pip.py
51
+ ./python.exe get-pip.py
52
+ python -m pip wheel torch torchvision torchaudio --pre --extra-index-url https://download.pytorch.org/whl/nightly/cu${{ inputs.cu }} -r ../ComfyUI/requirements.txt pygit2 -w ../temp_wheel_dir
53
+ ls ../temp_wheel_dir
54
+ ./python.exe -s -m pip install --pre ../temp_wheel_dir/*
55
+ sed -i '1i../ComfyUI' ./python3${{ inputs.python_minor }}._pth
56
+
57
+ rm ./Lib/site-packages/torch/lib/dnnl.lib #I don't think this is actually used and I need the space
58
+ cd ..
59
+
60
+ git clone --depth 1 https://github.com/comfyanonymous/taesd
61
+ cp taesd/*.safetensors ./ComfyUI_copy/models/vae_approx/
62
+
63
+ mkdir ComfyUI_windows_portable_nightly_pytorch
64
+ mv python_embeded ComfyUI_windows_portable_nightly_pytorch
65
+ mv ComfyUI_copy ComfyUI_windows_portable_nightly_pytorch/ComfyUI
66
+
67
+ cd ComfyUI_windows_portable_nightly_pytorch
68
+
69
+ mkdir update
70
+ cp -r ComfyUI/.ci/update_windows/* ./update/
71
+ cp -r ComfyUI/.ci/windows_base_files/* ./
72
+ cp -r ComfyUI/.ci/windows_nightly_base_files/* ./
73
+
74
+ echo "call update_comfyui.bat nopause
75
+ ..\python_embeded\python.exe -s -m pip install --upgrade --pre torch torchvision torchaudio --extra-index-url https://download.pytorch.org/whl/nightly/cu${{ inputs.cu }} -r ../ComfyUI/requirements.txt pygit2
76
+ pause" > ./update/update_comfyui_and_python_dependencies.bat
77
+ cd ..
78
+
79
+ "C:\Program Files\7-Zip\7z.exe" a -t7z -m0=lzma2 -mx=9 -mfb=128 -md=512m -ms=on -mf=BCJ2 ComfyUI_windows_portable_nightly_pytorch.7z ComfyUI_windows_portable_nightly_pytorch
80
+ mv ComfyUI_windows_portable_nightly_pytorch.7z ComfyUI/ComfyUI_windows_portable_nvidia_or_cpu_nightly_pytorch.7z
81
+
82
+ cd ComfyUI_windows_portable_nightly_pytorch
83
+ python_embeded/python.exe -s ComfyUI/main.py --quick-test-for-ci --cpu
84
+
85
+ ls
86
+
87
+ - name: Upload binaries to release
88
+ uses: svenstaro/upload-release-action@v2
89
+ with:
90
+ repo_token: ${{ secrets.GITHUB_TOKEN }}
91
+ file: ComfyUI_windows_portable_nvidia_or_cpu_nightly_pytorch.7z
92
+ tag: "latest"
93
+ overwrite: true
.github/workflows/windows_release_package.yml ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: "Windows Release packaging"
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ inputs:
6
+ cu:
7
+ description: 'cuda version'
8
+ required: true
9
+ type: string
10
+ default: "128"
11
+
12
+ python_minor:
13
+ description: 'python minor version'
14
+ required: true
15
+ type: string
16
+ default: "12"
17
+
18
+ python_patch:
19
+ description: 'python patch version'
20
+ required: true
21
+ type: string
22
+ default: "10"
23
+ # push:
24
+ # branches:
25
+ # - master
26
+
27
+ jobs:
28
+ package_comfyui:
29
+ permissions:
30
+ contents: "write"
31
+ packages: "write"
32
+ pull-requests: "read"
33
+ runs-on: windows-latest
34
+ steps:
35
+ - uses: actions/cache/restore@v4
36
+ id: cache
37
+ with:
38
+ path: |
39
+ cu${{ inputs.cu }}_python_deps.tar
40
+ update_comfyui_and_python_dependencies.bat
41
+ key: ${{ runner.os }}-build-cu${{ inputs.cu }}-${{ inputs.python_minor }}
42
+ - shell: bash
43
+ run: |
44
+ mv cu${{ inputs.cu }}_python_deps.tar ../
45
+ mv update_comfyui_and_python_dependencies.bat ../
46
+ cd ..
47
+ tar xf cu${{ inputs.cu }}_python_deps.tar
48
+ pwd
49
+ ls
50
+
51
+ - uses: actions/checkout@v4
52
+ with:
53
+ fetch-depth: 150
54
+ persist-credentials: false
55
+ - shell: bash
56
+ run: |
57
+ cd ..
58
+ cp -r ComfyUI ComfyUI_copy
59
+ curl https://www.python.org/ftp/python/3.${{ inputs.python_minor }}.${{ inputs.python_patch }}/python-3.${{ inputs.python_minor }}.${{ inputs.python_patch }}-embed-amd64.zip -o python_embeded.zip
60
+ unzip python_embeded.zip -d python_embeded
61
+ cd python_embeded
62
+ echo 'import site' >> ./python3${{ inputs.python_minor }}._pth
63
+ curl https://bootstrap.pypa.io/get-pip.py -o get-pip.py
64
+ ./python.exe get-pip.py
65
+ ./python.exe -s -m pip install ../cu${{ inputs.cu }}_python_deps/*
66
+ sed -i '1i../ComfyUI' ./python3${{ inputs.python_minor }}._pth
67
+ cd ..
68
+
69
+ git clone --depth 1 https://github.com/comfyanonymous/taesd
70
+ cp taesd/*.safetensors ./ComfyUI_copy/models/vae_approx/
71
+
72
+ mkdir ComfyUI_windows_portable
73
+ mv python_embeded ComfyUI_windows_portable
74
+ mv ComfyUI_copy ComfyUI_windows_portable/ComfyUI
75
+
76
+ cd ComfyUI_windows_portable
77
+
78
+ mkdir update
79
+ cp -r ComfyUI/.ci/update_windows/* ./update/
80
+ cp -r ComfyUI/.ci/windows_base_files/* ./
81
+ cp ../update_comfyui_and_python_dependencies.bat ./update/
82
+
83
+ cd ..
84
+
85
+ "C:\Program Files\7-Zip\7z.exe" a -t7z -m0=lzma2 -mx=9 -mfb=128 -md=512m -ms=on -mf=BCJ2 ComfyUI_windows_portable.7z ComfyUI_windows_portable
86
+ mv ComfyUI_windows_portable.7z ComfyUI/new_ComfyUI_windows_portable_nvidia_cu${{ inputs.cu }}_or_cpu.7z
87
+
88
+ cd ComfyUI_windows_portable
89
+ python_embeded/python.exe -s ComfyUI/main.py --quick-test-for-ci --cpu
90
+
91
+ python_embeded/python.exe -s ./update/update.py ComfyUI/
92
+
93
+ ls
94
+
95
+ - name: Upload binaries to release
96
+ uses: svenstaro/upload-release-action@v2
97
+ with:
98
+ repo_token: ${{ secrets.GITHUB_TOKEN }}
99
+ file: new_ComfyUI_windows_portable_nvidia_cu${{ inputs.cu }}_or_cpu.7z
100
+ tag: "latest"
101
+ overwrite: true
102
+
alembic_db/README.md ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ ## Generate new revision
2
+
3
+ 1. Update models in `/app/database/models.py`
4
+ 2. Run `alembic revision --autogenerate -m "{your message}"`
alembic_db/env.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy import engine_from_config
2
+ from sqlalchemy import pool
3
+
4
+ from alembic import context
5
+
6
+ # this is the Alembic Config object, which provides
7
+ # access to the values within the .ini file in use.
8
+ config = context.config
9
+
10
+
11
+ from app.database.models import Base
12
+ target_metadata = Base.metadata
13
+
14
+ # other values from the config, defined by the needs of env.py,
15
+ # can be acquired:
16
+ # my_important_option = config.get_main_option("my_important_option")
17
+ # ... etc.
18
+
19
+
20
+ def run_migrations_offline() -> None:
21
+ """Run migrations in 'offline' mode.
22
+ This configures the context with just a URL
23
+ and not an Engine, though an Engine is acceptable
24
+ here as well. By skipping the Engine creation
25
+ we don't even need a DBAPI to be available.
26
+ Calls to context.execute() here emit the given string to the
27
+ script output.
28
+ """
29
+ url = config.get_main_option("sqlalchemy.url")
30
+ context.configure(
31
+ url=url,
32
+ target_metadata=target_metadata,
33
+ literal_binds=True,
34
+ dialect_opts={"paramstyle": "named"},
35
+ )
36
+
37
+ with context.begin_transaction():
38
+ context.run_migrations()
39
+
40
+
41
+ def run_migrations_online() -> None:
42
+ """Run migrations in 'online' mode.
43
+ In this scenario we need to create an Engine
44
+ and associate a connection with the context.
45
+ """
46
+ connectable = engine_from_config(
47
+ config.get_section(config.config_ini_section, {}),
48
+ prefix="sqlalchemy.",
49
+ poolclass=pool.NullPool,
50
+ )
51
+
52
+ with connectable.connect() as connection:
53
+ context.configure(
54
+ connection=connection, target_metadata=target_metadata
55
+ )
56
+
57
+ with context.begin_transaction():
58
+ context.run_migrations()
59
+
60
+
61
+ if context.is_offline_mode():
62
+ run_migrations_offline()
63
+ else:
64
+ run_migrations_online()
alembic_db/script.py.mako ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """${message}
2
+
3
+ Revision ID: ${up_revision}
4
+ Revises: ${down_revision | comma,n}
5
+ Create Date: ${create_date}
6
+
7
+ """
8
+ from typing import Sequence, Union
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+ ${imports if imports else ""}
13
+
14
+ # revision identifiers, used by Alembic.
15
+ revision: str = ${repr(up_revision)}
16
+ down_revision: Union[str, None] = ${repr(down_revision)}
17
+ branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
18
+ depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
19
+
20
+
21
+ def upgrade() -> None:
22
+ """Upgrade schema."""
23
+ ${upgrades if upgrades else "pass"}
24
+
25
+
26
+ def downgrade() -> None:
27
+ """Downgrade schema."""
28
+ ${downgrades if downgrades else "pass"}
api_server/__init__.py ADDED
File without changes
api_server/routes/__init__.py ADDED
File without changes
api_server/routes/internal/README.md ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ # ComfyUI Internal Routes
2
+
3
+ All routes under the `/internal` path are designated for **internal use by ComfyUI only**. These routes are not intended for use by external applications may change at any time without notice.
api_server/routes/internal/__init__.py ADDED
File without changes
api_server/routes/internal/internal_routes.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from aiohttp import web
2
+ from typing import Optional
3
+ from folder_paths import folder_names_and_paths, get_directory_by_type
4
+ from api_server.services.terminal_service import TerminalService
5
+ import app.logger
6
+ import os
7
+
8
+ class InternalRoutes:
9
+ '''
10
+ The top level web router for internal routes: /internal/*
11
+ The endpoints here should NOT be depended upon. It is for ComfyUI frontend use only.
12
+ Check README.md for more information.
13
+ '''
14
+
15
+ def __init__(self, prompt_server):
16
+ self.routes: web.RouteTableDef = web.RouteTableDef()
17
+ self._app: Optional[web.Application] = None
18
+ self.prompt_server = prompt_server
19
+ self.terminal_service = TerminalService(prompt_server)
20
+
21
+ def setup_routes(self):
22
+ @self.routes.get('/logs')
23
+ async def get_logs(request):
24
+ return web.json_response("".join([(l["t"] + " - " + l["m"]) for l in app.logger.get_logs()]))
25
+
26
+ @self.routes.get('/logs/raw')
27
+ async def get_raw_logs(request):
28
+ self.terminal_service.update_size()
29
+ return web.json_response({
30
+ "entries": list(app.logger.get_logs()),
31
+ "size": {"cols": self.terminal_service.cols, "rows": self.terminal_service.rows}
32
+ })
33
+
34
+ @self.routes.patch('/logs/subscribe')
35
+ async def subscribe_logs(request):
36
+ json_data = await request.json()
37
+ client_id = json_data["clientId"]
38
+ enabled = json_data["enabled"]
39
+ if enabled:
40
+ self.terminal_service.subscribe(client_id)
41
+ else:
42
+ self.terminal_service.unsubscribe(client_id)
43
+
44
+ return web.Response(status=200)
45
+
46
+
47
+ @self.routes.get('/folder_paths')
48
+ async def get_folder_paths(request):
49
+ response = {}
50
+ for key in folder_names_and_paths:
51
+ response[key] = folder_names_and_paths[key][0]
52
+ return web.json_response(response)
53
+
54
+ @self.routes.get('/files/{directory_type}')
55
+ async def get_files(request: web.Request) -> web.Response:
56
+ directory_type = request.match_info['directory_type']
57
+ if directory_type not in ("output", "input", "temp"):
58
+ return web.json_response({"error": "Invalid directory type"}, status=400)
59
+
60
+ directory = get_directory_by_type(directory_type)
61
+ sorted_files = sorted(
62
+ (entry for entry in os.scandir(directory) if entry.is_file()),
63
+ key=lambda entry: -entry.stat().st_mtime
64
+ )
65
+ return web.json_response([entry.name for entry in sorted_files], status=200)
66
+
67
+
68
+ def get_app(self):
69
+ if self._app is None:
70
+ self._app = web.Application()
71
+ self.setup_routes()
72
+ self._app.add_routes(self.routes)
73
+ return self._app
api_server/services/__init__.py ADDED
File without changes
api_server/services/terminal_service.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from app.logger import on_flush
2
+ import os
3
+ import shutil
4
+
5
+
6
+ class TerminalService:
7
+ def __init__(self, server):
8
+ self.server = server
9
+ self.cols = None
10
+ self.rows = None
11
+ self.subscriptions = set()
12
+ on_flush(self.send_messages)
13
+
14
+ def get_terminal_size(self):
15
+ try:
16
+ size = os.get_terminal_size()
17
+ return (size.columns, size.lines)
18
+ except OSError:
19
+ try:
20
+ size = shutil.get_terminal_size()
21
+ return (size.columns, size.lines)
22
+ except OSError:
23
+ return (80, 24) # fallback to 80x24
24
+
25
+ def update_size(self):
26
+ columns, lines = self.get_terminal_size()
27
+ changed = False
28
+
29
+ if columns != self.cols:
30
+ self.cols = columns
31
+ changed = True
32
+
33
+ if lines != self.rows:
34
+ self.rows = lines
35
+ changed = True
36
+
37
+ if changed:
38
+ return {"cols": self.cols, "rows": self.rows}
39
+
40
+ return None
41
+
42
+ def subscribe(self, client_id):
43
+ self.subscriptions.add(client_id)
44
+
45
+ def unsubscribe(self, client_id):
46
+ self.subscriptions.discard(client_id)
47
+
48
+ def send_messages(self, entries):
49
+ if not len(entries) or not len(self.subscriptions):
50
+ return
51
+
52
+ new_size = self.update_size()
53
+
54
+ for client_id in self.subscriptions.copy(): # prevent: Set changed size during iteration
55
+ if client_id not in self.server.sockets:
56
+ # Automatically unsub if the socket has disconnected
57
+ self.unsubscribe(client_id)
58
+ continue
59
+
60
+ self.server.send_sync("logs", {"entries": entries, "size": new_size}, client_id)
api_server/utils/file_operations.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from typing import List, Union, TypedDict, Literal
3
+ from typing_extensions import TypeGuard
4
+ class FileInfo(TypedDict):
5
+ name: str
6
+ path: str
7
+ type: Literal["file"]
8
+ size: int
9
+
10
+ class DirectoryInfo(TypedDict):
11
+ name: str
12
+ path: str
13
+ type: Literal["directory"]
14
+
15
+ FileSystemItem = Union[FileInfo, DirectoryInfo]
16
+
17
+ def is_file_info(item: FileSystemItem) -> TypeGuard[FileInfo]:
18
+ return item["type"] == "file"
19
+
20
+ class FileSystemOperations:
21
+ @staticmethod
22
+ def walk_directory(directory: str) -> List[FileSystemItem]:
23
+ file_list: List[FileSystemItem] = []
24
+ for root, dirs, files in os.walk(directory):
25
+ for name in files:
26
+ file_path = os.path.join(root, name)
27
+ relative_path = os.path.relpath(file_path, directory)
28
+ file_list.append({
29
+ "name": name,
30
+ "path": relative_path,
31
+ "type": "file",
32
+ "size": os.path.getsize(file_path)
33
+ })
34
+ for name in dirs:
35
+ dir_path = os.path.join(root, name)
36
+ relative_path = os.path.relpath(dir_path, directory)
37
+ file_list.append({
38
+ "name": name,
39
+ "path": relative_path,
40
+ "type": "directory"
41
+ })
42
+ return file_list
app/__init__.py ADDED
File without changes
app/app_settings.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ from aiohttp import web
4
+ import logging
5
+
6
+
7
+ class AppSettings():
8
+ def __init__(self, user_manager):
9
+ self.user_manager = user_manager
10
+
11
+ def get_settings(self, request):
12
+ try:
13
+ file = self.user_manager.get_request_user_filepath(
14
+ request,
15
+ "comfy.settings.json"
16
+ )
17
+ except KeyError as e:
18
+ logging.error("User settings not found.")
19
+ raise web.HTTPUnauthorized() from e
20
+ if os.path.isfile(file):
21
+ try:
22
+ with open(file) as f:
23
+ return json.load(f)
24
+ except:
25
+ logging.error(f"The user settings file is corrupted: {file}")
26
+ return {}
27
+ else:
28
+ return {}
29
+
30
+ def save_settings(self, request, settings):
31
+ file = self.user_manager.get_request_user_filepath(
32
+ request, "comfy.settings.json")
33
+ with open(file, "w") as f:
34
+ f.write(json.dumps(settings, indent=4))
35
+
36
+ def add_routes(self, routes):
37
+ @routes.get("/settings")
38
+ async def get_settings(request):
39
+ return web.json_response(self.get_settings(request))
40
+
41
+ @routes.get("/settings/{id}")
42
+ async def get_setting(request):
43
+ value = None
44
+ settings = self.get_settings(request)
45
+ setting_id = request.match_info.get("id", None)
46
+ if setting_id and setting_id in settings:
47
+ value = settings[setting_id]
48
+ return web.json_response(value)
49
+
50
+ @routes.post("/settings")
51
+ async def post_settings(request):
52
+ settings = self.get_settings(request)
53
+ new_settings = await request.json()
54
+ self.save_settings(request, {**settings, **new_settings})
55
+ return web.Response(status=200)
56
+
57
+ @routes.post("/settings/{id}")
58
+ async def post_setting(request):
59
+ setting_id = request.match_info.get("id", None)
60
+ if not setting_id:
61
+ return web.Response(status=400)
62
+ settings = self.get_settings(request)
63
+ settings[setting_id] = await request.json()
64
+ self.save_settings(request, settings)
65
+ return web.Response(status=200)
app/custom_node_manager.py ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import folder_paths
5
+ import glob
6
+ from aiohttp import web
7
+ import json
8
+ import logging
9
+ from functools import lru_cache
10
+
11
+ from utils.json_util import merge_json_recursive
12
+
13
+
14
+ # Extra locale files to load into main.json
15
+ EXTRA_LOCALE_FILES = [
16
+ "nodeDefs.json",
17
+ "commands.json",
18
+ "settings.json",
19
+ ]
20
+
21
+
22
+ def safe_load_json_file(file_path: str) -> dict:
23
+ if not os.path.exists(file_path):
24
+ return {}
25
+
26
+ try:
27
+ with open(file_path, "r", encoding="utf-8") as f:
28
+ return json.load(f)
29
+ except json.JSONDecodeError:
30
+ logging.error(f"Error loading {file_path}")
31
+ return {}
32
+
33
+
34
+ class CustomNodeManager:
35
+ @lru_cache(maxsize=1)
36
+ def build_translations(self):
37
+ """Load all custom nodes translations during initialization. Translations are
38
+ expected to be loaded from `locales/` folder.
39
+
40
+ The folder structure is expected to be the following:
41
+ - custom_nodes/
42
+ - custom_node_1/
43
+ - locales/
44
+ - en/
45
+ - main.json
46
+ - commands.json
47
+ - settings.json
48
+
49
+ returned translations are expected to be in the following format:
50
+ {
51
+ "en": {
52
+ "nodeDefs": {...},
53
+ "commands": {...},
54
+ "settings": {...},
55
+ ...{other main.json keys}
56
+ }
57
+ }
58
+ """
59
+
60
+ translations = {}
61
+
62
+ for folder in folder_paths.get_folder_paths("custom_nodes"):
63
+ # Sort glob results for deterministic ordering
64
+ for custom_node_dir in sorted(glob.glob(os.path.join(folder, "*/"))):
65
+ locales_dir = os.path.join(custom_node_dir, "locales")
66
+ if not os.path.exists(locales_dir):
67
+ continue
68
+
69
+ for lang_dir in glob.glob(os.path.join(locales_dir, "*/")):
70
+ lang_code = os.path.basename(os.path.dirname(lang_dir))
71
+
72
+ if lang_code not in translations:
73
+ translations[lang_code] = {}
74
+
75
+ # Load main.json
76
+ main_file = os.path.join(lang_dir, "main.json")
77
+ node_translations = safe_load_json_file(main_file)
78
+
79
+ # Load extra locale files
80
+ for extra_file in EXTRA_LOCALE_FILES:
81
+ extra_file_path = os.path.join(lang_dir, extra_file)
82
+ key = extra_file.split(".")[0]
83
+ json_data = safe_load_json_file(extra_file_path)
84
+ if json_data:
85
+ node_translations[key] = json_data
86
+
87
+ if node_translations:
88
+ translations[lang_code] = merge_json_recursive(
89
+ translations[lang_code], node_translations
90
+ )
91
+
92
+ return translations
93
+
94
+ def add_routes(self, routes, webapp, loadedModules):
95
+
96
+ example_workflow_folder_names = ["example_workflows", "example", "examples", "workflow", "workflows"]
97
+
98
+ @routes.get("/workflow_templates")
99
+ async def get_workflow_templates(request):
100
+ """Returns a web response that contains the map of custom_nodes names and their associated workflow templates. The ones without templates are omitted."""
101
+
102
+ files = []
103
+
104
+ for folder in folder_paths.get_folder_paths("custom_nodes"):
105
+ for folder_name in example_workflow_folder_names:
106
+ pattern = os.path.join(folder, f"*/{folder_name}/*.json")
107
+ matched_files = glob.glob(pattern)
108
+ files.extend(matched_files)
109
+
110
+ workflow_templates_dict = (
111
+ {}
112
+ ) # custom_nodes folder name -> example workflow names
113
+ for file in files:
114
+ custom_nodes_name = os.path.basename(
115
+ os.path.dirname(os.path.dirname(file))
116
+ )
117
+ workflow_name = os.path.splitext(os.path.basename(file))[0]
118
+ workflow_templates_dict.setdefault(custom_nodes_name, []).append(
119
+ workflow_name
120
+ )
121
+ return web.json_response(workflow_templates_dict)
122
+
123
+ # Serve workflow templates from custom nodes.
124
+ for module_name, module_dir in loadedModules:
125
+ for folder_name in example_workflow_folder_names:
126
+ workflows_dir = os.path.join(module_dir, folder_name)
127
+
128
+ if os.path.exists(workflows_dir):
129
+ if folder_name != "example_workflows":
130
+ logging.debug(
131
+ "Found example workflow folder '%s' for custom node '%s', consider renaming it to 'example_workflows'",
132
+ folder_name, module_name)
133
+
134
+ webapp.add_routes(
135
+ [
136
+ web.static(
137
+ "/api/workflow_templates/" + module_name, workflows_dir
138
+ )
139
+ ]
140
+ )
141
+
142
+ @routes.get("/i18n")
143
+ async def get_i18n(request):
144
+ """Returns translations from all custom nodes' locales folders."""
145
+ return web.json_response(self.build_translations())
app/database/db.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import os
3
+ import shutil
4
+ from app.logger import log_startup_warning
5
+ from utils.install_util import get_missing_requirements_message
6
+ from comfy.cli_args import args
7
+
8
+ _DB_AVAILABLE = False
9
+ Session = None
10
+
11
+
12
+ try:
13
+ from alembic import command
14
+ from alembic.config import Config
15
+ from alembic.runtime.migration import MigrationContext
16
+ from alembic.script import ScriptDirectory
17
+ from sqlalchemy import create_engine
18
+ from sqlalchemy.orm import sessionmaker
19
+
20
+ _DB_AVAILABLE = True
21
+ except ImportError as e:
22
+ log_startup_warning(
23
+ f"""
24
+ ------------------------------------------------------------------------
25
+ Error importing dependencies: {e}
26
+ {get_missing_requirements_message()}
27
+ This error is happening because ComfyUI now uses a local sqlite database.
28
+ ------------------------------------------------------------------------
29
+ """.strip()
30
+ )
31
+
32
+
33
+ def dependencies_available():
34
+ """
35
+ Temporary function to check if the dependencies are available
36
+ """
37
+ return _DB_AVAILABLE
38
+
39
+
40
+ def can_create_session():
41
+ """
42
+ Temporary function to check if the database is available to create a session
43
+ During initial release there may be environmental issues (or missing dependencies) that prevent the database from being created
44
+ """
45
+ return dependencies_available() and Session is not None
46
+
47
+
48
+ def get_alembic_config():
49
+ root_path = os.path.join(os.path.dirname(__file__), "../..")
50
+ config_path = os.path.abspath(os.path.join(root_path, "alembic.ini"))
51
+ scripts_path = os.path.abspath(os.path.join(root_path, "alembic_db"))
52
+
53
+ config = Config(config_path)
54
+ config.set_main_option("script_location", scripts_path)
55
+ config.set_main_option("sqlalchemy.url", args.database_url)
56
+
57
+ return config
58
+
59
+
60
+ def get_db_path():
61
+ url = args.database_url
62
+ if url.startswith("sqlite:///"):
63
+ return url.split("///")[1]
64
+ else:
65
+ raise ValueError(f"Unsupported database URL '{url}'.")
66
+
67
+
68
+ def init_db():
69
+ db_url = args.database_url
70
+ logging.debug(f"Database URL: {db_url}")
71
+ db_path = get_db_path()
72
+ db_exists = os.path.exists(db_path)
73
+
74
+ config = get_alembic_config()
75
+
76
+ # Check if we need to upgrade
77
+ engine = create_engine(db_url)
78
+ conn = engine.connect()
79
+
80
+ context = MigrationContext.configure(conn)
81
+ current_rev = context.get_current_revision()
82
+
83
+ script = ScriptDirectory.from_config(config)
84
+ target_rev = script.get_current_head()
85
+
86
+ if target_rev is None:
87
+ logging.warning("No target revision found.")
88
+ elif current_rev != target_rev:
89
+ # Backup the database pre upgrade
90
+ backup_path = db_path + ".bkp"
91
+ if db_exists:
92
+ shutil.copy(db_path, backup_path)
93
+ else:
94
+ backup_path = None
95
+
96
+ try:
97
+ command.upgrade(config, target_rev)
98
+ logging.info(f"Database upgraded from {current_rev} to {target_rev}")
99
+ except Exception as e:
100
+ if backup_path:
101
+ # Restore the database from backup if upgrade fails
102
+ shutil.copy(backup_path, db_path)
103
+ os.remove(backup_path)
104
+ logging.exception("Error upgrading database: ")
105
+ raise e
106
+
107
+ global Session
108
+ Session = sessionmaker(bind=engine)
109
+
110
+
111
+ def create_session():
112
+ return Session()
app/database/models.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy.orm import declarative_base
2
+
3
+ Base = declarative_base()
4
+
5
+
6
+ def to_dict(obj):
7
+ fields = obj.__table__.columns.keys()
8
+ return {
9
+ field: (val.to_dict() if hasattr(val, "to_dict") else val)
10
+ for field in fields
11
+ if (val := getattr(obj, field))
12
+ }
13
+
14
+ # TODO: Define models here
app/frontend_management.py ADDED
@@ -0,0 +1,326 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ import argparse
3
+ import logging
4
+ import os
5
+ import re
6
+ import sys
7
+ import tempfile
8
+ import zipfile
9
+ import importlib
10
+ from dataclasses import dataclass
11
+ from functools import cached_property
12
+ from pathlib import Path
13
+ from typing import TypedDict, Optional
14
+ from importlib.metadata import version
15
+
16
+ import requests
17
+ from typing_extensions import NotRequired
18
+
19
+ from utils.install_util import get_missing_requirements_message, requirements_path
20
+
21
+ from comfy.cli_args import DEFAULT_VERSION_STRING
22
+ import app.logger
23
+
24
+
25
+ def frontend_install_warning_message():
26
+ return f"""
27
+ {get_missing_requirements_message()}
28
+
29
+ This error is happening because the ComfyUI frontend is no longer shipped as part of the main repo but as a pip package instead.
30
+ """.strip()
31
+
32
+
33
+ def check_frontend_version():
34
+ """Check if the frontend version is up to date."""
35
+
36
+ def parse_version(version: str) -> tuple[int, int, int]:
37
+ return tuple(map(int, version.split(".")))
38
+
39
+ try:
40
+ frontend_version_str = version("comfyui-frontend-package")
41
+ frontend_version = parse_version(frontend_version_str)
42
+ with open(requirements_path, "r", encoding="utf-8") as f:
43
+ required_frontend = parse_version(f.readline().split("=")[-1])
44
+ if frontend_version < required_frontend:
45
+ app.logger.log_startup_warning(
46
+ f"""
47
+ ________________________________________________________________________
48
+ WARNING WARNING WARNING WARNING WARNING
49
+
50
+ Installed frontend version {".".join(map(str, frontend_version))} is lower than the recommended version {".".join(map(str, required_frontend))}.
51
+
52
+ {frontend_install_warning_message()}
53
+ ________________________________________________________________________
54
+ """.strip()
55
+ )
56
+ else:
57
+ logging.info("ComfyUI frontend version: {}".format(frontend_version_str))
58
+ except Exception as e:
59
+ logging.error(f"Failed to check frontend version: {e}")
60
+
61
+
62
+ REQUEST_TIMEOUT = 10 # seconds
63
+
64
+
65
+ class Asset(TypedDict):
66
+ url: str
67
+
68
+
69
+ class Release(TypedDict):
70
+ id: int
71
+ tag_name: str
72
+ name: str
73
+ prerelease: bool
74
+ created_at: str
75
+ published_at: str
76
+ body: str
77
+ assets: NotRequired[list[Asset]]
78
+
79
+
80
+ @dataclass
81
+ class FrontEndProvider:
82
+ owner: str
83
+ repo: str
84
+
85
+ @property
86
+ def folder_name(self) -> str:
87
+ return f"{self.owner}_{self.repo}"
88
+
89
+ @property
90
+ def release_url(self) -> str:
91
+ return f"https://api.github.com/repos/{self.owner}/{self.repo}/releases"
92
+
93
+ @cached_property
94
+ def all_releases(self) -> list[Release]:
95
+ releases = []
96
+ api_url = self.release_url
97
+ while api_url:
98
+ response = requests.get(api_url, timeout=REQUEST_TIMEOUT)
99
+ response.raise_for_status() # Raises an HTTPError if the response was an error
100
+ releases.extend(response.json())
101
+ # GitHub uses the Link header to provide pagination links. Check if it exists and update api_url accordingly.
102
+ if "next" in response.links:
103
+ api_url = response.links["next"]["url"]
104
+ else:
105
+ api_url = None
106
+ return releases
107
+
108
+ @cached_property
109
+ def latest_release(self) -> Release:
110
+ latest_release_url = f"{self.release_url}/latest"
111
+ response = requests.get(latest_release_url, timeout=REQUEST_TIMEOUT)
112
+ response.raise_for_status() # Raises an HTTPError if the response was an error
113
+ return response.json()
114
+
115
+ @cached_property
116
+ def latest_prerelease(self) -> Release:
117
+ """Get the latest pre-release version - even if it's older than the latest release"""
118
+ release = [release for release in self.all_releases if release["prerelease"]]
119
+
120
+ if not release:
121
+ raise ValueError("No pre-releases found")
122
+
123
+ # GitHub returns releases in reverse chronological order, so first is latest
124
+ return release[0]
125
+
126
+ def get_release(self, version: str) -> Release:
127
+ if version == "latest":
128
+ return self.latest_release
129
+ elif version == "prerelease":
130
+ return self.latest_prerelease
131
+ else:
132
+ for release in self.all_releases:
133
+ if release["tag_name"] in [version, f"v{version}"]:
134
+ return release
135
+ raise ValueError(f"Version {version} not found in releases")
136
+
137
+
138
+ def download_release_asset_zip(release: Release, destination_path: str) -> None:
139
+ """Download dist.zip from github release."""
140
+ asset_url = None
141
+ for asset in release.get("assets", []):
142
+ if asset["name"] == "dist.zip":
143
+ asset_url = asset["url"]
144
+ break
145
+
146
+ if not asset_url:
147
+ raise ValueError("dist.zip not found in the release assets")
148
+
149
+ # Use a temporary file to download the zip content
150
+ with tempfile.TemporaryFile() as tmp_file:
151
+ headers = {"Accept": "application/octet-stream"}
152
+ response = requests.get(
153
+ asset_url, headers=headers, allow_redirects=True, timeout=REQUEST_TIMEOUT
154
+ )
155
+ response.raise_for_status() # Ensure we got a successful response
156
+
157
+ # Write the content to the temporary file
158
+ tmp_file.write(response.content)
159
+
160
+ # Go back to the beginning of the temporary file
161
+ tmp_file.seek(0)
162
+
163
+ # Extract the zip file content to the destination path
164
+ with zipfile.ZipFile(tmp_file, "r") as zip_ref:
165
+ zip_ref.extractall(destination_path)
166
+
167
+
168
+ class FrontendManager:
169
+ CUSTOM_FRONTENDS_ROOT = str(Path(__file__).parents[1] / "web_custom_versions")
170
+
171
+ @classmethod
172
+ def default_frontend_path(cls) -> str:
173
+ try:
174
+ import comfyui_frontend_package
175
+
176
+ return str(importlib.resources.files(comfyui_frontend_package) / "static")
177
+ except ImportError:
178
+ logging.error(
179
+ f"""
180
+ ********** ERROR ***********
181
+
182
+ comfyui-frontend-package is not installed.
183
+
184
+ {frontend_install_warning_message()}
185
+
186
+ ********** ERROR ***********
187
+ """.strip()
188
+ )
189
+ sys.exit(-1)
190
+
191
+ @classmethod
192
+ def templates_path(cls) -> str:
193
+ try:
194
+ import comfyui_workflow_templates
195
+
196
+ return str(
197
+ importlib.resources.files(comfyui_workflow_templates) / "templates"
198
+ )
199
+ except ImportError:
200
+ logging.error(
201
+ f"""
202
+ ********** ERROR ***********
203
+
204
+ comfyui-workflow-templates is not installed.
205
+
206
+ {frontend_install_warning_message()}
207
+
208
+ ********** ERROR ***********
209
+ """.strip()
210
+ )
211
+
212
+ @classmethod
213
+ def embedded_docs_path(cls) -> str:
214
+ """Get the path to embedded documentation"""
215
+ try:
216
+ import comfyui_embedded_docs
217
+
218
+ return str(
219
+ importlib.resources.files(comfyui_embedded_docs) / "docs"
220
+ )
221
+ except ImportError:
222
+ logging.info("comfyui-embedded-docs package not found")
223
+ return None
224
+
225
+ @classmethod
226
+ def parse_version_string(cls, value: str) -> tuple[str, str, str]:
227
+ """
228
+ Args:
229
+ value (str): The version string to parse.
230
+
231
+ Returns:
232
+ tuple[str, str]: A tuple containing provider name and version.
233
+
234
+ Raises:
235
+ argparse.ArgumentTypeError: If the version string is invalid.
236
+ """
237
+ VERSION_PATTERN = r"^([a-zA-Z0-9][a-zA-Z0-9-]{0,38})/([a-zA-Z0-9_.-]+)@(v?\d+\.\d+\.\d+[-._a-zA-Z0-9]*|latest|prerelease)$"
238
+ match_result = re.match(VERSION_PATTERN, value)
239
+ if match_result is None:
240
+ raise argparse.ArgumentTypeError(f"Invalid version string: {value}")
241
+
242
+ return match_result.group(1), match_result.group(2), match_result.group(3)
243
+
244
+ @classmethod
245
+ def init_frontend_unsafe(
246
+ cls, version_string: str, provider: Optional[FrontEndProvider] = None
247
+ ) -> str:
248
+ """
249
+ Initializes the frontend for the specified version.
250
+
251
+ Args:
252
+ version_string (str): The version string.
253
+ provider (FrontEndProvider, optional): The provider to use. Defaults to None.
254
+
255
+ Returns:
256
+ str: The path to the initialized frontend.
257
+
258
+ Raises:
259
+ Exception: If there is an error during the initialization process.
260
+ main error source might be request timeout or invalid URL.
261
+ """
262
+ if version_string == DEFAULT_VERSION_STRING:
263
+ check_frontend_version()
264
+ return cls.default_frontend_path()
265
+
266
+ repo_owner, repo_name, version = cls.parse_version_string(version_string)
267
+
268
+ if version.startswith("v"):
269
+ expected_path = str(
270
+ Path(cls.CUSTOM_FRONTENDS_ROOT)
271
+ / f"{repo_owner}_{repo_name}"
272
+ / version.lstrip("v")
273
+ )
274
+ if os.path.exists(expected_path):
275
+ logging.info(
276
+ f"Using existing copy of specific frontend version tag: {repo_owner}/{repo_name}@{version}"
277
+ )
278
+ return expected_path
279
+
280
+ logging.info(
281
+ f"Initializing frontend: {repo_owner}/{repo_name}@{version}, requesting version details from GitHub..."
282
+ )
283
+
284
+ provider = provider or FrontEndProvider(repo_owner, repo_name)
285
+ release = provider.get_release(version)
286
+
287
+ semantic_version = release["tag_name"].lstrip("v")
288
+ web_root = str(
289
+ Path(cls.CUSTOM_FRONTENDS_ROOT) / provider.folder_name / semantic_version
290
+ )
291
+ if not os.path.exists(web_root):
292
+ try:
293
+ os.makedirs(web_root, exist_ok=True)
294
+ logging.info(
295
+ "Downloading frontend(%s) version(%s) to (%s)",
296
+ provider.folder_name,
297
+ semantic_version,
298
+ web_root,
299
+ )
300
+ logging.debug(release)
301
+ download_release_asset_zip(release, destination_path=web_root)
302
+ finally:
303
+ # Clean up the directory if it is empty, i.e. the download failed
304
+ if not os.listdir(web_root):
305
+ os.rmdir(web_root)
306
+
307
+ return web_root
308
+
309
+ @classmethod
310
+ def init_frontend(cls, version_string: str) -> str:
311
+ """
312
+ Initializes the frontend with the specified version string.
313
+
314
+ Args:
315
+ version_string (str): The version string to initialize the frontend with.
316
+
317
+ Returns:
318
+ str: The path of the initialized frontend.
319
+ """
320
+ try:
321
+ return cls.init_frontend_unsafe(version_string)
322
+ except Exception as e:
323
+ logging.error("Failed to initialize frontend: %s", e)
324
+ logging.info("Falling back to the default frontend.")
325
+ check_frontend_version()
326
+ return cls.default_frontend_path()
app/logger.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from collections import deque
2
+ from datetime import datetime
3
+ import io
4
+ import logging
5
+ import sys
6
+ import threading
7
+
8
+ logs = None
9
+ stdout_interceptor = None
10
+ stderr_interceptor = None
11
+
12
+
13
+ class LogInterceptor(io.TextIOWrapper):
14
+ def __init__(self, stream, *args, **kwargs):
15
+ buffer = stream.buffer
16
+ encoding = stream.encoding
17
+ super().__init__(buffer, *args, **kwargs, encoding=encoding, line_buffering=stream.line_buffering)
18
+ self._lock = threading.Lock()
19
+ self._flush_callbacks = []
20
+ self._logs_since_flush = []
21
+
22
+ def write(self, data):
23
+ entry = {"t": datetime.now().isoformat(), "m": data}
24
+ with self._lock:
25
+ self._logs_since_flush.append(entry)
26
+
27
+ # Simple handling for cr to overwrite the last output if it isnt a full line
28
+ # else logs just get full of progress messages
29
+ if isinstance(data, str) and data.startswith("\r") and not logs[-1]["m"].endswith("\n"):
30
+ logs.pop()
31
+ logs.append(entry)
32
+ super().write(data)
33
+
34
+ def flush(self):
35
+ super().flush()
36
+ for cb in self._flush_callbacks:
37
+ cb(self._logs_since_flush)
38
+ self._logs_since_flush = []
39
+
40
+ def on_flush(self, callback):
41
+ self._flush_callbacks.append(callback)
42
+
43
+
44
+ def get_logs():
45
+ return logs
46
+
47
+
48
+ def on_flush(callback):
49
+ if stdout_interceptor is not None:
50
+ stdout_interceptor.on_flush(callback)
51
+ if stderr_interceptor is not None:
52
+ stderr_interceptor.on_flush(callback)
53
+
54
+ def setup_logger(log_level: str = 'INFO', capacity: int = 300, use_stdout: bool = False):
55
+ global logs
56
+ if logs:
57
+ return
58
+
59
+ # Override output streams and log to buffer
60
+ logs = deque(maxlen=capacity)
61
+
62
+ global stdout_interceptor
63
+ global stderr_interceptor
64
+ stdout_interceptor = sys.stdout = LogInterceptor(sys.stdout)
65
+ stderr_interceptor = sys.stderr = LogInterceptor(sys.stderr)
66
+
67
+ # Setup default global logger
68
+ logger = logging.getLogger()
69
+ logger.setLevel(log_level)
70
+
71
+ stream_handler = logging.StreamHandler()
72
+ stream_handler.setFormatter(logging.Formatter("%(message)s"))
73
+
74
+ if use_stdout:
75
+ # Only errors and critical to stderr
76
+ stream_handler.addFilter(lambda record: not record.levelno < logging.ERROR)
77
+
78
+ # Lesser to stdout
79
+ stdout_handler = logging.StreamHandler(sys.stdout)
80
+ stdout_handler.setFormatter(logging.Formatter("%(message)s"))
81
+ stdout_handler.addFilter(lambda record: record.levelno < logging.ERROR)
82
+ logger.addHandler(stdout_handler)
83
+
84
+ logger.addHandler(stream_handler)
85
+
86
+
87
+ STARTUP_WARNINGS = []
88
+
89
+
90
+ def log_startup_warning(msg):
91
+ logging.warning(msg)
92
+ STARTUP_WARNINGS.append(msg)
93
+
94
+
95
+ def print_startup_warnings():
96
+ for s in STARTUP_WARNINGS:
97
+ logging.warning(s)
98
+ STARTUP_WARNINGS.clear()
app/model_manager.py ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import base64
5
+ import json
6
+ import time
7
+ import logging
8
+ import folder_paths
9
+ import glob
10
+ import comfy.utils
11
+ from aiohttp import web
12
+ from PIL import Image
13
+ from io import BytesIO
14
+ from folder_paths import map_legacy, filter_files_extensions, filter_files_content_types
15
+
16
+
17
+ class ModelFileManager:
18
+ def __init__(self) -> None:
19
+ self.cache: dict[str, tuple[list[dict], dict[str, float], float]] = {}
20
+
21
+ def get_cache(self, key: str, default=None) -> tuple[list[dict], dict[str, float], float] | None:
22
+ return self.cache.get(key, default)
23
+
24
+ def set_cache(self, key: str, value: tuple[list[dict], dict[str, float], float]):
25
+ self.cache[key] = value
26
+
27
+ def clear_cache(self):
28
+ self.cache.clear()
29
+
30
+ def add_routes(self, routes):
31
+ # NOTE: This is an experiment to replace `/models`
32
+ @routes.get("/experiment/models")
33
+ async def get_model_folders(request):
34
+ model_types = list(folder_paths.folder_names_and_paths.keys())
35
+ folder_black_list = ["configs", "custom_nodes"]
36
+ output_folders: list[dict] = []
37
+ for folder in model_types:
38
+ if folder in folder_black_list:
39
+ continue
40
+ output_folders.append({"name": folder, "folders": folder_paths.get_folder_paths(folder)})
41
+ return web.json_response(output_folders)
42
+
43
+ # NOTE: This is an experiment to replace `/models/{folder}`
44
+ @routes.get("/experiment/models/{folder}")
45
+ async def get_all_models(request):
46
+ folder = request.match_info.get("folder", None)
47
+ if not folder in folder_paths.folder_names_and_paths:
48
+ return web.Response(status=404)
49
+ files = self.get_model_file_list(folder)
50
+ return web.json_response(files)
51
+
52
+ @routes.get("/experiment/models/preview/{folder}/{path_index}/{filename:.*}")
53
+ async def get_model_preview(request):
54
+ folder_name = request.match_info.get("folder", None)
55
+ path_index = int(request.match_info.get("path_index", None))
56
+ filename = request.match_info.get("filename", None)
57
+
58
+ if not folder_name in folder_paths.folder_names_and_paths:
59
+ return web.Response(status=404)
60
+
61
+ folders = folder_paths.folder_names_and_paths[folder_name]
62
+ folder = folders[0][path_index]
63
+ full_filename = os.path.join(folder, filename)
64
+
65
+ previews = self.get_model_previews(full_filename)
66
+ default_preview = previews[0] if len(previews) > 0 else None
67
+ if default_preview is None or (isinstance(default_preview, str) and not os.path.isfile(default_preview)):
68
+ return web.Response(status=404)
69
+
70
+ try:
71
+ with Image.open(default_preview) as img:
72
+ img_bytes = BytesIO()
73
+ img.save(img_bytes, format="WEBP")
74
+ img_bytes.seek(0)
75
+ return web.Response(body=img_bytes.getvalue(), content_type="image/webp")
76
+ except:
77
+ return web.Response(status=404)
78
+
79
+ def get_model_file_list(self, folder_name: str):
80
+ folder_name = map_legacy(folder_name)
81
+ folders = folder_paths.folder_names_and_paths[folder_name]
82
+ output_list: list[dict] = []
83
+
84
+ for index, folder in enumerate(folders[0]):
85
+ if not os.path.isdir(folder):
86
+ continue
87
+ out = self.cache_model_file_list_(folder)
88
+ if out is None:
89
+ out = self.recursive_search_models_(folder, index)
90
+ self.set_cache(folder, out)
91
+ output_list.extend(out[0])
92
+
93
+ return output_list
94
+
95
+ def cache_model_file_list_(self, folder: str):
96
+ model_file_list_cache = self.get_cache(folder)
97
+
98
+ if model_file_list_cache is None:
99
+ return None
100
+ if not os.path.isdir(folder):
101
+ return None
102
+ if os.path.getmtime(folder) != model_file_list_cache[1]:
103
+ return None
104
+ for x in model_file_list_cache[1]:
105
+ time_modified = model_file_list_cache[1][x]
106
+ folder = x
107
+ if os.path.getmtime(folder) != time_modified:
108
+ return None
109
+
110
+ return model_file_list_cache
111
+
112
+ def recursive_search_models_(self, directory: str, pathIndex: int) -> tuple[list[str], dict[str, float], float]:
113
+ if not os.path.isdir(directory):
114
+ return [], {}, time.perf_counter()
115
+
116
+ excluded_dir_names = [".git"]
117
+ # TODO use settings
118
+ include_hidden_files = False
119
+
120
+ result: list[str] = []
121
+ dirs: dict[str, float] = {}
122
+
123
+ for dirpath, subdirs, filenames in os.walk(directory, followlinks=True, topdown=True):
124
+ subdirs[:] = [d for d in subdirs if d not in excluded_dir_names]
125
+ if not include_hidden_files:
126
+ subdirs[:] = [d for d in subdirs if not d.startswith(".")]
127
+ filenames = [f for f in filenames if not f.startswith(".")]
128
+
129
+ filenames = filter_files_extensions(filenames, folder_paths.supported_pt_extensions)
130
+
131
+ for file_name in filenames:
132
+ try:
133
+ relative_path = os.path.relpath(os.path.join(dirpath, file_name), directory)
134
+ result.append(relative_path)
135
+ except:
136
+ logging.warning(f"Warning: Unable to access {file_name}. Skipping this file.")
137
+ continue
138
+
139
+ for d in subdirs:
140
+ path: str = os.path.join(dirpath, d)
141
+ try:
142
+ dirs[path] = os.path.getmtime(path)
143
+ except FileNotFoundError:
144
+ logging.warning(f"Warning: Unable to access {path}. Skipping this path.")
145
+ continue
146
+
147
+ return [{"name": f, "pathIndex": pathIndex} for f in result], dirs, time.perf_counter()
148
+
149
+ def get_model_previews(self, filepath: str) -> list[str | BytesIO]:
150
+ dirname = os.path.dirname(filepath)
151
+
152
+ if not os.path.exists(dirname):
153
+ return []
154
+
155
+ basename = os.path.splitext(filepath)[0]
156
+ match_files = glob.glob(f"{basename}.*", recursive=False)
157
+ image_files = filter_files_content_types(match_files, "image")
158
+ safetensors_file = next(filter(lambda x: x.endswith(".safetensors"), match_files), None)
159
+ safetensors_metadata = {}
160
+
161
+ result: list[str | BytesIO] = []
162
+
163
+ for filename in image_files:
164
+ _basename = os.path.splitext(filename)[0]
165
+ if _basename == basename:
166
+ result.append(filename)
167
+ if _basename == f"{basename}.preview":
168
+ result.append(filename)
169
+
170
+ if safetensors_file:
171
+ safetensors_filepath = os.path.join(dirname, safetensors_file)
172
+ header = comfy.utils.safetensors_header(safetensors_filepath, max_size=8*1024*1024)
173
+ if header:
174
+ safetensors_metadata = json.loads(header)
175
+ safetensors_images = safetensors_metadata.get("__metadata__", {}).get("ssmd_cover_images", None)
176
+ if safetensors_images:
177
+ safetensors_images = json.loads(safetensors_images)
178
+ for image in safetensors_images:
179
+ result.append(BytesIO(base64.b64decode(image)))
180
+
181
+ return result
182
+
183
+ def __exit__(self, exc_type, exc_value, traceback):
184
+ self.clear_cache()
app/user_manager.py ADDED
@@ -0,0 +1,436 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ import json
3
+ import os
4
+ import re
5
+ import uuid
6
+ import glob
7
+ import shutil
8
+ import logging
9
+ from aiohttp import web
10
+ from urllib import parse
11
+ from comfy.cli_args import args
12
+ import folder_paths
13
+ from .app_settings import AppSettings
14
+ from typing import TypedDict
15
+
16
+ default_user = "default"
17
+
18
+
19
+ class FileInfo(TypedDict):
20
+ path: str
21
+ size: int
22
+ modified: int
23
+
24
+
25
+ def get_file_info(path: str, relative_to: str) -> FileInfo:
26
+ return {
27
+ "path": os.path.relpath(path, relative_to).replace(os.sep, '/'),
28
+ "size": os.path.getsize(path),
29
+ "modified": os.path.getmtime(path)
30
+ }
31
+
32
+
33
+ class UserManager():
34
+ def __init__(self):
35
+ user_directory = folder_paths.get_user_directory()
36
+
37
+ self.settings = AppSettings(self)
38
+ if not os.path.exists(user_directory):
39
+ os.makedirs(user_directory, exist_ok=True)
40
+ if not args.multi_user:
41
+ logging.warning("****** User settings have been changed to be stored on the server instead of browser storage. ******")
42
+ logging.warning("****** For multi-user setups add the --multi-user CLI argument to enable multiple user profiles. ******")
43
+
44
+ if args.multi_user:
45
+ if os.path.isfile(self.get_users_file()):
46
+ with open(self.get_users_file()) as f:
47
+ self.users = json.load(f)
48
+ else:
49
+ self.users = {}
50
+ else:
51
+ self.users = {"default": "default"}
52
+
53
+ def get_users_file(self):
54
+ return os.path.join(folder_paths.get_user_directory(), "users.json")
55
+
56
+ def get_request_user_id(self, request):
57
+ user = "default"
58
+ if args.multi_user and "comfy-user" in request.headers:
59
+ user = request.headers["comfy-user"]
60
+
61
+ if user not in self.users:
62
+ raise KeyError("Unknown user: " + user)
63
+
64
+ return user
65
+
66
+ def get_request_user_filepath(self, request, file, type="userdata", create_dir=True):
67
+ user_directory = folder_paths.get_user_directory()
68
+
69
+ if type == "userdata":
70
+ root_dir = user_directory
71
+ else:
72
+ raise KeyError("Unknown filepath type:" + type)
73
+
74
+ user = self.get_request_user_id(request)
75
+ path = user_root = os.path.abspath(os.path.join(root_dir, user))
76
+
77
+ # prevent leaving /{type}
78
+ if os.path.commonpath((root_dir, user_root)) != root_dir:
79
+ return None
80
+
81
+ if file is not None:
82
+ # Check if filename is url encoded
83
+ if "%" in file:
84
+ file = parse.unquote(file)
85
+
86
+ # prevent leaving /{type}/{user}
87
+ path = os.path.abspath(os.path.join(user_root, file))
88
+ if os.path.commonpath((user_root, path)) != user_root:
89
+ return None
90
+
91
+ parent = os.path.split(path)[0]
92
+
93
+ if create_dir and not os.path.exists(parent):
94
+ os.makedirs(parent, exist_ok=True)
95
+
96
+ return path
97
+
98
+ def add_user(self, name):
99
+ name = name.strip()
100
+ if not name:
101
+ raise ValueError("username not provided")
102
+ user_id = re.sub("[^a-zA-Z0-9-_]+", '-', name)
103
+ user_id = user_id + "_" + str(uuid.uuid4())
104
+
105
+ self.users[user_id] = name
106
+
107
+ with open(self.get_users_file(), "w") as f:
108
+ json.dump(self.users, f)
109
+
110
+ return user_id
111
+
112
+ def add_routes(self, routes):
113
+ self.settings.add_routes(routes)
114
+
115
+ @routes.get("/users")
116
+ async def get_users(request):
117
+ if args.multi_user:
118
+ return web.json_response({"storage": "server", "users": self.users})
119
+ else:
120
+ user_dir = self.get_request_user_filepath(request, None, create_dir=False)
121
+ return web.json_response({
122
+ "storage": "server",
123
+ "migrated": os.path.exists(user_dir)
124
+ })
125
+
126
+ @routes.post("/users")
127
+ async def post_users(request):
128
+ body = await request.json()
129
+ username = body["username"]
130
+ if username in self.users.values():
131
+ return web.json_response({"error": "Duplicate username."}, status=400)
132
+
133
+ user_id = self.add_user(username)
134
+ return web.json_response(user_id)
135
+
136
+ @routes.get("/userdata")
137
+ async def listuserdata(request):
138
+ """
139
+ List user data files in a specified directory.
140
+
141
+ This endpoint allows listing files in a user's data directory, with options for recursion,
142
+ full file information, and path splitting.
143
+
144
+ Query Parameters:
145
+ - dir (required): The directory to list files from.
146
+ - recurse (optional): If "true", recursively list files in subdirectories.
147
+ - full_info (optional): If "true", return detailed file information (path, size, modified time).
148
+ - split (optional): If "true", split file paths into components (only applies when full_info is false).
149
+
150
+ Returns:
151
+ - 400: If 'dir' parameter is missing.
152
+ - 403: If the requested path is not allowed.
153
+ - 404: If the requested directory does not exist.
154
+ - 200: JSON response with the list of files or file information.
155
+
156
+ The response format depends on the query parameters:
157
+ - Default: List of relative file paths.
158
+ - full_info=true: List of dictionaries with file details.
159
+ - split=true (and full_info=false): List of lists, each containing path components.
160
+ """
161
+ directory = request.rel_url.query.get('dir', '')
162
+ if not directory:
163
+ return web.Response(status=400, text="Directory not provided")
164
+
165
+ path = self.get_request_user_filepath(request, directory)
166
+ if not path:
167
+ return web.Response(status=403, text="Invalid directory")
168
+
169
+ if not os.path.exists(path):
170
+ return web.Response(status=404, text="Directory not found")
171
+
172
+ recurse = request.rel_url.query.get('recurse', '').lower() == "true"
173
+ full_info = request.rel_url.query.get('full_info', '').lower() == "true"
174
+ split_path = request.rel_url.query.get('split', '').lower() == "true"
175
+
176
+ # Use different patterns based on whether we're recursing or not
177
+ if recurse:
178
+ pattern = os.path.join(glob.escape(path), '**', '*')
179
+ else:
180
+ pattern = os.path.join(glob.escape(path), '*')
181
+
182
+ def process_full_path(full_path: str) -> FileInfo | str | list[str]:
183
+ if full_info:
184
+ return get_file_info(full_path, path)
185
+
186
+ rel_path = os.path.relpath(full_path, path).replace(os.sep, '/')
187
+ if split_path:
188
+ return [rel_path] + rel_path.split('/')
189
+
190
+ return rel_path
191
+
192
+ results = [
193
+ process_full_path(full_path)
194
+ for full_path in glob.glob(pattern, recursive=recurse)
195
+ if os.path.isfile(full_path)
196
+ ]
197
+
198
+ return web.json_response(results)
199
+
200
+ @routes.get("/v2/userdata")
201
+ async def list_userdata_v2(request):
202
+ """
203
+ List files and directories in a user's data directory.
204
+
205
+ This endpoint provides a structured listing of contents within a specified
206
+ subdirectory of the user's data storage.
207
+
208
+ Query Parameters:
209
+ - path (optional): The relative path within the user's data directory
210
+ to list. Defaults to the root ('').
211
+
212
+ Returns:
213
+ - 400: If the requested path is invalid, outside the user's data directory, or is not a directory.
214
+ - 404: If the requested path does not exist.
215
+ - 403: If the user is invalid.
216
+ - 500: If there is an error reading the directory contents.
217
+ - 200: JSON response containing a list of file and directory objects.
218
+ Each object includes:
219
+ - name: The name of the file or directory.
220
+ - type: 'file' or 'directory'.
221
+ - path: The relative path from the user's data root.
222
+ - size (for files): The size in bytes.
223
+ - modified (for files): The last modified timestamp (Unix epoch).
224
+ """
225
+ requested_rel_path = request.rel_url.query.get('path', '')
226
+
227
+ # URL-decode the path parameter
228
+ try:
229
+ requested_rel_path = parse.unquote(requested_rel_path)
230
+ except Exception as e:
231
+ logging.warning(f"Failed to decode path parameter: {requested_rel_path}, Error: {e}")
232
+ return web.Response(status=400, text="Invalid characters in path parameter")
233
+
234
+
235
+ # Check user validity and get the absolute path for the requested directory
236
+ try:
237
+ base_user_path = self.get_request_user_filepath(request, None, create_dir=False)
238
+
239
+ if requested_rel_path:
240
+ target_abs_path = self.get_request_user_filepath(request, requested_rel_path, create_dir=False)
241
+ else:
242
+ target_abs_path = base_user_path
243
+
244
+ except KeyError as e:
245
+ # Invalid user detected by get_request_user_id inside get_request_user_filepath
246
+ logging.warning(f"Access denied for user: {e}")
247
+ return web.Response(status=403, text="Invalid user specified in request")
248
+
249
+
250
+ if not target_abs_path:
251
+ # Path traversal or other issue detected by get_request_user_filepath
252
+ return web.Response(status=400, text="Invalid path requested")
253
+
254
+ # Handle cases where the user directory or target path doesn't exist
255
+ if not os.path.exists(target_abs_path):
256
+ # Check if it's the base user directory that's missing (new user case)
257
+ if target_abs_path == base_user_path:
258
+ # It's okay if the base user directory doesn't exist yet, return empty list
259
+ return web.json_response([])
260
+ else:
261
+ # A specific subdirectory was requested but doesn't exist
262
+ return web.Response(status=404, text="Requested path not found")
263
+
264
+ if not os.path.isdir(target_abs_path):
265
+ return web.Response(status=400, text="Requested path is not a directory")
266
+
267
+ results = []
268
+ try:
269
+ for root, dirs, files in os.walk(target_abs_path, topdown=True):
270
+ # Process directories
271
+ for dir_name in dirs:
272
+ dir_path = os.path.join(root, dir_name)
273
+ rel_path = os.path.relpath(dir_path, base_user_path).replace(os.sep, '/')
274
+ results.append({
275
+ "name": dir_name,
276
+ "path": rel_path,
277
+ "type": "directory"
278
+ })
279
+
280
+ # Process files
281
+ for file_name in files:
282
+ file_path = os.path.join(root, file_name)
283
+ rel_path = os.path.relpath(file_path, base_user_path).replace(os.sep, '/')
284
+ entry_info = {
285
+ "name": file_name,
286
+ "path": rel_path,
287
+ "type": "file"
288
+ }
289
+ try:
290
+ stats = os.stat(file_path) # Use os.stat for potentially better performance with os.walk
291
+ entry_info["size"] = stats.st_size
292
+ entry_info["modified"] = stats.st_mtime
293
+ except OSError as stat_error:
294
+ logging.warning(f"Could not stat file {file_path}: {stat_error}")
295
+ pass # Include file with available info
296
+ results.append(entry_info)
297
+ except OSError as e:
298
+ logging.error(f"Error listing directory {target_abs_path}: {e}")
299
+ return web.Response(status=500, text="Error reading directory contents")
300
+
301
+ # Sort results alphabetically, directories first then files
302
+ results.sort(key=lambda x: (x['type'] != 'directory', x['name'].lower()))
303
+
304
+ return web.json_response(results)
305
+
306
+ def get_user_data_path(request, check_exists = False, param = "file"):
307
+ file = request.match_info.get(param, None)
308
+ if not file:
309
+ return web.Response(status=400)
310
+
311
+ path = self.get_request_user_filepath(request, file)
312
+ if not path:
313
+ return web.Response(status=403)
314
+
315
+ if check_exists and not os.path.exists(path):
316
+ return web.Response(status=404)
317
+
318
+ return path
319
+
320
+ @routes.get("/userdata/{file}")
321
+ async def getuserdata(request):
322
+ path = get_user_data_path(request, check_exists=True)
323
+ if not isinstance(path, str):
324
+ return path
325
+
326
+ return web.FileResponse(path)
327
+
328
+ @routes.post("/userdata/{file}")
329
+ async def post_userdata(request):
330
+ """
331
+ Upload or update a user data file.
332
+
333
+ This endpoint handles file uploads to a user's data directory, with options for
334
+ controlling overwrite behavior and response format.
335
+
336
+ Query Parameters:
337
+ - overwrite (optional): If "false", prevents overwriting existing files. Defaults to "true".
338
+ - full_info (optional): If "true", returns detailed file information (path, size, modified time).
339
+ If "false", returns only the relative file path.
340
+
341
+ Path Parameters:
342
+ - file: The target file path (URL encoded if necessary).
343
+
344
+ Returns:
345
+ - 400: If 'file' parameter is missing.
346
+ - 403: If the requested path is not allowed.
347
+ - 409: If overwrite=false and the file already exists.
348
+ - 200: JSON response with either:
349
+ - Full file information (if full_info=true)
350
+ - Relative file path (if full_info=false)
351
+
352
+ The request body should contain the raw file content to be written.
353
+ """
354
+ path = get_user_data_path(request)
355
+ if not isinstance(path, str):
356
+ return path
357
+
358
+ overwrite = request.query.get("overwrite", 'true') != "false"
359
+ full_info = request.query.get('full_info', 'false').lower() == "true"
360
+
361
+ if not overwrite and os.path.exists(path):
362
+ return web.Response(status=409, text="File already exists")
363
+
364
+ body = await request.read()
365
+
366
+ with open(path, "wb") as f:
367
+ f.write(body)
368
+
369
+ user_path = self.get_request_user_filepath(request, None)
370
+ if full_info:
371
+ resp = get_file_info(path, user_path)
372
+ else:
373
+ resp = os.path.relpath(path, user_path)
374
+
375
+ return web.json_response(resp)
376
+
377
+ @routes.delete("/userdata/{file}")
378
+ async def delete_userdata(request):
379
+ path = get_user_data_path(request, check_exists=True)
380
+ if not isinstance(path, str):
381
+ return path
382
+
383
+ os.remove(path)
384
+
385
+ return web.Response(status=204)
386
+
387
+ @routes.post("/userdata/{file}/move/{dest}")
388
+ async def move_userdata(request):
389
+ """
390
+ Move or rename a user data file.
391
+
392
+ This endpoint handles moving or renaming files within a user's data directory, with options for
393
+ controlling overwrite behavior and response format.
394
+
395
+ Path Parameters:
396
+ - file: The source file path (URL encoded if necessary)
397
+ - dest: The destination file path (URL encoded if necessary)
398
+
399
+ Query Parameters:
400
+ - overwrite (optional): If "false", prevents overwriting existing files. Defaults to "true".
401
+ - full_info (optional): If "true", returns detailed file information (path, size, modified time).
402
+ If "false", returns only the relative file path.
403
+
404
+ Returns:
405
+ - 400: If either 'file' or 'dest' parameter is missing
406
+ - 403: If either requested path is not allowed
407
+ - 404: If the source file does not exist
408
+ - 409: If overwrite=false and the destination file already exists
409
+ - 200: JSON response with either:
410
+ - Full file information (if full_info=true)
411
+ - Relative file path (if full_info=false)
412
+ """
413
+ source = get_user_data_path(request, check_exists=True)
414
+ if not isinstance(source, str):
415
+ return source
416
+
417
+ dest = get_user_data_path(request, check_exists=False, param="dest")
418
+ if not isinstance(source, str):
419
+ return dest
420
+
421
+ overwrite = request.query.get("overwrite", 'true') != "false"
422
+ full_info = request.query.get('full_info', 'false').lower() == "true"
423
+
424
+ if not overwrite and os.path.exists(dest):
425
+ return web.Response(status=409, text="File already exists")
426
+
427
+ logging.info(f"moving '{source}' -> '{dest}'")
428
+ shutil.move(source, dest)
429
+
430
+ user_path = self.get_request_user_filepath(request, None)
431
+ if full_info:
432
+ resp = get_file_info(dest, user_path)
433
+ else:
434
+ resp = os.path.relpath(dest, user_path)
435
+
436
+ return web.json_response(resp)
comfy/checkpoint_pickle.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pickle
2
+
3
+ load = pickle.load
4
+
5
+ class Empty:
6
+ pass
7
+
8
+ class Unpickler(pickle.Unpickler):
9
+ def find_class(self, module, name):
10
+ #TODO: safe unpickle
11
+ if module.startswith("pytorch_lightning"):
12
+ return Empty
13
+ return super().find_class(module, name)
comfy/cldm/cldm.py ADDED
@@ -0,0 +1,433 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #taken from: https://github.com/lllyasviel/ControlNet
2
+ #and modified
3
+
4
+ import torch
5
+ import torch.nn as nn
6
+
7
+ from ..ldm.modules.diffusionmodules.util import (
8
+ timestep_embedding,
9
+ )
10
+
11
+ from ..ldm.modules.attention import SpatialTransformer
12
+ from ..ldm.modules.diffusionmodules.openaimodel import UNetModel, TimestepEmbedSequential, ResBlock, Downsample
13
+ from ..ldm.util import exists
14
+ from .control_types import UNION_CONTROLNET_TYPES
15
+ from collections import OrderedDict
16
+ import comfy.ops
17
+ from comfy.ldm.modules.attention import optimized_attention
18
+
19
+ class OptimizedAttention(nn.Module):
20
+ def __init__(self, c, nhead, dropout=0.0, dtype=None, device=None, operations=None):
21
+ super().__init__()
22
+ self.heads = nhead
23
+ self.c = c
24
+
25
+ self.in_proj = operations.Linear(c, c * 3, bias=True, dtype=dtype, device=device)
26
+ self.out_proj = operations.Linear(c, c, bias=True, dtype=dtype, device=device)
27
+
28
+ def forward(self, x):
29
+ x = self.in_proj(x)
30
+ q, k, v = x.split(self.c, dim=2)
31
+ out = optimized_attention(q, k, v, self.heads)
32
+ return self.out_proj(out)
33
+
34
+ class QuickGELU(nn.Module):
35
+ def forward(self, x: torch.Tensor):
36
+ return x * torch.sigmoid(1.702 * x)
37
+
38
+ class ResBlockUnionControlnet(nn.Module):
39
+ def __init__(self, dim, nhead, dtype=None, device=None, operations=None):
40
+ super().__init__()
41
+ self.attn = OptimizedAttention(dim, nhead, dtype=dtype, device=device, operations=operations)
42
+ self.ln_1 = operations.LayerNorm(dim, dtype=dtype, device=device)
43
+ self.mlp = nn.Sequential(
44
+ OrderedDict([("c_fc", operations.Linear(dim, dim * 4, dtype=dtype, device=device)), ("gelu", QuickGELU()),
45
+ ("c_proj", operations.Linear(dim * 4, dim, dtype=dtype, device=device))]))
46
+ self.ln_2 = operations.LayerNorm(dim, dtype=dtype, device=device)
47
+
48
+ def attention(self, x: torch.Tensor):
49
+ return self.attn(x)
50
+
51
+ def forward(self, x: torch.Tensor):
52
+ x = x + self.attention(self.ln_1(x))
53
+ x = x + self.mlp(self.ln_2(x))
54
+ return x
55
+
56
+ class ControlledUnetModel(UNetModel):
57
+ #implemented in the ldm unet
58
+ pass
59
+
60
+ class ControlNet(nn.Module):
61
+ def __init__(
62
+ self,
63
+ image_size,
64
+ in_channels,
65
+ model_channels,
66
+ hint_channels,
67
+ num_res_blocks,
68
+ dropout=0,
69
+ channel_mult=(1, 2, 4, 8),
70
+ conv_resample=True,
71
+ dims=2,
72
+ num_classes=None,
73
+ use_checkpoint=False,
74
+ dtype=torch.float32,
75
+ num_heads=-1,
76
+ num_head_channels=-1,
77
+ num_heads_upsample=-1,
78
+ use_scale_shift_norm=False,
79
+ resblock_updown=False,
80
+ use_new_attention_order=False,
81
+ use_spatial_transformer=False, # custom transformer support
82
+ transformer_depth=1, # custom transformer support
83
+ context_dim=None, # custom transformer support
84
+ n_embed=None, # custom support for prediction of discrete ids into codebook of first stage vq model
85
+ legacy=True,
86
+ disable_self_attentions=None,
87
+ num_attention_blocks=None,
88
+ disable_middle_self_attn=False,
89
+ use_linear_in_transformer=False,
90
+ adm_in_channels=None,
91
+ transformer_depth_middle=None,
92
+ transformer_depth_output=None,
93
+ attn_precision=None,
94
+ union_controlnet_num_control_type=None,
95
+ device=None,
96
+ operations=comfy.ops.disable_weight_init,
97
+ **kwargs,
98
+ ):
99
+ super().__init__()
100
+ assert use_spatial_transformer == True, "use_spatial_transformer has to be true"
101
+ if use_spatial_transformer:
102
+ assert context_dim is not None, 'Fool!! You forgot to include the dimension of your cross-attention conditioning...'
103
+
104
+ if context_dim is not None:
105
+ assert use_spatial_transformer, 'Fool!! You forgot to use the spatial transformer for your cross-attention conditioning...'
106
+ # from omegaconf.listconfig import ListConfig
107
+ # if type(context_dim) == ListConfig:
108
+ # context_dim = list(context_dim)
109
+
110
+ if num_heads_upsample == -1:
111
+ num_heads_upsample = num_heads
112
+
113
+ if num_heads == -1:
114
+ assert num_head_channels != -1, 'Either num_heads or num_head_channels has to be set'
115
+
116
+ if num_head_channels == -1:
117
+ assert num_heads != -1, 'Either num_heads or num_head_channels has to be set'
118
+
119
+ self.dims = dims
120
+ self.image_size = image_size
121
+ self.in_channels = in_channels
122
+ self.model_channels = model_channels
123
+
124
+ if isinstance(num_res_blocks, int):
125
+ self.num_res_blocks = len(channel_mult) * [num_res_blocks]
126
+ else:
127
+ if len(num_res_blocks) != len(channel_mult):
128
+ raise ValueError("provide num_res_blocks either as an int (globally constant) or "
129
+ "as a list/tuple (per-level) with the same length as channel_mult")
130
+ self.num_res_blocks = num_res_blocks
131
+
132
+ if disable_self_attentions is not None:
133
+ # should be a list of booleans, indicating whether to disable self-attention in TransformerBlocks or not
134
+ assert len(disable_self_attentions) == len(channel_mult)
135
+ if num_attention_blocks is not None:
136
+ assert len(num_attention_blocks) == len(self.num_res_blocks)
137
+ assert all(map(lambda i: self.num_res_blocks[i] >= num_attention_blocks[i], range(len(num_attention_blocks))))
138
+
139
+ transformer_depth = transformer_depth[:]
140
+
141
+ self.dropout = dropout
142
+ self.channel_mult = channel_mult
143
+ self.conv_resample = conv_resample
144
+ self.num_classes = num_classes
145
+ self.use_checkpoint = use_checkpoint
146
+ self.dtype = dtype
147
+ self.num_heads = num_heads
148
+ self.num_head_channels = num_head_channels
149
+ self.num_heads_upsample = num_heads_upsample
150
+ self.predict_codebook_ids = n_embed is not None
151
+
152
+ time_embed_dim = model_channels * 4
153
+ self.time_embed = nn.Sequential(
154
+ operations.Linear(model_channels, time_embed_dim, dtype=self.dtype, device=device),
155
+ nn.SiLU(),
156
+ operations.Linear(time_embed_dim, time_embed_dim, dtype=self.dtype, device=device),
157
+ )
158
+
159
+ if self.num_classes is not None:
160
+ if isinstance(self.num_classes, int):
161
+ self.label_emb = nn.Embedding(num_classes, time_embed_dim)
162
+ elif self.num_classes == "continuous":
163
+ self.label_emb = nn.Linear(1, time_embed_dim)
164
+ elif self.num_classes == "sequential":
165
+ assert adm_in_channels is not None
166
+ self.label_emb = nn.Sequential(
167
+ nn.Sequential(
168
+ operations.Linear(adm_in_channels, time_embed_dim, dtype=self.dtype, device=device),
169
+ nn.SiLU(),
170
+ operations.Linear(time_embed_dim, time_embed_dim, dtype=self.dtype, device=device),
171
+ )
172
+ )
173
+ else:
174
+ raise ValueError()
175
+
176
+ self.input_blocks = nn.ModuleList(
177
+ [
178
+ TimestepEmbedSequential(
179
+ operations.conv_nd(dims, in_channels, model_channels, 3, padding=1, dtype=self.dtype, device=device)
180
+ )
181
+ ]
182
+ )
183
+ self.zero_convs = nn.ModuleList([self.make_zero_conv(model_channels, operations=operations, dtype=self.dtype, device=device)])
184
+
185
+ self.input_hint_block = TimestepEmbedSequential(
186
+ operations.conv_nd(dims, hint_channels, 16, 3, padding=1, dtype=self.dtype, device=device),
187
+ nn.SiLU(),
188
+ operations.conv_nd(dims, 16, 16, 3, padding=1, dtype=self.dtype, device=device),
189
+ nn.SiLU(),
190
+ operations.conv_nd(dims, 16, 32, 3, padding=1, stride=2, dtype=self.dtype, device=device),
191
+ nn.SiLU(),
192
+ operations.conv_nd(dims, 32, 32, 3, padding=1, dtype=self.dtype, device=device),
193
+ nn.SiLU(),
194
+ operations.conv_nd(dims, 32, 96, 3, padding=1, stride=2, dtype=self.dtype, device=device),
195
+ nn.SiLU(),
196
+ operations.conv_nd(dims, 96, 96, 3, padding=1, dtype=self.dtype, device=device),
197
+ nn.SiLU(),
198
+ operations.conv_nd(dims, 96, 256, 3, padding=1, stride=2, dtype=self.dtype, device=device),
199
+ nn.SiLU(),
200
+ operations.conv_nd(dims, 256, model_channels, 3, padding=1, dtype=self.dtype, device=device)
201
+ )
202
+
203
+ self._feature_size = model_channels
204
+ input_block_chans = [model_channels]
205
+ ch = model_channels
206
+ ds = 1
207
+ for level, mult in enumerate(channel_mult):
208
+ for nr in range(self.num_res_blocks[level]):
209
+ layers = [
210
+ ResBlock(
211
+ ch,
212
+ time_embed_dim,
213
+ dropout,
214
+ out_channels=mult * model_channels,
215
+ dims=dims,
216
+ use_checkpoint=use_checkpoint,
217
+ use_scale_shift_norm=use_scale_shift_norm,
218
+ dtype=self.dtype,
219
+ device=device,
220
+ operations=operations,
221
+ )
222
+ ]
223
+ ch = mult * model_channels
224
+ num_transformers = transformer_depth.pop(0)
225
+ if num_transformers > 0:
226
+ if num_head_channels == -1:
227
+ dim_head = ch // num_heads
228
+ else:
229
+ num_heads = ch // num_head_channels
230
+ dim_head = num_head_channels
231
+ if legacy:
232
+ #num_heads = 1
233
+ dim_head = ch // num_heads if use_spatial_transformer else num_head_channels
234
+ if exists(disable_self_attentions):
235
+ disabled_sa = disable_self_attentions[level]
236
+ else:
237
+ disabled_sa = False
238
+
239
+ if not exists(num_attention_blocks) or nr < num_attention_blocks[level]:
240
+ layers.append(
241
+ SpatialTransformer(
242
+ ch, num_heads, dim_head, depth=num_transformers, context_dim=context_dim,
243
+ disable_self_attn=disabled_sa, use_linear=use_linear_in_transformer,
244
+ use_checkpoint=use_checkpoint, attn_precision=attn_precision, dtype=self.dtype, device=device, operations=operations
245
+ )
246
+ )
247
+ self.input_blocks.append(TimestepEmbedSequential(*layers))
248
+ self.zero_convs.append(self.make_zero_conv(ch, operations=operations, dtype=self.dtype, device=device))
249
+ self._feature_size += ch
250
+ input_block_chans.append(ch)
251
+ if level != len(channel_mult) - 1:
252
+ out_ch = ch
253
+ self.input_blocks.append(
254
+ TimestepEmbedSequential(
255
+ ResBlock(
256
+ ch,
257
+ time_embed_dim,
258
+ dropout,
259
+ out_channels=out_ch,
260
+ dims=dims,
261
+ use_checkpoint=use_checkpoint,
262
+ use_scale_shift_norm=use_scale_shift_norm,
263
+ down=True,
264
+ dtype=self.dtype,
265
+ device=device,
266
+ operations=operations
267
+ )
268
+ if resblock_updown
269
+ else Downsample(
270
+ ch, conv_resample, dims=dims, out_channels=out_ch, dtype=self.dtype, device=device, operations=operations
271
+ )
272
+ )
273
+ )
274
+ ch = out_ch
275
+ input_block_chans.append(ch)
276
+ self.zero_convs.append(self.make_zero_conv(ch, operations=operations, dtype=self.dtype, device=device))
277
+ ds *= 2
278
+ self._feature_size += ch
279
+
280
+ if num_head_channels == -1:
281
+ dim_head = ch // num_heads
282
+ else:
283
+ num_heads = ch // num_head_channels
284
+ dim_head = num_head_channels
285
+ if legacy:
286
+ #num_heads = 1
287
+ dim_head = ch // num_heads if use_spatial_transformer else num_head_channels
288
+ mid_block = [
289
+ ResBlock(
290
+ ch,
291
+ time_embed_dim,
292
+ dropout,
293
+ dims=dims,
294
+ use_checkpoint=use_checkpoint,
295
+ use_scale_shift_norm=use_scale_shift_norm,
296
+ dtype=self.dtype,
297
+ device=device,
298
+ operations=operations
299
+ )]
300
+ if transformer_depth_middle >= 0:
301
+ mid_block += [SpatialTransformer( # always uses a self-attn
302
+ ch, num_heads, dim_head, depth=transformer_depth_middle, context_dim=context_dim,
303
+ disable_self_attn=disable_middle_self_attn, use_linear=use_linear_in_transformer,
304
+ use_checkpoint=use_checkpoint, attn_precision=attn_precision, dtype=self.dtype, device=device, operations=operations
305
+ ),
306
+ ResBlock(
307
+ ch,
308
+ time_embed_dim,
309
+ dropout,
310
+ dims=dims,
311
+ use_checkpoint=use_checkpoint,
312
+ use_scale_shift_norm=use_scale_shift_norm,
313
+ dtype=self.dtype,
314
+ device=device,
315
+ operations=operations
316
+ )]
317
+ self.middle_block = TimestepEmbedSequential(*mid_block)
318
+ self.middle_block_out = self.make_zero_conv(ch, operations=operations, dtype=self.dtype, device=device)
319
+ self._feature_size += ch
320
+
321
+ if union_controlnet_num_control_type is not None:
322
+ self.num_control_type = union_controlnet_num_control_type
323
+ num_trans_channel = 320
324
+ num_trans_head = 8
325
+ num_trans_layer = 1
326
+ num_proj_channel = 320
327
+ # task_scale_factor = num_trans_channel ** 0.5
328
+ self.task_embedding = nn.Parameter(torch.empty(self.num_control_type, num_trans_channel, dtype=self.dtype, device=device))
329
+
330
+ self.transformer_layes = nn.Sequential(*[ResBlockUnionControlnet(num_trans_channel, num_trans_head, dtype=self.dtype, device=device, operations=operations) for _ in range(num_trans_layer)])
331
+ self.spatial_ch_projs = operations.Linear(num_trans_channel, num_proj_channel, dtype=self.dtype, device=device)
332
+ #-----------------------------------------------------------------------------------------------------
333
+
334
+ control_add_embed_dim = 256
335
+ class ControlAddEmbedding(nn.Module):
336
+ def __init__(self, in_dim, out_dim, num_control_type, dtype=None, device=None, operations=None):
337
+ super().__init__()
338
+ self.num_control_type = num_control_type
339
+ self.in_dim = in_dim
340
+ self.linear_1 = operations.Linear(in_dim * num_control_type, out_dim, dtype=dtype, device=device)
341
+ self.linear_2 = operations.Linear(out_dim, out_dim, dtype=dtype, device=device)
342
+ def forward(self, control_type, dtype, device):
343
+ c_type = torch.zeros((self.num_control_type,), device=device)
344
+ c_type[control_type] = 1.0
345
+ c_type = timestep_embedding(c_type.flatten(), self.in_dim, repeat_only=False).to(dtype).reshape((-1, self.num_control_type * self.in_dim))
346
+ return self.linear_2(torch.nn.functional.silu(self.linear_1(c_type)))
347
+
348
+ self.control_add_embedding = ControlAddEmbedding(control_add_embed_dim, time_embed_dim, self.num_control_type, dtype=self.dtype, device=device, operations=operations)
349
+ else:
350
+ self.task_embedding = None
351
+ self.control_add_embedding = None
352
+
353
+ def union_controlnet_merge(self, hint, control_type, emb, context):
354
+ # Equivalent to: https://github.com/xinsir6/ControlNetPlus/tree/main
355
+ inputs = []
356
+ condition_list = []
357
+
358
+ for idx in range(min(1, len(control_type))):
359
+ controlnet_cond = self.input_hint_block(hint[idx], emb, context)
360
+ feat_seq = torch.mean(controlnet_cond, dim=(2, 3))
361
+ if idx < len(control_type):
362
+ feat_seq += self.task_embedding[control_type[idx]].to(dtype=feat_seq.dtype, device=feat_seq.device)
363
+
364
+ inputs.append(feat_seq.unsqueeze(1))
365
+ condition_list.append(controlnet_cond)
366
+
367
+ x = torch.cat(inputs, dim=1)
368
+ x = self.transformer_layes(x)
369
+ controlnet_cond_fuser = None
370
+ for idx in range(len(control_type)):
371
+ alpha = self.spatial_ch_projs(x[:, idx])
372
+ alpha = alpha.unsqueeze(-1).unsqueeze(-1)
373
+ o = condition_list[idx] + alpha
374
+ if controlnet_cond_fuser is None:
375
+ controlnet_cond_fuser = o
376
+ else:
377
+ controlnet_cond_fuser += o
378
+ return controlnet_cond_fuser
379
+
380
+ def make_zero_conv(self, channels, operations=None, dtype=None, device=None):
381
+ return TimestepEmbedSequential(operations.conv_nd(self.dims, channels, channels, 1, padding=0, dtype=dtype, device=device))
382
+
383
+ def forward(self, x, hint, timesteps, context, y=None, **kwargs):
384
+ t_emb = timestep_embedding(timesteps, self.model_channels, repeat_only=False).to(x.dtype)
385
+ emb = self.time_embed(t_emb)
386
+
387
+ guided_hint = None
388
+ if self.control_add_embedding is not None: #Union Controlnet
389
+ control_type = kwargs.get("control_type", [])
390
+
391
+ if any([c >= self.num_control_type for c in control_type]):
392
+ max_type = max(control_type)
393
+ max_type_name = {
394
+ v: k for k, v in UNION_CONTROLNET_TYPES.items()
395
+ }[max_type]
396
+ raise ValueError(
397
+ f"Control type {max_type_name}({max_type}) is out of range for the number of control types" +
398
+ f"({self.num_control_type}) supported.\n" +
399
+ "Please consider using the ProMax ControlNet Union model.\n" +
400
+ "https://huggingface.co/xinsir/controlnet-union-sdxl-1.0/tree/main"
401
+ )
402
+
403
+ emb += self.control_add_embedding(control_type, emb.dtype, emb.device)
404
+ if len(control_type) > 0:
405
+ if len(hint.shape) < 5:
406
+ hint = hint.unsqueeze(dim=0)
407
+ guided_hint = self.union_controlnet_merge(hint, control_type, emb, context)
408
+
409
+ if guided_hint is None:
410
+ guided_hint = self.input_hint_block(hint, emb, context)
411
+
412
+ out_output = []
413
+ out_middle = []
414
+
415
+ if self.num_classes is not None:
416
+ assert y.shape[0] == x.shape[0]
417
+ emb = emb + self.label_emb(y)
418
+
419
+ h = x
420
+ for module, zero_conv in zip(self.input_blocks, self.zero_convs):
421
+ if guided_hint is not None:
422
+ h = module(h, emb, context)
423
+ h += guided_hint
424
+ guided_hint = None
425
+ else:
426
+ h = module(h, emb, context)
427
+ out_output.append(zero_conv(h, emb, context))
428
+
429
+ h = self.middle_block(h, emb, context)
430
+ out_middle.append(self.middle_block_out(h, emb, context))
431
+
432
+ return {"middle": out_middle, "output": out_output}
433
+