Add files using upload-large-folder tool
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .ci/update_windows/update.py +151 -0
- .ci/update_windows/update_comfyui.bat +8 -0
- .ci/update_windows/update_comfyui_stable.bat +8 -0
- .ci/windows_base_files/README_VERY_IMPORTANT.txt +34 -0
- .ci/windows_base_files/run_cpu.bat +2 -0
- .ci/windows_base_files/run_nvidia_gpu.bat +2 -0
- .ci/windows_base_files/run_nvidia_gpu_fast_fp16_accumulation.bat +2 -0
- .ci/windows_nightly_base_files/run_nvidia_gpu_fast.bat +2 -0
- .gitattributes +1 -0
- .github/ISSUE_TEMPLATE/bug-report.yml +56 -0
- .github/ISSUE_TEMPLATE/config.yml +11 -0
- .github/ISSUE_TEMPLATE/feature-request.yml +32 -0
- .github/ISSUE_TEMPLATE/user-support.yml +40 -0
- .github/workflows/check-line-endings.yml +40 -0
- .github/workflows/pullrequest-ci-run.yml +53 -0
- .github/workflows/release-webhook.yml +108 -0
- .github/workflows/ruff.yml +23 -0
- .github/workflows/stable-release.yml +105 -0
- .github/workflows/stale-issues.yml +21 -0
- .github/workflows/test-build.yml +31 -0
- .github/workflows/test-ci.yml +96 -0
- .github/workflows/test-launch.yml +45 -0
- .github/workflows/test-unit.yml +30 -0
- .github/workflows/update-api-stubs.yml +56 -0
- .github/workflows/update-version.yml +58 -0
- .github/workflows/windows_release_dependencies.yml +71 -0
- .github/workflows/windows_release_nightly_pytorch.yml +93 -0
- .github/workflows/windows_release_package.yml +102 -0
- alembic_db/README.md +4 -0
- alembic_db/env.py +64 -0
- alembic_db/script.py.mako +28 -0
- api_server/__init__.py +0 -0
- api_server/routes/__init__.py +0 -0
- api_server/routes/internal/README.md +3 -0
- api_server/routes/internal/__init__.py +0 -0
- api_server/routes/internal/internal_routes.py +73 -0
- api_server/services/__init__.py +0 -0
- api_server/services/terminal_service.py +60 -0
- api_server/utils/file_operations.py +42 -0
- app/__init__.py +0 -0
- app/app_settings.py +65 -0
- app/custom_node_manager.py +145 -0
- app/database/db.py +112 -0
- app/database/models.py +14 -0
- app/frontend_management.py +326 -0
- app/logger.py +98 -0
- app/model_manager.py +184 -0
- app/user_manager.py +436 -0
- comfy/checkpoint_pickle.py +13 -0
- 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 |
+
|