Modify the structure to be installable via pip.
This commit is contained in:
17
comfyui_manager/__init__.py
Normal file
17
comfyui_manager/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
import os
|
||||
import logging
|
||||
|
||||
def prestartup():
|
||||
from . import prestartup_script # noqa: F401
|
||||
logging.info('[PRE] ComfyUI-Manager')
|
||||
|
||||
|
||||
def start():
|
||||
logging.info('[START] ComfyUI-Manager')
|
||||
from .glob import manager_server # noqa: F401
|
||||
from .glob import share_3rdparty # noqa: F401
|
||||
from .glob import cm_global # noqa: F401
|
||||
|
||||
if os.environ.get('ENABLE_LEGACY_COMFYUI_MANAGER_FRONT', 'false') == 'true':
|
||||
import nodes
|
||||
nodes.EXTENSION_WEB_DIRS['comfyui-manager-legacy'] = os.path.join(os.path.dirname(__file__), 'js')
|
||||
1276
comfyui_manager/cm-cli.py
Normal file
1276
comfyui_manager/cm-cli.py
Normal file
File diff suppressed because it is too large
Load Diff
117
comfyui_manager/glob/cm_global.py
Normal file
117
comfyui_manager/glob/cm_global.py
Normal file
@@ -0,0 +1,117 @@
|
||||
import traceback
|
||||
|
||||
#
|
||||
# Global Var
|
||||
#
|
||||
# Usage:
|
||||
# import cm_global
|
||||
# cm_global.variables['comfyui.revision'] = 1832
|
||||
# print(f"log mode: {cm_global.variables['logger.enabled']}")
|
||||
#
|
||||
variables = {}
|
||||
|
||||
|
||||
#
|
||||
# Global API
|
||||
#
|
||||
# Usage:
|
||||
# [register API]
|
||||
# import cm_global
|
||||
#
|
||||
# def api_hello(msg):
|
||||
# print(f"hello: {msg}")
|
||||
# return msg
|
||||
#
|
||||
# cm_global.register_api('hello', api_hello)
|
||||
#
|
||||
# [use API]
|
||||
# import cm_global
|
||||
#
|
||||
# test = cm_global.try_call(api='hello', msg='an example')
|
||||
# print(f"'{test}' is returned")
|
||||
#
|
||||
|
||||
APIs = {}
|
||||
|
||||
|
||||
def register_api(k, f):
|
||||
global APIs
|
||||
APIs[k] = f
|
||||
|
||||
|
||||
def try_call(**kwargs):
|
||||
if 'api' in kwargs:
|
||||
api_name = kwargs['api']
|
||||
try:
|
||||
api = APIs.get(api_name)
|
||||
if api is not None:
|
||||
del kwargs['api']
|
||||
return api(**kwargs)
|
||||
else:
|
||||
print(f"WARN: The '{kwargs['api']}' API has not been registered.")
|
||||
except Exception as e:
|
||||
print(f"ERROR: An exception occurred while calling the '{api_name}' API.")
|
||||
raise e
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
#
|
||||
# Extension Info
|
||||
#
|
||||
# Usage:
|
||||
# import cm_global
|
||||
#
|
||||
# cm_global.extension_infos['my_extension'] = {'version': [0, 1], 'name': 'me', 'description': 'example extension', }
|
||||
#
|
||||
extension_infos = {}
|
||||
|
||||
on_extension_registered_handlers = {}
|
||||
|
||||
|
||||
def register_extension(extension_name, v):
|
||||
global extension_infos
|
||||
global on_extension_registered_handlers
|
||||
extension_infos[extension_name] = v
|
||||
|
||||
if extension_name in on_extension_registered_handlers:
|
||||
for k, f in on_extension_registered_handlers[extension_name]:
|
||||
try:
|
||||
f(extension_name, v)
|
||||
except Exception:
|
||||
print(f"[ERROR] '{k}' on_extension_registered_handlers")
|
||||
traceback.print_exc()
|
||||
|
||||
del on_extension_registered_handlers[extension_name]
|
||||
|
||||
|
||||
def add_on_extension_registered(k, extension_name, f):
|
||||
global on_extension_registered_handlers
|
||||
if extension_name in extension_infos:
|
||||
try:
|
||||
v = extension_infos[extension_name]
|
||||
f(extension_name, v)
|
||||
except Exception:
|
||||
print(f"[ERROR] '{k}' on_extension_registered_handler")
|
||||
traceback.print_exc()
|
||||
else:
|
||||
if extension_name not in on_extension_registered_handlers:
|
||||
on_extension_registered_handlers[extension_name] = []
|
||||
|
||||
on_extension_registered_handlers[extension_name].append((k, f))
|
||||
|
||||
|
||||
def add_on_revision_detected(k, f):
|
||||
if 'comfyui.revision' in variables:
|
||||
try:
|
||||
f(variables['comfyui.revision'])
|
||||
except Exception:
|
||||
print(f"[ERROR] '{k}' on_revision_detected_handler")
|
||||
traceback.print_exc()
|
||||
else:
|
||||
variables['cm.on_revision_detected_handler'].append((k, f))
|
||||
|
||||
|
||||
error_dict = {}
|
||||
|
||||
disable_front = False
|
||||
254
comfyui_manager/glob/cnr_utils.py
Normal file
254
comfyui_manager/glob/cnr_utils.py
Normal file
@@ -0,0 +1,254 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import List
|
||||
|
||||
from . import manager_core
|
||||
from . import manager_util
|
||||
|
||||
import requests
|
||||
import toml
|
||||
|
||||
base_url = "https://api.comfy.org"
|
||||
|
||||
|
||||
lock = asyncio.Lock()
|
||||
|
||||
is_cache_loading = False
|
||||
|
||||
async def get_cnr_data(cache_mode=True, dont_wait=True):
|
||||
try:
|
||||
return await _get_cnr_data(cache_mode, dont_wait)
|
||||
except asyncio.TimeoutError:
|
||||
print("A timeout occurred during the fetch process from ComfyRegistry.")
|
||||
return await _get_cnr_data(cache_mode=True, dont_wait=True) # timeout fallback
|
||||
|
||||
async def _get_cnr_data(cache_mode=True, dont_wait=True):
|
||||
global is_cache_loading
|
||||
|
||||
uri = f'{base_url}/nodes'
|
||||
|
||||
async def fetch_all():
|
||||
remained = True
|
||||
page = 1
|
||||
|
||||
full_nodes = {}
|
||||
|
||||
|
||||
# Determine form factor based on environment and platform
|
||||
is_desktop = bool(os.environ.get('__COMFYUI_DESKTOP_VERSION__'))
|
||||
system = platform.system().lower()
|
||||
is_windows = system == 'windows'
|
||||
is_mac = system == 'darwin'
|
||||
is_linux = system == 'linux'
|
||||
|
||||
# Get ComfyUI version tag
|
||||
if is_desktop:
|
||||
# extract version from pyproject.toml instead of git tag
|
||||
comfyui_ver = manager_core.get_current_comfyui_ver() or 'unknown'
|
||||
else:
|
||||
comfyui_ver = manager_core.get_comfyui_tag() or 'unknown'
|
||||
|
||||
if is_desktop:
|
||||
if is_windows:
|
||||
form_factor = 'desktop-win'
|
||||
elif is_mac:
|
||||
form_factor = 'desktop-mac'
|
||||
else:
|
||||
form_factor = 'other'
|
||||
else:
|
||||
if is_windows:
|
||||
form_factor = 'git-windows'
|
||||
elif is_mac:
|
||||
form_factor = 'git-mac'
|
||||
elif is_linux:
|
||||
form_factor = 'git-linux'
|
||||
else:
|
||||
form_factor = 'other'
|
||||
|
||||
while remained:
|
||||
# Add comfyui_version and form_factor to the API request
|
||||
sub_uri = f'{base_url}/nodes?page={page}&limit=30&comfyui_version={comfyui_ver}&form_factor={form_factor}'
|
||||
sub_json_obj = await asyncio.wait_for(manager_util.get_data_with_cache(sub_uri, cache_mode=False, silent=True, dont_cache=True), timeout=30)
|
||||
remained = page < sub_json_obj['totalPages']
|
||||
|
||||
for x in sub_json_obj['nodes']:
|
||||
full_nodes[x['id']] = x
|
||||
|
||||
if page % 5 == 0:
|
||||
print(f"FETCH ComfyRegistry Data: {page}/{sub_json_obj['totalPages']}")
|
||||
|
||||
page += 1
|
||||
time.sleep(0.5)
|
||||
|
||||
print("FETCH ComfyRegistry Data [DONE]")
|
||||
|
||||
for v in full_nodes.values():
|
||||
if 'latest_version' not in v:
|
||||
v['latest_version'] = dict(version='nightly')
|
||||
|
||||
return {'nodes': list(full_nodes.values())}
|
||||
|
||||
if cache_mode:
|
||||
is_cache_loading = True
|
||||
cache_state = manager_util.get_cache_state(uri)
|
||||
|
||||
if dont_wait:
|
||||
if cache_state == 'not-cached':
|
||||
return {}
|
||||
else:
|
||||
print("[ComfyUI-Manager] The ComfyRegistry cache update is still in progress, so an outdated cache is being used.")
|
||||
with open(manager_util.get_cache_path(uri), 'r', encoding="UTF-8", errors="ignore") as json_file:
|
||||
return json.load(json_file)['nodes']
|
||||
|
||||
if cache_state == 'cached':
|
||||
with open(manager_util.get_cache_path(uri), 'r', encoding="UTF-8", errors="ignore") as json_file:
|
||||
return json.load(json_file)['nodes']
|
||||
|
||||
try:
|
||||
json_obj = await fetch_all()
|
||||
manager_util.save_to_cache(uri, json_obj)
|
||||
return json_obj['nodes']
|
||||
except:
|
||||
res = {}
|
||||
print("Cannot connect to comfyregistry.")
|
||||
finally:
|
||||
if cache_mode:
|
||||
is_cache_loading = False
|
||||
|
||||
return res
|
||||
|
||||
|
||||
@dataclass
|
||||
class NodeVersion:
|
||||
changelog: str
|
||||
dependencies: List[str]
|
||||
deprecated: bool
|
||||
id: str
|
||||
version: str
|
||||
download_url: str
|
||||
|
||||
|
||||
def map_node_version(api_node_version):
|
||||
"""
|
||||
Maps node version data from API response to NodeVersion dataclass.
|
||||
|
||||
Args:
|
||||
api_data (dict): The 'node_version' part of the API response.
|
||||
|
||||
Returns:
|
||||
NodeVersion: An instance of NodeVersion dataclass populated with data from the API.
|
||||
"""
|
||||
return NodeVersion(
|
||||
changelog=api_node_version.get(
|
||||
"changelog", ""
|
||||
), # Provide a default value if 'changelog' is missing
|
||||
dependencies=api_node_version.get(
|
||||
"dependencies", []
|
||||
), # Provide a default empty list if 'dependencies' is missing
|
||||
deprecated=api_node_version.get(
|
||||
"deprecated", False
|
||||
), # Assume False if 'deprecated' is not specified
|
||||
id=api_node_version[
|
||||
"id"
|
||||
], # 'id' should be mandatory; raise KeyError if missing
|
||||
version=api_node_version[
|
||||
"version"
|
||||
], # 'version' should be mandatory; raise KeyError if missing
|
||||
download_url=api_node_version.get(
|
||||
"downloadUrl", ""
|
||||
), # Provide a default value if 'downloadUrl' is missing
|
||||
)
|
||||
|
||||
|
||||
def install_node(node_id, version=None):
|
||||
"""
|
||||
Retrieves the node version for installation.
|
||||
|
||||
Args:
|
||||
node_id (str): The unique identifier of the node.
|
||||
version (str, optional): Specific version of the node to retrieve. If omitted, the latest version is returned.
|
||||
|
||||
Returns:
|
||||
NodeVersion: Node version data or error message.
|
||||
"""
|
||||
if version is None:
|
||||
url = f"{base_url}/nodes/{node_id}/install"
|
||||
else:
|
||||
url = f"{base_url}/nodes/{node_id}/install?version={version}"
|
||||
|
||||
response = requests.get(url)
|
||||
if response.status_code == 200:
|
||||
# Convert the API response to a NodeVersion object
|
||||
return map_node_version(response.json())
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def all_versions_of_node(node_id):
|
||||
url = f"{base_url}/nodes/{node_id}/versions?statuses=NodeVersionStatusActive&statuses=NodeVersionStatusPending"
|
||||
|
||||
response = requests.get(url)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def read_cnr_info(fullpath):
|
||||
try:
|
||||
toml_path = os.path.join(fullpath, 'pyproject.toml')
|
||||
tracking_path = os.path.join(fullpath, '.tracking')
|
||||
|
||||
if not os.path.exists(toml_path) or not os.path.exists(tracking_path):
|
||||
return None # not valid CNR node pack
|
||||
|
||||
with open(toml_path, "r", encoding="utf-8") as f:
|
||||
data = toml.load(f)
|
||||
|
||||
project = data.get('project', {})
|
||||
name = project.get('name').strip().lower()
|
||||
|
||||
# normalize version
|
||||
# for example: 2.5 -> 2.5.0
|
||||
version = str(manager_util.StrictVersion(project.get('version')))
|
||||
|
||||
urls = project.get('urls', {})
|
||||
repository = urls.get('Repository')
|
||||
|
||||
if name and version: # repository is optional
|
||||
return {
|
||||
"id": name,
|
||||
"version": version,
|
||||
"url": repository
|
||||
}
|
||||
|
||||
return None
|
||||
except Exception:
|
||||
return None # not valid CNR node pack
|
||||
|
||||
|
||||
def generate_cnr_id(fullpath, cnr_id):
|
||||
cnr_id_path = os.path.join(fullpath, '.git', '.cnr-id')
|
||||
try:
|
||||
if not os.path.exists(cnr_id_path):
|
||||
with open(cnr_id_path, "w") as f:
|
||||
return f.write(cnr_id)
|
||||
except:
|
||||
print(f"[ComfyUI Manager] unable to create file: {cnr_id_path}")
|
||||
|
||||
|
||||
def read_cnr_id(fullpath):
|
||||
cnr_id_path = os.path.join(fullpath, '.git', '.cnr-id')
|
||||
try:
|
||||
if os.path.exists(cnr_id_path):
|
||||
with open(cnr_id_path) as f:
|
||||
return f.read().strip()
|
||||
except:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
526
comfyui_manager/glob/git_helper.py
Normal file
526
comfyui_manager/glob/git_helper.py
Normal file
@@ -0,0 +1,526 @@
|
||||
import subprocess
|
||||
import sys
|
||||
import os
|
||||
import traceback
|
||||
|
||||
import git
|
||||
import json
|
||||
import yaml
|
||||
import requests
|
||||
from tqdm.auto import tqdm
|
||||
from git.remote import RemoteProgress
|
||||
|
||||
|
||||
comfy_path = os.environ.get('COMFYUI_PATH')
|
||||
git_exe_path = os.environ.get('GIT_EXE_PATH')
|
||||
|
||||
if comfy_path is None:
|
||||
print("git_helper: environment variable 'COMFYUI_PATH' is not specified.")
|
||||
exit(-1)
|
||||
|
||||
if not os.path.exists(os.path.join(comfy_path, 'folder_paths.py')):
|
||||
print("git_helper: '{comfy_path}' is not a valid 'COMFYUI_PATH' location.")
|
||||
exit(-1)
|
||||
|
||||
def download_url(url, dest_folder, filename=None):
|
||||
# Ensure the destination folder exists
|
||||
if not os.path.exists(dest_folder):
|
||||
os.makedirs(dest_folder)
|
||||
|
||||
# Extract filename from URL if not provided
|
||||
if filename is None:
|
||||
filename = os.path.basename(url)
|
||||
|
||||
# Full path to save the file
|
||||
dest_path = os.path.join(dest_folder, filename)
|
||||
|
||||
# Download the file
|
||||
response = requests.get(url, stream=True)
|
||||
if response.status_code == 200:
|
||||
with open(dest_path, 'wb') as file:
|
||||
for chunk in response.iter_content(chunk_size=1024):
|
||||
if chunk:
|
||||
file.write(chunk)
|
||||
else:
|
||||
print(f"Failed to download file from {url}")
|
||||
|
||||
|
||||
nodelist_path = os.path.join(os.path.dirname(__file__), "custom-node-list.json")
|
||||
working_directory = os.getcwd()
|
||||
|
||||
if os.path.basename(working_directory) != 'custom_nodes':
|
||||
print("WARN: This script should be executed in custom_nodes dir")
|
||||
print(f"DBG: INFO {working_directory}")
|
||||
print(f"DBG: INFO {sys.argv}")
|
||||
# exit(-1)
|
||||
|
||||
|
||||
class GitProgress(RemoteProgress):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.pbar = tqdm(ascii=True)
|
||||
|
||||
def update(self, op_code, cur_count, max_count=None, message=''):
|
||||
self.pbar.total = max_count
|
||||
self.pbar.n = cur_count
|
||||
self.pbar.pos = 0
|
||||
self.pbar.refresh()
|
||||
|
||||
|
||||
def gitclone(custom_nodes_path, url, target_hash=None, repo_path=None):
|
||||
repo_name = os.path.splitext(os.path.basename(url))[0]
|
||||
|
||||
if repo_path is None:
|
||||
repo_path = os.path.join(custom_nodes_path, repo_name)
|
||||
|
||||
# Clone the repository from the remote URL
|
||||
repo = git.Repo.clone_from(url, repo_path, recursive=True, progress=GitProgress())
|
||||
|
||||
if target_hash is not None:
|
||||
print(f"CHECKOUT: {repo_name} [{target_hash}]")
|
||||
repo.git.checkout(target_hash)
|
||||
|
||||
repo.git.clear_cache()
|
||||
repo.close()
|
||||
|
||||
|
||||
def gitcheck(path, do_fetch=False):
|
||||
try:
|
||||
# Fetch the latest commits from the remote repository
|
||||
repo = git.Repo(path)
|
||||
|
||||
if repo.head.is_detached:
|
||||
print("CUSTOM NODE CHECK: True")
|
||||
return
|
||||
|
||||
current_branch = repo.active_branch
|
||||
branch_name = current_branch.name
|
||||
|
||||
remote_name = current_branch.tracking_branch().remote_name
|
||||
remote = repo.remote(name=remote_name)
|
||||
|
||||
if do_fetch:
|
||||
remote.fetch()
|
||||
|
||||
# Get the current commit hash and the commit hash of the remote branch
|
||||
commit_hash = repo.head.commit.hexsha
|
||||
|
||||
if f'{remote_name}/{branch_name}' in repo.refs:
|
||||
remote_commit_hash = repo.refs[f'{remote_name}/{branch_name}'].object.hexsha
|
||||
else:
|
||||
print("CUSTOM NODE CHECK: True") # non default branch is treated as updatable
|
||||
return
|
||||
|
||||
# Compare the commit hashes to determine if the local repository is behind the remote repository
|
||||
if commit_hash != remote_commit_hash:
|
||||
# Get the commit dates
|
||||
commit_date = repo.head.commit.committed_datetime
|
||||
remote_commit_date = repo.refs[f'{remote_name}/{branch_name}'].object.committed_datetime
|
||||
|
||||
# Compare the commit dates to determine if the local repository is behind the remote repository
|
||||
if commit_date < remote_commit_date:
|
||||
print("CUSTOM NODE CHECK: True")
|
||||
else:
|
||||
print("CUSTOM NODE CHECK: False")
|
||||
except Exception as e:
|
||||
print(e)
|
||||
print("CUSTOM NODE CHECK: Error")
|
||||
|
||||
|
||||
def get_remote_name(repo):
|
||||
available_remotes = [remote.name for remote in repo.remotes]
|
||||
if 'origin' in available_remotes:
|
||||
return 'origin'
|
||||
elif 'upstream' in available_remotes:
|
||||
return 'upstream'
|
||||
elif len(available_remotes) > 0:
|
||||
return available_remotes[0]
|
||||
|
||||
if not available_remotes:
|
||||
print(f"[ComfyUI-Manager] No remotes are configured for this repository: {repo.working_dir}")
|
||||
else:
|
||||
print(f"[ComfyUI-Manager] Available remotes in '{repo.working_dir}': ")
|
||||
for remote in available_remotes:
|
||||
print(f"- {remote}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def switch_to_default_branch(repo):
|
||||
remote_name = get_remote_name(repo)
|
||||
|
||||
try:
|
||||
if remote_name is None:
|
||||
return False
|
||||
|
||||
default_branch = repo.git.symbolic_ref(f'refs/remotes/{remote_name}/HEAD').replace(f'refs/remotes/{remote_name}/', '')
|
||||
repo.git.checkout(default_branch)
|
||||
return True
|
||||
except:
|
||||
# try checkout master
|
||||
# try checkout main if failed
|
||||
try:
|
||||
repo.git.checkout(repo.heads.master)
|
||||
return True
|
||||
except:
|
||||
try:
|
||||
if remote_name is not None:
|
||||
repo.git.checkout('-b', 'master', f'{remote_name}/master')
|
||||
return True
|
||||
except:
|
||||
try:
|
||||
repo.git.checkout(repo.heads.main)
|
||||
return True
|
||||
except:
|
||||
try:
|
||||
if remote_name is not None:
|
||||
repo.git.checkout('-b', 'main', f'{remote_name}/main')
|
||||
return True
|
||||
except:
|
||||
pass
|
||||
|
||||
print("[ComfyUI Manager] Failed to switch to the default branch")
|
||||
return False
|
||||
|
||||
|
||||
def gitpull(path):
|
||||
# Check if the path is a git repository
|
||||
if not os.path.exists(os.path.join(path, '.git')):
|
||||
raise ValueError('Not a git repository')
|
||||
|
||||
# Pull the latest changes from the remote repository
|
||||
repo = git.Repo(path)
|
||||
if repo.is_dirty():
|
||||
print(f"STASH: '{path}' is dirty.")
|
||||
repo.git.stash()
|
||||
|
||||
commit_hash = repo.head.commit.hexsha
|
||||
try:
|
||||
if repo.head.is_detached:
|
||||
switch_to_default_branch(repo)
|
||||
|
||||
current_branch = repo.active_branch
|
||||
branch_name = current_branch.name
|
||||
|
||||
remote_name = current_branch.tracking_branch().remote_name
|
||||
remote = repo.remote(name=remote_name)
|
||||
|
||||
if f'{remote_name}/{branch_name}' not in repo.refs:
|
||||
switch_to_default_branch(repo)
|
||||
current_branch = repo.active_branch
|
||||
branch_name = current_branch.name
|
||||
|
||||
remote.fetch()
|
||||
if f'{remote_name}/{branch_name}' in repo.refs:
|
||||
remote_commit_hash = repo.refs[f'{remote_name}/{branch_name}'].object.hexsha
|
||||
else:
|
||||
print("CUSTOM NODE PULL: Fail") # update fail
|
||||
return
|
||||
|
||||
if commit_hash == remote_commit_hash:
|
||||
print("CUSTOM NODE PULL: None") # there is no update
|
||||
repo.close()
|
||||
return
|
||||
|
||||
remote.pull()
|
||||
|
||||
repo.git.submodule('update', '--init', '--recursive')
|
||||
new_commit_hash = repo.head.commit.hexsha
|
||||
|
||||
if commit_hash != new_commit_hash:
|
||||
print("CUSTOM NODE PULL: Success") # update success
|
||||
else:
|
||||
print("CUSTOM NODE PULL: Fail") # update fail
|
||||
except Exception as e:
|
||||
print(e)
|
||||
print("CUSTOM NODE PULL: Fail") # unknown git error
|
||||
|
||||
repo.close()
|
||||
|
||||
|
||||
def checkout_comfyui_hash(target_hash):
|
||||
repo = git.Repo(comfy_path)
|
||||
commit_hash = repo.head.commit.hexsha
|
||||
|
||||
if commit_hash != target_hash:
|
||||
try:
|
||||
print(f"CHECKOUT: ComfyUI [{target_hash}]")
|
||||
repo.git.checkout(target_hash)
|
||||
except git.GitCommandError as e:
|
||||
print(f"Error checking out the ComfyUI: {str(e)}")
|
||||
|
||||
|
||||
def checkout_custom_node_hash(git_custom_node_infos):
|
||||
repo_name_to_url = {}
|
||||
|
||||
for url in git_custom_node_infos.keys():
|
||||
repo_name = url.split('/')[-1]
|
||||
|
||||
if repo_name.endswith('.git'):
|
||||
repo_name = repo_name[:-4]
|
||||
|
||||
repo_name_to_url[repo_name] = url
|
||||
|
||||
for path in os.listdir(working_directory):
|
||||
if path.endswith("ComfyUI-Manager"):
|
||||
continue
|
||||
|
||||
fullpath = os.path.join(working_directory, path)
|
||||
|
||||
if os.path.isdir(fullpath):
|
||||
is_disabled = path.endswith(".disabled")
|
||||
|
||||
try:
|
||||
git_dir = os.path.join(fullpath, '.git')
|
||||
if not os.path.exists(git_dir):
|
||||
continue
|
||||
|
||||
need_checkout = False
|
||||
repo_name = os.path.basename(fullpath)
|
||||
|
||||
if repo_name.endswith('.disabled'):
|
||||
repo_name = repo_name[:-9]
|
||||
|
||||
if repo_name not in repo_name_to_url:
|
||||
if not is_disabled:
|
||||
# should be disabled
|
||||
print(f"DISABLE: {repo_name}")
|
||||
new_path = fullpath + ".disabled"
|
||||
os.rename(fullpath, new_path)
|
||||
need_checkout = False
|
||||
else:
|
||||
item = git_custom_node_infos[repo_name_to_url[repo_name]]
|
||||
if item['disabled'] and is_disabled:
|
||||
pass
|
||||
elif item['disabled'] and not is_disabled:
|
||||
# disable
|
||||
print(f"DISABLE: {repo_name}")
|
||||
new_path = fullpath + ".disabled"
|
||||
os.rename(fullpath, new_path)
|
||||
|
||||
elif not item['disabled'] and is_disabled:
|
||||
# enable
|
||||
print(f"ENABLE: {repo_name}")
|
||||
new_path = fullpath[:-9]
|
||||
os.rename(fullpath, new_path)
|
||||
fullpath = new_path
|
||||
need_checkout = True
|
||||
else:
|
||||
need_checkout = True
|
||||
|
||||
if need_checkout:
|
||||
repo = git.Repo(fullpath)
|
||||
commit_hash = repo.head.commit.hexsha
|
||||
|
||||
if commit_hash != item['hash']:
|
||||
print(f"CHECKOUT: {repo_name} [{item['hash']}]")
|
||||
repo.git.checkout(item['hash'])
|
||||
|
||||
except Exception:
|
||||
print(f"Failed to restore snapshots for the custom node '{path}'")
|
||||
|
||||
# clone missing
|
||||
for k, v in git_custom_node_infos.items():
|
||||
if 'ComfyUI-Manager' in k:
|
||||
continue
|
||||
|
||||
if not v['disabled']:
|
||||
repo_name = k.split('/')[-1]
|
||||
if repo_name.endswith('.git'):
|
||||
repo_name = repo_name[:-4]
|
||||
|
||||
path = os.path.join(working_directory, repo_name)
|
||||
if not os.path.exists(path):
|
||||
print(f"CLONE: {path}")
|
||||
gitclone(working_directory, k, target_hash=v['hash'])
|
||||
|
||||
|
||||
def invalidate_custom_node_file(file_custom_node_infos):
|
||||
global nodelist_path
|
||||
|
||||
enabled_set = set()
|
||||
for item in file_custom_node_infos:
|
||||
if not item['disabled']:
|
||||
enabled_set.add(item['filename'])
|
||||
|
||||
for path in os.listdir(working_directory):
|
||||
fullpath = os.path.join(working_directory, path)
|
||||
|
||||
if not os.path.isdir(fullpath) and fullpath.endswith('.py'):
|
||||
if path not in enabled_set:
|
||||
print(f"DISABLE: {path}")
|
||||
new_path = fullpath+'.disabled'
|
||||
os.rename(fullpath, new_path)
|
||||
|
||||
elif not os.path.isdir(fullpath) and fullpath.endswith('.py.disabled'):
|
||||
path = path[:-9]
|
||||
if path in enabled_set:
|
||||
print(f"ENABLE: {path}")
|
||||
new_path = fullpath[:-9]
|
||||
os.rename(fullpath, new_path)
|
||||
|
||||
# download missing: just support for 'copy' style
|
||||
py_to_url = {}
|
||||
|
||||
with open(nodelist_path, 'r', encoding="UTF-8") as json_file:
|
||||
info = json.load(json_file)
|
||||
for item in info['custom_nodes']:
|
||||
if item['install_type'] == 'copy':
|
||||
for url in item['files']:
|
||||
if url.endswith('.py'):
|
||||
py = url.split('/')[-1]
|
||||
py_to_url[py] = url
|
||||
|
||||
for item in file_custom_node_infos:
|
||||
filename = item['filename']
|
||||
if not item['disabled']:
|
||||
target_path = os.path.join(working_directory, filename)
|
||||
|
||||
if not os.path.exists(target_path) and filename in py_to_url:
|
||||
url = py_to_url[filename]
|
||||
print(f"DOWNLOAD: {filename}")
|
||||
download_url(url, working_directory)
|
||||
|
||||
|
||||
def apply_snapshot(path):
|
||||
try:
|
||||
if os.path.exists(path):
|
||||
if not path.endswith('.json') and not path.endswith('.yaml'):
|
||||
print(f"Snapshot file not found: `{path}`")
|
||||
print("APPLY SNAPSHOT: False")
|
||||
return None
|
||||
|
||||
with open(path, 'r', encoding="UTF-8") as snapshot_file:
|
||||
if path.endswith('.json'):
|
||||
info = json.load(snapshot_file)
|
||||
elif path.endswith('.yaml'):
|
||||
info = yaml.load(snapshot_file, Loader=yaml.SafeLoader)
|
||||
info = info['custom_nodes']
|
||||
else:
|
||||
# impossible case
|
||||
print("APPLY SNAPSHOT: False")
|
||||
return None
|
||||
|
||||
comfyui_hash = info['comfyui']
|
||||
git_custom_node_infos = info['git_custom_nodes']
|
||||
file_custom_node_infos = info['file_custom_nodes']
|
||||
|
||||
if comfyui_hash:
|
||||
checkout_comfyui_hash(comfyui_hash)
|
||||
checkout_custom_node_hash(git_custom_node_infos)
|
||||
invalidate_custom_node_file(file_custom_node_infos)
|
||||
|
||||
print("APPLY SNAPSHOT: True")
|
||||
if 'pips' in info and info['pips']:
|
||||
return info['pips']
|
||||
else:
|
||||
return None
|
||||
|
||||
print(f"Snapshot file not found: `{path}`")
|
||||
print("APPLY SNAPSHOT: False")
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
print(e)
|
||||
traceback.print_exc()
|
||||
print("APPLY SNAPSHOT: False")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def restore_pip_snapshot(pips, options):
|
||||
non_url = []
|
||||
local_url = []
|
||||
non_local_url = []
|
||||
for k, v in pips.items():
|
||||
if v == "":
|
||||
non_url.append(k)
|
||||
else:
|
||||
if v.startswith('file:'):
|
||||
local_url.append(v)
|
||||
else:
|
||||
non_local_url.append(v)
|
||||
|
||||
failed = []
|
||||
if '--pip-non-url' in options:
|
||||
# try all at once
|
||||
res = 1
|
||||
try:
|
||||
res = subprocess.check_call([sys.executable, '-m', 'pip', 'install'] + non_url)
|
||||
except:
|
||||
pass
|
||||
|
||||
# fallback
|
||||
if res != 0:
|
||||
for x in non_url:
|
||||
res = 1
|
||||
try:
|
||||
res = subprocess.check_call([sys.executable, '-m', 'pip', 'install', x])
|
||||
except:
|
||||
pass
|
||||
|
||||
if res != 0:
|
||||
failed.append(x)
|
||||
|
||||
if '--pip-non-local-url' in options:
|
||||
for x in non_local_url:
|
||||
res = 1
|
||||
try:
|
||||
res = subprocess.check_call([sys.executable, '-m', 'pip', 'install', x])
|
||||
except:
|
||||
pass
|
||||
|
||||
if res != 0:
|
||||
failed.append(x)
|
||||
|
||||
if '--pip-local-url' in options:
|
||||
for x in local_url:
|
||||
res = 1
|
||||
try:
|
||||
res = subprocess.check_call([sys.executable, '-m', 'pip', 'install', x])
|
||||
except:
|
||||
pass
|
||||
|
||||
if res != 0:
|
||||
failed.append(x)
|
||||
|
||||
print(f"Installation failed for pip packages: {failed}")
|
||||
|
||||
|
||||
def setup_environment():
|
||||
if git_exe_path is not None:
|
||||
git.Git().update_environment(GIT_PYTHON_GIT_EXECUTABLE=git_exe_path)
|
||||
|
||||
|
||||
setup_environment()
|
||||
|
||||
|
||||
try:
|
||||
if sys.argv[1] == "--clone":
|
||||
repo_path = None
|
||||
if len(sys.argv) > 4:
|
||||
repo_path = sys.argv[4]
|
||||
|
||||
gitclone(sys.argv[2], sys.argv[3], repo_path=repo_path)
|
||||
elif sys.argv[1] == "--check":
|
||||
gitcheck(sys.argv[2], False)
|
||||
elif sys.argv[1] == "--fetch":
|
||||
gitcheck(sys.argv[2], True)
|
||||
elif sys.argv[1] == "--pull":
|
||||
gitpull(sys.argv[2])
|
||||
elif sys.argv[1] == "--apply-snapshot":
|
||||
options = set()
|
||||
for x in sys.argv:
|
||||
if x in ['--pip-non-url', '--pip-local-url', '--pip-non-local-url']:
|
||||
options.add(x)
|
||||
|
||||
pips = apply_snapshot(sys.argv[2])
|
||||
|
||||
if pips and len(options) > 0:
|
||||
restore_pip_snapshot(pips, options)
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
sys.exit(-1)
|
||||
|
||||
|
||||
85
comfyui_manager/glob/git_utils.py
Normal file
85
comfyui_manager/glob/git_utils.py
Normal file
@@ -0,0 +1,85 @@
|
||||
import os
|
||||
import configparser
|
||||
|
||||
|
||||
GITHUB_ENDPOINT = os.getenv('GITHUB_ENDPOINT')
|
||||
|
||||
|
||||
def is_git_repo(path: str) -> bool:
|
||||
""" Check if the path is a git repository. """
|
||||
# NOTE: Checking it through `git.Repo` must be avoided.
|
||||
# It locks the file, causing issues on Windows.
|
||||
return os.path.exists(os.path.join(path, '.git'))
|
||||
|
||||
|
||||
def get_commit_hash(fullpath):
|
||||
git_head = os.path.join(fullpath, '.git', 'HEAD')
|
||||
if os.path.exists(git_head):
|
||||
with open(git_head) as f:
|
||||
line = f.readline()
|
||||
|
||||
if line.startswith("ref: "):
|
||||
ref = os.path.join(fullpath, '.git', line[5:].strip())
|
||||
if os.path.exists(ref):
|
||||
with open(ref) as f2:
|
||||
return f2.readline().strip()
|
||||
else:
|
||||
return "unknown"
|
||||
else:
|
||||
return line
|
||||
|
||||
return "unknown"
|
||||
|
||||
|
||||
def git_url(fullpath):
|
||||
"""
|
||||
resolve version of unclassified custom node based on remote url in .git/config
|
||||
"""
|
||||
git_config_path = os.path.join(fullpath, '.git', 'config')
|
||||
|
||||
if not os.path.exists(git_config_path):
|
||||
return None
|
||||
|
||||
# Set `strict=False` to allow duplicate `vscode-merge-base` sections, addressing <https://github.com/ltdrdata/ComfyUI-Manager/issues/1529>
|
||||
config = configparser.ConfigParser(strict=False)
|
||||
config.read(git_config_path)
|
||||
|
||||
for k, v in config.items():
|
||||
if k.startswith('remote ') and 'url' in v:
|
||||
return v['url']
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def normalize_url(url) -> str:
|
||||
github_id = normalize_to_github_id(url)
|
||||
if github_id is not None:
|
||||
url = f"https://github.com/{github_id}"
|
||||
|
||||
return url
|
||||
|
||||
|
||||
def normalize_to_github_id(url) -> str:
|
||||
if 'github' in url or (GITHUB_ENDPOINT is not None and GITHUB_ENDPOINT in url):
|
||||
author = os.path.basename(os.path.dirname(url))
|
||||
|
||||
if author.startswith('git@github.com:'):
|
||||
author = author.split(':')[1]
|
||||
|
||||
repo_name = os.path.basename(url)
|
||||
if repo_name.endswith('.git'):
|
||||
repo_name = repo_name[:-4]
|
||||
|
||||
return f"{author}/{repo_name}"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_url_for_clone(url):
|
||||
url = normalize_url(url)
|
||||
|
||||
if GITHUB_ENDPOINT is not None and url.startswith('https://github.com/'):
|
||||
url = GITHUB_ENDPOINT + url[18:] # url[18:] -> remove `https://github.com`
|
||||
|
||||
return url
|
||||
|
||||
3355
comfyui_manager/glob/manager_core.py
Normal file
3355
comfyui_manager/glob/manager_core.py
Normal file
File diff suppressed because it is too large
Load Diff
159
comfyui_manager/glob/manager_downloader.py
Normal file
159
comfyui_manager/glob/manager_downloader.py
Normal file
@@ -0,0 +1,159 @@
|
||||
import os
|
||||
from urllib.parse import urlparse
|
||||
import urllib
|
||||
import sys
|
||||
import logging
|
||||
import requests
|
||||
from huggingface_hub import HfApi
|
||||
from tqdm.auto import tqdm
|
||||
|
||||
|
||||
aria2 = os.getenv('COMFYUI_MANAGER_ARIA2_SERVER')
|
||||
HF_ENDPOINT = os.getenv('HF_ENDPOINT')
|
||||
|
||||
|
||||
if aria2 is not None:
|
||||
secret = os.getenv('COMFYUI_MANAGER_ARIA2_SECRET')
|
||||
url = urlparse(aria2)
|
||||
port = url.port
|
||||
host = url.scheme + '://' + url.hostname
|
||||
import aria2p
|
||||
|
||||
aria2 = aria2p.API(aria2p.Client(host=host, port=port, secret=secret))
|
||||
|
||||
|
||||
def basic_download_url(url, dest_folder: str, filename: str):
|
||||
'''
|
||||
Download file from url to dest_folder with filename
|
||||
using requests library.
|
||||
'''
|
||||
import requests
|
||||
|
||||
# Ensure the destination folder exists
|
||||
if not os.path.exists(dest_folder):
|
||||
os.makedirs(dest_folder)
|
||||
|
||||
# Full path to save the file
|
||||
dest_path = os.path.join(dest_folder, filename)
|
||||
|
||||
# Download the file
|
||||
response = requests.get(url, stream=True)
|
||||
if response.status_code == 200:
|
||||
with open(dest_path, 'wb') as file:
|
||||
for chunk in response.iter_content(chunk_size=1024):
|
||||
if chunk:
|
||||
file.write(chunk)
|
||||
else:
|
||||
raise Exception(f"Failed to download file from {url}")
|
||||
|
||||
|
||||
def download_url(model_url: str, model_dir: str, filename: str):
|
||||
if HF_ENDPOINT:
|
||||
model_url = model_url.replace('https://huggingface.co', HF_ENDPOINT)
|
||||
logging.info(f"model_url replaced by HF_ENDPOINT, new = {model_url}")
|
||||
if aria2:
|
||||
return aria2_download_url(model_url, model_dir, filename)
|
||||
else:
|
||||
from torchvision.datasets.utils import download_url as torchvision_download_url
|
||||
return torchvision_download_url(model_url, model_dir, filename)
|
||||
|
||||
|
||||
def aria2_find_task(dir: str, filename: str):
|
||||
target = os.path.join(dir, filename)
|
||||
|
||||
downloads = aria2.get_downloads()
|
||||
|
||||
for download in downloads:
|
||||
for file in download.files:
|
||||
if file.is_metadata:
|
||||
continue
|
||||
if str(file.path) == target:
|
||||
return download
|
||||
|
||||
|
||||
def aria2_download_url(model_url: str, model_dir: str, filename: str):
|
||||
import manager_core as core
|
||||
import tqdm
|
||||
import time
|
||||
|
||||
if model_dir.startswith(core.comfy_path):
|
||||
model_dir = model_dir[len(core.comfy_path) :]
|
||||
|
||||
download_dir = model_dir if model_dir.startswith('/') else os.path.join('/models', model_dir)
|
||||
|
||||
download = aria2_find_task(download_dir, filename)
|
||||
if download is None:
|
||||
options = {'dir': download_dir, 'out': filename}
|
||||
download = aria2.add(model_url, options)[0]
|
||||
|
||||
if download.is_active:
|
||||
with tqdm.tqdm(
|
||||
total=download.total_length,
|
||||
bar_format='{l_bar}{bar}{r_bar}',
|
||||
desc=filename,
|
||||
unit='B',
|
||||
unit_scale=True,
|
||||
) as progress_bar:
|
||||
while download.is_active:
|
||||
if progress_bar.total == 0 and download.total_length != 0:
|
||||
progress_bar.reset(download.total_length)
|
||||
progress_bar.update(download.completed_length - progress_bar.n)
|
||||
time.sleep(1)
|
||||
download.update()
|
||||
|
||||
|
||||
def download_url_with_agent(url, save_path):
|
||||
try:
|
||||
headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3'}
|
||||
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
response = urllib.request.urlopen(req)
|
||||
data = response.read()
|
||||
|
||||
if not os.path.exists(os.path.dirname(save_path)):
|
||||
os.makedirs(os.path.dirname(save_path))
|
||||
|
||||
with open(save_path, 'wb') as f:
|
||||
f.write(data)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Download error: {url} / {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
print("Installation was successful.")
|
||||
return True
|
||||
|
||||
# NOTE: snapshot_download doesn't provide file size tqdm.
|
||||
def download_repo_in_bytes(repo_id, local_dir):
|
||||
api = HfApi()
|
||||
repo_info = api.repo_info(repo_id=repo_id, files_metadata=True)
|
||||
|
||||
os.makedirs(local_dir, exist_ok=True)
|
||||
|
||||
total_size = 0
|
||||
for file_info in repo_info.siblings:
|
||||
if file_info.size is not None:
|
||||
total_size += file_info.size
|
||||
|
||||
pbar = tqdm(total=total_size, unit="B", unit_scale=True, desc="Downloading")
|
||||
|
||||
for file_info in repo_info.siblings:
|
||||
out_path = os.path.join(local_dir, file_info.rfilename)
|
||||
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
||||
|
||||
if file_info.size is None:
|
||||
continue
|
||||
|
||||
download_url = f"https://huggingface.co/{repo_id}/resolve/main/{file_info.rfilename}"
|
||||
|
||||
with requests.get(download_url, stream=True) as r, open(out_path, "wb") as f:
|
||||
r.raise_for_status()
|
||||
for chunk in r.iter_content(chunk_size=65536):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
pbar.update(len(chunk))
|
||||
|
||||
pbar.close()
|
||||
|
||||
|
||||
1699
comfyui_manager/glob/manager_server.py
Normal file
1699
comfyui_manager/glob/manager_server.py
Normal file
File diff suppressed because it is too large
Load Diff
533
comfyui_manager/glob/manager_util.py
Normal file
533
comfyui_manager/glob/manager_util.py
Normal file
@@ -0,0 +1,533 @@
|
||||
"""
|
||||
description:
|
||||
`manager_util` is the lightest module shared across the prestartup_script, main code, and cm-cli of ComfyUI-Manager.
|
||||
"""
|
||||
import traceback
|
||||
|
||||
import aiohttp
|
||||
import json
|
||||
import threading
|
||||
import os
|
||||
from datetime import datetime
|
||||
import subprocess
|
||||
import sys
|
||||
import re
|
||||
import logging
|
||||
import platform
|
||||
import shlex
|
||||
|
||||
|
||||
cache_lock = threading.Lock()
|
||||
session_lock = threading.Lock()
|
||||
|
||||
comfyui_manager_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
||||
cache_dir = os.path.join(comfyui_manager_path, '.cache') # This path is also updated together in **manager_core.update_user_directory**.
|
||||
|
||||
use_uv = False
|
||||
|
||||
|
||||
def add_python_path_to_env():
|
||||
if platform.system() != "Windows":
|
||||
sep = ':'
|
||||
else:
|
||||
sep = ';'
|
||||
|
||||
os.environ['PATH'] = os.path.dirname(sys.executable)+sep+os.environ['PATH']
|
||||
|
||||
|
||||
def make_pip_cmd(cmd):
|
||||
if 'python_embeded' in sys.executable:
|
||||
if use_uv:
|
||||
return [sys.executable, '-s', '-m', 'uv', 'pip'] + cmd
|
||||
else:
|
||||
return [sys.executable, '-s', '-m', 'pip'] + cmd
|
||||
else:
|
||||
# FIXED: https://github.com/ltdrdata/ComfyUI-Manager/issues/1667
|
||||
if use_uv:
|
||||
return [sys.executable, '-m', 'uv', 'pip'] + cmd
|
||||
else:
|
||||
return [sys.executable, '-m', 'pip'] + cmd
|
||||
|
||||
# DON'T USE StrictVersion - cannot handle pre_release version
|
||||
# try:
|
||||
# from distutils.version import StrictVersion
|
||||
# except:
|
||||
# print(f"[ComfyUI-Manager] 'distutils' package not found. Activating fallback mode for compatibility.")
|
||||
class StrictVersion:
|
||||
def __init__(self, version_string):
|
||||
self.version_string = version_string
|
||||
self.major = 0
|
||||
self.minor = 0
|
||||
self.patch = 0
|
||||
self.pre_release = None
|
||||
self.parse_version_string()
|
||||
|
||||
def parse_version_string(self):
|
||||
parts = self.version_string.split('.')
|
||||
if not parts:
|
||||
raise ValueError("Version string must not be empty")
|
||||
|
||||
self.major = int(parts[0])
|
||||
self.minor = int(parts[1]) if len(parts) > 1 else 0
|
||||
self.patch = int(parts[2]) if len(parts) > 2 else 0
|
||||
|
||||
# Handling pre-release versions if present
|
||||
if len(parts) > 3:
|
||||
self.pre_release = parts[3]
|
||||
|
||||
def __str__(self):
|
||||
version = f"{self.major}.{self.minor}.{self.patch}"
|
||||
if self.pre_release:
|
||||
version += f"-{self.pre_release}"
|
||||
return version
|
||||
|
||||
def __eq__(self, other):
|
||||
return (self.major, self.minor, self.patch, self.pre_release) == \
|
||||
(other.major, other.minor, other.patch, other.pre_release)
|
||||
|
||||
def __lt__(self, other):
|
||||
if (self.major, self.minor, self.patch) == (other.major, other.minor, other.patch):
|
||||
return self.pre_release_compare(self.pre_release, other.pre_release) < 0
|
||||
return (self.major, self.minor, self.patch) < (other.major, other.minor, other.patch)
|
||||
|
||||
@staticmethod
|
||||
def pre_release_compare(pre1, pre2):
|
||||
if pre1 == pre2:
|
||||
return 0
|
||||
if pre1 is None:
|
||||
return 1
|
||||
if pre2 is None:
|
||||
return -1
|
||||
return -1 if pre1 < pre2 else 1
|
||||
|
||||
def __le__(self, other):
|
||||
return self == other or self < other
|
||||
|
||||
def __gt__(self, other):
|
||||
return not self <= other
|
||||
|
||||
def __ge__(self, other):
|
||||
return not self < other
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self == other
|
||||
|
||||
|
||||
def simple_hash(input_string):
|
||||
hash_value = 0
|
||||
for char in input_string:
|
||||
hash_value = (hash_value * 31 + ord(char)) % (2**32)
|
||||
|
||||
return hash_value
|
||||
|
||||
|
||||
def is_file_created_within_one_day(file_path):
|
||||
if not os.path.exists(file_path):
|
||||
return False
|
||||
|
||||
file_creation_time = os.path.getctime(file_path)
|
||||
current_time = datetime.now().timestamp()
|
||||
time_difference = current_time - file_creation_time
|
||||
|
||||
return time_difference <= 86400
|
||||
|
||||
|
||||
async def get_data(uri, silent=False):
|
||||
if not silent:
|
||||
print(f"FETCH DATA from: {uri}", end="")
|
||||
|
||||
if uri.startswith("http"):
|
||||
async with aiohttp.ClientSession(trust_env=True, connector=aiohttp.TCPConnector(verify_ssl=False)) as session:
|
||||
headers = {
|
||||
'Cache-Control': 'no-cache',
|
||||
'Pragma': 'no-cache',
|
||||
'Expires': '0'
|
||||
}
|
||||
async with session.get(uri, headers=headers) as resp:
|
||||
json_text = await resp.text()
|
||||
else:
|
||||
with cache_lock:
|
||||
with open(uri, "r", encoding="utf-8") as f:
|
||||
json_text = f.read()
|
||||
|
||||
try:
|
||||
json_obj = json.loads(json_text)
|
||||
except Exception as e:
|
||||
logging.error(f"[ComfyUI-Manager] An error occurred while fetching '{uri}': {e}")
|
||||
|
||||
return {}
|
||||
|
||||
if not silent:
|
||||
print(" [DONE]")
|
||||
|
||||
return json_obj
|
||||
|
||||
|
||||
def get_cache_path(uri):
|
||||
cache_uri = str(simple_hash(uri)) + '_' + os.path.basename(uri).replace('&', "_").replace('?', "_").replace('=', "_")
|
||||
return os.path.join(cache_dir, cache_uri+'.json')
|
||||
|
||||
|
||||
def get_cache_state(uri):
|
||||
cache_uri = get_cache_path(uri)
|
||||
|
||||
if not os.path.exists(cache_uri):
|
||||
return "not-cached"
|
||||
elif is_file_created_within_one_day(cache_uri):
|
||||
return "cached"
|
||||
|
||||
return "expired"
|
||||
|
||||
|
||||
def save_to_cache(uri, json_obj, silent=False):
|
||||
cache_uri = get_cache_path(uri)
|
||||
|
||||
with cache_lock:
|
||||
with open(cache_uri, "w", encoding='utf-8') as file:
|
||||
json.dump(json_obj, file, indent=4, sort_keys=True)
|
||||
if not silent:
|
||||
logging.info(f"[ComfyUI-Manager] default cache updated: {uri}")
|
||||
|
||||
|
||||
async def get_data_with_cache(uri, silent=False, cache_mode=True, dont_wait=False, dont_cache=False):
|
||||
cache_uri = get_cache_path(uri)
|
||||
|
||||
if cache_mode and dont_wait:
|
||||
# NOTE: return the cache if possible, even if it is expired, so do not cache
|
||||
if not os.path.exists(cache_uri):
|
||||
logging.error(f"[ComfyUI-Manager] The network connection is unstable, so it is operating in fallback mode: {uri}")
|
||||
|
||||
return {}
|
||||
else:
|
||||
if not is_file_created_within_one_day(cache_uri):
|
||||
logging.error(f"[ComfyUI-Manager] The network connection is unstable, so it is operating in outdated cache mode: {uri}")
|
||||
|
||||
return await get_data(cache_uri, silent=silent)
|
||||
|
||||
if cache_mode and is_file_created_within_one_day(cache_uri):
|
||||
json_obj = await get_data(cache_uri, silent=silent)
|
||||
else:
|
||||
json_obj = await get_data(uri, silent=silent)
|
||||
if not dont_cache:
|
||||
with cache_lock:
|
||||
with open(cache_uri, "w", encoding='utf-8') as file:
|
||||
json.dump(json_obj, file, indent=4, sort_keys=True)
|
||||
if not silent:
|
||||
logging.info(f"[ComfyUI-Manager] default cache updated: {uri}")
|
||||
|
||||
return json_obj
|
||||
|
||||
|
||||
def sanitize_tag(x):
|
||||
return x.replace('<', '<').replace('>', '>')
|
||||
|
||||
|
||||
def extract_package_as_zip(file_path, extract_path):
|
||||
import zipfile
|
||||
try:
|
||||
with zipfile.ZipFile(file_path, "r") as zip_ref:
|
||||
zip_ref.extractall(extract_path)
|
||||
extracted_files = zip_ref.namelist()
|
||||
logging.info(f"Extracted zip file to {extract_path}")
|
||||
return extracted_files
|
||||
except zipfile.BadZipFile:
|
||||
logging.error(f"File '{file_path}' is not a zip or is corrupted.")
|
||||
return None
|
||||
|
||||
|
||||
pip_map = None
|
||||
|
||||
|
||||
def get_installed_packages(renew=False):
|
||||
global pip_map
|
||||
|
||||
if renew or pip_map is None:
|
||||
try:
|
||||
result = subprocess.check_output(make_pip_cmd(['list']), universal_newlines=True)
|
||||
|
||||
pip_map = {}
|
||||
for line in result.split('\n'):
|
||||
x = line.strip()
|
||||
if x:
|
||||
y = line.split()
|
||||
if y[0] == 'Package' or y[0].startswith('-'):
|
||||
continue
|
||||
|
||||
normalized_name = y[0].lower().replace('-', '_')
|
||||
pip_map[normalized_name] = y[1]
|
||||
except subprocess.CalledProcessError:
|
||||
logging.error("[ComfyUI-Manager] Failed to retrieve the information of installed pip packages.")
|
||||
return set()
|
||||
|
||||
return pip_map
|
||||
|
||||
|
||||
def clear_pip_cache():
|
||||
global pip_map
|
||||
pip_map = None
|
||||
|
||||
|
||||
def parse_requirement_line(line):
|
||||
tokens = shlex.split(line)
|
||||
if not tokens:
|
||||
return None
|
||||
|
||||
package_spec = tokens[0]
|
||||
|
||||
pattern = re.compile(
|
||||
r'^(?P<package>[A-Za-z0-9_.+-]+)'
|
||||
r'(?P<operator>==|>=|<=|!=|~=|>|<)?'
|
||||
r'(?P<version>[A-Za-z0-9_.+-]*)$'
|
||||
)
|
||||
m = pattern.match(package_spec)
|
||||
if not m:
|
||||
return None
|
||||
|
||||
package = m.group('package')
|
||||
operator = m.group('operator') or None
|
||||
version = m.group('version') or None
|
||||
|
||||
index_url = None
|
||||
if '--index-url' in tokens:
|
||||
idx = tokens.index('--index-url')
|
||||
if idx + 1 < len(tokens):
|
||||
index_url = tokens[idx + 1]
|
||||
|
||||
res = {'package': package}
|
||||
|
||||
if operator is not None:
|
||||
res['operator'] = operator
|
||||
|
||||
if version is not None:
|
||||
res['version'] = StrictVersion(version)
|
||||
|
||||
if index_url is not None:
|
||||
res['index_url'] = index_url
|
||||
|
||||
return res
|
||||
|
||||
|
||||
torch_torchvision_torchaudio_version_map = {
|
||||
'2.6.0': ('0.21.0', '2.6.0'),
|
||||
'2.5.1': ('0.20.0', '2.5.0'),
|
||||
'2.5.0': ('0.20.0', '2.5.0'),
|
||||
'2.4.1': ('0.19.1', '2.4.1'),
|
||||
'2.4.0': ('0.19.0', '2.4.0'),
|
||||
'2.3.1': ('0.18.1', '2.3.1'),
|
||||
'2.3.0': ('0.18.0', '2.3.0'),
|
||||
'2.2.2': ('0.17.2', '2.2.2'),
|
||||
'2.2.1': ('0.17.1', '2.2.1'),
|
||||
'2.2.0': ('0.17.0', '2.2.0'),
|
||||
'2.1.2': ('0.16.2', '2.1.2'),
|
||||
'2.1.1': ('0.16.1', '2.1.1'),
|
||||
'2.1.0': ('0.16.0', '2.1.0'),
|
||||
'2.0.1': ('0.15.2', '2.0.1'),
|
||||
'2.0.0': ('0.15.1', '2.0.0'),
|
||||
}
|
||||
|
||||
|
||||
|
||||
class PIPFixer:
|
||||
def __init__(self, prev_pip_versions, comfyui_path, manager_files_path):
|
||||
self.prev_pip_versions = { **prev_pip_versions }
|
||||
self.comfyui_path = comfyui_path
|
||||
self.manager_files_path = manager_files_path
|
||||
|
||||
def torch_rollback(self):
|
||||
spec = self.prev_pip_versions['torch'].split('+')
|
||||
if len(spec) > 0:
|
||||
platform = spec[1]
|
||||
else:
|
||||
cmd = make_pip_cmd(['install', '--force', 'torch', 'torchvision', 'torchaudio'])
|
||||
subprocess.check_output(cmd, universal_newlines=True)
|
||||
logging.error(cmd)
|
||||
return
|
||||
|
||||
torch_ver = StrictVersion(spec[0])
|
||||
torch_ver = f"{torch_ver.major}.{torch_ver.minor}.{torch_ver.patch}"
|
||||
torch_torchvision_torchaudio_ver = torch_torchvision_torchaudio_version_map.get(torch_ver)
|
||||
|
||||
if torch_torchvision_torchaudio_ver is None:
|
||||
cmd = make_pip_cmd(['install', '--pre', 'torch', 'torchvision', 'torchaudio',
|
||||
'--index-url', f"https://download.pytorch.org/whl/nightly/{platform}"])
|
||||
logging.info("[ComfyUI-Manager] restore PyTorch to nightly version")
|
||||
else:
|
||||
torchvision_ver, torchaudio_ver = torch_torchvision_torchaudio_ver
|
||||
cmd = make_pip_cmd(['install', f'torch=={torch_ver}', f'torchvision=={torchvision_ver}', f"torchaudio=={torchaudio_ver}",
|
||||
'--index-url', f"https://download.pytorch.org/whl/{platform}"])
|
||||
logging.info(f"[ComfyUI-Manager] restore PyTorch to {torch_ver}+{platform}")
|
||||
|
||||
subprocess.check_output(cmd, universal_newlines=True)
|
||||
|
||||
def fix_broken(self):
|
||||
new_pip_versions = get_installed_packages(True)
|
||||
|
||||
# remove `comfy` python package
|
||||
try:
|
||||
if 'comfy' in new_pip_versions:
|
||||
cmd = make_pip_cmd(['uninstall', 'comfy'])
|
||||
subprocess.check_output(cmd, universal_newlines=True)
|
||||
|
||||
logging.warning("[ComfyUI-Manager] 'comfy' python package is uninstalled.\nWARN: The 'comfy' package is completely unrelated to ComfyUI and should never be installed as it causes conflicts with ComfyUI.")
|
||||
except Exception as e:
|
||||
logging.error("[ComfyUI-Manager] Failed to uninstall `comfy` python package")
|
||||
logging.error(e)
|
||||
|
||||
# fix torch - reinstall torch packages if version is changed
|
||||
try:
|
||||
if 'torch' not in self.prev_pip_versions or 'torchvision' not in self.prev_pip_versions or 'torchaudio' not in self.prev_pip_versions:
|
||||
logging.error("[ComfyUI-Manager] PyTorch is not installed")
|
||||
elif self.prev_pip_versions['torch'] != new_pip_versions['torch'] \
|
||||
or self.prev_pip_versions['torchvision'] != new_pip_versions['torchvision'] \
|
||||
or self.prev_pip_versions['torchaudio'] != new_pip_versions['torchaudio']:
|
||||
self.torch_rollback()
|
||||
except Exception as e:
|
||||
logging.error("[ComfyUI-Manager] Failed to restore PyTorch")
|
||||
logging.error(e)
|
||||
|
||||
# fix opencv
|
||||
try:
|
||||
ocp = new_pip_versions.get('opencv-contrib-python')
|
||||
ocph = new_pip_versions.get('opencv-contrib-python-headless')
|
||||
op = new_pip_versions.get('opencv-python')
|
||||
oph = new_pip_versions.get('opencv-python-headless')
|
||||
|
||||
versions = [ocp, ocph, op, oph]
|
||||
versions = [StrictVersion(x) for x in versions if x is not None]
|
||||
versions.sort(reverse=True)
|
||||
|
||||
if len(versions) > 0:
|
||||
# upgrade to maximum version
|
||||
targets = []
|
||||
cur = versions[0]
|
||||
if ocp is not None and StrictVersion(ocp) != cur:
|
||||
targets.append('opencv-contrib-python')
|
||||
if ocph is not None and StrictVersion(ocph) != cur:
|
||||
targets.append('opencv-contrib-python-headless')
|
||||
if op is not None and StrictVersion(op) != cur:
|
||||
targets.append('opencv-python')
|
||||
if oph is not None and StrictVersion(oph) != cur:
|
||||
targets.append('opencv-python-headless')
|
||||
|
||||
if len(targets) > 0:
|
||||
for x in targets:
|
||||
cmd = make_pip_cmd(['install', f"{x}=={versions[0].version_string}", "numpy<2"])
|
||||
subprocess.check_output(cmd, universal_newlines=True)
|
||||
|
||||
logging.info(f"[ComfyUI-Manager] 'opencv' dependencies were fixed: {targets}")
|
||||
except Exception as e:
|
||||
logging.error("[ComfyUI-Manager] Failed to restore opencv")
|
||||
logging.error(e)
|
||||
|
||||
# fix numpy
|
||||
try:
|
||||
np = new_pip_versions.get('numpy')
|
||||
if np is not None:
|
||||
if StrictVersion(np) >= StrictVersion('2'):
|
||||
cmd = make_pip_cmd(['install', "numpy<2"])
|
||||
subprocess.check_output(cmd , universal_newlines=True)
|
||||
|
||||
logging.info("[ComfyUI-Manager] 'numpy' dependency were fixed")
|
||||
except Exception as e:
|
||||
logging.error("[ComfyUI-Manager] Failed to restore numpy")
|
||||
logging.error(e)
|
||||
|
||||
# fix missing frontend
|
||||
try:
|
||||
# NOTE: package name in requirements is 'comfyui-frontend-package'
|
||||
# but, package name from `pip freeze` is 'comfyui_frontend_package'
|
||||
# but, package name from `uv pip freeze` is 'comfyui-frontend-package'
|
||||
#
|
||||
# get_installed_packages returns normalized name (i.e. comfyui_frontend_package)
|
||||
if 'comfyui_frontend_package' not in new_pip_versions:
|
||||
requirements_path = os.path.join(self.comfyui_path, 'requirements.txt')
|
||||
|
||||
with open(requirements_path, 'r') as file:
|
||||
lines = file.readlines()
|
||||
|
||||
front_line = next((line.strip() for line in lines if line.startswith('comfyui-frontend-package')), None)
|
||||
if front_line is None:
|
||||
logging.info("[ComfyUI-Manager] Skipped fixing the 'comfyui-frontend-package' dependency because the ComfyUI is outdated.")
|
||||
else:
|
||||
cmd = make_pip_cmd(['install', front_line])
|
||||
subprocess.check_output(cmd , universal_newlines=True)
|
||||
logging.info("[ComfyUI-Manager] 'comfyui-frontend-package' dependency were fixed")
|
||||
except Exception as e:
|
||||
logging.error("[ComfyUI-Manager] Failed to restore comfyui-frontend-package")
|
||||
logging.error(e)
|
||||
|
||||
# restore based on custom list
|
||||
pip_auto_fix_path = os.path.join(self.manager_files_path, "pip_auto_fix.list")
|
||||
if os.path.exists(pip_auto_fix_path):
|
||||
with open(pip_auto_fix_path, 'r', encoding="UTF-8", errors="ignore") as f:
|
||||
fixed_list = []
|
||||
|
||||
for x in f.readlines():
|
||||
try:
|
||||
parsed = parse_requirement_line(x)
|
||||
need_to_reinstall = True
|
||||
|
||||
normalized_name = parsed['package'].lower().replace('-', '_')
|
||||
if normalized_name in new_pip_versions:
|
||||
if 'version' in parsed and 'operator' in parsed:
|
||||
cur = StrictVersion(new_pip_versions[parsed['package']])
|
||||
dest = parsed['version']
|
||||
op = parsed['operator']
|
||||
if cur == dest:
|
||||
if op in ['==', '>=', '<=']:
|
||||
need_to_reinstall = False
|
||||
elif cur < dest:
|
||||
if op in ['<=', '<', '~=', '!=']:
|
||||
need_to_reinstall = False
|
||||
elif cur > dest:
|
||||
if op in ['>=', '>', '~=', '!=']:
|
||||
need_to_reinstall = False
|
||||
|
||||
if need_to_reinstall:
|
||||
cmd_args = ['install']
|
||||
if 'version' in parsed and 'operator' in parsed:
|
||||
cmd_args.append(parsed['package']+parsed['operator']+parsed['version'].version_string)
|
||||
|
||||
if 'index_url' in parsed:
|
||||
cmd_args.append('--index-url')
|
||||
cmd_args.append(parsed['index_url'])
|
||||
|
||||
cmd = make_pip_cmd(cmd_args)
|
||||
subprocess.check_output(cmd, universal_newlines=True)
|
||||
|
||||
fixed_list.append(parsed['package'])
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
logging.error(f"[ComfyUI-Manager] Failed to restore '{x}'")
|
||||
logging.error(e)
|
||||
|
||||
if len(fixed_list) > 0:
|
||||
logging.info(f"[ComfyUI-Manager] dependencies in pip_auto_fix.json were fixed: {fixed_list}")
|
||||
|
||||
def sanitize(data):
|
||||
return data.replace("<", "<").replace(">", ">")
|
||||
|
||||
|
||||
def sanitize_filename(input_string):
|
||||
result_string = re.sub(r'[^a-zA-Z0-9_]', '_', input_string)
|
||||
return result_string
|
||||
|
||||
|
||||
def robust_readlines(fullpath):
|
||||
import chardet
|
||||
try:
|
||||
with open(fullpath, "r") as f:
|
||||
return f.readlines()
|
||||
except:
|
||||
encoding = None
|
||||
with open(fullpath, "rb") as f:
|
||||
raw_data = f.read()
|
||||
result = chardet.detect(raw_data)
|
||||
encoding = result['encoding']
|
||||
|
||||
if encoding is not None:
|
||||
with open(fullpath, "r", encoding=encoding) as f:
|
||||
return f.readlines()
|
||||
|
||||
print(f"[ComfyUI-Manager] Failed to recognize encoding for: {fullpath}")
|
||||
return []
|
||||
72
comfyui_manager/glob/node_package.py
Normal file
72
comfyui_manager/glob/node_package.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
|
||||
from .git_utils import get_commit_hash
|
||||
|
||||
|
||||
@dataclass
|
||||
class InstalledNodePackage:
|
||||
"""Information about an installed node package."""
|
||||
|
||||
id: str
|
||||
fullpath: str
|
||||
disabled: bool
|
||||
version: str
|
||||
|
||||
@property
|
||||
def is_unknown(self) -> bool:
|
||||
return self.version == "unknown"
|
||||
|
||||
@property
|
||||
def is_nightly(self) -> bool:
|
||||
return self.version == "nightly"
|
||||
|
||||
@property
|
||||
def is_from_cnr(self) -> bool:
|
||||
return not self.is_unknown and not self.is_nightly
|
||||
|
||||
@property
|
||||
def is_enabled(self) -> bool:
|
||||
return not self.disabled
|
||||
|
||||
@property
|
||||
def is_disabled(self) -> bool:
|
||||
return self.disabled
|
||||
|
||||
def get_commit_hash(self) -> str:
|
||||
return get_commit_hash(self.fullpath)
|
||||
|
||||
def isValid(self) -> bool:
|
||||
if self.is_from_cnr:
|
||||
return os.path.exists(os.path.join(self.fullpath, '.tracking'))
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def from_fullpath(fullpath: str, resolve_from_path) -> InstalledNodePackage:
|
||||
parent_folder_name = os.path.basename(os.path.dirname(fullpath))
|
||||
module_name = os.path.basename(fullpath)
|
||||
|
||||
if module_name.endswith(".disabled"):
|
||||
node_id = module_name[:-9]
|
||||
disabled = True
|
||||
elif parent_folder_name == ".disabled":
|
||||
# Nodes under custom_nodes/.disabled/* are disabled
|
||||
node_id = module_name
|
||||
disabled = True
|
||||
else:
|
||||
node_id = module_name
|
||||
disabled = False
|
||||
|
||||
info = resolve_from_path(fullpath)
|
||||
if info is None:
|
||||
version = 'unknown'
|
||||
else:
|
||||
node_id = info['id'] # robust module guessing
|
||||
version = info['ver']
|
||||
|
||||
return InstalledNodePackage(
|
||||
id=node_id, fullpath=fullpath, disabled=disabled, version=version
|
||||
)
|
||||
117
comfyui_manager/glob/security_check.py
Normal file
117
comfyui_manager/glob/security_check.py
Normal file
@@ -0,0 +1,117 @@
|
||||
import sys
|
||||
import subprocess
|
||||
import os
|
||||
|
||||
|
||||
def security_check():
|
||||
print("[START] Security scan")
|
||||
|
||||
custom_nodes_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
|
||||
comfyui_path = os.path.abspath(os.path.join(custom_nodes_path, '..'))
|
||||
|
||||
guide = {
|
||||
"ComfyUI_LLMVISION": """
|
||||
0.Remove ComfyUI\\custom_nodes\\ComfyUI_LLMVISION.
|
||||
1.Remove pip packages: openai-1.16.3.dist-info, anthropic-0.21.4.dist-info, openai-1.30.2.dist-info, anthropic-0.21.5.dist-info, anthropic-0.26.1.dist-info, %LocalAppData%\\rundll64.exe
|
||||
(For portable versions, it is recommended to reinstall. If you are using a venv, it is advised to recreate the venv.)
|
||||
2.Remove these files in your system: lib/browser/admin.py, Cadmino.py, Fadmino.py, VISION-D.exe, BeamNG.UI.exe
|
||||
3.Check your Windows registry for the key listed above and remove it.
|
||||
(HKEY_CURRENT_USER\\Software\\OpenAICLI)
|
||||
4.Run a malware scanner.
|
||||
5.Change all of your passwords, everywhere.
|
||||
|
||||
(Reinstall OS is recommended.)
|
||||
\n
|
||||
Detailed information: https://old.reddit.com/r/comfyui/comments/1dbls5n/psa_if_youve_used_the_comfyui_llmvision_node_from/
|
||||
""",
|
||||
"lolMiner": """
|
||||
1. Remove pip packages: lolMiner*
|
||||
2. Remove files: lolMiner*, 4G_Ethash_Linux_Readme.txt, mine* in ComfyUI dir.
|
||||
|
||||
(Reinstall ComfyUI is recommended.)
|
||||
""",
|
||||
"ultralytics==8.3.41": f"""
|
||||
Execute following commands:
|
||||
{sys.executable} -m pip uninstall ultralytics
|
||||
{sys.executable} -m pip install ultralytics==8.3.40
|
||||
|
||||
And kill and remove /tmp/ultralytics_runner
|
||||
|
||||
|
||||
The version 8.3.41 to 8.3.42 of the Ultralytics package you installed is compromised. Please uninstall that version and reinstall the latest version.
|
||||
https://blog.comfy.org/comfyui-statement-on-the-ultralytics-crypto-miner-situation/
|
||||
""",
|
||||
"ultralytics==8.3.42": f"""
|
||||
Execute following commands:
|
||||
{sys.executable} -m pip uninstall ultralytics
|
||||
{sys.executable} -m pip install ultralytics==8.3.40
|
||||
|
||||
And kill and remove /tmp/ultralytics_runner
|
||||
|
||||
|
||||
The version 8.3.41 to 8.3.42 of the Ultralytics package you installed is compromised. Please uninstall that version and reinstall the latest version.
|
||||
https://blog.comfy.org/comfyui-statement-on-the-ultralytics-crypto-miner-situation/
|
||||
"""
|
||||
}
|
||||
|
||||
node_blacklist = {"ComfyUI_LLMVISION": "ComfyUI_LLMVISION"}
|
||||
|
||||
pip_blacklist = {
|
||||
"AppleBotzz": "ComfyUI_LLMVISION",
|
||||
"ultralytics==8.3.41": "ultralytics==8.3.41"
|
||||
}
|
||||
|
||||
file_blacklist = {
|
||||
"ComfyUI_LLMVISION": ["%LocalAppData%\\rundll64.exe"],
|
||||
"lolMiner": [os.path.join(comfyui_path, 'lolMiner')]
|
||||
}
|
||||
|
||||
installed_pips = subprocess.check_output([sys.executable, '-m', "pip", "freeze"], text=True)
|
||||
|
||||
detected = set()
|
||||
try:
|
||||
anthropic_info = subprocess.check_output([sys.executable, '-m', "pip", "show", "anthropic"], text=True, stderr=subprocess.DEVNULL)
|
||||
anthropic_reqs = [x for x in anthropic_info.split('\n') if x.startswith("Requires")][0].split(': ')[1]
|
||||
if "pycrypto" in anthropic_reqs:
|
||||
location = [x for x in anthropic_info.split('\n') if x.startswith("Location")][0].split(': ')[1]
|
||||
for fi in os.listdir(location):
|
||||
if fi.startswith("anthropic"):
|
||||
guide["ComfyUI_LLMVISION"] = f"\n0.Remove {os.path.join(location, fi)}" + guide["ComfyUI_LLMVISION"]
|
||||
detected.add("ComfyUI_LLMVISION")
|
||||
except subprocess.CalledProcessError:
|
||||
pass
|
||||
|
||||
for k, v in node_blacklist.items():
|
||||
if os.path.exists(os.path.join(custom_nodes_path, k)):
|
||||
print(f"[SECURITY ALERT] custom node '{k}' is dangerous.")
|
||||
detected.add(v)
|
||||
|
||||
for k, v in pip_blacklist.items():
|
||||
if k in installed_pips:
|
||||
detected.add(v)
|
||||
break
|
||||
|
||||
for k, v in file_blacklist.items():
|
||||
for x in v:
|
||||
if os.path.exists(os.path.expandvars(x)):
|
||||
detected.add(k)
|
||||
break
|
||||
|
||||
if len(detected) > 0:
|
||||
for line in installed_pips.split('\n'):
|
||||
for k, v in pip_blacklist.items():
|
||||
if k in line:
|
||||
print(f"[SECURITY ALERT] '{line}' is dangerous.")
|
||||
|
||||
print("\n########################################################################")
|
||||
print(" Malware has been detected, forcibly terminating ComfyUI execution.")
|
||||
print("########################################################################\n")
|
||||
|
||||
for x in detected:
|
||||
print(f"\n======== TARGET: {x} =========")
|
||||
print("\nTODO:")
|
||||
print(guide.get(x))
|
||||
|
||||
exit(-1)
|
||||
|
||||
print("[DONE] Security scan")
|
||||
385
comfyui_manager/glob/share_3rdparty.py
Normal file
385
comfyui_manager/glob/share_3rdparty.py
Normal file
@@ -0,0 +1,385 @@
|
||||
import mimetypes
|
||||
from . import manager_core as core
|
||||
|
||||
import os
|
||||
from aiohttp import web
|
||||
import aiohttp
|
||||
import json
|
||||
import hashlib
|
||||
|
||||
import folder_paths
|
||||
from server import PromptServer
|
||||
|
||||
|
||||
def extract_model_file_names(json_data):
|
||||
"""Extract unique file names from the input JSON data."""
|
||||
file_names = set()
|
||||
model_filename_extensions = {'.safetensors', '.ckpt', '.pt', '.pth', '.bin'}
|
||||
|
||||
# Recursively search for file names in the JSON data
|
||||
def recursive_search(data):
|
||||
if isinstance(data, dict):
|
||||
for value in data.values():
|
||||
recursive_search(value)
|
||||
elif isinstance(data, list):
|
||||
for item in data:
|
||||
recursive_search(item)
|
||||
elif isinstance(data, str) and '.' in data:
|
||||
file_names.add(os.path.basename(data)) # file_names.add(data)
|
||||
|
||||
recursive_search(json_data)
|
||||
return [f for f in list(file_names) if os.path.splitext(f)[1] in model_filename_extensions]
|
||||
|
||||
|
||||
def find_file_paths(base_dir, file_names):
|
||||
"""Find the paths of the files in the base directory."""
|
||||
file_paths = {}
|
||||
|
||||
for root, dirs, files in os.walk(base_dir):
|
||||
# Exclude certain directories
|
||||
dirs[:] = [d for d in dirs if d not in ['.git']]
|
||||
|
||||
for file in files:
|
||||
if file in file_names:
|
||||
file_paths[file] = os.path.join(root, file)
|
||||
return file_paths
|
||||
|
||||
|
||||
def compute_sha256_checksum(filepath):
|
||||
"""Compute the SHA256 checksum of a file, in chunks"""
|
||||
sha256 = hashlib.sha256()
|
||||
with open(filepath, 'rb') as f:
|
||||
for chunk in iter(lambda: f.read(4096), b''):
|
||||
sha256.update(chunk)
|
||||
return sha256.hexdigest()
|
||||
|
||||
|
||||
@PromptServer.instance.routes.get("/v2/manager/share_option")
|
||||
async def share_option(request):
|
||||
if "value" in request.rel_url.query:
|
||||
core.get_config()['share_option'] = request.rel_url.query['value']
|
||||
core.write_config()
|
||||
else:
|
||||
return web.Response(text=core.get_config()['share_option'], status=200)
|
||||
|
||||
return web.Response(status=200)
|
||||
|
||||
|
||||
def get_openart_auth():
|
||||
if not os.path.exists(os.path.join(core.manager_files_path, ".openart_key")):
|
||||
return None
|
||||
try:
|
||||
with open(os.path.join(core.manager_files_path, ".openart_key"), "r") as f:
|
||||
openart_key = f.read().strip()
|
||||
return openart_key if openart_key else None
|
||||
except:
|
||||
return None
|
||||
|
||||
|
||||
def get_matrix_auth():
|
||||
if not os.path.exists(os.path.join(core.manager_files_path, "matrix_auth")):
|
||||
return None
|
||||
try:
|
||||
with open(os.path.join(core.manager_files_path, "matrix_auth"), "r") as f:
|
||||
matrix_auth = f.read()
|
||||
homeserver, username, password = matrix_auth.strip().split("\n")
|
||||
if not homeserver or not username or not password:
|
||||
return None
|
||||
return {
|
||||
"homeserver": homeserver,
|
||||
"username": username,
|
||||
"password": password,
|
||||
}
|
||||
except:
|
||||
return None
|
||||
|
||||
|
||||
def get_comfyworkflows_auth():
|
||||
if not os.path.exists(os.path.join(core.manager_files_path, "comfyworkflows_sharekey")):
|
||||
return None
|
||||
try:
|
||||
with open(os.path.join(core.manager_files_path, "comfyworkflows_sharekey"), "r") as f:
|
||||
share_key = f.read()
|
||||
if not share_key.strip():
|
||||
return None
|
||||
return share_key
|
||||
except:
|
||||
return None
|
||||
|
||||
|
||||
def get_youml_settings():
|
||||
if not os.path.exists(os.path.join(core.manager_files_path, ".youml")):
|
||||
return None
|
||||
try:
|
||||
with open(os.path.join(core.manager_files_path, ".youml"), "r") as f:
|
||||
youml_settings = f.read().strip()
|
||||
return youml_settings if youml_settings else None
|
||||
except:
|
||||
return None
|
||||
|
||||
|
||||
def set_youml_settings(settings):
|
||||
with open(os.path.join(core.manager_files_path, ".youml"), "w") as f:
|
||||
f.write(settings)
|
||||
|
||||
|
||||
@PromptServer.instance.routes.get("/v2/manager/get_openart_auth")
|
||||
async def api_get_openart_auth(request):
|
||||
# print("Getting stored Matrix credentials...")
|
||||
openart_key = get_openart_auth()
|
||||
if not openart_key:
|
||||
return web.Response(status=404)
|
||||
return web.json_response({"openart_key": openart_key})
|
||||
|
||||
|
||||
@PromptServer.instance.routes.post("/v2/manager/set_openart_auth")
|
||||
async def api_set_openart_auth(request):
|
||||
json_data = await request.json()
|
||||
openart_key = json_data['openart_key']
|
||||
with open(os.path.join(core.manager_files_path, ".openart_key"), "w") as f:
|
||||
f.write(openart_key)
|
||||
return web.Response(status=200)
|
||||
|
||||
|
||||
@PromptServer.instance.routes.get("/v2/manager/get_matrix_auth")
|
||||
async def api_get_matrix_auth(request):
|
||||
# print("Getting stored Matrix credentials...")
|
||||
matrix_auth = get_matrix_auth()
|
||||
if not matrix_auth:
|
||||
return web.Response(status=404)
|
||||
return web.json_response(matrix_auth)
|
||||
|
||||
|
||||
@PromptServer.instance.routes.get("/v2/manager/youml/settings")
|
||||
async def api_get_youml_settings(request):
|
||||
youml_settings = get_youml_settings()
|
||||
if not youml_settings:
|
||||
return web.Response(status=404)
|
||||
return web.json_response(json.loads(youml_settings))
|
||||
|
||||
|
||||
@PromptServer.instance.routes.post("/v2/manager/youml/settings")
|
||||
async def api_set_youml_settings(request):
|
||||
json_data = await request.json()
|
||||
set_youml_settings(json.dumps(json_data))
|
||||
return web.Response(status=200)
|
||||
|
||||
|
||||
@PromptServer.instance.routes.get("/v2/manager/get_comfyworkflows_auth")
|
||||
async def api_get_comfyworkflows_auth(request):
|
||||
# Check if the user has provided Matrix credentials in a file called 'matrix_accesstoken'
|
||||
# in the same directory as the ComfyUI base folder
|
||||
# print("Getting stored Comfyworkflows.com auth...")
|
||||
comfyworkflows_auth = get_comfyworkflows_auth()
|
||||
if not comfyworkflows_auth:
|
||||
return web.Response(status=404)
|
||||
return web.json_response({"comfyworkflows_sharekey": comfyworkflows_auth})
|
||||
|
||||
|
||||
@PromptServer.instance.routes.post("/v2/manager/set_esheep_workflow_and_images")
|
||||
async def set_esheep_workflow_and_images(request):
|
||||
json_data = await request.json()
|
||||
with open(os.path.join(core.manager_files_path, "esheep_share_message.json"), "w", encoding='utf-8') as file:
|
||||
json.dump(json_data, file, indent=4)
|
||||
return web.Response(status=200)
|
||||
|
||||
|
||||
@PromptServer.instance.routes.get("/v2/manager/get_esheep_workflow_and_images")
|
||||
async def get_esheep_workflow_and_images(request):
|
||||
with open(os.path.join(core.manager_files_path, "esheep_share_message.json"), 'r', encoding='utf-8') as file:
|
||||
data = json.load(file)
|
||||
return web.Response(status=200, text=json.dumps(data))
|
||||
|
||||
|
||||
def set_matrix_auth(json_data):
|
||||
homeserver = json_data['homeserver']
|
||||
username = json_data['username']
|
||||
password = json_data['password']
|
||||
with open(os.path.join(core.manager_files_path, "matrix_auth"), "w") as f:
|
||||
f.write("\n".join([homeserver, username, password]))
|
||||
|
||||
|
||||
def set_comfyworkflows_auth(comfyworkflows_sharekey):
|
||||
with open(os.path.join(core.manager_files_path, "comfyworkflows_sharekey"), "w") as f:
|
||||
f.write(comfyworkflows_sharekey)
|
||||
|
||||
|
||||
def has_provided_matrix_auth(matrix_auth):
|
||||
return matrix_auth['homeserver'].strip() and matrix_auth['username'].strip() and matrix_auth['password'].strip()
|
||||
|
||||
|
||||
def has_provided_comfyworkflows_auth(comfyworkflows_sharekey):
|
||||
return comfyworkflows_sharekey.strip()
|
||||
|
||||
|
||||
@PromptServer.instance.routes.post("/v2/manager/share")
|
||||
async def share_art(request):
|
||||
# get json data
|
||||
json_data = await request.json()
|
||||
|
||||
matrix_auth = json_data['matrix_auth']
|
||||
comfyworkflows_sharekey = json_data['cw_auth']['cw_sharekey']
|
||||
|
||||
set_matrix_auth(matrix_auth)
|
||||
set_comfyworkflows_auth(comfyworkflows_sharekey)
|
||||
|
||||
share_destinations = json_data['share_destinations']
|
||||
credits = json_data['credits']
|
||||
title = json_data['title']
|
||||
description = json_data['description']
|
||||
is_nsfw = json_data['is_nsfw']
|
||||
prompt = json_data['prompt']
|
||||
potential_outputs = json_data['potential_outputs']
|
||||
selected_output_index = json_data['selected_output_index']
|
||||
|
||||
try:
|
||||
output_to_share = potential_outputs[int(selected_output_index)]
|
||||
except:
|
||||
# for now, pick the first output
|
||||
output_to_share = potential_outputs[0]
|
||||
|
||||
assert output_to_share['type'] in ('image', 'output')
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
|
||||
if output_to_share['type'] == 'image':
|
||||
asset_filename = output_to_share['image']['filename']
|
||||
asset_subfolder = output_to_share['image']['subfolder']
|
||||
|
||||
if output_to_share['image']['type'] == 'temp':
|
||||
output_dir = folder_paths.get_temp_directory()
|
||||
else:
|
||||
asset_filename = output_to_share['output']['filename']
|
||||
asset_subfolder = output_to_share['output']['subfolder']
|
||||
|
||||
if asset_subfolder:
|
||||
asset_filepath = os.path.join(output_dir, asset_subfolder, asset_filename)
|
||||
else:
|
||||
asset_filepath = os.path.join(output_dir, asset_filename)
|
||||
|
||||
# get the mime type of the asset
|
||||
assetFileType = mimetypes.guess_type(asset_filepath)[0]
|
||||
|
||||
share_website_host = "UNKNOWN"
|
||||
if "comfyworkflows" in share_destinations:
|
||||
share_website_host = "https://comfyworkflows.com"
|
||||
share_endpoint = f"{share_website_host}/api"
|
||||
|
||||
# get presigned urls
|
||||
async with aiohttp.ClientSession(trust_env=True, connector=aiohttp.TCPConnector(verify_ssl=False)) as session:
|
||||
async with session.post(
|
||||
f"{share_endpoint}/get_presigned_urls",
|
||||
json={
|
||||
"assetFileName": asset_filename,
|
||||
"assetFileType": assetFileType,
|
||||
"workflowJsonFileName": 'workflow.json',
|
||||
"workflowJsonFileType": 'application/json',
|
||||
},
|
||||
) as resp:
|
||||
assert resp.status == 200
|
||||
presigned_urls_json = await resp.json()
|
||||
assetFilePresignedUrl = presigned_urls_json["assetFilePresignedUrl"]
|
||||
assetFileKey = presigned_urls_json["assetFileKey"]
|
||||
workflowJsonFilePresignedUrl = presigned_urls_json["workflowJsonFilePresignedUrl"]
|
||||
workflowJsonFileKey = presigned_urls_json["workflowJsonFileKey"]
|
||||
|
||||
# upload asset
|
||||
async with aiohttp.ClientSession(trust_env=True, connector=aiohttp.TCPConnector(verify_ssl=False)) as session:
|
||||
async with session.put(assetFilePresignedUrl, data=open(asset_filepath, "rb")) as resp:
|
||||
assert resp.status == 200
|
||||
|
||||
# upload workflow json
|
||||
async with aiohttp.ClientSession(trust_env=True, connector=aiohttp.TCPConnector(verify_ssl=False)) as session:
|
||||
async with session.put(workflowJsonFilePresignedUrl, data=json.dumps(prompt['workflow']).encode('utf-8')) as resp:
|
||||
assert resp.status == 200
|
||||
|
||||
model_filenames = extract_model_file_names(prompt['workflow'])
|
||||
model_file_paths = find_file_paths(folder_paths.base_path, model_filenames)
|
||||
|
||||
models_info = {}
|
||||
for filename, filepath in model_file_paths.items():
|
||||
models_info[filename] = {
|
||||
"filename": filename,
|
||||
"sha256_checksum": compute_sha256_checksum(filepath),
|
||||
"relative_path": os.path.relpath(filepath, folder_paths.base_path),
|
||||
}
|
||||
|
||||
# make a POST request to /api/upload_workflow with form data key values
|
||||
async with aiohttp.ClientSession(trust_env=True, connector=aiohttp.TCPConnector(verify_ssl=False)) as session:
|
||||
form = aiohttp.FormData()
|
||||
if comfyworkflows_sharekey:
|
||||
form.add_field("shareKey", comfyworkflows_sharekey)
|
||||
form.add_field("source", "comfyui_manager")
|
||||
form.add_field("assetFileKey", assetFileKey)
|
||||
form.add_field("assetFileType", assetFileType)
|
||||
form.add_field("workflowJsonFileKey", workflowJsonFileKey)
|
||||
form.add_field("sharedWorkflowWorkflowJsonString", json.dumps(prompt['workflow']))
|
||||
form.add_field("sharedWorkflowPromptJsonString", json.dumps(prompt['output']))
|
||||
form.add_field("shareWorkflowCredits", credits)
|
||||
form.add_field("shareWorkflowTitle", title)
|
||||
form.add_field("shareWorkflowDescription", description)
|
||||
form.add_field("shareWorkflowIsNSFW", str(is_nsfw).lower())
|
||||
form.add_field("currentSnapshot", json.dumps(await core.get_current_snapshot()))
|
||||
form.add_field("modelsInfo", json.dumps(models_info))
|
||||
|
||||
async with session.post(
|
||||
f"{share_endpoint}/upload_workflow",
|
||||
data=form,
|
||||
) as resp:
|
||||
assert resp.status == 200
|
||||
upload_workflow_json = await resp.json()
|
||||
workflowId = upload_workflow_json["workflowId"]
|
||||
|
||||
# check if the user has provided Matrix credentials
|
||||
if "matrix" in share_destinations:
|
||||
comfyui_share_room_id = '!LGYSoacpJPhIfBqVfb:matrix.org'
|
||||
filename = os.path.basename(asset_filepath)
|
||||
content_type = assetFileType
|
||||
|
||||
try:
|
||||
from matrix_client.api import MatrixHttpApi
|
||||
from matrix_client.client import MatrixClient
|
||||
|
||||
homeserver = 'matrix.org'
|
||||
if matrix_auth:
|
||||
homeserver = matrix_auth.get('homeserver', 'matrix.org')
|
||||
homeserver = homeserver.replace("http://", "https://")
|
||||
if not homeserver.startswith("https://"):
|
||||
homeserver = "https://" + homeserver
|
||||
|
||||
client = MatrixClient(homeserver)
|
||||
try:
|
||||
token = client.login(username=matrix_auth['username'], password=matrix_auth['password'])
|
||||
if not token:
|
||||
return web.json_response({"error": "Invalid Matrix credentials."}, content_type='application/json', status=400)
|
||||
except:
|
||||
return web.json_response({"error": "Invalid Matrix credentials."}, content_type='application/json', status=400)
|
||||
|
||||
matrix = MatrixHttpApi(homeserver, token=token)
|
||||
with open(asset_filepath, 'rb') as f:
|
||||
mxc_url = matrix.media_upload(f.read(), content_type, filename=filename)['content_uri']
|
||||
|
||||
workflow_json_mxc_url = matrix.media_upload(prompt['workflow'], 'application/json', filename='workflow.json')['content_uri']
|
||||
|
||||
text_content = ""
|
||||
if title:
|
||||
text_content += f"{title}\n"
|
||||
if description:
|
||||
text_content += f"{description}\n"
|
||||
if credits:
|
||||
text_content += f"\ncredits: {credits}\n"
|
||||
matrix.send_message(comfyui_share_room_id, text_content)
|
||||
matrix.send_content(comfyui_share_room_id, mxc_url, filename, 'm.image')
|
||||
matrix.send_content(comfyui_share_room_id, workflow_json_mxc_url, 'workflow.json', 'm.file')
|
||||
except:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return web.json_response({"error": "An error occurred when sharing your art to Matrix."}, content_type='application/json', status=500)
|
||||
|
||||
return web.json_response({
|
||||
"comfyworkflows": {
|
||||
"url": None if "comfyworkflows" not in share_destinations else f"{share_website_host}/workflows/{workflowId}",
|
||||
},
|
||||
"matrix": {
|
||||
"success": None if "matrix" not in share_destinations else True
|
||||
}
|
||||
}, content_type='application/json', status=200)
|
||||
67
comfyui_manager/js/cm-api.js
Normal file
67
comfyui_manager/js/cm-api.js
Normal file
@@ -0,0 +1,67 @@
|
||||
import { api } from "../../scripts/api.js";
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { sleep, customConfirm, customAlert } from "./common.js";
|
||||
|
||||
async function tryInstallCustomNode(event) {
|
||||
let msg = '-= [ComfyUI Manager] extension installation request =-\n\n';
|
||||
msg += `The '${event.detail.sender}' extension requires the installation of the '${event.detail.target.title}' extension. `;
|
||||
|
||||
if(event.detail.target.installed == 'Disabled') {
|
||||
msg += 'However, the extension is currently disabled. Would you like to enable it and reboot?'
|
||||
}
|
||||
else if(event.detail.target.installed == 'True') {
|
||||
msg += 'However, it seems that the extension is in an import-fail state or is not compatible with the current version. Please address this issue.';
|
||||
}
|
||||
else {
|
||||
msg += `Would you like to install it and reboot?`;
|
||||
}
|
||||
|
||||
msg += `\n\nRequest message:\n${event.detail.msg}`;
|
||||
|
||||
if(event.detail.target.installed == 'True') {
|
||||
customAlert(msg);
|
||||
return;
|
||||
}
|
||||
const res = await customConfirm(msg);
|
||||
if(res) {
|
||||
if(event.detail.target.installed == 'Disabled') {
|
||||
const response = await api.fetchApi(`/v2/customnode/toggle_active`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(event.detail.target)
|
||||
});
|
||||
}
|
||||
else {
|
||||
await sleep(300);
|
||||
app.ui.dialog.show(`Installing... '${event.detail.target.title}'`);
|
||||
|
||||
const response = await api.fetchApi(`/v2/customnode/install`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(event.detail.target)
|
||||
});
|
||||
|
||||
if(response.status == 403) {
|
||||
show_message('This action is not allowed with this security level configuration.');
|
||||
return false;
|
||||
}
|
||||
else if(response.status == 400) {
|
||||
let msg = await res.text();
|
||||
show_message(msg);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let response = await api.fetchApi("/v2/manager/reboot");
|
||||
if(response.status == 403) {
|
||||
show_message('This action is not allowed with this security level configuration.');
|
||||
return false;
|
||||
}
|
||||
|
||||
await sleep(300);
|
||||
|
||||
app.ui.dialog.show(`Rebooting...`);
|
||||
}
|
||||
}
|
||||
|
||||
api.addEventListener("cm-api-try-install-customnode", tryInstallCustomNode);
|
||||
1634
comfyui_manager/js/comfyui-manager.js
Normal file
1634
comfyui_manager/js/comfyui-manager.js
Normal file
File diff suppressed because it is too large
Load Diff
1102
comfyui_manager/js/comfyui-share-common.js
Normal file
1102
comfyui_manager/js/comfyui-share-common.js
Normal file
File diff suppressed because it is too large
Load Diff
985
comfyui_manager/js/comfyui-share-copus.js
Normal file
985
comfyui_manager/js/comfyui-share-copus.js
Normal file
@@ -0,0 +1,985 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { $el, ComfyDialog } from "../../scripts/ui.js";
|
||||
import { customAlert } from "./common.js";
|
||||
|
||||
const env = "prod";
|
||||
|
||||
let DEFAULT_HOMEPAGE_URL = "https://copus.io";
|
||||
|
||||
let API_ENDPOINT = "https://api.client.prod.copus.io";
|
||||
|
||||
if (env !== "prod") {
|
||||
API_ENDPOINT = "https://api.test.copus.io";
|
||||
DEFAULT_HOMEPAGE_URL = "https://test.copus.io";
|
||||
}
|
||||
|
||||
const style = `
|
||||
.copus-share-dialog a {
|
||||
color: #f8f8f8;
|
||||
}
|
||||
.copus-share-dialog a:hover {
|
||||
color: #007bff;
|
||||
}
|
||||
.output_label {
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
.output_label:hover {
|
||||
border: 5px solid #59E8C6;
|
||||
}
|
||||
.output_label.checked {
|
||||
border: 5px solid #59E8C6;
|
||||
}
|
||||
`;
|
||||
|
||||
// Shared component styles
|
||||
const sectionStyle = {
|
||||
marginBottom: 0,
|
||||
padding: 0,
|
||||
borderRadius: "8px",
|
||||
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.05)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
position: "relative",
|
||||
};
|
||||
|
||||
export class CopusShareDialog extends ComfyDialog {
|
||||
static instance = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
$el("style", {
|
||||
textContent: style,
|
||||
parent: document.head,
|
||||
});
|
||||
this.element = $el(
|
||||
"div.comfy-modal.copus-share-dialog",
|
||||
{
|
||||
parent: document.body,
|
||||
style: {
|
||||
"overflow-y": "auto",
|
||||
},
|
||||
},
|
||||
[$el("div.comfy-modal-content", {}, [...this.createButtons()])]
|
||||
);
|
||||
this.selectedOutputIndex = 0;
|
||||
this.selectedOutput_lock = 0;
|
||||
this.selectedNodeId = null;
|
||||
this.uploadedImages = [];
|
||||
this.allFilesImages = [];
|
||||
this.selectedFile = null;
|
||||
this.allFiles = [];
|
||||
this.titleNum = 0;
|
||||
}
|
||||
|
||||
createButtons() {
|
||||
const inputStyle = {
|
||||
display: "block",
|
||||
minWidth: "500px",
|
||||
width: "100%",
|
||||
padding: "10px",
|
||||
margin: "10px 0",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid #ddd",
|
||||
boxSizing: "border-box",
|
||||
};
|
||||
|
||||
const textAreaStyle = {
|
||||
display: "block",
|
||||
minWidth: "500px",
|
||||
width: "100%",
|
||||
padding: "10px",
|
||||
margin: "10px 0",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid #ddd",
|
||||
boxSizing: "border-box",
|
||||
minHeight: "100px",
|
||||
background: "#222",
|
||||
resize: "vertical",
|
||||
color: "#f2f2f2",
|
||||
fontFamily: "Arial",
|
||||
fontWeight: "400",
|
||||
fontSize: "15px",
|
||||
};
|
||||
|
||||
const hyperLinkStyle = {
|
||||
display: "block",
|
||||
marginBottom: "15px",
|
||||
fontWeight: "bold",
|
||||
fontSize: "14px",
|
||||
};
|
||||
|
||||
const labelStyle = {
|
||||
color: "#f8f8f8",
|
||||
display: "block",
|
||||
margin: "10px 0 0 0",
|
||||
fontWeight: "bold",
|
||||
textDecoration: "none",
|
||||
};
|
||||
|
||||
const buttonStyle = {
|
||||
padding: "10px 80px",
|
||||
margin: "10px 5px",
|
||||
borderRadius: "4px",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: "#fff",
|
||||
backgroundColor: "#007bff",
|
||||
};
|
||||
|
||||
// upload images input
|
||||
this.uploadImagesInput = $el("input", {
|
||||
type: "file",
|
||||
multiple: false,
|
||||
style: inputStyle,
|
||||
accept: "image/*",
|
||||
});
|
||||
|
||||
this.uploadImagesInput.addEventListener("change", async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) {
|
||||
this.previewImage.src = "";
|
||||
this.previewImage.style.display = "none";
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
const imgData = e.target.result;
|
||||
this.previewImage.src = imgData;
|
||||
this.previewImage.style.display = "block";
|
||||
this.selectedFile = null;
|
||||
// Once user uploads an image, we uncheck all radio buttons
|
||||
this.radioButtons.forEach((ele) => {
|
||||
ele.checked = false;
|
||||
ele.parentElement.classList.remove("checked");
|
||||
});
|
||||
|
||||
// Add the opacity style toggle here to indicate that they only need
|
||||
// to upload one image or choose one from the outputs.
|
||||
this.outputsSection.style.opacity = 0.35;
|
||||
this.uploadImagesInput.style.opacity = 1;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
// preview image
|
||||
this.previewImage = $el("img", {
|
||||
src: "",
|
||||
style: {
|
||||
width: "100%",
|
||||
maxHeight: "100px",
|
||||
objectFit: "contain",
|
||||
display: "none",
|
||||
marginTop: "10px",
|
||||
},
|
||||
});
|
||||
|
||||
this.keyInput = $el("input", {
|
||||
type: "password",
|
||||
placeholder: "Copy & paste your API key",
|
||||
style: inputStyle,
|
||||
});
|
||||
this.TitleInput = $el("input", {
|
||||
type: "text",
|
||||
placeholder: "Title (Required)",
|
||||
style: inputStyle,
|
||||
maxLength: "70",
|
||||
oninput: () => {
|
||||
const titleNum = this.TitleInput.value.length;
|
||||
titleNumDom.textContent = `${titleNum}/70`;
|
||||
},
|
||||
});
|
||||
this.SubTitleInput = $el("input", {
|
||||
type: "text",
|
||||
placeholder: "Subtitle (Optional)",
|
||||
style: inputStyle,
|
||||
maxLength: "350",
|
||||
oninput: () => {
|
||||
const titleNum = this.SubTitleInput.value.length;
|
||||
subTitleNumDom.textContent = `${titleNum}/350`;
|
||||
},
|
||||
});
|
||||
this.LockInput = $el("input", {
|
||||
type: "text",
|
||||
placeholder: "",
|
||||
style: {
|
||||
width: "100px",
|
||||
padding: "7px",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid #ddd",
|
||||
boxSizing: "border-box",
|
||||
},
|
||||
oninput: (event) => {
|
||||
let input = event.target.value;
|
||||
// Use a regular expression to match a number with up to two decimal places
|
||||
const regex = /^\d*\.?\d{0,2}$/;
|
||||
if (!regex.test(input)) {
|
||||
// If the input doesn't match, remove the last entered character
|
||||
event.target.value = input.slice(0, -1);
|
||||
}
|
||||
const numericValue = parseFloat(input);
|
||||
if (numericValue > 9999) {
|
||||
input = "9999";
|
||||
}
|
||||
// Update the input field with the valid value
|
||||
event.target.value = input;
|
||||
},
|
||||
});
|
||||
this.descriptionInput = $el("textarea", {
|
||||
placeholder: "Content (Optional)",
|
||||
style: {
|
||||
...textAreaStyle,
|
||||
minHeight: "100px",
|
||||
},
|
||||
});
|
||||
|
||||
// Header Section
|
||||
const headerSection = $el("h3", {
|
||||
textContent: "Share your workflow to Copus",
|
||||
size: 3,
|
||||
color: "white",
|
||||
style: {
|
||||
"text-align": "center",
|
||||
color: "white",
|
||||
margin: "0 0 10px 0",
|
||||
},
|
||||
});
|
||||
this.getAPIKeyLink = $el(
|
||||
"a",
|
||||
{
|
||||
style: {
|
||||
...hyperLinkStyle,
|
||||
color: "#59E8C6",
|
||||
},
|
||||
href: `${DEFAULT_HOMEPAGE_URL}?fromPage=comfyUI`,
|
||||
target: "_blank",
|
||||
},
|
||||
["👉 Get your API key here"]
|
||||
);
|
||||
const linkSection = $el(
|
||||
"div",
|
||||
{
|
||||
style: {
|
||||
marginTop: "10px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
},
|
||||
},
|
||||
[
|
||||
// this.communityLink,
|
||||
this.getAPIKeyLink,
|
||||
]
|
||||
);
|
||||
|
||||
// Account Section
|
||||
const accountSection = $el("div", { style: sectionStyle }, [
|
||||
$el("label", { style: labelStyle }, ["1️⃣ Copus API Key"]),
|
||||
this.keyInput,
|
||||
]);
|
||||
|
||||
// Output Upload Section
|
||||
const outputUploadSection = $el("div", { style: sectionStyle }, [
|
||||
$el(
|
||||
"label",
|
||||
{
|
||||
style: {
|
||||
...labelStyle,
|
||||
margin: "10px 0 0 0",
|
||||
},
|
||||
},
|
||||
["2️⃣ Image/Thumbnail (Required)"]
|
||||
),
|
||||
this.previewImage,
|
||||
this.uploadImagesInput,
|
||||
]);
|
||||
|
||||
// Outputs Section
|
||||
this.outputsSection = $el(
|
||||
"div",
|
||||
{
|
||||
id: "selectOutputs",
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const titleNumDom = $el(
|
||||
"label",
|
||||
{
|
||||
style: {
|
||||
fontSize: "12px",
|
||||
position: "absolute",
|
||||
right: "10px",
|
||||
bottom: "-10px",
|
||||
color: "#999",
|
||||
},
|
||||
},
|
||||
["0/70"]
|
||||
);
|
||||
const subTitleNumDom = $el(
|
||||
"label",
|
||||
{
|
||||
style: {
|
||||
fontSize: "12px",
|
||||
position: "absolute",
|
||||
right: "10px",
|
||||
bottom: "-10px",
|
||||
color: "#999",
|
||||
},
|
||||
},
|
||||
["0/350"]
|
||||
);
|
||||
const descriptionNumDom = $el(
|
||||
"label",
|
||||
{
|
||||
style: {
|
||||
fontSize: "12px",
|
||||
position: "absolute",
|
||||
right: "10px",
|
||||
bottom: "-10px",
|
||||
color: "#999",
|
||||
},
|
||||
},
|
||||
["0/70"]
|
||||
);
|
||||
// Additional Inputs Section
|
||||
const additionalInputsSection = $el(
|
||||
"div",
|
||||
{ style: { ...sectionStyle, } },
|
||||
[
|
||||
$el("label", { style: labelStyle }, ["3️⃣ Title "]),
|
||||
this.TitleInput,
|
||||
titleNumDom,
|
||||
]
|
||||
);
|
||||
const SubtitleSection = $el("div", { style: sectionStyle }, [
|
||||
$el("label", { style: labelStyle }, ["4️⃣ Subtitle "]),
|
||||
this.SubTitleInput,
|
||||
subTitleNumDom,
|
||||
]);
|
||||
const DescriptionSection = $el("div", { style: sectionStyle }, [
|
||||
$el("label", { style: labelStyle }, ["5️⃣ Description "]),
|
||||
this.descriptionInput,
|
||||
// descriptionNumDom,
|
||||
]);
|
||||
// switch between outputs section and additional inputs section
|
||||
this.radioButtons_lock = [];
|
||||
|
||||
this.radioButtonsCheck_lock = $el("input", {
|
||||
type: "radio",
|
||||
name: "output_type_lock",
|
||||
value: "0",
|
||||
id: "blockchain1_lock",
|
||||
checked: true,
|
||||
});
|
||||
this.radioButtonsCheckOff_lock = $el("input", {
|
||||
type: "radio",
|
||||
name: "output_type_lock",
|
||||
value: "1",
|
||||
id: "blockchain_lock",
|
||||
});
|
||||
|
||||
const blockChainSection_lock = $el("div", { style: sectionStyle }, [
|
||||
$el("label", { style: labelStyle }, ["6️⃣ Pay to download"]),
|
||||
$el(
|
||||
"label",
|
||||
{
|
||||
style: {
|
||||
marginTop: "10px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
cursor: "pointer",
|
||||
},
|
||||
},
|
||||
[
|
||||
this.radioButtonsCheck_lock,
|
||||
$el("div", { style: { marginLeft: "5px" ,display:'flex',alignItems:'center'} }, [
|
||||
$el("span", { style: { marginLeft: "5px" } }, ["ON"]),
|
||||
$el("span", { style: { marginLeft: "20px",marginRight:'10px' ,color:'#fff'} }, ["Price US$"]),
|
||||
this.LockInput
|
||||
]),
|
||||
]
|
||||
),
|
||||
$el(
|
||||
"label",
|
||||
{ style: { display: "flex", alignItems: "center", cursor: "pointer" } },
|
||||
[
|
||||
this.radioButtonsCheckOff_lock,
|
||||
$el("span", { style: { marginLeft: "5px" } }, ["OFF"]),
|
||||
]
|
||||
),
|
||||
|
||||
$el(
|
||||
"p",
|
||||
{ style: { fontSize: "16px", color: "#fff", margin: "10px 0 0 0" } },
|
||||
["Get paid from your workflow. You can change the price and withdraw your earnings on Copus."]
|
||||
),
|
||||
]);
|
||||
|
||||
this.radioButtons = [];
|
||||
|
||||
this.radioButtonsCheck = $el("input", {
|
||||
type: "radio",
|
||||
name: "output_type",
|
||||
value: "0",
|
||||
id: "blockchain1",
|
||||
checked: true,
|
||||
});
|
||||
this.radioButtonsCheckOff = $el("input", {
|
||||
type: "radio",
|
||||
name: "output_type",
|
||||
value: "1",
|
||||
id: "blockchain",
|
||||
});
|
||||
|
||||
const blockChainSection = $el("div", { style: sectionStyle }, [
|
||||
$el("label", { style: labelStyle }, ["7️⃣ Store on blockchain "]),
|
||||
$el(
|
||||
"label",
|
||||
{
|
||||
style: {
|
||||
marginTop: "10px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
cursor: "pointer",
|
||||
},
|
||||
},
|
||||
[
|
||||
this.radioButtonsCheck,
|
||||
$el("span", { style: { marginLeft: "5px" } }, ["ON"]),
|
||||
]
|
||||
),
|
||||
$el(
|
||||
"label",
|
||||
{ style: { display: "flex", alignItems: "center", cursor: "pointer" } },
|
||||
[
|
||||
this.radioButtonsCheckOff,
|
||||
$el("span", { style: { marginLeft: "5px" } }, ["OFF"]),
|
||||
]
|
||||
),
|
||||
$el(
|
||||
"p",
|
||||
{ style: { fontSize: "16px", color: "#fff", margin: "10px 0 0 0" } },
|
||||
["Secure ownership with a permanent & decentralized storage"]
|
||||
),
|
||||
]);
|
||||
|
||||
|
||||
// Message Section
|
||||
this.message = $el(
|
||||
"div",
|
||||
{
|
||||
style: {
|
||||
color: "#ff3d00",
|
||||
textAlign: "center",
|
||||
padding: "10px",
|
||||
fontSize: "20px",
|
||||
},
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
this.shareButton = $el("button", {
|
||||
type: "submit",
|
||||
textContent: "Share",
|
||||
style: buttonStyle,
|
||||
onclick: () => {
|
||||
this.handleShareButtonClick();
|
||||
},
|
||||
});
|
||||
|
||||
// Share and Close Buttons
|
||||
const buttonsSection = $el(
|
||||
"div",
|
||||
{
|
||||
style: {
|
||||
textAlign: "right",
|
||||
marginTop: "20px",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
},
|
||||
[
|
||||
$el("button", {
|
||||
type: "button",
|
||||
textContent: "Close",
|
||||
style: {
|
||||
...buttonStyle,
|
||||
backgroundColor: undefined,
|
||||
},
|
||||
onclick: () => {
|
||||
this.close();
|
||||
},
|
||||
}),
|
||||
this.shareButton,
|
||||
]
|
||||
);
|
||||
|
||||
// Composing the full layout
|
||||
const layout = [
|
||||
headerSection,
|
||||
linkSection,
|
||||
accountSection,
|
||||
outputUploadSection,
|
||||
this.outputsSection,
|
||||
additionalInputsSection,
|
||||
SubtitleSection,
|
||||
DescriptionSection,
|
||||
// contestSection,
|
||||
blockChainSection_lock,
|
||||
blockChainSection,
|
||||
this.message,
|
||||
buttonsSection,
|
||||
];
|
||||
|
||||
return layout;
|
||||
}
|
||||
/**
|
||||
* api
|
||||
* @param {url} path
|
||||
* @param {params} options
|
||||
* @param {statusText} statusText
|
||||
* @returns
|
||||
*/
|
||||
async fetchApi(path, options, statusText) {
|
||||
if (statusText) {
|
||||
this.message.textContent = statusText;
|
||||
}
|
||||
const fullPath = new URL(API_ENDPOINT + path);
|
||||
const response = await fetch(fullPath, options);
|
||||
if (!response.ok) {
|
||||
throw new Error(response.statusText);
|
||||
}
|
||||
if (statusText) {
|
||||
this.message.textContent = "";
|
||||
}
|
||||
const data = await response.json();
|
||||
return {
|
||||
ok: response.ok,
|
||||
statusText: response.statusText,
|
||||
status: response.status,
|
||||
data,
|
||||
};
|
||||
}
|
||||
/**
|
||||
* @param {file} uploadFile
|
||||
*/
|
||||
async uploadThumbnail(uploadFile, type) {
|
||||
const form = new FormData();
|
||||
form.append("file", uploadFile);
|
||||
form.append("apiToken", this.keyInput.value);
|
||||
try {
|
||||
const res = await this.fetchApi(
|
||||
`/client/common/opus/uploadImage`,
|
||||
{
|
||||
method: "POST",
|
||||
body: form,
|
||||
},
|
||||
"Uploading thumbnail..."
|
||||
);
|
||||
if (res.status && res.data.status && res.data) {
|
||||
const { data } = res.data;
|
||||
if (type) {
|
||||
this.allFilesImages.push({
|
||||
url: data,
|
||||
});
|
||||
}
|
||||
this.uploadedImages.push({
|
||||
url: data,
|
||||
});
|
||||
} else {
|
||||
throw new Error("make sure your API key is correct and try again later");
|
||||
}
|
||||
} catch (e) {
|
||||
if (e?.response?.status === 413) {
|
||||
throw new Error("File size is too large (max 20MB)");
|
||||
} else {
|
||||
throw new Error("Error uploading thumbnail: " + e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleShareButtonClick() {
|
||||
this.message.textContent = "";
|
||||
try {
|
||||
this.shareButton.disabled = true;
|
||||
this.shareButton.textContent = "Sharing...";
|
||||
await this.share();
|
||||
} catch (e) {
|
||||
customAlert(e.message);
|
||||
}
|
||||
this.shareButton.disabled = false;
|
||||
this.shareButton.textContent = "Share";
|
||||
}
|
||||
/**
|
||||
* share
|
||||
* @param {string} title
|
||||
* @param {string} subtitle
|
||||
* @param {string} content
|
||||
* @param {boolean} storeOnChain
|
||||
* @param {string} coverUrl
|
||||
* @param {string[]} imageUrls
|
||||
* @param {string} apiToken
|
||||
*/
|
||||
async share() {
|
||||
const prompt = await app.graphToPrompt();
|
||||
const workflowJSON = prompt["workflow"];
|
||||
const form_values = {
|
||||
title: this.TitleInput.value,
|
||||
subTitle: this.SubTitleInput.value,
|
||||
content: this.descriptionInput.value,
|
||||
storeOnChain: this.radioButtonsCheck.checked ? true : false,
|
||||
lockState:this.radioButtonsCheck_lock.checked ? 2 : 0,
|
||||
unlockPrice:this.LockInput.value,
|
||||
};
|
||||
|
||||
if (!this.keyInput.value) {
|
||||
throw new Error("API key is required");
|
||||
}
|
||||
|
||||
if (!this.uploadImagesInput.files[0] && !this.selectedFile) {
|
||||
throw new Error("Thumbnail is required");
|
||||
}
|
||||
|
||||
if (!form_values.title) {
|
||||
throw new Error("Title is required");
|
||||
}
|
||||
|
||||
if(this.radioButtonsCheck_lock.checked){
|
||||
if (!this.LockInput.value){
|
||||
throw new Error("Price is required");
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.uploadedImages.length) {
|
||||
if (this.selectedFile) {
|
||||
await this.uploadThumbnail(this.selectedFile);
|
||||
} else {
|
||||
for (const file of this.uploadImagesInput.files) {
|
||||
try {
|
||||
await this.uploadThumbnail(file);
|
||||
} catch (e) {
|
||||
this.uploadedImages = [];
|
||||
throw new Error(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.uploadImagesInput.files.length === 0) {
|
||||
throw new Error("No thumbnail uploaded");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.allFiles.length > 0) {
|
||||
for (const file of this.allFiles) {
|
||||
try {
|
||||
await this.uploadThumbnail(file, true);
|
||||
} catch (e) {
|
||||
this.allFilesImages = [];
|
||||
throw new Error(e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
const res = await this.fetchApi(
|
||||
"/client/common/opus/shareFromComfyUI",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
workflowJson: workflowJSON,
|
||||
apiToken: this.keyInput.value,
|
||||
coverUrl: this.uploadedImages[0].url,
|
||||
imageUrls: this.allFilesImages.map((image) => image.url),
|
||||
...form_values,
|
||||
}),
|
||||
},
|
||||
"Uploading workflow..."
|
||||
);
|
||||
|
||||
if (res.status && res.data.status && res.data) {
|
||||
localStorage.setItem("copus_token",this.keyInput.value);
|
||||
const { data } = res.data;
|
||||
if (data) {
|
||||
const url = `${DEFAULT_HOMEPAGE_URL}/work/${data}`;
|
||||
this.message.innerHTML = `Workflow has been shared successfully. <a href="${url}" target="_blank">Click here to view it.</a>`;
|
||||
this.previewImage.src = "";
|
||||
this.previewImage.style.display = "none";
|
||||
this.uploadedImages = [];
|
||||
this.allFilesImages = [];
|
||||
this.allFiles = [];
|
||||
this.TitleInput.value = "";
|
||||
this.SubTitleInput.value = "";
|
||||
this.descriptionInput.value = "";
|
||||
this.selectedFile = null;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
throw new Error("Error sharing workflow: " + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async fetchImageBlob(url) {
|
||||
const response = await fetch(url);
|
||||
const blob = await response.blob();
|
||||
return blob;
|
||||
}
|
||||
|
||||
async show({ potential_outputs, potential_output_nodes } = {}) {
|
||||
// Sort `potential_output_nodes` by node ID to make the order always
|
||||
// consistent, but we should also keep `potential_outputs` in the same
|
||||
// order as `potential_output_nodes`.
|
||||
const potential_output_to_order = {};
|
||||
potential_output_nodes.forEach((node, index) => {
|
||||
if (node.id in potential_output_to_order) {
|
||||
potential_output_to_order[node.id][1].push(potential_outputs[index]);
|
||||
} else {
|
||||
potential_output_to_order[node.id] = [node, [potential_outputs[index]]];
|
||||
}
|
||||
});
|
||||
// Sort the object `potential_output_to_order` by key (node ID)
|
||||
const sorted_potential_output_to_order = Object.fromEntries(
|
||||
Object.entries(potential_output_to_order).sort(
|
||||
(a, b) => a[0].id - b[0].id
|
||||
)
|
||||
);
|
||||
const sorted_potential_outputs = [];
|
||||
const sorted_potential_output_nodes = [];
|
||||
for (const [key, value] of Object.entries(
|
||||
sorted_potential_output_to_order
|
||||
)) {
|
||||
sorted_potential_output_nodes.push(value[0]);
|
||||
sorted_potential_outputs.push(...value[1]);
|
||||
}
|
||||
potential_output_nodes = sorted_potential_output_nodes;
|
||||
potential_outputs = sorted_potential_outputs;
|
||||
const apiToken = localStorage.getItem("copus_token");
|
||||
this.message.innerHTML = "";
|
||||
this.message.textContent = "";
|
||||
this.element.style.display = "block";
|
||||
this.previewImage.src = "";
|
||||
this.previewImage.style.display = "none";
|
||||
this.keyInput.value = apiToken!=null?apiToken:"";
|
||||
this.uploadedImages = [];
|
||||
this.allFilesImages = [];
|
||||
this.allFiles = [];
|
||||
// If `selectedNodeId` is provided, we will select the corresponding radio
|
||||
// button for the node. In addition, we move the selected radio button to
|
||||
// the top of the list.
|
||||
if (this.selectedNodeId) {
|
||||
const index = potential_output_nodes.findIndex(
|
||||
(node) => node.id === this.selectedNodeId
|
||||
);
|
||||
if (index >= 0) {
|
||||
this.selectedOutputIndex = index;
|
||||
}
|
||||
}
|
||||
|
||||
this.radioButtons = [];
|
||||
const new_radio_buttons = $el(
|
||||
"div",
|
||||
{
|
||||
id: "selectOutput-Options",
|
||||
style: {
|
||||
"overflow-y": "scroll",
|
||||
"max-height": "200px",
|
||||
display: "grid",
|
||||
"grid-template-columns": "repeat(auto-fit, minmax(100px, 1fr))",
|
||||
"grid-template-rows": "auto",
|
||||
"grid-column-gap": "10px",
|
||||
"grid-row-gap": "10px",
|
||||
"margin-bottom": "10px",
|
||||
padding: "10px",
|
||||
"border-radius": "8px",
|
||||
"box-shadow": "0 2px 4px rgba(0, 0, 0, 0.05)",
|
||||
"background-color": "var(--bg-color)",
|
||||
},
|
||||
},
|
||||
potential_outputs.map((output, index) => {
|
||||
const { node_id } = output;
|
||||
const radio_button = $el(
|
||||
"input",
|
||||
{
|
||||
type: "radio",
|
||||
name: "selectOutputImages",
|
||||
value: index,
|
||||
required: index === 0,
|
||||
},
|
||||
[]
|
||||
);
|
||||
let radio_button_img;
|
||||
let filename;
|
||||
if (output.type === "image" || output.type === "temp") {
|
||||
radio_button_img = $el(
|
||||
"img",
|
||||
{
|
||||
src: `/view?filename=${output.image.filename}&subfolder=${output.image.subfolder}&type=${output.image.type}`,
|
||||
style: {
|
||||
width: "100px",
|
||||
height: "100px",
|
||||
objectFit: "cover",
|
||||
borderRadius: "5px",
|
||||
},
|
||||
},
|
||||
[]
|
||||
);
|
||||
filename = output.image.filename;
|
||||
} else if (output.type === "output") {
|
||||
radio_button_img = $el(
|
||||
"img",
|
||||
{
|
||||
src: output.output.value,
|
||||
style: {
|
||||
width: "auto",
|
||||
height: "100px",
|
||||
objectFit: "cover",
|
||||
borderRadius: "5px",
|
||||
},
|
||||
},
|
||||
[]
|
||||
);
|
||||
filename = output.filename;
|
||||
} else {
|
||||
// unsupported output type
|
||||
// this should never happen
|
||||
radio_button_img = $el(
|
||||
"img",
|
||||
{
|
||||
src: "",
|
||||
style: { width: "auto", height: "100px" },
|
||||
},
|
||||
[]
|
||||
);
|
||||
}
|
||||
const radio_button_text = $el(
|
||||
"span",
|
||||
{
|
||||
style: {
|
||||
color: "gray",
|
||||
display: "block",
|
||||
fontSize: "12px",
|
||||
overflowX: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
textWrap: "nowrap",
|
||||
maxWidth: "100px",
|
||||
},
|
||||
},
|
||||
[output.title]
|
||||
);
|
||||
const node_id_chip = $el(
|
||||
"span",
|
||||
{
|
||||
style: {
|
||||
color: "#FBFBFD",
|
||||
display: "block",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
fontSize: "12px",
|
||||
overflowX: "hidden",
|
||||
padding: "2px 3px",
|
||||
textOverflow: "ellipsis",
|
||||
textWrap: "nowrap",
|
||||
maxWidth: "100px",
|
||||
position: "absolute",
|
||||
top: "3px",
|
||||
left: "3px",
|
||||
borderRadius: "3px",
|
||||
},
|
||||
},
|
||||
[`Node: ${node_id}`]
|
||||
);
|
||||
radio_button.style.color = "var(--fg-color)";
|
||||
radio_button.checked = this.selectedOutputIndex === index;
|
||||
|
||||
radio_button.onchange = async () => {
|
||||
this.selectedOutputIndex = parseInt(radio_button.value);
|
||||
|
||||
// Remove the "checked" class from all radio buttons
|
||||
this.radioButtons.forEach((ele) => {
|
||||
ele.parentElement.classList.remove("checked");
|
||||
});
|
||||
radio_button.parentElement.classList.add("checked");
|
||||
|
||||
this.fetchImageBlob(radio_button_img.src).then((blob) => {
|
||||
const file = new File([blob], filename, {
|
||||
type: blob.type,
|
||||
});
|
||||
this.previewImage.src = radio_button_img.src;
|
||||
this.previewImage.style.display = "block";
|
||||
this.selectedFile = file;
|
||||
});
|
||||
|
||||
// Add the opacity style toggle here to indicate that they only need
|
||||
// to upload one image or choose one from the outputs.
|
||||
this.outputsSection.style.opacity = 1;
|
||||
this.uploadImagesInput.style.opacity = 0.35;
|
||||
};
|
||||
|
||||
if (radio_button.checked) {
|
||||
this.fetchImageBlob(radio_button_img.src).then((blob) => {
|
||||
const file = new File([blob], filename, {
|
||||
type: blob.type,
|
||||
});
|
||||
this.previewImage.src = radio_button_img.src;
|
||||
this.previewImage.style.display = "block";
|
||||
this.selectedFile = file;
|
||||
});
|
||||
// Add the opacity style toggle here to indicate that they only need
|
||||
// to upload one image or choose one from the outputs.
|
||||
this.outputsSection.style.opacity = 1;
|
||||
this.uploadImagesInput.style.opacity = 0.35;
|
||||
}
|
||||
this.radioButtons.push(radio_button);
|
||||
let src = "";
|
||||
if (output.type === "image" || output.type === "temp") {
|
||||
filename = output.image.filename;
|
||||
src = `/view?filename=${output.image.filename}&subfolder=${output.image.subfolder}&type=${output.image.type}`;
|
||||
} else if (output.type === "output") {
|
||||
src = output.output.value;
|
||||
filename = output.filename;
|
||||
}
|
||||
if (src) {
|
||||
this.fetchImageBlob(src).then((blob) => {
|
||||
const file = new File([blob], filename, {
|
||||
type: blob.type,
|
||||
});
|
||||
this.allFiles.push(file);
|
||||
});
|
||||
}
|
||||
return $el(
|
||||
`label.output_label${radio_button.checked ? ".checked" : ""}`,
|
||||
{
|
||||
style: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginBottom: "10px",
|
||||
cursor: "pointer",
|
||||
position: "relative",
|
||||
},
|
||||
},
|
||||
[radio_button_img, radio_button_text, radio_button, node_id_chip]
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
const header = $el(
|
||||
"p",
|
||||
{
|
||||
textContent:
|
||||
this.radioButtons.length === 0
|
||||
? "Queue Prompt to see the outputs"
|
||||
: "Or choose one from the outputs (scroll to see all)",
|
||||
size: 2,
|
||||
color: "white",
|
||||
style: {
|
||||
color: "white",
|
||||
margin: "0 0 5px 0",
|
||||
fontSize: "12px",
|
||||
},
|
||||
},
|
||||
[]
|
||||
);
|
||||
this.outputsSection.innerHTML = "";
|
||||
this.outputsSection.appendChild(header);
|
||||
this.outputsSection.appendChild(new_radio_buttons);
|
||||
}
|
||||
}
|
||||
746
comfyui_manager/js/comfyui-share-openart.js
Normal file
746
comfyui_manager/js/comfyui-share-openart.js
Normal file
@@ -0,0 +1,746 @@
|
||||
import {app} from "../../scripts/app.js";
|
||||
import {api} from "../../scripts/api.js";
|
||||
import {ComfyDialog, $el} from "../../scripts/ui.js";
|
||||
import { customAlert } from "./common.js";
|
||||
|
||||
const LOCAL_STORAGE_KEY = "openart_comfy_workflow_key";
|
||||
const DEFAULT_HOMEPAGE_URL = "https://openart.ai/workflows/dev?developer=true";
|
||||
//const DEFAULT_HOMEPAGE_URL = "http://localhost:8080/workflows/dev?developer=true";
|
||||
|
||||
const API_ENDPOINT = "https://openart.ai/api";
|
||||
//const API_ENDPOINT = "http://localhost:8080/api";
|
||||
|
||||
const style = `
|
||||
.openart-share-dialog a {
|
||||
color: #f8f8f8;
|
||||
}
|
||||
.openart-share-dialog a:hover {
|
||||
color: #007bff;
|
||||
}
|
||||
.output_label {
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
.output_label:hover {
|
||||
border: 5px solid #59E8C6;
|
||||
}
|
||||
.output_label.checked {
|
||||
border: 5px solid #59E8C6;
|
||||
}
|
||||
`;
|
||||
|
||||
// Shared component styles
|
||||
const sectionStyle = {
|
||||
marginBottom: 0,
|
||||
padding: 0,
|
||||
borderRadius: "8px",
|
||||
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.05)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
};
|
||||
|
||||
export class OpenArtShareDialog extends ComfyDialog {
|
||||
static instance = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
$el("style", {
|
||||
textContent: style,
|
||||
parent: document.head,
|
||||
});
|
||||
this.element = $el(
|
||||
"div.comfy-modal.openart-share-dialog",
|
||||
{
|
||||
parent: document.body,
|
||||
style: {
|
||||
"overflow-y": "auto",
|
||||
},
|
||||
},
|
||||
[$el("div.comfy-modal-content", {}, [...this.createButtons()])]
|
||||
);
|
||||
this.selectedOutputIndex = 0;
|
||||
this.selectedNodeId = null;
|
||||
this.uploadedImages = [];
|
||||
this.selectedFile = null;
|
||||
}
|
||||
|
||||
async readKey() {
|
||||
let key = ""
|
||||
try {
|
||||
key = await api.fetchApi(`/v2/manager/get_openart_auth`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
return data.openart_key;
|
||||
})
|
||||
.catch(error => {
|
||||
// console.log(error);
|
||||
});
|
||||
} catch (error) {
|
||||
// console.log(error);
|
||||
}
|
||||
return key || "";
|
||||
}
|
||||
|
||||
async saveKey(value) {
|
||||
await api.fetchApi(`/v2/manager/set_openart_auth`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
openart_key: value
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
createButtons() {
|
||||
const inputStyle = {
|
||||
display: "block",
|
||||
minWidth: "500px",
|
||||
width: "100%",
|
||||
padding: "10px",
|
||||
margin: "10px 0",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid #ddd",
|
||||
boxSizing: "border-box",
|
||||
};
|
||||
|
||||
const hyperLinkStyle = {
|
||||
display: "block",
|
||||
marginBottom: "15px",
|
||||
fontWeight: "bold",
|
||||
fontSize: "14px",
|
||||
};
|
||||
|
||||
const labelStyle = {
|
||||
color: "#f8f8f8",
|
||||
display: "block",
|
||||
margin: "10px 0 0 0",
|
||||
fontWeight: "bold",
|
||||
textDecoration: "none",
|
||||
};
|
||||
|
||||
const buttonStyle = {
|
||||
padding: "10px 80px",
|
||||
margin: "10px 5px",
|
||||
borderRadius: "4px",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: "#fff",
|
||||
backgroundColor: "#007bff",
|
||||
};
|
||||
|
||||
// upload images input
|
||||
this.uploadImagesInput = $el("input", {
|
||||
type: "file",
|
||||
multiple: false,
|
||||
style: inputStyle,
|
||||
accept: "image/*",
|
||||
});
|
||||
|
||||
this.uploadImagesInput.addEventListener("change", async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) {
|
||||
this.previewImage.src = "";
|
||||
this.previewImage.style.display = "none";
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
const imgData = e.target.result;
|
||||
this.previewImage.src = imgData;
|
||||
this.previewImage.style.display = "block";
|
||||
this.selectedFile = null
|
||||
// Once user uploads an image, we uncheck all radio buttons
|
||||
this.radioButtons.forEach((ele) => {
|
||||
ele.checked = false;
|
||||
ele.parentElement.classList.remove("checked");
|
||||
});
|
||||
|
||||
// Add the opacity style toggle here to indicate that they only need
|
||||
// to upload one image or choose one from the outputs.
|
||||
this.outputsSection.style.opacity = 0.35;
|
||||
this.uploadImagesInput.style.opacity = 1;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
// preview image
|
||||
this.previewImage = $el("img", {
|
||||
src: "",
|
||||
style: {
|
||||
width: "100%",
|
||||
maxHeight: "100px",
|
||||
objectFit: "contain",
|
||||
display: "none",
|
||||
marginTop: '10px',
|
||||
},
|
||||
});
|
||||
|
||||
this.keyInput = $el("input", {
|
||||
type: "password",
|
||||
placeholder: "Copy & paste your API key",
|
||||
style: inputStyle,
|
||||
});
|
||||
this.NameInput = $el("input", {
|
||||
type: "text",
|
||||
placeholder: "Title (required)",
|
||||
style: inputStyle,
|
||||
});
|
||||
this.descriptionInput = $el("textarea", {
|
||||
placeholder: "Description (optional)",
|
||||
style: {
|
||||
...inputStyle,
|
||||
minHeight: "100px",
|
||||
},
|
||||
});
|
||||
|
||||
// Header Section
|
||||
const headerSection = $el("h3", {
|
||||
textContent: "Share your workflow to OpenArt",
|
||||
size: 3,
|
||||
color: "white",
|
||||
style: {
|
||||
'text-align': 'center',
|
||||
color: 'var(--input-text)',
|
||||
margin: '0 0 10px 0',
|
||||
}
|
||||
});
|
||||
|
||||
// LinkSection
|
||||
this.communityLink = $el("a", {
|
||||
style: hyperLinkStyle,
|
||||
href: DEFAULT_HOMEPAGE_URL,
|
||||
target: "_blank"
|
||||
}, ["👉 Check out thousands of workflows shared from the community"])
|
||||
this.getAPIKeyLink = $el("a", {
|
||||
style: {
|
||||
...hyperLinkStyle,
|
||||
color: "#59E8C6"
|
||||
},
|
||||
href: DEFAULT_HOMEPAGE_URL,
|
||||
target: "_blank"
|
||||
}, ["👉 Get your API key here"])
|
||||
const linkSection = $el(
|
||||
"div",
|
||||
{
|
||||
style: {
|
||||
marginTop: "10px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
},
|
||||
},
|
||||
[
|
||||
this.communityLink,
|
||||
this.getAPIKeyLink,
|
||||
]
|
||||
);
|
||||
|
||||
// Account Section
|
||||
const accountSection = $el("div", {style: sectionStyle}, [
|
||||
$el("label", {style: labelStyle}, ["1️⃣ OpenArt API Key"]),
|
||||
this.keyInput,
|
||||
]);
|
||||
|
||||
// Output Upload Section
|
||||
const outputUploadSection = $el("div", {style: sectionStyle}, [
|
||||
$el("label", {
|
||||
style: {
|
||||
...labelStyle,
|
||||
margin: "10px 0 0 0"
|
||||
}
|
||||
}, ["2️⃣ Image/Thumbnail (Required)"]),
|
||||
this.previewImage,
|
||||
this.uploadImagesInput,
|
||||
]);
|
||||
|
||||
// Outputs Section
|
||||
this.outputsSection = $el("div", {
|
||||
id: "selectOutputs",
|
||||
}, []);
|
||||
|
||||
// Additional Inputs Section
|
||||
const additionalInputsSection = $el("div", {style: sectionStyle}, [
|
||||
$el("label", {style: labelStyle}, ["3️⃣ Workflow Information"]),
|
||||
this.NameInput,
|
||||
this.descriptionInput,
|
||||
]);
|
||||
|
||||
// OpenArt Contest Section
|
||||
/*
|
||||
this.joinContestCheckbox = $el("input", {
|
||||
type: 'checkbox',
|
||||
id: "join_contest"s
|
||||
}, [])
|
||||
this.joinContestDescription = $el("a", {
|
||||
style: {
|
||||
...hyperLinkStyle,
|
||||
display: 'inline-block',
|
||||
color: "#59E8C6",
|
||||
fontSize: '12px',
|
||||
marginLeft: '10px',
|
||||
marginBottom: 0,
|
||||
},
|
||||
href: "https://contest.openart.ai/",
|
||||
target: "_blank"
|
||||
}, ["🏆 I'm participating in the OpenArt workflow contest"])
|
||||
this.joinContestLabel = $el("label", {
|
||||
style: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
}
|
||||
}, [this.joinContestCheckbox, this.joinContestDescription])
|
||||
const contestSection = $el("div", {style: sectionStyle}, [
|
||||
this.joinContestLabel,
|
||||
]);
|
||||
*/
|
||||
|
||||
// Message Section
|
||||
this.message = $el(
|
||||
"div",
|
||||
{
|
||||
style: {
|
||||
color: "#ff3d00",
|
||||
textAlign: "center",
|
||||
padding: "10px",
|
||||
fontSize: "20px",
|
||||
},
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
this.shareButton = $el("button", {
|
||||
type: "submit",
|
||||
textContent: "Share",
|
||||
style: buttonStyle,
|
||||
onclick: () => {
|
||||
this.handleShareButtonClick();
|
||||
},
|
||||
});
|
||||
|
||||
// Share and Close Buttons
|
||||
const buttonsSection = $el(
|
||||
"div",
|
||||
{
|
||||
style: {
|
||||
textAlign: "right",
|
||||
marginTop: "20px",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
},
|
||||
[
|
||||
$el("button", {
|
||||
type: "button",
|
||||
textContent: "Close",
|
||||
style: {
|
||||
...buttonStyle,
|
||||
backgroundColor: undefined,
|
||||
},
|
||||
onclick: () => {
|
||||
this.close();
|
||||
},
|
||||
}),
|
||||
this.shareButton,
|
||||
]
|
||||
);
|
||||
|
||||
// Composing the full layout
|
||||
const layout = [
|
||||
headerSection,
|
||||
linkSection,
|
||||
accountSection,
|
||||
outputUploadSection,
|
||||
this.outputsSection,
|
||||
additionalInputsSection,
|
||||
// contestSection,
|
||||
this.message,
|
||||
buttonsSection,
|
||||
];
|
||||
|
||||
return layout;
|
||||
}
|
||||
|
||||
async fetchApi(path, options, statusText) {
|
||||
if (statusText) {
|
||||
this.message.textContent = statusText;
|
||||
}
|
||||
const addSearchParams = (url, params = {}) =>
|
||||
new URL(
|
||||
`${url.origin}${url.pathname}?${new URLSearchParams([
|
||||
...Array.from(url.searchParams.entries()),
|
||||
...Object.entries(params),
|
||||
])}`
|
||||
);
|
||||
|
||||
const fullPath = addSearchParams(new URL(API_ENDPOINT + path), {
|
||||
workflow_api_key: this.keyInput.value,
|
||||
});
|
||||
|
||||
const response = await fetch(fullPath, options);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(response.statusText);
|
||||
}
|
||||
|
||||
if (statusText) {
|
||||
this.message.textContent = "";
|
||||
}
|
||||
const data = await response.json();
|
||||
return {
|
||||
ok: response.ok,
|
||||
statusText: response.statusText,
|
||||
status: response.status,
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
async uploadThumbnail(uploadFile) {
|
||||
const form = new FormData();
|
||||
form.append("file", uploadFile);
|
||||
try {
|
||||
const res = await this.fetchApi(
|
||||
`/v2/workflows/upload_thumbnail`,
|
||||
{
|
||||
method: "POST",
|
||||
body: form,
|
||||
},
|
||||
"Uploading thumbnail..."
|
||||
);
|
||||
|
||||
if (res.ok && res.data) {
|
||||
const {image_url, width, height} = res.data;
|
||||
this.uploadedImages.push({
|
||||
url: image_url,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (e?.response?.status === 413) {
|
||||
throw new Error("File size is too large (max 20MB)");
|
||||
} else {
|
||||
throw new Error("Error uploading thumbnail: " + e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleShareButtonClick() {
|
||||
this.message.textContent = "";
|
||||
await this.saveKey(this.keyInput.value);
|
||||
try {
|
||||
this.shareButton.disabled = true;
|
||||
this.shareButton.textContent = "Sharing...";
|
||||
await this.share();
|
||||
} catch (e) {
|
||||
customAlert(e.message);
|
||||
}
|
||||
this.shareButton.disabled = false;
|
||||
this.shareButton.textContent = "Share";
|
||||
}
|
||||
|
||||
async share() {
|
||||
const prompt = await app.graphToPrompt();
|
||||
const workflowJSON = prompt["workflow"];
|
||||
const workflowAPIJSON = prompt["output"];
|
||||
const form_values = {
|
||||
name: this.NameInput.value,
|
||||
description: this.descriptionInput.value,
|
||||
};
|
||||
|
||||
if (!this.keyInput.value) {
|
||||
throw new Error("API key is required");
|
||||
}
|
||||
|
||||
if (!this.uploadImagesInput.files[0] && !this.selectedFile) {
|
||||
throw new Error("Thumbnail is required");
|
||||
}
|
||||
|
||||
if (!form_values.name) {
|
||||
throw new Error("Title is required");
|
||||
}
|
||||
|
||||
const current_snapshot = await api.fetchApi(`/v2/snapshot/get_current`)
|
||||
.then(response => response.json())
|
||||
.catch(error => {
|
||||
// console.log(error);
|
||||
});
|
||||
|
||||
|
||||
if (!this.uploadedImages.length) {
|
||||
if (this.selectedFile) {
|
||||
await this.uploadThumbnail(this.selectedFile);
|
||||
} else {
|
||||
for (const file of this.uploadImagesInput.files) {
|
||||
try {
|
||||
await this.uploadThumbnail(file);
|
||||
} catch (e) {
|
||||
this.uploadedImages = [];
|
||||
throw new Error(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.uploadImagesInput.files.length === 0) {
|
||||
throw new Error("No thumbnail uploaded");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// const join_contest = this.joinContestCheckbox.checked;
|
||||
|
||||
try {
|
||||
const response = await this.fetchApi(
|
||||
"/v2/workflows/publish",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({
|
||||
workflow_json: workflowJSON,
|
||||
upload_images: this.uploadedImages,
|
||||
form_values,
|
||||
advanced_config: {
|
||||
workflow_api_json: workflowAPIJSON,
|
||||
snapshot: current_snapshot,
|
||||
},
|
||||
// join_contest,
|
||||
}),
|
||||
},
|
||||
"Uploading workflow..."
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const {workflow_id} = response.data;
|
||||
if (workflow_id) {
|
||||
const url = `https://openart.ai/workflows/-/-/${workflow_id}`;
|
||||
this.message.innerHTML = `Workflow has been shared successfully. <a href="${url}" target="_blank">Click here to view it.</a>`;
|
||||
this.previewImage.src = "";
|
||||
this.previewImage.style.display = "none";
|
||||
this.uploadedImages = [];
|
||||
this.NameInput.value = "";
|
||||
this.descriptionInput.value = "";
|
||||
this.radioButtons.forEach((ele) => {
|
||||
ele.checked = false;
|
||||
ele.parentElement.classList.remove("checked");
|
||||
});
|
||||
this.selectedOutputIndex = 0;
|
||||
this.selectedNodeId = null;
|
||||
this.selectedFile = null;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
throw new Error("Error sharing workflow: " + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async fetchImageBlob(url) {
|
||||
const response = await fetch(url);
|
||||
const blob = await response.blob();
|
||||
return blob;
|
||||
}
|
||||
|
||||
async show({potential_outputs, potential_output_nodes} = {}) {
|
||||
// Sort `potential_output_nodes` by node ID to make the order always
|
||||
// consistent, but we should also keep `potential_outputs` in the same
|
||||
// order as `potential_output_nodes`.
|
||||
const potential_output_to_order = {};
|
||||
potential_output_nodes.forEach((node, index) => {
|
||||
if (node.id in potential_output_to_order) {
|
||||
potential_output_to_order[node.id][1].push(potential_outputs[index]);
|
||||
} else {
|
||||
potential_output_to_order[node.id] = [node, [potential_outputs[index]]];
|
||||
}
|
||||
})
|
||||
// Sort the object `potential_output_to_order` by key (node ID)
|
||||
const sorted_potential_output_to_order = Object.fromEntries(
|
||||
Object.entries(potential_output_to_order).sort((a, b) => a[0].id - b[0].id)
|
||||
);
|
||||
const sorted_potential_outputs = []
|
||||
const sorted_potential_output_nodes = []
|
||||
for (const [key, value] of Object.entries(sorted_potential_output_to_order)) {
|
||||
sorted_potential_output_nodes.push(value[0]);
|
||||
sorted_potential_outputs.push(...value[1]);
|
||||
}
|
||||
potential_output_nodes = sorted_potential_output_nodes;
|
||||
potential_outputs = sorted_potential_outputs;
|
||||
|
||||
this.message.innerHTML = "";
|
||||
this.message.textContent = "";
|
||||
this.element.style.display = "block";
|
||||
this.previewImage.src = "";
|
||||
this.previewImage.style.display = "none";
|
||||
const key = await this.readKey();
|
||||
this.keyInput.value = key;
|
||||
this.uploadedImages = [];
|
||||
|
||||
// If `selectedNodeId` is provided, we will select the corresponding radio
|
||||
// button for the node. In addition, we move the selected radio button to
|
||||
// the top of the list.
|
||||
if (this.selectedNodeId) {
|
||||
const index = potential_output_nodes.findIndex(node => node.id === this.selectedNodeId);
|
||||
if (index >= 0) {
|
||||
this.selectedOutputIndex = index;
|
||||
}
|
||||
}
|
||||
|
||||
this.radioButtons = [];
|
||||
const new_radio_buttons = $el("div",
|
||||
{
|
||||
id: "selectOutput-Options",
|
||||
style: {
|
||||
'overflow-y': 'scroll',
|
||||
'max-height': '200px',
|
||||
|
||||
'display': 'grid',
|
||||
'grid-template-columns': 'repeat(auto-fit, minmax(100px, 1fr))',
|
||||
'grid-template-rows': 'auto',
|
||||
'grid-column-gap': '10px',
|
||||
'grid-row-gap': '10px',
|
||||
'margin-bottom': '10px',
|
||||
'padding': '10px',
|
||||
'border-radius': '8px',
|
||||
'box-shadow': '0 2px 4px rgba(0, 0, 0, 0.05)',
|
||||
'background-color': 'var(--bg-color)',
|
||||
}
|
||||
},
|
||||
potential_outputs.map((output, index) => {
|
||||
const {node_id} = output;
|
||||
const radio_button = $el("input", {
|
||||
type: 'radio',
|
||||
name: "selectOutputImages",
|
||||
value: index,
|
||||
required: index === 0
|
||||
}, [])
|
||||
let radio_button_img;
|
||||
let filename;
|
||||
if (output.type === "image" || output.type === "temp") {
|
||||
radio_button_img = $el("img", {
|
||||
src: `/view?filename=${output.image.filename}&subfolder=${output.image.subfolder}&type=${output.image.type}`,
|
||||
style: {
|
||||
width: "100px",
|
||||
height: "100px",
|
||||
objectFit: "cover",
|
||||
borderRadius: "5px"
|
||||
}
|
||||
}, []);
|
||||
filename = output.image.filename
|
||||
} else if (output.type === "output") {
|
||||
radio_button_img = $el("img", {
|
||||
src: output.output.value,
|
||||
style: {
|
||||
width: "auto",
|
||||
height: "100px",
|
||||
objectFit: "cover",
|
||||
borderRadius: "5px"
|
||||
}
|
||||
}, []);
|
||||
filename = output.filename
|
||||
} else {
|
||||
// unsupported output type
|
||||
// this should never happen
|
||||
// TODO
|
||||
radio_button_img = $el("img", {
|
||||
src: "",
|
||||
style: {width: "auto", height: "100px"}
|
||||
}, []);
|
||||
}
|
||||
const radio_button_text = $el("span", {
|
||||
style: {
|
||||
color: 'gray',
|
||||
display: 'block',
|
||||
fontSize: '12px',
|
||||
overflowX: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
textWrap: 'nowrap',
|
||||
maxWidth: '100px',
|
||||
}
|
||||
}, [output.title])
|
||||
const node_id_chip = $el("span", {
|
||||
style: {
|
||||
color: '#FBFBFD',
|
||||
display: 'block',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
fontSize: '12px',
|
||||
overflowX: 'hidden',
|
||||
padding: '2px 3px',
|
||||
textOverflow: 'ellipsis',
|
||||
textWrap: 'nowrap',
|
||||
maxWidth: '100px',
|
||||
position: 'absolute',
|
||||
top: '3px',
|
||||
left: '3px',
|
||||
borderRadius: '3px',
|
||||
}
|
||||
}, [`Node: ${node_id}`])
|
||||
radio_button.style.color = "var(--fg-color)";
|
||||
radio_button.checked = this.selectedOutputIndex === index;
|
||||
|
||||
radio_button.onchange = async () => {
|
||||
this.selectedOutputIndex = parseInt(radio_button.value);
|
||||
|
||||
// Remove the "checked" class from all radio buttons
|
||||
this.radioButtons.forEach((ele) => {
|
||||
ele.parentElement.classList.remove("checked");
|
||||
});
|
||||
radio_button.parentElement.classList.add("checked");
|
||||
|
||||
this.fetchImageBlob(radio_button_img.src).then((blob) => {
|
||||
const file = new File([blob], filename, {
|
||||
type: blob.type,
|
||||
});
|
||||
this.previewImage.src = radio_button_img.src;
|
||||
this.previewImage.style.display = "block";
|
||||
this.selectedFile = file;
|
||||
})
|
||||
|
||||
// Add the opacity style toggle here to indicate that they only need
|
||||
// to upload one image or choose one from the outputs.
|
||||
this.outputsSection.style.opacity = 1;
|
||||
this.uploadImagesInput.style.opacity = 0.35;
|
||||
};
|
||||
|
||||
if (radio_button.checked) {
|
||||
this.fetchImageBlob(radio_button_img.src).then((blob) => {
|
||||
const file = new File([blob], filename, {
|
||||
type: blob.type,
|
||||
});
|
||||
this.previewImage.src = radio_button_img.src;
|
||||
this.previewImage.style.display = "block";
|
||||
this.selectedFile = file;
|
||||
})
|
||||
// Add the opacity style toggle here to indicate that they only need
|
||||
// to upload one image or choose one from the outputs.
|
||||
this.outputsSection.style.opacity = 1;
|
||||
this.uploadImagesInput.style.opacity = 0.35;
|
||||
}
|
||||
|
||||
this.radioButtons.push(radio_button);
|
||||
|
||||
return $el(`label.output_label${radio_button.checked ? '.checked' : ''}`, {
|
||||
style: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginBottom: "10px",
|
||||
cursor: "pointer",
|
||||
position: 'relative',
|
||||
}
|
||||
}, [radio_button_img, radio_button_text, radio_button, node_id_chip]);
|
||||
})
|
||||
);
|
||||
|
||||
const header =
|
||||
$el("p", {
|
||||
textContent: this.radioButtons.length === 0 ? "Queue Prompt to see the outputs" : "Or choose one from the outputs (scroll to see all)",
|
||||
size: 2,
|
||||
color: "white",
|
||||
style: {
|
||||
color: 'var(--input-text)',
|
||||
margin: '0 0 5px 0',
|
||||
fontSize: '12px',
|
||||
},
|
||||
}, [])
|
||||
this.outputsSection.innerHTML = "";
|
||||
this.outputsSection.appendChild(header);
|
||||
this.outputsSection.appendChild(new_radio_buttons);
|
||||
}
|
||||
}
|
||||
569
comfyui_manager/js/comfyui-share-youml.js
Normal file
569
comfyui_manager/js/comfyui-share-youml.js
Normal file
@@ -0,0 +1,569 @@
|
||||
import {app} from "../../scripts/app.js";
|
||||
import {api} from "../../scripts/api.js";
|
||||
import {ComfyDialog, $el} from "../../scripts/ui.js";
|
||||
import { customAlert } from "./common.js";
|
||||
|
||||
const BASE_URL = "https://youml.com";
|
||||
//const BASE_URL = "http://localhost:3000";
|
||||
const DEFAULT_HOMEPAGE_URL = `${BASE_URL}/?from=comfyui`;
|
||||
const TOKEN_PAGE_URL = `${BASE_URL}/my-token`;
|
||||
const API_ENDPOINT = `${BASE_URL}/api`;
|
||||
|
||||
const style = `
|
||||
.youml-share-dialog {
|
||||
overflow-y: auto;
|
||||
}
|
||||
.youml-share-dialog .dialog-header {
|
||||
text-align: center;
|
||||
color: white;
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
.youml-share-dialog .dialog-section {
|
||||
margin-bottom: 0;
|
||||
padding: 0;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
.youml-share-dialog input, .youml-share-dialog textarea {
|
||||
display: block;
|
||||
min-width: 500px;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ddd;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.youml-share-dialog textarea {
|
||||
color: var(--input-text);
|
||||
background-color: var(--comfy-input-bg);
|
||||
}
|
||||
.youml-share-dialog .workflow-description {
|
||||
min-height: 75px;
|
||||
}
|
||||
.youml-share-dialog label {
|
||||
color: #f8f8f8;
|
||||
display: block;
|
||||
margin: 5px 0 0 0;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
}
|
||||
.youml-share-dialog .action-button {
|
||||
padding: 10px 80px;
|
||||
margin: 10px 5px;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.youml-share-dialog .share-button {
|
||||
color: #fff;
|
||||
background-color: #007bff;
|
||||
}
|
||||
.youml-share-dialog .close-button {
|
||||
background-color: none;
|
||||
}
|
||||
.youml-share-dialog .action-button-panel {
|
||||
text-align: right;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.youml-share-dialog .status-message {
|
||||
color: #fd7909;
|
||||
text-align: center;
|
||||
padding: 5px;
|
||||
font-size: 18px;
|
||||
}
|
||||
.youml-share-dialog .status-message a {
|
||||
color: white;
|
||||
}
|
||||
.youml-share-dialog .output-panel {
|
||||
overflow: auto;
|
||||
max-height: 180px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
|
||||
grid-template-rows: auto;
|
||||
grid-column-gap: 10px;
|
||||
grid-row-gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
background-color: var(--bg-color);
|
||||
}
|
||||
.youml-share-dialog .output-panel .output-image {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
objectFit: cover;
|
||||
borderRadius: 5px;
|
||||
}
|
||||
|
||||
.youml-share-dialog .output-panel .radio-button {
|
||||
color:var(--fg-color);
|
||||
}
|
||||
.youml-share-dialog .output-panel .radio-text {
|
||||
color: gray;
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
overflow-x: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-wrap: nowrap;
|
||||
max-width: 100px;
|
||||
}
|
||||
.youml-share-dialog .output-panel .node-id {
|
||||
color: #FBFBFD;
|
||||
display: block;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
font-size: 12px;
|
||||
overflow-x: hidden;
|
||||
padding: 2px 3px;
|
||||
text-overflow: ellipsis;
|
||||
text-wrap: nowrap;
|
||||
max-width: 100px;
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.youml-share-dialog .output-panel .output-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 10px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
.youml-share-dialog .output-panel .output-label:hover {
|
||||
border: 5px solid #007bff;
|
||||
}
|
||||
.youml-share-dialog .output-panel .output-label.checked {
|
||||
border: 5px solid #007bff;
|
||||
}
|
||||
.youml-share-dialog .missing-output-message{
|
||||
color: #fd7909;
|
||||
font-size: 16px;
|
||||
margin-bottom:10px
|
||||
}
|
||||
.youml-share-dialog .select-output-message{
|
||||
color: white;
|
||||
margin-bottom:5px
|
||||
}
|
||||
`;
|
||||
|
||||
export class YouMLShareDialog extends ComfyDialog {
|
||||
static instance = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
$el("style", {
|
||||
textContent: style,
|
||||
parent: document.head,
|
||||
});
|
||||
this.element = $el(
|
||||
"div.comfy-modal.youml-share-dialog",
|
||||
{
|
||||
parent: document.body,
|
||||
},
|
||||
[$el("div.comfy-modal-content", {}, [...this.createLayout()])]
|
||||
);
|
||||
this.selectedOutputIndex = 0;
|
||||
this.selectedNodeId = null;
|
||||
this.uploadedImages = [];
|
||||
this.selectedFile = null;
|
||||
}
|
||||
|
||||
async loadToken() {
|
||||
let key = ""
|
||||
try {
|
||||
const response = await api.fetchApi(`/v2/manager/youml/settings`)
|
||||
const settings = await response.json()
|
||||
return settings.token
|
||||
} catch (error) {
|
||||
}
|
||||
return key || "";
|
||||
}
|
||||
|
||||
async saveToken(value) {
|
||||
await api.fetchApi(`/v2/manager/youml/settings`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
token: value
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
createLayout() {
|
||||
// Header Section
|
||||
const headerSection = $el("h3.dialog-header", {
|
||||
textContent: "Share your workflow to YouML.com",
|
||||
size: 3,
|
||||
});
|
||||
|
||||
// Workflow Info Section
|
||||
this.nameInput = $el("input", {
|
||||
type: "text",
|
||||
placeholder: "Name (required)",
|
||||
});
|
||||
this.descriptionInput = $el("textarea.workflow-description", {
|
||||
placeholder: "Description (optional, markdown supported)",
|
||||
});
|
||||
const workflowMetadata = $el("div.dialog-section", {}, [
|
||||
$el("label", {}, ["Workflow info"]),
|
||||
this.nameInput,
|
||||
this.descriptionInput,
|
||||
]);
|
||||
|
||||
// Outputs Section
|
||||
this.outputsSection = $el("div.dialog-section", {
|
||||
id: "selectOutputs",
|
||||
}, []);
|
||||
|
||||
const outputUploadSection = $el("div.dialog-section", {}, [
|
||||
$el("label", {}, ["Thumbnail"]),
|
||||
this.outputsSection,
|
||||
]);
|
||||
|
||||
// API Token Section
|
||||
this.apiTokenInput = $el("input", {
|
||||
type: "password",
|
||||
placeholder: "Copy & paste your API token",
|
||||
});
|
||||
const getAPITokenButton = $el("button", {
|
||||
href: DEFAULT_HOMEPAGE_URL,
|
||||
target: "_blank",
|
||||
onclick: () => window.open(TOKEN_PAGE_URL, "_blank"),
|
||||
}, ["Get your API Token"])
|
||||
|
||||
const apiTokenSection = $el("div.dialog-section", {}, [
|
||||
$el("label", {}, ["YouML API Token"]),
|
||||
this.apiTokenInput,
|
||||
getAPITokenButton,
|
||||
]);
|
||||
|
||||
// Message Section
|
||||
this.message = $el("div.status-message", {}, []);
|
||||
|
||||
// Share and Close Buttons
|
||||
this.shareButton = $el("button.action-button.share-button", {
|
||||
type: "submit",
|
||||
textContent: "Share",
|
||||
onclick: () => {
|
||||
this.handleShareButtonClick();
|
||||
},
|
||||
});
|
||||
|
||||
const buttonsSection = $el(
|
||||
"div.action-button-panel",
|
||||
{},
|
||||
[
|
||||
$el("button.action-button.close-button", {
|
||||
type: "button",
|
||||
textContent: "Close",
|
||||
onclick: () => {
|
||||
this.close();
|
||||
},
|
||||
}),
|
||||
this.shareButton,
|
||||
]
|
||||
);
|
||||
|
||||
// Composing the full layout
|
||||
const layout = [
|
||||
headerSection,
|
||||
workflowMetadata,
|
||||
outputUploadSection,
|
||||
apiTokenSection,
|
||||
this.message,
|
||||
buttonsSection,
|
||||
];
|
||||
|
||||
return layout;
|
||||
}
|
||||
|
||||
async fetchYoumlApi(path, options, statusText) {
|
||||
if (statusText) {
|
||||
this.message.textContent = statusText;
|
||||
}
|
||||
|
||||
const fullPath = new URL(API_ENDPOINT + path)
|
||||
|
||||
const fetchOptions = Object.assign({}, options)
|
||||
|
||||
fetchOptions.headers = {
|
||||
...fetchOptions.headers,
|
||||
"Authorization": `Bearer ${this.apiTokenInput.value}`,
|
||||
"User-Agent": "ComfyUI-Manager-Youml/1.0.0",
|
||||
}
|
||||
|
||||
const response = await fetch(fullPath, fetchOptions);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(response.statusText + " " + (await response.text()));
|
||||
}
|
||||
|
||||
if (statusText) {
|
||||
this.message.textContent = "";
|
||||
}
|
||||
const data = await response.json();
|
||||
return {
|
||||
ok: response.ok,
|
||||
statusText: response.statusText,
|
||||
status: response.status,
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
async uploadThumbnail(uploadFile, recipeId) {
|
||||
const form = new FormData();
|
||||
form.append("file", uploadFile, uploadFile.name);
|
||||
try {
|
||||
const res = await this.fetchYoumlApi(
|
||||
`/v1/comfy/recipes/${recipeId}/thumbnail`,
|
||||
{
|
||||
method: "POST",
|
||||
body: form,
|
||||
},
|
||||
"Uploading thumbnail..."
|
||||
);
|
||||
|
||||
} catch (e) {
|
||||
if (e?.response?.status === 413) {
|
||||
throw new Error("File size is too large (max 20MB)");
|
||||
} else {
|
||||
throw new Error("Error uploading thumbnail: " + e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleShareButtonClick() {
|
||||
this.message.textContent = "";
|
||||
await this.saveToken(this.apiTokenInput.value);
|
||||
try {
|
||||
this.shareButton.disabled = true;
|
||||
this.shareButton.textContent = "Sharing...";
|
||||
await this.share();
|
||||
} catch (e) {
|
||||
customAlert(e.message);
|
||||
} finally {
|
||||
this.shareButton.disabled = false;
|
||||
this.shareButton.textContent = "Share";
|
||||
}
|
||||
}
|
||||
|
||||
async share() {
|
||||
const prompt = await app.graphToPrompt();
|
||||
const workflowJSON = prompt["workflow"];
|
||||
const workflowAPIJSON = prompt["output"];
|
||||
const form_values = {
|
||||
name: this.nameInput.value,
|
||||
description: this.descriptionInput.value,
|
||||
};
|
||||
|
||||
if (!this.apiTokenInput.value) {
|
||||
throw new Error("API token is required");
|
||||
}
|
||||
|
||||
if (!this.selectedFile) {
|
||||
throw new Error("Thumbnail is required");
|
||||
}
|
||||
|
||||
if (!form_values.name) {
|
||||
throw new Error("Title is required");
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
let snapshotData = null;
|
||||
try {
|
||||
const snapshot = await api.fetchApi(`/v2/snapshot/get_current`)
|
||||
snapshotData = await snapshot.json()
|
||||
} catch (e) {
|
||||
console.error("Failed to get snapshot", e)
|
||||
}
|
||||
|
||||
const request = {
|
||||
name: this.nameInput.value,
|
||||
description: this.descriptionInput.value,
|
||||
workflowUiJson: JSON.stringify(workflowJSON),
|
||||
workflowApiJson: JSON.stringify(workflowAPIJSON),
|
||||
}
|
||||
|
||||
if (snapshotData) {
|
||||
request.snapshotJson = JSON.stringify(snapshotData)
|
||||
}
|
||||
|
||||
const response = await this.fetchYoumlApi(
|
||||
"/v1/comfy/recipes",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify(request),
|
||||
},
|
||||
"Uploading workflow..."
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const {id, recipePageUrl, editorPageUrl} = response.data;
|
||||
if (id) {
|
||||
let messagePrefix = "Workflow has been shared."
|
||||
if (this.selectedFile) {
|
||||
try {
|
||||
await this.uploadThumbnail(this.selectedFile, id);
|
||||
} catch (e) {
|
||||
console.error("Thumbnail upload failed: ", e);
|
||||
messagePrefix = "Workflow has been shared, but thumbnail upload failed. You can create a thumbnail on YouML later."
|
||||
}
|
||||
}
|
||||
this.message.innerHTML = `${messagePrefix} To turn your workflow into an interactive app, ` +
|
||||
`<a href="${recipePageUrl}" target="_blank">visit it on YouML</a>`;
|
||||
|
||||
this.uploadedImages = [];
|
||||
this.nameInput.value = "";
|
||||
this.descriptionInput.value = "";
|
||||
this.radioButtons.forEach((ele) => {
|
||||
ele.checked = false;
|
||||
ele.parentElement.classList.remove("checked");
|
||||
});
|
||||
this.selectedOutputIndex = 0;
|
||||
this.selectedNodeId = null;
|
||||
this.selectedFile = null;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
throw new Error("Error sharing workflow: " + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async fetchImageBlob(url) {
|
||||
const response = await fetch(url);
|
||||
const blob = await response.blob();
|
||||
return blob;
|
||||
}
|
||||
|
||||
async show(potentialOutputs, potentialOutputNodes) {
|
||||
const potentialOutputsToOrder = {};
|
||||
potentialOutputNodes.forEach((node, index) => {
|
||||
if (node.id in potentialOutputsToOrder) {
|
||||
potentialOutputsToOrder[node.id][1].push(potentialOutputs[index]);
|
||||
} else {
|
||||
potentialOutputsToOrder[node.id] = [node, [potentialOutputs[index]]];
|
||||
}
|
||||
})
|
||||
const sortedPotentialOutputsToOrder = Object.fromEntries(
|
||||
Object.entries(potentialOutputsToOrder).sort((a, b) => a[0].id - b[0].id)
|
||||
);
|
||||
const sortedPotentialOutputs = []
|
||||
const sortedPotentiaOutputNodes = []
|
||||
for (const [key, value] of Object.entries(sortedPotentialOutputsToOrder)) {
|
||||
sortedPotentiaOutputNodes.push(value[0]);
|
||||
sortedPotentialOutputs.push(...value[1]);
|
||||
}
|
||||
potentialOutputNodes = sortedPotentiaOutputNodes;
|
||||
potentialOutputs = sortedPotentialOutputs;
|
||||
|
||||
|
||||
// If `selectedNodeId` is provided, we will select the corresponding radio
|
||||
// button for the node. In addition, we move the selected radio button to
|
||||
// the top of the list.
|
||||
if (this.selectedNodeId) {
|
||||
const index = potentialOutputNodes.findIndex(node => node.id === this.selectedNodeId);
|
||||
if (index >= 0) {
|
||||
this.selectedOutputIndex = index;
|
||||
}
|
||||
}
|
||||
|
||||
this.radioButtons = [];
|
||||
const newRadioButtons = $el("div.output-panel",
|
||||
{
|
||||
id: "selectOutput-Options",
|
||||
},
|
||||
potentialOutputs.map((output, index) => {
|
||||
const {node_id: nodeId} = output;
|
||||
const radioButton = $el("input.radio-button", {
|
||||
type: "radio",
|
||||
name: "selectOutputImages",
|
||||
value: index,
|
||||
required: index === 0
|
||||
}, [])
|
||||
let radioButtonImage;
|
||||
let filename;
|
||||
if (output.type === "image" || output.type === "temp") {
|
||||
radioButtonImage = $el("img.output-image", {
|
||||
src: `/view?filename=${output.image.filename}&subfolder=${output.image.subfolder}&type=${output.image.type}`,
|
||||
}, []);
|
||||
filename = output.image.filename
|
||||
} else if (output.type === "output") {
|
||||
radioButtonImage = $el("img.output-image", {
|
||||
src: output.output.value,
|
||||
}, []);
|
||||
filename = output.output.filename
|
||||
} else {
|
||||
radioButtonImage = $el("img.output-image", {
|
||||
src: "",
|
||||
}, []);
|
||||
}
|
||||
const radioButtonText = $el("span.radio-text", {}, [output.title])
|
||||
const nodeIdChip = $el("span.node-id", {}, [`Node: ${nodeId}`])
|
||||
radioButton.checked = this.selectedOutputIndex === index;
|
||||
|
||||
radioButton.onchange = async () => {
|
||||
this.selectedOutputIndex = parseInt(radioButton.value);
|
||||
|
||||
// Remove the "checked" class from all radio buttons
|
||||
this.radioButtons.forEach((ele) => {
|
||||
ele.parentElement.classList.remove("checked");
|
||||
});
|
||||
radioButton.parentElement.classList.add("checked");
|
||||
|
||||
this.fetchImageBlob(radioButtonImage.src).then((blob) => {
|
||||
const file = new File([blob], filename, {
|
||||
type: blob.type,
|
||||
});
|
||||
this.selectedFile = file;
|
||||
})
|
||||
};
|
||||
|
||||
if (radioButton.checked) {
|
||||
this.fetchImageBlob(radioButtonImage.src).then((blob) => {
|
||||
const file = new File([blob], filename, {
|
||||
type: blob.type,
|
||||
});
|
||||
this.selectedFile = file;
|
||||
})
|
||||
}
|
||||
|
||||
this.radioButtons.push(radioButton);
|
||||
|
||||
return $el(`label.output-label${radioButton.checked ? '.checked' : ''}`, {},
|
||||
[radioButtonImage, radioButtonText, radioButton, nodeIdChip]);
|
||||
})
|
||||
);
|
||||
|
||||
let header;
|
||||
if (this.radioButtons.length === 0) {
|
||||
header = $el("div.missing-output-message", {textContent: "Queue Prompt to see the outputs and select a thumbnail"}, [])
|
||||
} else {
|
||||
header = $el("div.select-output-message", {textContent: "Choose one from the outputs (scroll to see all)"}, [])
|
||||
}
|
||||
|
||||
this.outputsSection.innerHTML = "";
|
||||
this.outputsSection.appendChild(header);
|
||||
if (this.radioButtons.length > 0) {
|
||||
this.outputsSection.appendChild(newRadioButtons);
|
||||
}
|
||||
|
||||
this.message.innerHTML = "";
|
||||
this.message.textContent = "";
|
||||
|
||||
const token = await this.loadToken();
|
||||
this.apiTokenInput.value = token;
|
||||
this.uploadedImages = [];
|
||||
|
||||
this.element.style.display = "block";
|
||||
}
|
||||
}
|
||||
654
comfyui_manager/js/common.js
Normal file
654
comfyui_manager/js/common.js
Normal file
@@ -0,0 +1,654 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { api } from "../../scripts/api.js";
|
||||
import { $el, ComfyDialog } from "../../scripts/ui.js";
|
||||
import { getBestPosition, getPositionStyle, getRect } from './popover-helper.js';
|
||||
|
||||
|
||||
function internalCustomConfirm(message, confirmMessage, cancelMessage) {
|
||||
return new Promise((resolve) => {
|
||||
// transparent bg
|
||||
const modalOverlay = document.createElement('div');
|
||||
modalOverlay.style.position = 'fixed';
|
||||
modalOverlay.style.top = 0;
|
||||
modalOverlay.style.left = 0;
|
||||
modalOverlay.style.width = '100%';
|
||||
modalOverlay.style.height = '100%';
|
||||
modalOverlay.style.backgroundColor = 'rgba(0, 0, 0, 0.8)';
|
||||
modalOverlay.style.display = 'flex';
|
||||
modalOverlay.style.alignItems = 'center';
|
||||
modalOverlay.style.justifyContent = 'center';
|
||||
modalOverlay.style.zIndex = '1101';
|
||||
|
||||
// Modal window container (dark bg)
|
||||
const modalDialog = document.createElement('div');
|
||||
modalDialog.style.backgroundColor = '#333';
|
||||
modalDialog.style.padding = '20px';
|
||||
modalDialog.style.borderRadius = '4px';
|
||||
modalDialog.style.maxWidth = '400px';
|
||||
modalDialog.style.width = '80%';
|
||||
modalDialog.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.5)';
|
||||
modalDialog.style.color = '#fff';
|
||||
|
||||
// Display message
|
||||
const modalMessage = document.createElement('p');
|
||||
modalMessage.textContent = message;
|
||||
modalMessage.style.margin = '0';
|
||||
modalMessage.style.padding = '0 0 20px';
|
||||
modalMessage.style.wordBreak = 'keep-all';
|
||||
|
||||
// Button container
|
||||
const modalButtons = document.createElement('div');
|
||||
modalButtons.style.display = 'flex';
|
||||
modalButtons.style.justifyContent = 'flex-end';
|
||||
|
||||
// Confirm button (green)
|
||||
const confirmButton = document.createElement('button');
|
||||
if(confirmMessage)
|
||||
confirmButton.textContent = confirmMessage;
|
||||
else
|
||||
confirmButton.textContent = 'Confirm';
|
||||
confirmButton.style.marginLeft = '10px';
|
||||
confirmButton.style.backgroundColor = '#28a745'; // green
|
||||
confirmButton.style.color = '#fff';
|
||||
confirmButton.style.border = 'none';
|
||||
confirmButton.style.padding = '6px 12px';
|
||||
confirmButton.style.borderRadius = '4px';
|
||||
confirmButton.style.cursor = 'pointer';
|
||||
confirmButton.style.fontWeight = 'bold';
|
||||
|
||||
// Cancel button (red)
|
||||
const cancelButton = document.createElement('button');
|
||||
if(cancelMessage)
|
||||
cancelButton.textContent = cancelMessage;
|
||||
else
|
||||
cancelButton.textContent = 'Cancel';
|
||||
|
||||
cancelButton.style.marginLeft = '10px';
|
||||
cancelButton.style.backgroundColor = '#dc3545'; // red
|
||||
cancelButton.style.color = '#fff';
|
||||
cancelButton.style.border = 'none';
|
||||
cancelButton.style.padding = '6px 12px';
|
||||
cancelButton.style.borderRadius = '4px';
|
||||
cancelButton.style.cursor = 'pointer';
|
||||
cancelButton.style.fontWeight = 'bold';
|
||||
|
||||
const closeModal = () => {
|
||||
document.body.removeChild(modalOverlay);
|
||||
};
|
||||
|
||||
confirmButton.addEventListener('click', () => {
|
||||
closeModal();
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
cancelButton.addEventListener('click', () => {
|
||||
closeModal();
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
modalButtons.appendChild(confirmButton);
|
||||
modalButtons.appendChild(cancelButton);
|
||||
modalDialog.appendChild(modalMessage);
|
||||
modalDialog.appendChild(modalButtons);
|
||||
modalOverlay.appendChild(modalDialog);
|
||||
document.body.appendChild(modalOverlay);
|
||||
});
|
||||
}
|
||||
|
||||
export function show_message(msg) {
|
||||
app.ui.dialog.show(msg);
|
||||
app.ui.dialog.element.style.zIndex = 1100;
|
||||
}
|
||||
|
||||
export async function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export async function customConfirm(message) {
|
||||
try {
|
||||
let res = await
|
||||
window['app'].extensionManager.dialog
|
||||
.confirm({
|
||||
title: 'Confirm',
|
||||
message: message
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
catch {
|
||||
let res = await internalCustomConfirm(message);
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function customAlert(message) {
|
||||
try {
|
||||
window['app'].extensionManager.toast.addAlert(message);
|
||||
}
|
||||
catch {
|
||||
alert(message);
|
||||
}
|
||||
}
|
||||
|
||||
export function infoToast(summary, message) {
|
||||
try {
|
||||
app.extensionManager.toast.add({
|
||||
severity: 'info',
|
||||
summary: summary,
|
||||
detail: message,
|
||||
life: 3000
|
||||
})
|
||||
}
|
||||
catch {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export async function customPrompt(title, message) {
|
||||
try {
|
||||
let res = await
|
||||
window['app'].extensionManager.dialog
|
||||
.prompt({
|
||||
title: title,
|
||||
message: message
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
catch {
|
||||
return prompt(title, message)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function rebootAPI() {
|
||||
if ('electronAPI' in window) {
|
||||
window.electronAPI.restartApp();
|
||||
return true;
|
||||
}
|
||||
|
||||
customConfirm("Are you sure you'd like to reboot the server?").then((isConfirmed) => {
|
||||
if (isConfirmed) {
|
||||
try {
|
||||
api.fetchApi("/v2/manager/reboot");
|
||||
}
|
||||
catch(exception) {}
|
||||
}
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
export var manager_instance = null;
|
||||
|
||||
export function setManagerInstance(obj) {
|
||||
manager_instance = obj;
|
||||
}
|
||||
|
||||
export function showToast(message, duration = 3000) {
|
||||
const toast = $el("div.comfy-toast", {textContent: message});
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => {
|
||||
toast.classList.add("comfy-toast-fadeout");
|
||||
setTimeout(() => toast.remove(), 500);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
function isValidURL(url) {
|
||||
if(url.includes('&'))
|
||||
return false;
|
||||
|
||||
const http_pattern = /^(https?|ftp):\/\/[^\s$?#]+$/;
|
||||
const ssh_pattern = /^(.+@|ssh:\/\/).+:.+$/;
|
||||
return http_pattern.test(url) || ssh_pattern.test(url);
|
||||
}
|
||||
|
||||
export async function install_pip(packages) {
|
||||
if(packages.includes('&'))
|
||||
app.ui.dialog.show(`Invalid PIP package enumeration: '${packages}'`);
|
||||
|
||||
const res = await api.fetchApi("/v2/customnode/install/pip", {
|
||||
method: "POST",
|
||||
body: packages,
|
||||
});
|
||||
|
||||
if(res.status == 403) {
|
||||
show_message('This action is not allowed with this security level configuration.');
|
||||
return;
|
||||
}
|
||||
|
||||
if(res.status == 200) {
|
||||
show_message(`PIP package installation is processed.<br>To apply the pip packages, please click the <button id='cm-reboot-button3'><font size='3px'>RESTART</font></button> button in ComfyUI.`);
|
||||
|
||||
const rebootButton = document.getElementById('cm-reboot-button3');
|
||||
const self = this;
|
||||
|
||||
rebootButton.addEventListener("click", rebootAPI);
|
||||
}
|
||||
else {
|
||||
show_message(`Failed to install '${packages}'<BR>See terminal log.`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function install_via_git_url(url, manager_dialog) {
|
||||
if(!url) {
|
||||
return;
|
||||
}
|
||||
|
||||
if(!isValidURL(url)) {
|
||||
show_message(`Invalid Git url '${url}'`);
|
||||
return;
|
||||
}
|
||||
|
||||
show_message(`Wait...<BR><BR>Installing '${url}'`);
|
||||
|
||||
const res = await api.fetchApi("/v2/customnode/install/git_url", {
|
||||
method: "POST",
|
||||
body: url,
|
||||
});
|
||||
|
||||
if(res.status == 403) {
|
||||
show_message('This action is not allowed with this security level configuration.');
|
||||
return;
|
||||
}
|
||||
|
||||
if(res.status == 200) {
|
||||
show_message(`'${url}' is installed<BR>To apply the installed custom node, please <button id='cm-reboot-button4'><font size='3px'>RESTART</font></button> ComfyUI.`);
|
||||
|
||||
const rebootButton = document.getElementById('cm-reboot-button4');
|
||||
const self = this;
|
||||
|
||||
rebootButton.addEventListener("click",
|
||||
function() {
|
||||
if(rebootAPI()) {
|
||||
manager_dialog.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
else {
|
||||
show_message(`Failed to install '${url}'<BR>See terminal log.`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function free_models(free_execution_cache) {
|
||||
try {
|
||||
let mode = "";
|
||||
if(free_execution_cache) {
|
||||
mode = '{"unload_models": true, "free_memory": true}';
|
||||
}
|
||||
else {
|
||||
mode = '{"unload_models": true}';
|
||||
}
|
||||
|
||||
let res = await api.fetchApi(`/free`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: mode
|
||||
});
|
||||
|
||||
if (res.status == 200) {
|
||||
if(free_execution_cache) {
|
||||
showToast("'Models' and 'Execution Cache' have been cleared.", 3000);
|
||||
}
|
||||
else {
|
||||
showToast("Models' have been unloaded.", 3000);
|
||||
}
|
||||
} else {
|
||||
showToast('Unloading of models failed. Installed ComfyUI may be an outdated version.', 5000);
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('An error occurred while trying to unload models.', 5000);
|
||||
}
|
||||
}
|
||||
|
||||
export function md5(inputString) {
|
||||
const hc = '0123456789abcdef';
|
||||
const rh = n => {let j,s='';for(j=0;j<=3;j++) s+=hc.charAt((n>>(j*8+4))&0x0F)+hc.charAt((n>>(j*8))&0x0F);return s;}
|
||||
const ad = (x,y) => {let l=(x&0xFFFF)+(y&0xFFFF);let m=(x>>16)+(y>>16)+(l>>16);return (m<<16)|(l&0xFFFF);}
|
||||
const rl = (n,c) => (n<<c)|(n>>>(32-c));
|
||||
const cm = (q,a,b,x,s,t) => ad(rl(ad(ad(a,q),ad(x,t)),s),b);
|
||||
const ff = (a,b,c,d,x,s,t) => cm((b&c)|((~b)&d),a,b,x,s,t);
|
||||
const gg = (a,b,c,d,x,s,t) => cm((b&d)|(c&(~d)),a,b,x,s,t);
|
||||
const hh = (a,b,c,d,x,s,t) => cm(b^c^d,a,b,x,s,t);
|
||||
const ii = (a,b,c,d,x,s,t) => cm(c^(b|(~d)),a,b,x,s,t);
|
||||
const sb = x => {
|
||||
let i;const nblk=((x.length+8)>>6)+1;const blks=[];for(i=0;i<nblk*16;i++) { blks[i]=0 };
|
||||
for(i=0;i<x.length;i++) {blks[i>>2]|=x.charCodeAt(i)<<((i%4)*8);}
|
||||
blks[i>>2]|=0x80<<((i%4)*8);blks[nblk*16-2]=x.length*8;return blks;
|
||||
}
|
||||
let i,x=sb(inputString),a=1732584193,b=-271733879,c=-1732584194,d=271733878,olda,oldb,oldc,oldd;
|
||||
for(i=0;i<x.length;i+=16) {olda=a;oldb=b;oldc=c;oldd=d;
|
||||
a=ff(a,b,c,d,x[i+ 0], 7, -680876936);d=ff(d,a,b,c,x[i+ 1],12, -389564586);c=ff(c,d,a,b,x[i+ 2],17, 606105819);
|
||||
b=ff(b,c,d,a,x[i+ 3],22,-1044525330);a=ff(a,b,c,d,x[i+ 4], 7, -176418897);d=ff(d,a,b,c,x[i+ 5],12, 1200080426);
|
||||
c=ff(c,d,a,b,x[i+ 6],17,-1473231341);b=ff(b,c,d,a,x[i+ 7],22, -45705983);a=ff(a,b,c,d,x[i+ 8], 7, 1770035416);
|
||||
d=ff(d,a,b,c,x[i+ 9],12,-1958414417);c=ff(c,d,a,b,x[i+10],17, -42063);b=ff(b,c,d,a,x[i+11],22,-1990404162);
|
||||
a=ff(a,b,c,d,x[i+12], 7, 1804603682);d=ff(d,a,b,c,x[i+13],12, -40341101);c=ff(c,d,a,b,x[i+14],17,-1502002290);
|
||||
b=ff(b,c,d,a,x[i+15],22, 1236535329);a=gg(a,b,c,d,x[i+ 1], 5, -165796510);d=gg(d,a,b,c,x[i+ 6], 9,-1069501632);
|
||||
c=gg(c,d,a,b,x[i+11],14, 643717713);b=gg(b,c,d,a,x[i+ 0],20, -373897302);a=gg(a,b,c,d,x[i+ 5], 5, -701558691);
|
||||
d=gg(d,a,b,c,x[i+10], 9, 38016083);c=gg(c,d,a,b,x[i+15],14, -660478335);b=gg(b,c,d,a,x[i+ 4],20, -405537848);
|
||||
a=gg(a,b,c,d,x[i+ 9], 5, 568446438);d=gg(d,a,b,c,x[i+14], 9,-1019803690);c=gg(c,d,a,b,x[i+ 3],14, -187363961);
|
||||
b=gg(b,c,d,a,x[i+ 8],20, 1163531501);a=gg(a,b,c,d,x[i+13], 5,-1444681467);d=gg(d,a,b,c,x[i+ 2], 9, -51403784);
|
||||
c=gg(c,d,a,b,x[i+ 7],14, 1735328473);b=gg(b,c,d,a,x[i+12],20,-1926607734);a=hh(a,b,c,d,x[i+ 5], 4, -378558);
|
||||
d=hh(d,a,b,c,x[i+ 8],11,-2022574463);c=hh(c,d,a,b,x[i+11],16, 1839030562);b=hh(b,c,d,a,x[i+14],23, -35309556);
|
||||
a=hh(a,b,c,d,x[i+ 1], 4,-1530992060);d=hh(d,a,b,c,x[i+ 4],11, 1272893353);c=hh(c,d,a,b,x[i+ 7],16, -155497632);
|
||||
b=hh(b,c,d,a,x[i+10],23,-1094730640);a=hh(a,b,c,d,x[i+13], 4, 681279174);d=hh(d,a,b,c,x[i+ 0],11, -358537222);
|
||||
c=hh(c,d,a,b,x[i+ 3],16, -722521979);b=hh(b,c,d,a,x[i+ 6],23, 76029189);a=hh(a,b,c,d,x[i+ 9], 4, -640364487);
|
||||
d=hh(d,a,b,c,x[i+12],11, -421815835);c=hh(c,d,a,b,x[i+15],16, 530742520);b=hh(b,c,d,a,x[i+ 2],23, -995338651);
|
||||
a=ii(a,b,c,d,x[i+ 0], 6, -198630844);d=ii(d,a,b,c,x[i+ 7],10, 1126891415);c=ii(c,d,a,b,x[i+14],15,-1416354905);
|
||||
b=ii(b,c,d,a,x[i+ 5],21, -57434055);a=ii(a,b,c,d,x[i+12], 6, 1700485571);d=ii(d,a,b,c,x[i+ 3],10,-1894986606);
|
||||
c=ii(c,d,a,b,x[i+10],15, -1051523);b=ii(b,c,d,a,x[i+ 1],21,-2054922799);a=ii(a,b,c,d,x[i+ 8], 6, 1873313359);
|
||||
d=ii(d,a,b,c,x[i+15],10, -30611744);c=ii(c,d,a,b,x[i+ 6],15,-1560198380);b=ii(b,c,d,a,x[i+13],21, 1309151649);
|
||||
a=ii(a,b,c,d,x[i+ 4], 6, -145523070);d=ii(d,a,b,c,x[i+11],10,-1120210379);c=ii(c,d,a,b,x[i+ 2],15, 718787259);
|
||||
b=ii(b,c,d,a,x[i+ 9],21, -343485551);a=ad(a,olda);b=ad(b,oldb);c=ad(c,oldc);d=ad(d,oldd);
|
||||
}
|
||||
return rh(a)+rh(b)+rh(c)+rh(d);
|
||||
}
|
||||
|
||||
export async function fetchData(route, options) {
|
||||
let err;
|
||||
const res = await api.fetchApi(route, options).catch(e => {
|
||||
err = e;
|
||||
});
|
||||
|
||||
if (!res) {
|
||||
return {
|
||||
status: 400,
|
||||
error: new Error("Unknown Error")
|
||||
}
|
||||
}
|
||||
|
||||
const { status, statusText } = res;
|
||||
if (err) {
|
||||
return {
|
||||
status,
|
||||
error: err
|
||||
}
|
||||
}
|
||||
|
||||
if (status !== 200) {
|
||||
return {
|
||||
status,
|
||||
error: new Error(statusText || "Unknown Error")
|
||||
}
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (!data) {
|
||||
return {
|
||||
status,
|
||||
error: new Error(`Failed to load data: ${route}`)
|
||||
}
|
||||
}
|
||||
return {
|
||||
status,
|
||||
data
|
||||
}
|
||||
}
|
||||
|
||||
// https://cenfun.github.io/open-icons/
|
||||
export const icons = {
|
||||
search: '<svg viewBox="0 0 24 24" width="100%" height="100%" pointer-events="none" xmlns="http://www.w3.org/2000/svg"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m21 21-4.486-4.494M19 10.5a8.5 8.5 0 1 1-17 0 8.5 8.5 0 0 1 17 0"/></svg>',
|
||||
conflicts: '<svg viewBox="0 0 400 400" width="100%" height="100%" pointer-events="none" xmlns="http://www.w3.org/2000/svg"><path fill="currentColor" d="m397.2 350.4.2-.2-180-320-.2.2C213.8 24.2 207.4 20 200 20s-13.8 4.2-17.2 10.4l-.2-.2-180 320 .2.2c-1.6 2.8-2.8 6-2.8 9.6 0 11 9 20 20 20h360c11 0 20-9 20-20 0-3.6-1.2-6.8-2.8-9.6M220 340h-40v-40h40zm0-60h-40V120h40z"/></svg>',
|
||||
passed: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 426.667 426.667"><path fill="#6AC259" d="M213.333,0C95.518,0,0,95.514,0,213.333s95.518,213.333,213.333,213.333c117.828,0,213.333-95.514,213.333-213.333S331.157,0,213.333,0z M174.199,322.918l-93.935-93.931l31.309-31.309l62.626,62.622l140.894-140.898l31.309,31.309L174.199,322.918z"/></svg>',
|
||||
download: '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" width="100%" height="100%" viewBox="0 0 32 32"><path fill="currentColor" d="M26 24v4H6v-4H4v4a2 2 0 0 0 2 2h20a2 2 0 0 0 2-2v-4zm0-10l-1.41-1.41L17 20.17V2h-2v18.17l-7.59-7.58L6 14l10 10l10-10z"></path></svg>',
|
||||
close: '<svg xmlns="http://www.w3.org/2000/svg" pointer-events="none" width="100%" height="100%" viewBox="0 0 16 16"><g fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="m7.116 8-4.558 4.558.884.884L8 8.884l4.558 4.558.884-.884L8.884 8l4.558-4.558-.884-.884L8 7.116 3.442 2.558l-.884.884L7.116 8z"/></g></svg>',
|
||||
arrowRight: '<svg xmlns="http://www.w3.org/2000/svg" pointer-events="none" width="100%" height="100%" viewBox="0 0 20 20"><path fill="currentColor" fill-rule="evenodd" d="m2.542 2.154 7.254 7.26c.136.14.204.302.204.483a.73.73 0 0 1-.204.5l-7.575 7.398c-.383.317-.724.317-1.022 0-.299-.317-.299-.643 0-.98l7.08-6.918-6.754-6.763c-.237-.343-.215-.654.066-.935.281-.28.598-.295.951-.045Zm9 0 7.254 7.26c.136.14.204.302.204.483a.73.73 0 0 1-.204.5l-7.575 7.398c-.383.317-.724.317-1.022 0-.299-.317-.299-.643 0-.98l7.08-6.918-6.754-6.763c-.237-.343-.215-.654.066-.935.281-.28.598-.295.951-.045Z"/></svg>'
|
||||
}
|
||||
|
||||
export function sanitizeHTML(str) {
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
export function showTerminal() {
|
||||
try {
|
||||
const panel = app.extensionManager.bottomPanel;
|
||||
const isTerminalVisible = panel.bottomPanelVisible && panel.activeBottomPanelTab.id === 'logs-terminal';
|
||||
if (!isTerminalVisible)
|
||||
panel.toggleBottomPanelTab('logs-terminal');
|
||||
}
|
||||
catch(exception) {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
let need_restart = false;
|
||||
|
||||
export function setNeedRestart(value) {
|
||||
need_restart = value;
|
||||
}
|
||||
|
||||
async function onReconnected(event) {
|
||||
if(need_restart) {
|
||||
setNeedRestart(false);
|
||||
|
||||
const confirmed = await customConfirm("To apply the changes to the node pack's installation status, you need to refresh the browser. Would you like to refresh?");
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.reload(true);
|
||||
}
|
||||
}
|
||||
|
||||
api.addEventListener('reconnected', onReconnected);
|
||||
|
||||
const storeId = "comfyui-manager-grid";
|
||||
let timeId;
|
||||
export function storeColumnWidth(gridId, columnItem) {
|
||||
clearTimeout(timeId);
|
||||
timeId = setTimeout(() => {
|
||||
let data = {};
|
||||
const dataStr = localStorage.getItem(storeId);
|
||||
if (dataStr) {
|
||||
try {
|
||||
data = JSON.parse(dataStr);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
if (!data[gridId]) {
|
||||
data[gridId] = {};
|
||||
}
|
||||
|
||||
data[gridId][columnItem.id] = columnItem.width;
|
||||
|
||||
localStorage.setItem(storeId, JSON.stringify(data));
|
||||
|
||||
}, 200)
|
||||
}
|
||||
|
||||
export function restoreColumnWidth(gridId, columns) {
|
||||
const dataStr = localStorage.getItem(storeId);
|
||||
if (!dataStr) {
|
||||
return;
|
||||
}
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(dataStr);
|
||||
} catch (e) {}
|
||||
if(!data) {
|
||||
return;
|
||||
}
|
||||
const widthMap = data[gridId];
|
||||
if (!widthMap) {
|
||||
return;
|
||||
}
|
||||
|
||||
columns.forEach(columnItem => {
|
||||
const w = widthMap[columnItem.id];
|
||||
if (w) {
|
||||
columnItem.width = w;
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
export function getTimeAgo(dateStr) {
|
||||
const date = new Date(dateStr);
|
||||
|
||||
if (!date || !(date instanceof Date) || isNaN(date.getTime())) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const units = [
|
||||
{ max: 2760000, value: 60000, name: 'minute', past: 'a minute ago', future: 'in a minute' },
|
||||
{ max: 72000000, value: 3600000, name: 'hour', past: 'an hour ago', future: 'in an hour' },
|
||||
{ max: 518400000, value: 86400000, name: 'day', past: 'yesterday', future: 'tomorrow' },
|
||||
{ max: 2419200000, value: 604800000, name: 'week', past: 'last week', future: 'in a week' },
|
||||
{ max: 28512000000, value: 2592000000, name: 'month', past: 'last month', future: 'in a month' }
|
||||
];
|
||||
const diff = Date.now() - date.getTime();
|
||||
// less than a minute
|
||||
if (Math.abs(diff) < 60000)
|
||||
return 'just now';
|
||||
for (let i = 0; i < units.length; i++) {
|
||||
if (Math.abs(diff) < units[i].max) {
|
||||
return format(diff, units[i].value, units[i].name, units[i].past, units[i].future, diff < 0);
|
||||
}
|
||||
}
|
||||
function format(diff, divisor, unit, past, future, isInTheFuture) {
|
||||
const val = Math.round(Math.abs(diff) / divisor);
|
||||
if (isInTheFuture)
|
||||
return val <= 1 ? future : 'in ' + val + ' ' + unit + 's';
|
||||
return val <= 1 ? past : val + ' ' + unit + 's ago';
|
||||
}
|
||||
return format(diff, 31536000000, 'year', 'last year', 'in a year', diff < 0);
|
||||
};
|
||||
|
||||
export const loadCss = (cssFile) => {
|
||||
const cssPath = import.meta.resolve(cssFile);
|
||||
//console.log(cssPath);
|
||||
const $link = document.createElement("link");
|
||||
$link.setAttribute("rel", 'stylesheet');
|
||||
$link.setAttribute("href", cssPath);
|
||||
document.head.appendChild($link);
|
||||
};
|
||||
|
||||
export const copyText = (text) => {
|
||||
return new Promise((resolve) => {
|
||||
let err;
|
||||
try {
|
||||
navigator.clipboard.writeText(text);
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
if (err) {
|
||||
resolve(false);
|
||||
} else {
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
function renderPopover($elem, target, options = {}) {
|
||||
// async microtask
|
||||
queueMicrotask(() => {
|
||||
|
||||
const containerRect = getRect(window);
|
||||
const targetRect = getRect(target);
|
||||
const elemRect = getRect($elem);
|
||||
|
||||
const positionInfo = getBestPosition(
|
||||
containerRect,
|
||||
targetRect,
|
||||
elemRect,
|
||||
options.positions
|
||||
);
|
||||
const style = getPositionStyle(positionInfo, {
|
||||
bgColor: options.bgColor,
|
||||
borderColor: options.borderColor,
|
||||
borderRadius: options.borderRadius
|
||||
});
|
||||
|
||||
$elem.style.top = positionInfo.top + "px";
|
||||
$elem.style.left = positionInfo.left + "px";
|
||||
$elem.style.background = style.background;
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
let $popover;
|
||||
export function hidePopover() {
|
||||
if ($popover) {
|
||||
$popover.remove();
|
||||
$popover = null;
|
||||
}
|
||||
}
|
||||
export function showPopover(target, text, className, options) {
|
||||
hidePopover();
|
||||
$popover = document.createElement("div");
|
||||
$popover.className = ['cn-popover', className].filter(it => it).join(" ");
|
||||
document.body.appendChild($popover);
|
||||
$popover.innerHTML = text;
|
||||
$popover.style.display = "block";
|
||||
renderPopover($popover, target, {
|
||||
borderRadius: 10,
|
||||
... options
|
||||
});
|
||||
}
|
||||
|
||||
let $tooltip;
|
||||
export function hideTooltip(target) {
|
||||
if ($tooltip) {
|
||||
$tooltip.style.display = "none";
|
||||
$tooltip.innerHTML = "";
|
||||
$tooltip.style.top = "0px";
|
||||
$tooltip.style.left = "0px";
|
||||
}
|
||||
}
|
||||
export function showTooltip(target, text, className = 'cn-tooltip', styleMap = {}) {
|
||||
if (!$tooltip) {
|
||||
$tooltip = document.createElement("div");
|
||||
$tooltip.className = className;
|
||||
$tooltip.style.cssText = `
|
||||
pointer-events: none;
|
||||
position: fixed;
|
||||
z-index: 10001;
|
||||
padding: 20px;
|
||||
color: #1e1e1e;
|
||||
max-width: 350px;
|
||||
filter: drop-shadow(1px 5px 5px rgb(0 0 0 / 30%));
|
||||
${Object.keys(styleMap).map(k=>k+":"+styleMap[k]+";").join("")}
|
||||
`;
|
||||
document.body.appendChild($tooltip);
|
||||
}
|
||||
|
||||
$tooltip.innerHTML = text;
|
||||
$tooltip.style.display = "block";
|
||||
renderPopover($tooltip, target, {
|
||||
positions: ['top', 'bottom', 'right', 'center'],
|
||||
bgColor: "#ffffff",
|
||||
borderColor: "#cccccc",
|
||||
borderRadius: 5
|
||||
});
|
||||
}
|
||||
|
||||
function initTooltip () {
|
||||
const mouseenterHandler = (e) => {
|
||||
const target = e.target;
|
||||
const text = target.getAttribute('tooltip');
|
||||
if (text) {
|
||||
showTooltip(target, text);
|
||||
}
|
||||
};
|
||||
const mouseleaveHandler = (e) => {
|
||||
const target = e.target;
|
||||
const text = target.getAttribute('tooltip');
|
||||
if (text) {
|
||||
hideTooltip(target);
|
||||
}
|
||||
};
|
||||
document.body.removeEventListener('mouseenter', mouseenterHandler, true);
|
||||
document.body.removeEventListener('mouseleave', mouseleaveHandler, true);
|
||||
document.body.addEventListener('mouseenter', mouseenterHandler, true);
|
||||
document.body.addEventListener('mouseleave', mouseleaveHandler, true);
|
||||
}
|
||||
|
||||
initTooltip();
|
||||
812
comfyui_manager/js/components-manager.js
Normal file
812
comfyui_manager/js/components-manager.js
Normal file
@@ -0,0 +1,812 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { api } from "../../scripts/api.js"
|
||||
import { sleep, show_message, customConfirm, customAlert } from "./common.js";
|
||||
import { GroupNodeConfig, GroupNodeHandler } from "../../extensions/core/groupNode.js";
|
||||
import { ComfyDialog, $el } from "../../scripts/ui.js";
|
||||
|
||||
const SEPARATOR = ">"
|
||||
|
||||
let pack_map = {};
|
||||
let rpack_map = {};
|
||||
|
||||
export function getPureName(node) {
|
||||
// group nodes/
|
||||
let category = null;
|
||||
if(node.category) {
|
||||
category = node.category.substring(12);
|
||||
}
|
||||
else {
|
||||
category = node.constructor.category?.substring(12);
|
||||
}
|
||||
if(category) {
|
||||
let purename = node.comfyClass.substring(category.length+1);
|
||||
return purename;
|
||||
}
|
||||
else if(node.comfyClass.startsWith('workflow/') || node.comfyClass.startsWith(`workflow${SEPARATOR}`)) {
|
||||
return node.comfyClass.substring(9);
|
||||
}
|
||||
else {
|
||||
return node.comfyClass;
|
||||
}
|
||||
}
|
||||
|
||||
function isValidVersionString(version) {
|
||||
const versionPattern = /^(\d+)\.(\d+)(\.(\d+))?$/;
|
||||
|
||||
const match = version.match(versionPattern);
|
||||
|
||||
return match !== null &&
|
||||
parseInt(match[1], 10) >= 0 &&
|
||||
parseInt(match[2], 10) >= 0 &&
|
||||
(!match[3] || parseInt(match[4], 10) >= 0);
|
||||
}
|
||||
|
||||
function register_pack_map(name, data) {
|
||||
if(data.packname) {
|
||||
pack_map[data.packname] = name;
|
||||
rpack_map[name] = data;
|
||||
}
|
||||
else {
|
||||
rpack_map[name] = data;
|
||||
}
|
||||
}
|
||||
|
||||
function storeGroupNode(name, data, register=true) {
|
||||
let extra = app.graph.extra;
|
||||
if (!extra) app.graph.extra = extra = {};
|
||||
let groupNodes = extra.groupNodes;
|
||||
if (!groupNodes) extra.groupNodes = groupNodes = {};
|
||||
groupNodes[name] = data;
|
||||
|
||||
if(register) {
|
||||
register_pack_map(name, data);
|
||||
}
|
||||
}
|
||||
|
||||
export async function load_components() {
|
||||
let data = await api.fetchApi('/v2/manager/component/loads', {method: "POST"});
|
||||
let components = await data.json();
|
||||
|
||||
let start_time = Date.now();
|
||||
let failed = [];
|
||||
let failed2 = [];
|
||||
|
||||
for(let name in components) {
|
||||
if(app.graph.extra?.groupNodes?.[name]) {
|
||||
if(data) {
|
||||
let data = components[name];
|
||||
|
||||
let category = data.packname;
|
||||
if(data.category) {
|
||||
category += SEPARATOR + data.category;
|
||||
}
|
||||
if(category == '') {
|
||||
category = 'components';
|
||||
}
|
||||
|
||||
const config = new GroupNodeConfig(name, data);
|
||||
await config.registerType(category);
|
||||
|
||||
register_pack_map(name, data);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let nodeData = components[name];
|
||||
|
||||
storeGroupNode(name, nodeData);
|
||||
|
||||
const config = new GroupNodeConfig(name, nodeData);
|
||||
|
||||
while(true) {
|
||||
try {
|
||||
let category = nodeData.packname;
|
||||
if(nodeData.category) {
|
||||
category += SEPARATOR + nodeData.category;
|
||||
}
|
||||
if(category == '') {
|
||||
category = 'components';
|
||||
}
|
||||
|
||||
await config.registerType(category);
|
||||
register_pack_map(name, nodeData);
|
||||
break;
|
||||
}
|
||||
catch {
|
||||
let elapsed_time = Date.now() - start_time;
|
||||
if (elapsed_time > 5000) {
|
||||
failed.push(name);
|
||||
break;
|
||||
} else {
|
||||
await sleep(100);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fallback1
|
||||
for(let i in failed) {
|
||||
let name = failed[i];
|
||||
|
||||
if(app.graph.extra?.groupNodes?.[name]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let nodeData = components[name];
|
||||
|
||||
storeGroupNode(name, nodeData);
|
||||
|
||||
const config = new GroupNodeConfig(name, nodeData);
|
||||
while(true) {
|
||||
try {
|
||||
let category = nodeData.packname;
|
||||
if(nodeData.workflow.category) {
|
||||
category += SEPARATOR + nodeData.category;
|
||||
}
|
||||
if(category == '') {
|
||||
category = 'components';
|
||||
}
|
||||
|
||||
await config.registerType(category);
|
||||
register_pack_map(name, nodeData);
|
||||
break;
|
||||
}
|
||||
catch {
|
||||
let elapsed_time = Date.now() - start_time;
|
||||
if (elapsed_time > 10000) {
|
||||
failed2.push(name);
|
||||
break;
|
||||
} else {
|
||||
await sleep(100);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fallback2
|
||||
for(let name in failed2) {
|
||||
let name = failed2[i];
|
||||
|
||||
let nodeData = components[name];
|
||||
|
||||
storeGroupNode(name, nodeData);
|
||||
|
||||
const config = new GroupNodeConfig(name, nodeData);
|
||||
while(true) {
|
||||
try {
|
||||
let category = nodeData.workflow.packname;
|
||||
if(nodeData.workflow.category) {
|
||||
category += SEPARATOR + nodeData.category;
|
||||
}
|
||||
if(category == '') {
|
||||
category = 'components';
|
||||
}
|
||||
|
||||
await config.registerType(category);
|
||||
register_pack_map(name, nodeData);
|
||||
break;
|
||||
}
|
||||
catch {
|
||||
let elapsed_time = Date.now() - start_time;
|
||||
if (elapsed_time > 30000) {
|
||||
failed.push(name);
|
||||
break;
|
||||
} else {
|
||||
await sleep(100);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function save_as_component(node, version, author, prefix, nodename, packname, category) {
|
||||
let component_name = `${prefix}::${nodename}`;
|
||||
|
||||
let subgraph = app.graph.extra?.groupNodes?.[component_name];
|
||||
if(!subgraph) {
|
||||
subgraph = app.graph.extra?.groupNodes?.[getPureName(node)];
|
||||
}
|
||||
|
||||
subgraph.version = version;
|
||||
subgraph.author = author;
|
||||
subgraph.datetime = Date.now();
|
||||
subgraph.packname = packname;
|
||||
subgraph.category = category;
|
||||
|
||||
let body =
|
||||
{
|
||||
name: component_name,
|
||||
workflow: subgraph
|
||||
};
|
||||
|
||||
pack_map[packname] = component_name;
|
||||
rpack_map[component_name] = subgraph;
|
||||
|
||||
const res = await api.fetchApi('/v2/manager/component/save', {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if(res.status == 200) {
|
||||
storeGroupNode(component_name, subgraph);
|
||||
const config = new GroupNodeConfig(component_name, subgraph);
|
||||
|
||||
let category = body.workflow.packname;
|
||||
if(body.workflow.category) {
|
||||
category += SEPARATOR + body.workflow.category;
|
||||
}
|
||||
if(category == '') {
|
||||
category = 'components';
|
||||
}
|
||||
|
||||
await config.registerType(category);
|
||||
|
||||
let path = await res.text();
|
||||
show_message(`Component '${component_name}' is saved into:\n${path}`);
|
||||
}
|
||||
else
|
||||
show_message(`Failed to save component.`);
|
||||
}
|
||||
|
||||
async function import_component(component_name, component, mode) {
|
||||
if(mode) {
|
||||
let body =
|
||||
{
|
||||
name: component_name,
|
||||
workflow: component
|
||||
};
|
||||
|
||||
const res = await api.fetchApi('/v2/manager/component/save', {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
}
|
||||
|
||||
let category = component.packname;
|
||||
if(component.category) {
|
||||
category += SEPARATOR + component.category;
|
||||
}
|
||||
if(category == '') {
|
||||
category = 'components';
|
||||
}
|
||||
|
||||
storeGroupNode(component_name, component);
|
||||
const config = new GroupNodeConfig(component_name, component);
|
||||
await config.registerType(category);
|
||||
}
|
||||
|
||||
function restore_to_loaded_component(component_name) {
|
||||
if(rpack_map[component_name]) {
|
||||
let component = rpack_map[component_name];
|
||||
storeGroupNode(component_name, component, false);
|
||||
const config = new GroupNodeConfig(component_name, component);
|
||||
config.registerType(component.category);
|
||||
}
|
||||
}
|
||||
|
||||
// Using a timestamp prevents duplicate pastes and ensures the prevention of re-deletion of litegrapheditor_clipboard.
|
||||
let last_paste_timestamp = null;
|
||||
|
||||
function versionCompare(v1, v2) {
|
||||
let ver1;
|
||||
let ver2;
|
||||
if(v1 && v1 != '') {
|
||||
ver1 = v1.split('.');
|
||||
ver1[0] = parseInt(ver1[0]);
|
||||
ver1[1] = parseInt(ver1[1]);
|
||||
if(ver1.length == 2)
|
||||
ver1.push(0);
|
||||
else
|
||||
ver1[2] = parseInt(ver2[2]);
|
||||
}
|
||||
else {
|
||||
ver1 = [0,0,0];
|
||||
}
|
||||
|
||||
if(v2 && v2 != '') {
|
||||
ver2 = v2.split('.');
|
||||
ver2[0] = parseInt(ver2[0]);
|
||||
ver2[1] = parseInt(ver2[1]);
|
||||
if(ver2.length == 2)
|
||||
ver2.push(0);
|
||||
else
|
||||
ver2[2] = parseInt(ver2[2]);
|
||||
}
|
||||
else {
|
||||
ver2 = [0,0,0];
|
||||
}
|
||||
|
||||
if(ver1[0] > ver2[0])
|
||||
return -1;
|
||||
else if(ver1[0] < ver2[0])
|
||||
return 1;
|
||||
|
||||
if(ver1[1] > ver2[1])
|
||||
return -1;
|
||||
else if(ver1[1] < ver2[1])
|
||||
return 1;
|
||||
|
||||
if(ver1[2] > ver2[2])
|
||||
return -1;
|
||||
else if(ver1[2] < ver2[2])
|
||||
return 1;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function checkVersion(name, component) {
|
||||
let msg = '';
|
||||
if(rpack_map[name]) {
|
||||
let old_version = rpack_map[name].version;
|
||||
if(!old_version || old_version == '') {
|
||||
msg = ` '${name}' Upgrade (V0.0 -> V${component.version})`;
|
||||
}
|
||||
else {
|
||||
let c = versionCompare(old_version, component.version);
|
||||
if(c < 0) {
|
||||
msg = ` '${name}' Downgrade (V${old_version} -> V${component.version})`;
|
||||
}
|
||||
else if(c > 0) {
|
||||
msg = ` '${name}' Upgrade (V${old_version} -> V${component.version})`;
|
||||
}
|
||||
else {
|
||||
msg = ` '${name}' Same version (V${component.version})`;
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
msg = `'${name}' NEW (V${component.version})`;
|
||||
}
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
async function handle_import_components(components) {
|
||||
let msg = 'Components:\n';
|
||||
let cnt = 0;
|
||||
for(let name in components) {
|
||||
let component = components[name];
|
||||
let v = checkVersion(name, component);
|
||||
|
||||
if(cnt < 10) {
|
||||
msg += v + '\n';
|
||||
}
|
||||
else if (cnt == 10) {
|
||||
msg += '...\n';
|
||||
}
|
||||
else {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
cnt++;
|
||||
}
|
||||
|
||||
let last_name = null;
|
||||
msg += '\nWill you load components?\n';
|
||||
const confirmed = await customConfirm(msg);
|
||||
if(confirmed) {
|
||||
const mode = await customConfirm('\nWill you save components?\n(cancel=load without save)');
|
||||
|
||||
for(let name in components) {
|
||||
let component = components[name];
|
||||
import_component(name, component, mode);
|
||||
last_name = name;
|
||||
}
|
||||
|
||||
if(mode) {
|
||||
show_message('Components are saved.');
|
||||
}
|
||||
else {
|
||||
show_message('Components are loaded.');
|
||||
}
|
||||
}
|
||||
|
||||
if(cnt == 1 && last_name) {
|
||||
const node = LiteGraph.createNode(`workflow${SEPARATOR}${last_name}`);
|
||||
node.pos = [app.canvas.graph_mouse[0], app.canvas.graph_mouse[1]];
|
||||
app.canvas.graph.add(node, false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePaste(e) {
|
||||
let data = (e.clipboardData || window.clipboardData);
|
||||
const items = data.items;
|
||||
for(const item of items) {
|
||||
if(item.kind == 'string' && item.type == 'text/plain') {
|
||||
data = data.getData("text/plain");
|
||||
try {
|
||||
let json_data = JSON.parse(data);
|
||||
if(json_data.kind == 'ComfyUI Components' && last_paste_timestamp != json_data.timestamp) {
|
||||
last_paste_timestamp = json_data.timestamp;
|
||||
await handle_import_components(json_data.components);
|
||||
|
||||
// disable paste node
|
||||
localStorage.removeItem("litegrapheditor_clipboard", null);
|
||||
}
|
||||
else {
|
||||
console.log('This components are already pasted: ignored');
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// nothing to do
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("paste", handlePaste);
|
||||
|
||||
|
||||
export class ComponentBuilderDialog extends ComfyDialog {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
clear() {
|
||||
while (this.element.children.length) {
|
||||
this.element.removeChild(this.element.children[0]);
|
||||
}
|
||||
}
|
||||
|
||||
show() {
|
||||
this.invalidateControl();
|
||||
|
||||
this.element.style.display = "block";
|
||||
this.element.style.zIndex = 1099;
|
||||
this.element.style.width = "500px";
|
||||
this.element.style.height = "480px";
|
||||
}
|
||||
|
||||
invalidateControl() {
|
||||
this.clear();
|
||||
|
||||
let self = this;
|
||||
|
||||
const close_button = $el("button", { id: "cm-close-button", type: "button", textContent: "Close", onclick: () => self.close() });
|
||||
this.save_button = $el("button",
|
||||
{ id: "cm-save-button", type: "button", textContent: "Save", onclick: () =>
|
||||
{
|
||||
save_as_component(self.target_node, self.version_string.value.trim(), self.author.value.trim(), self.node_prefix.value.trim(),
|
||||
self.getNodeName(), self.getPackName(), self.category.value.trim());
|
||||
}
|
||||
});
|
||||
|
||||
let default_nodename = getPureName(this.target_node).trim();
|
||||
|
||||
let groupNode = app.graph.extra.groupNodes[default_nodename];
|
||||
let default_packname = groupNode.packname;
|
||||
if(!default_packname) {
|
||||
default_packname = '';
|
||||
}
|
||||
|
||||
let default_category = groupNode.category;
|
||||
if(!default_category) {
|
||||
default_category = '';
|
||||
}
|
||||
|
||||
this.default_ver = groupNode.version;
|
||||
if(!this.default_ver) {
|
||||
this.default_ver = '0.0';
|
||||
}
|
||||
|
||||
let default_author = groupNode.author;
|
||||
if(!default_author) {
|
||||
default_author = '';
|
||||
}
|
||||
|
||||
let delimiterIndex = default_nodename.indexOf('::');
|
||||
let default_prefix = "";
|
||||
if(delimiterIndex != -1) {
|
||||
default_prefix = default_nodename.substring(0, delimiterIndex);
|
||||
default_nodename = default_nodename.substring(delimiterIndex + 2);
|
||||
}
|
||||
|
||||
if(!default_prefix) {
|
||||
this.save_button.disabled = true;
|
||||
}
|
||||
|
||||
this.pack_list = this.createPackListCombo();
|
||||
|
||||
let version_string = this.createLabeledInput('input version (e.g. 1.0)', '*Version : ', this.default_ver);
|
||||
this.version_string = version_string[1];
|
||||
this.version_string.disabled = true;
|
||||
|
||||
let author = this.createLabeledInput('input author (e.g. Dr.Lt.Data)', 'Author : ', default_author);
|
||||
this.author = author[1];
|
||||
|
||||
let node_prefix = this.createLabeledInput('input node prefix (e.g. mypack)', '*Prefix : ', default_prefix);
|
||||
this.node_prefix = node_prefix[1];
|
||||
|
||||
let manual_nodename = this.createLabeledInput('input node name (e.g. MAKE_BASIC_PIPE)', 'Nodename : ', default_nodename);
|
||||
this.manual_nodename = manual_nodename[1];
|
||||
|
||||
let manual_packname = this.createLabeledInput('input pack name (e.g. mypack)', 'Packname : ', default_packname);
|
||||
this.manual_packname = manual_packname[1];
|
||||
|
||||
let category = this.createLabeledInput('input category (e.g. util/pipe)', 'Category : ', default_category);
|
||||
this.category = category[1];
|
||||
|
||||
this.node_label = this.createNodeLabel();
|
||||
|
||||
let author_mode = this.createAuthorModeCheck();
|
||||
this.author_mode = author_mode[0];
|
||||
|
||||
const content =
|
||||
$el("div.comfy-modal-content",
|
||||
[
|
||||
$el("tr.cm-title", {}, [
|
||||
$el("font", {size:6, color:"white"}, [`ComfyUI-Manager: Component Builder`])]
|
||||
),
|
||||
$el("br", {}, []),
|
||||
$el("div.cm-menu-container",
|
||||
[
|
||||
author_mode[0],
|
||||
author_mode[1],
|
||||
category[0],
|
||||
author[0],
|
||||
node_prefix[0],
|
||||
manual_nodename[0],
|
||||
manual_packname[0],
|
||||
version_string[0],
|
||||
this.pack_list,
|
||||
$el("br", {}, []),
|
||||
this.node_label
|
||||
]),
|
||||
|
||||
$el("br", {}, []),
|
||||
this.save_button,
|
||||
close_button,
|
||||
]
|
||||
);
|
||||
|
||||
content.style.width = '100%';
|
||||
content.style.height = '100%';
|
||||
|
||||
this.element = $el("div.comfy-modal", { id:'cm-manager-dialog', parent: document.body }, [ content ]);
|
||||
}
|
||||
|
||||
validateInput() {
|
||||
let msg = "";
|
||||
|
||||
if(!isValidVersionString(this.version_string.value)) {
|
||||
msg += 'Invalid version string: '+event.value+"\n";
|
||||
}
|
||||
|
||||
if(this.node_prefix.value.trim() == '') {
|
||||
msg += 'Node prefix cannot be empty\n';
|
||||
}
|
||||
|
||||
if(this.manual_nodename.value.trim() == '') {
|
||||
msg += 'Node name cannot be empty\n';
|
||||
}
|
||||
|
||||
if(msg != '') {
|
||||
// alert(msg);
|
||||
}
|
||||
|
||||
this.save_button.disabled = msg != "";
|
||||
}
|
||||
|
||||
getPackName() {
|
||||
if(this.pack_list.selectedIndex == 0) {
|
||||
return this.manual_packname.value.trim();
|
||||
}
|
||||
|
||||
return this.pack_list.value.trim();
|
||||
}
|
||||
|
||||
getNodeName() {
|
||||
if(this.manual_nodename.value.trim() != '') {
|
||||
return this.manual_nodename.value.trim();
|
||||
}
|
||||
|
||||
return getPureName(this.target_node);
|
||||
}
|
||||
|
||||
createAuthorModeCheck() {
|
||||
let check = $el("input",{type:'checkbox', id:"author-mode"},[])
|
||||
const check_label = $el("label",{for:"author-mode"},["Enable author mode"]);
|
||||
check_label.style.color = "var(--fg-color)";
|
||||
check_label.style.cursor = "pointer";
|
||||
check.checked = false;
|
||||
|
||||
let self = this;
|
||||
check.onchange = () => {
|
||||
self.version_string.disabled = !check.checked;
|
||||
|
||||
if(!check.checked) {
|
||||
self.version_string.value = self.default_ver;
|
||||
}
|
||||
else {
|
||||
customAlert('If you are not the author, it is not recommended to change the version, as it may cause component update issues.');
|
||||
}
|
||||
};
|
||||
|
||||
return [check, check_label];
|
||||
}
|
||||
|
||||
createNodeLabel() {
|
||||
let label = $el('p');
|
||||
label.className = 'cb-node-label';
|
||||
if(this.target_node.comfyClass.includes('::'))
|
||||
label.textContent = getPureName(this.target_node);
|
||||
else
|
||||
label.textContent = " _::" + getPureName(this.target_node);
|
||||
return label;
|
||||
}
|
||||
|
||||
createLabeledInput(placeholder, label, value) {
|
||||
let textbox = $el('input.cb-widget-input', {type:'text', placeholder:placeholder, value:value}, []);
|
||||
|
||||
let self = this;
|
||||
textbox.onchange = () => {
|
||||
this.validateInput.call(self);
|
||||
this.node_label.textContent = this.node_prefix.value + "::" + this.manual_nodename.value;
|
||||
}
|
||||
let row = $el('span.cb-widget', {}, [ $el('span.cb-widget-input-label', label), textbox]);
|
||||
|
||||
return [row, textbox];
|
||||
}
|
||||
|
||||
createPackListCombo() {
|
||||
let combo = document.createElement("select");
|
||||
combo.className = "cb-widget";
|
||||
let default_packname_option = { value: '##manual', text: 'Packname: Manual' };
|
||||
|
||||
combo.appendChild($el('option', default_packname_option, []));
|
||||
for(let name in pack_map) {
|
||||
combo.appendChild($el('option', { value: name, text: 'Packname: '+ name }, []));
|
||||
}
|
||||
|
||||
let self = this;
|
||||
combo.onchange = function () {
|
||||
if(combo.selectedIndex == 0) {
|
||||
self.manual_packname.disabled = false;
|
||||
}
|
||||
else {
|
||||
self.manual_packname.disabled = true;
|
||||
}
|
||||
};
|
||||
|
||||
return combo;
|
||||
}
|
||||
}
|
||||
|
||||
let orig_handleFile = app.handleFile;
|
||||
|
||||
async function handleFile(file) {
|
||||
if (file.name?.endsWith(".json") || file.name?.endsWith(".pack")) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
let is_component = false;
|
||||
const jsonContent = JSON.parse(reader.result);
|
||||
for(let name in jsonContent) {
|
||||
let cand = jsonContent[name];
|
||||
is_component = cand.datetime && cand.version;
|
||||
break;
|
||||
}
|
||||
|
||||
if(is_component) {
|
||||
await handle_import_components(jsonContent);
|
||||
}
|
||||
else {
|
||||
orig_handleFile.call(app, file);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
orig_handleFile.call(app, file);
|
||||
}
|
||||
|
||||
app.handleFile = handleFile;
|
||||
|
||||
let current_component_policy = 'workflow';
|
||||
try {
|
||||
api.fetchApi('/v2/manager/policy/component')
|
||||
.then(response => response.text())
|
||||
.then(data => { current_component_policy = data; });
|
||||
}
|
||||
catch {}
|
||||
|
||||
function getChangedVersion(groupNodes) {
|
||||
if(!Object.keys(pack_map).length || !groupNodes)
|
||||
return null;
|
||||
|
||||
let res = {};
|
||||
for(let component_name in groupNodes) {
|
||||
let data = groupNodes[component_name];
|
||||
|
||||
if(rpack_map[component_name]) {
|
||||
let v = versionCompare(data.version, rpack_map[component_name].version);
|
||||
res[component_name] = v;
|
||||
}
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
const loadGraphData = app.loadGraphData;
|
||||
app.loadGraphData = async function () {
|
||||
if(arguments.length == 0)
|
||||
return await loadGraphData.apply(this, arguments);
|
||||
|
||||
let graphData = arguments[0];
|
||||
let groupNodes = graphData.extra?.groupNodes;
|
||||
let res = getChangedVersion(groupNodes);
|
||||
|
||||
if(res) {
|
||||
let target_components = null;
|
||||
switch(current_component_policy) {
|
||||
case 'higher':
|
||||
target_components = Object.keys(res).filter(key => res[key] == 1);
|
||||
break;
|
||||
|
||||
case 'mine':
|
||||
target_components = Object.keys(res);
|
||||
break;
|
||||
|
||||
default:
|
||||
// do nothing
|
||||
}
|
||||
|
||||
if(target_components) {
|
||||
for(let i in target_components) {
|
||||
let component_name = target_components[i];
|
||||
let component = rpack_map[component_name];
|
||||
if(component && graphData.extra?.groupNodes) {
|
||||
graphData.extra.groupNodes[component_name] = component;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
console.log('Empty components: policy ignored');
|
||||
}
|
||||
|
||||
arguments[0] = graphData;
|
||||
return await loadGraphData.apply(this, arguments);
|
||||
};
|
||||
|
||||
export function set_component_policy(v) {
|
||||
current_component_policy = v;
|
||||
}
|
||||
|
||||
let graphToPrompt = app.graphToPrompt;
|
||||
app.graphToPrompt = async function () {
|
||||
let p = await graphToPrompt.call(app);
|
||||
try {
|
||||
let groupNodes = p.workflow.extra?.groupNodes;
|
||||
if(groupNodes) {
|
||||
p.workflow.extra = { ... p.workflow.extra};
|
||||
|
||||
// get used group nodes
|
||||
let used_group_nodes = new Set();
|
||||
for(let node of p.workflow.nodes) {
|
||||
if(node.type.startsWith(`workflow/`) || node.type.startsWith(`workflow${SEPARATOR}`)) {
|
||||
used_group_nodes.add(node.type.substring(9));
|
||||
}
|
||||
}
|
||||
|
||||
// remove unused group nodes
|
||||
let new_groupNodes = {};
|
||||
for (let key in p.workflow.extra.groupNodes) {
|
||||
if (used_group_nodes.has(key)) {
|
||||
new_groupNodes[key] = p.workflow.extra.groupNodes[key];
|
||||
}
|
||||
}
|
||||
p.workflow.extra.groupNodes = new_groupNodes;
|
||||
}
|
||||
}
|
||||
catch(e) {
|
||||
console.log(`Failed to filtering group nodes: ${e}`);
|
||||
}
|
||||
|
||||
return p;
|
||||
}
|
||||
699
comfyui_manager/js/custom-nodes-manager.css
Normal file
699
comfyui_manager/js/custom-nodes-manager.css
Normal file
@@ -0,0 +1,699 @@
|
||||
.cn-manager {
|
||||
--grid-font: -apple-system, BlinkMacSystemFont, "Segue UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
|
||||
z-index: 1099;
|
||||
width: 80%;
|
||||
height: 80%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
color: var(--fg-color);
|
||||
font-family: arial, sans-serif;
|
||||
text-underline-offset: 3px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.cn-manager .cn-flex-auto {
|
||||
flex: auto;
|
||||
}
|
||||
|
||||
.cn-manager button {
|
||||
font-size: 16px;
|
||||
color: var(--input-text);
|
||||
background-color: var(--comfy-input-bg);
|
||||
border-radius: 8px;
|
||||
border-color: var(--border-color);
|
||||
border-style: solid;
|
||||
margin: 0;
|
||||
padding: 4px 8px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.cn-manager button:disabled,
|
||||
.cn-manager input:disabled,
|
||||
.cn-manager select:disabled {
|
||||
color: gray;
|
||||
}
|
||||
|
||||
.cn-manager button:disabled {
|
||||
background-color: var(--comfy-input-bg);
|
||||
}
|
||||
|
||||
.cn-manager .cn-manager-restart {
|
||||
display: none;
|
||||
background-color: #500000;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cn-manager .cn-manager-stop {
|
||||
display: none;
|
||||
background-color: #500000;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cn-manager .cn-manager-back {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.arrow-icon {
|
||||
height: 1em;
|
||||
width: 1em;
|
||||
margin-right: 5px;
|
||||
transform: translateY(2px);
|
||||
}
|
||||
|
||||
.cn-icon {
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.cn-icon svg {
|
||||
display: block;
|
||||
margin: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.cn-manager-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
padding: 0 5px;
|
||||
}
|
||||
|
||||
.cn-manager-header label {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cn-manager-filter {
|
||||
height: 28px;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.cn-manager-keywords {
|
||||
height: 28px;
|
||||
line-height: 28px;
|
||||
padding: 0 5px 0 26px;
|
||||
background-size: 16px;
|
||||
background-position: 5px center;
|
||||
background-repeat: no-repeat;
|
||||
background-image: url("data:image/svg+xml;charset=utf8,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20width%3D%22100%25%22%20height%3D%22100%25%22%20pointer-events%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill%3D%22none%22%20stroke%3D%22%23888%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20stroke-width%3D%222%22%20d%3D%22m21%2021-4.486-4.494M19%2010.5a8.5%208.5%200%201%201-17%200%208.5%208.5%200%200%201%2017%200%22%2F%3E%3C%2Fsvg%3E");
|
||||
}
|
||||
|
||||
.cn-manager-status {
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.cn-manager-grid {
|
||||
flex: auto;
|
||||
border: 1px solid var(--border-color);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cn-manager-selection {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cn-manager-message {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cn-manager-footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cn-manager-grid .tg-turbogrid {
|
||||
font-family: var(--grid-font);
|
||||
font-size: 15px;
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
.cn-manager-grid .tg-turbogrid .tg-highlight::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
content: "";
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
background-color: #80bdff11;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.cn-manager-grid .cn-pack-name a {
|
||||
color: skyblue;
|
||||
text-decoration: none;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.cn-manager-grid .cn-pack-desc a {
|
||||
color: #5555FF;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.cn-manager-grid .tg-cell a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.cn-manager-grid .cn-pack-version {
|
||||
line-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.cn-manager-grid .cn-pack-nodes {
|
||||
line-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
cursor: pointer;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.cn-manager-grid .cn-pack-nodes:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.cn-manager-grid .cn-pack-conflicts {
|
||||
color: orange;
|
||||
}
|
||||
|
||||
.cn-popover {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
padding: 20px;
|
||||
color: #1e1e1e;
|
||||
filter: drop-shadow(1px 5px 5px rgb(0 0 0 / 30%));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cn-flyover {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
width: 50%;
|
||||
height: 100%;
|
||||
background-color: var(--comfy-menu-bg);
|
||||
animation-duration: 0.2s;
|
||||
animation-fill-mode: both;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cn-flyover::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
content: "";
|
||||
z-index: 10;
|
||||
display: block;
|
||||
width: 10px;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
left: -10px;
|
||||
background-image: linear-gradient(to left, rgb(0 0 0 / 20%), rgb(0 0 0 / 0%));
|
||||
}
|
||||
|
||||
.cn-flyover-header {
|
||||
height: 45px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.cn-flyover-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
opacity: 0.8;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.cn-flyover-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.cn-flyover-close svg {
|
||||
display: block;
|
||||
margin: 0;
|
||||
pointer-events: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.cn-flyover-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: bold;
|
||||
gap: 10px;
|
||||
flex: auto;
|
||||
}
|
||||
|
||||
.cn-flyover-body {
|
||||
height: calc(100% - 45px);
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
background-color: var(--comfy-menu-secondary-bg);
|
||||
}
|
||||
|
||||
@keyframes cn-slide-in-right {
|
||||
from {
|
||||
visibility: visible;
|
||||
transform: translate3d(100%, 0, 0);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.cn-slide-in-right {
|
||||
animation-name: cn-slide-in-right;
|
||||
}
|
||||
|
||||
@keyframes cn-slide-out-right {
|
||||
from {
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
to {
|
||||
visibility: hidden;
|
||||
transform: translate3d(100%, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.cn-slide-out-right {
|
||||
animation-name: cn-slide-out-right;
|
||||
}
|
||||
|
||||
.cn-nodes-list {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cn-nodes-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cn-nodes-row:nth-child(odd) {
|
||||
background-color: rgb(0 0 0 / 5%);
|
||||
}
|
||||
|
||||
.cn-nodes-row:hover {
|
||||
background-color: rgb(0 0 0 / 10%);
|
||||
}
|
||||
|
||||
.cn-nodes-sn {
|
||||
text-align: right;
|
||||
min-width: 35px;
|
||||
color: var(--drag-text);
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
padding: 8px 5px;
|
||||
}
|
||||
|
||||
.cn-nodes-name {
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
padding: 8px 5px;
|
||||
}
|
||||
|
||||
.cn-nodes-name::after {
|
||||
content: attr(action);
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
top: 50%;
|
||||
left: 100%;
|
||||
transform: translate(5px, -50%);
|
||||
font-size: 12px;
|
||||
color: var(--drag-text);
|
||||
background-color: var(--comfy-input-bg);
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 3px 8px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cn-nodes-name.action::after {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cn-nodes-name:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.cn-nodes-conflict .cn-nodes-name,
|
||||
.cn-nodes-conflict .cn-icon {
|
||||
color: orange;
|
||||
}
|
||||
|
||||
.cn-conflicts-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
padding: 5px 0;
|
||||
}
|
||||
|
||||
.cn-conflicts-list b {
|
||||
font-weight: normal;
|
||||
color: var(--descrip-text);
|
||||
}
|
||||
|
||||
.cn-nodes-pack {
|
||||
cursor: pointer;
|
||||
color: skyblue;
|
||||
}
|
||||
|
||||
.cn-nodes-pack:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.cn-pack-badge {
|
||||
font-size: 12px;
|
||||
font-weight: normal;
|
||||
background-color: var(--comfy-input-bg);
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 3px 8px;
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
.cn-preview {
|
||||
min-width: 300px;
|
||||
max-width: 500px;
|
||||
min-height: 120px;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
padding: 12px;
|
||||
color: var(--fg-color);
|
||||
}
|
||||
|
||||
.cn-preview-header {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--comfy-input-bg);
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.cn-preview-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: grey;
|
||||
position: relative;
|
||||
filter: drop-shadow(1px 2px 3px rgb(0 0 0 / 30%));
|
||||
}
|
||||
|
||||
.cn-preview-dot.cn-preview-optional::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background-color: var(--comfy-input-bg);
|
||||
border-radius: 50%;
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
.cn-preview-dot.cn-preview-grid {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.cn-preview-dot.cn-preview-grid::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
border-left: 1px solid var(--comfy-input-bg);
|
||||
border-right: 1px solid var(--comfy-input-bg);
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
left: 2px;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.cn-preview-dot.cn-preview-grid::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
border-top: 1px solid var(--comfy-input-bg);
|
||||
border-bottom: 1px solid var(--comfy-input-bg);
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
left: 0;
|
||||
top: 2px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.cn-preview-name {
|
||||
flex: auto;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.cn-preview-io {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 10px 10px;
|
||||
}
|
||||
|
||||
.cn-preview-column > div {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
height: 18px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.cn-preview-input {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.cn-preview-output {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.cn-preview-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 0 10px 10px 10px;
|
||||
}
|
||||
|
||||
.cn-preview-switch {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: var(--bg-color);
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
text-wrap: nowrap;
|
||||
padding: 2px 20px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cn-preview-switch::before,
|
||||
.cn-preview-switch::after {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
top: 50%;
|
||||
transform: translate(0, -50%);
|
||||
color: var(--fg-color);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.cn-preview-switch::before {
|
||||
content: "◀";
|
||||
left: 5px;
|
||||
}
|
||||
|
||||
.cn-preview-switch::after {
|
||||
content: "▶";
|
||||
right: 5px;
|
||||
}
|
||||
|
||||
.cn-preview-value {
|
||||
color: var(--descrip-text);
|
||||
}
|
||||
|
||||
.cn-preview-string {
|
||||
min-height: 30px;
|
||||
max-height: 300px;
|
||||
background: var(--bg-color);
|
||||
color: var(--descrip-text);
|
||||
border-radius: 3px;
|
||||
padding: 3px 5px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.cn-preview-description {
|
||||
margin: 0px 10px 10px 10px;
|
||||
padding: 6px;
|
||||
background: var(--border-color);
|
||||
color: var(--descrip-text);
|
||||
border-radius: 5px;
|
||||
font-style: italic;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.cn-tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.cn-tag-list > div {
|
||||
background-color: var(--border-color);
|
||||
border-radius: 5px;
|
||||
padding: 0 5px;
|
||||
}
|
||||
|
||||
.cn-install-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 3px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.cn-selected-buttons {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.cn-manager .cn-btn-enable {
|
||||
background-color: #333399;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cn-manager .cn-btn-disable {
|
||||
background-color: #442277;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cn-manager .cn-btn-update {
|
||||
background-color: #1155AA;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cn-manager .cn-btn-try-update {
|
||||
background-color: Gray;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cn-manager .cn-btn-try-fix {
|
||||
background-color: #6495ED;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cn-manager .cn-btn-import-failed {
|
||||
background-color: #AA1111;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cn-manager .cn-btn-install {
|
||||
background-color: black;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cn-manager .cn-btn-try-install {
|
||||
background-color: Gray;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cn-manager .cn-btn-uninstall {
|
||||
background-color: #993333;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cn-manager .cn-btn-reinstall {
|
||||
background-color: #993333;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cn-manager .cn-btn-switch {
|
||||
background-color: #448833;
|
||||
color: white;
|
||||
|
||||
}
|
||||
|
||||
@keyframes cn-btn-loading-bg {
|
||||
0% {
|
||||
left: 0;
|
||||
}
|
||||
100% {
|
||||
left: -105px;
|
||||
}
|
||||
}
|
||||
|
||||
.cn-manager button.cn-btn-loading {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-color: rgb(0 119 207 / 80%);
|
||||
background-color: var(--comfy-input-bg);
|
||||
}
|
||||
|
||||
.cn-manager button.cn-btn-loading::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
content: "";
|
||||
width: 500px;
|
||||
height: 100%;
|
||||
background-image: repeating-linear-gradient(
|
||||
-45deg,
|
||||
rgb(0 119 207 / 30%),
|
||||
rgb(0 119 207 / 30%) 10px,
|
||||
transparent 10px,
|
||||
transparent 15px
|
||||
);
|
||||
animation: cn-btn-loading-bg 2s linear infinite;
|
||||
}
|
||||
|
||||
.cn-manager-light .cn-pack-name a {
|
||||
color: blue;
|
||||
}
|
||||
|
||||
.cn-manager-light .cm-warn-note {
|
||||
background-color: #ccc !important;
|
||||
}
|
||||
|
||||
.cn-manager-light .cn-btn-install {
|
||||
background-color: #333;
|
||||
}
|
||||
2173
comfyui_manager/js/custom-nodes-manager.js
Normal file
2173
comfyui_manager/js/custom-nodes-manager.js
Normal file
File diff suppressed because it is too large
Load Diff
213
comfyui_manager/js/model-manager.css
Normal file
213
comfyui_manager/js/model-manager.css
Normal file
@@ -0,0 +1,213 @@
|
||||
.cmm-manager {
|
||||
--grid-font: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
|
||||
z-index: 1099;
|
||||
width: 80%;
|
||||
height: 80%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
color: var(--fg-color);
|
||||
font-family: arial, sans-serif;
|
||||
}
|
||||
|
||||
.cmm-manager .cmm-flex-auto {
|
||||
flex: auto;
|
||||
}
|
||||
|
||||
.cmm-manager button {
|
||||
font-size: 16px;
|
||||
color: var(--input-text);
|
||||
background-color: var(--comfy-input-bg);
|
||||
border-radius: 8px;
|
||||
border-color: var(--border-color);
|
||||
border-style: solid;
|
||||
margin: 0;
|
||||
padding: 4px 8px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.cmm-manager button:disabled,
|
||||
.cmm-manager input:disabled,
|
||||
.cmm-manager select:disabled {
|
||||
color: gray;
|
||||
}
|
||||
|
||||
.cmm-manager button:disabled {
|
||||
background-color: var(--comfy-input-bg);
|
||||
}
|
||||
|
||||
.cmm-manager .cmm-manager-refresh {
|
||||
display: none;
|
||||
background-color: #000080;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cmm-manager .cmm-manager-stop {
|
||||
display: none;
|
||||
background-color: #500000;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cmm-manager-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
padding: 0 5px;
|
||||
}
|
||||
|
||||
.cmm-manager-header label {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cmm-manager-type,
|
||||
.cmm-manager-base,
|
||||
.cmm-manager-filter {
|
||||
height: 28px;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.cmm-manager-keywords {
|
||||
height: 28px;
|
||||
line-height: 28px;
|
||||
padding: 0 5px 0 26px;
|
||||
background-size: 16px;
|
||||
background-position: 5px center;
|
||||
background-repeat: no-repeat;
|
||||
background-image: url("data:image/svg+xml;charset=utf8,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20width%3D%22100%25%22%20height%3D%22100%25%22%20pointer-events%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill%3D%22none%22%20stroke%3D%22%23888%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20stroke-width%3D%222%22%20d%3D%22m21%2021-4.486-4.494M19%2010.5a8.5%208.5%200%201%201-17%200%208.5%208.5%200%200%201%2017%200%22%2F%3E%3C%2Fsvg%3E");
|
||||
}
|
||||
|
||||
.cmm-manager-status {
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.cmm-manager-grid {
|
||||
flex: auto;
|
||||
border: 1px solid var(--border-color);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cmm-manager-selection {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cmm-manager-footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cmm-manager-grid .tg-turbogrid {
|
||||
font-family: var(--grid-font);
|
||||
font-size: 15px;
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
.cmm-manager-grid .cmm-node-name a {
|
||||
color: skyblue;
|
||||
text-decoration: none;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.cmm-manager-grid .cmm-node-desc a {
|
||||
color: #5555FF;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.cmm-manager-grid .tg-cell a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.cmm-icon-passed {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
position: absolute;
|
||||
left: calc(50% - 10px);
|
||||
top: calc(50% - 10px);
|
||||
}
|
||||
|
||||
.cmm-manager .cmm-btn-enable {
|
||||
background-color: blue;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cmm-manager .cmm-btn-disable {
|
||||
background-color: MediumSlateBlue;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cmm-manager .cmm-btn-install {
|
||||
background-color: black;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cmm-btn-download {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
position: absolute;
|
||||
left: calc(50% - 10px);
|
||||
top: calc(50% - 10px);
|
||||
cursor: pointer;
|
||||
opacity: 0.8;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.cmm-btn-download:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.cmm-manager-light .cmm-btn-download {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
@keyframes cmm-btn-loading-bg {
|
||||
0% {
|
||||
left: 0;
|
||||
}
|
||||
100% {
|
||||
left: -105px;
|
||||
}
|
||||
}
|
||||
|
||||
.cmm-manager button.cmm-btn-loading {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-color: rgb(0 119 207 / 80%);
|
||||
background-color: var(--comfy-input-bg);
|
||||
}
|
||||
|
||||
.cmm-manager button.cmm-btn-loading::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
content: "";
|
||||
width: 500px;
|
||||
height: 100%;
|
||||
background-image: repeating-linear-gradient(
|
||||
-45deg,
|
||||
rgb(0 119 207 / 30%),
|
||||
rgb(0 119 207 / 30%) 10px,
|
||||
transparent 10px,
|
||||
transparent 15px
|
||||
);
|
||||
animation: cmm-btn-loading-bg 2s linear infinite;
|
||||
}
|
||||
|
||||
.cmm-manager-light .cmm-node-name a {
|
||||
color: blue;
|
||||
}
|
||||
|
||||
.cmm-manager-light .cm-warn-note {
|
||||
background-color: #ccc !important;
|
||||
}
|
||||
|
||||
.cmm-manager-light .cmm-btn-install {
|
||||
background-color: #333;
|
||||
}
|
||||
798
comfyui_manager/js/model-manager.js
Normal file
798
comfyui_manager/js/model-manager.js
Normal file
@@ -0,0 +1,798 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { $el } from "../../scripts/ui.js";
|
||||
import {
|
||||
manager_instance, rebootAPI,
|
||||
fetchData, md5, icons, show_message, customAlert, infoToast, showTerminal,
|
||||
storeColumnWidth, restoreColumnWidth, loadCss
|
||||
} from "./common.js";
|
||||
import { api } from "../../scripts/api.js";
|
||||
|
||||
// https://cenfun.github.io/turbogrid/api.html
|
||||
import TG from "./turbogrid.esm.js";
|
||||
|
||||
loadCss("./model-manager.css");
|
||||
|
||||
const gridId = "model";
|
||||
|
||||
const pageHtml = `
|
||||
<div class="cmm-manager-header">
|
||||
<label>Filter
|
||||
<select class="cmm-manager-filter"></select>
|
||||
</label>
|
||||
<label>Type
|
||||
<select class="cmm-manager-type"></select>
|
||||
</label>
|
||||
<label>Base
|
||||
<select class="cmm-manager-base"></select>
|
||||
</label>
|
||||
<input class="cmm-manager-keywords" type="search" placeholder="Search" />
|
||||
<div class="cmm-manager-status"></div>
|
||||
<div class="cmm-flex-auto"></div>
|
||||
</div>
|
||||
<div class="cmm-manager-grid"></div>
|
||||
<div class="cmm-manager-selection"></div>
|
||||
<div class="cmm-manager-message"></div>
|
||||
<div class="cmm-manager-footer">
|
||||
<button class="cmm-manager-back">
|
||||
<svg class="arrow-icon" width="14" height="14" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2 8H18M2 8L8 2M2 8L8 14" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Back
|
||||
</button>
|
||||
<button class="cmm-manager-refresh">Refresh</button>
|
||||
<button class="cmm-manager-stop">Stop</button>
|
||||
<div class="cmm-flex-auto"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
export class ModelManager {
|
||||
static instance = null;
|
||||
|
||||
constructor(app, manager_dialog) {
|
||||
this.app = app;
|
||||
this.manager_dialog = manager_dialog;
|
||||
this.id = "cmm-manager";
|
||||
|
||||
this.filter = '';
|
||||
this.type = '';
|
||||
this.base = '';
|
||||
this.keywords = '';
|
||||
|
||||
this.init();
|
||||
|
||||
api.addEventListener("cm-queue-status", this.onQueueStatus);
|
||||
}
|
||||
|
||||
init() {
|
||||
this.element = $el("div", {
|
||||
parent: document.body,
|
||||
className: "comfy-modal cmm-manager"
|
||||
});
|
||||
this.element.innerHTML = pageHtml;
|
||||
this.initFilter();
|
||||
this.bindEvents();
|
||||
this.initGrid();
|
||||
}
|
||||
|
||||
initFilter() {
|
||||
|
||||
this.filterList = [{
|
||||
label: "All",
|
||||
value: ""
|
||||
}, {
|
||||
label: "Installed",
|
||||
value: "True"
|
||||
}, {
|
||||
label: "Not Installed",
|
||||
value: "False"
|
||||
}];
|
||||
|
||||
this.typeList = [{
|
||||
label: "All",
|
||||
value: ""
|
||||
}];
|
||||
|
||||
this.baseList = [{
|
||||
label: "All",
|
||||
value: ""
|
||||
}];
|
||||
|
||||
this.updateFilter();
|
||||
|
||||
}
|
||||
|
||||
updateFilter() {
|
||||
const $filter = this.element.querySelector(".cmm-manager-filter");
|
||||
$filter.innerHTML = this.filterList.map(item => {
|
||||
const selected = item.value === this.filter ? " selected" : "";
|
||||
return `<option value="${item.value}"${selected}>${item.label}</option>`
|
||||
}).join("");
|
||||
|
||||
const $type = this.element.querySelector(".cmm-manager-type");
|
||||
$type.innerHTML = this.typeList.map(item => {
|
||||
const selected = item.value === this.type ? " selected" : "";
|
||||
return `<option value="${item.value}"${selected}>${item.label}</option>`
|
||||
}).join("");
|
||||
|
||||
const $base = this.element.querySelector(".cmm-manager-base");
|
||||
$base.innerHTML = this.baseList.map(item => {
|
||||
const selected = item.value === this.base ? " selected" : "";
|
||||
return `<option value="${item.value}"${selected}>${item.label}</option>`
|
||||
}).join("");
|
||||
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
const eventsMap = {
|
||||
".cmm-manager-filter": {
|
||||
change: (e) => {
|
||||
this.filter = e.target.value;
|
||||
this.updateGrid();
|
||||
}
|
||||
},
|
||||
".cmm-manager-type": {
|
||||
change: (e) => {
|
||||
this.type = e.target.value;
|
||||
this.updateGrid();
|
||||
}
|
||||
},
|
||||
".cmm-manager-base": {
|
||||
change: (e) => {
|
||||
this.base = e.target.value;
|
||||
this.updateGrid();
|
||||
}
|
||||
},
|
||||
|
||||
".cmm-manager-keywords": {
|
||||
input: (e) => {
|
||||
const keywords = `${e.target.value}`.trim();
|
||||
if (keywords !== this.keywords) {
|
||||
this.keywords = keywords;
|
||||
this.updateGrid();
|
||||
}
|
||||
},
|
||||
focus: (e) => e.target.select()
|
||||
},
|
||||
|
||||
".cmm-manager-selection": {
|
||||
click: (e) => {
|
||||
const target = e.target;
|
||||
const mode = target.getAttribute("mode");
|
||||
if (mode === "install") {
|
||||
this.installModels(this.selectedModels, target);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
".cmm-manager-refresh": {
|
||||
click: () => {
|
||||
app.refreshComboInNodes();
|
||||
}
|
||||
},
|
||||
|
||||
".cmm-manager-stop": {
|
||||
click: () => {
|
||||
api.fetchApi('/v2/manager/queue/reset');
|
||||
infoToast('Cancel', 'Remaining tasks will stop after completing the current task.');
|
||||
}
|
||||
},
|
||||
|
||||
".cmm-manager-back": {
|
||||
click: (e) => {
|
||||
this.close()
|
||||
manager_instance.show();
|
||||
}
|
||||
}
|
||||
};
|
||||
Object.keys(eventsMap).forEach(selector => {
|
||||
const target = this.element.querySelector(selector);
|
||||
if (target) {
|
||||
const events = eventsMap[selector];
|
||||
if (events) {
|
||||
Object.keys(events).forEach(type => {
|
||||
target.addEventListener(type, events[type]);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ===========================================================================================
|
||||
|
||||
initGrid() {
|
||||
const container = this.element.querySelector(".cmm-manager-grid");
|
||||
const grid = new TG.Grid(container);
|
||||
this.grid = grid;
|
||||
|
||||
grid.bind('onUpdated', (e, d) => {
|
||||
|
||||
this.showStatus(`${grid.viewRows.length.toLocaleString()} external models`);
|
||||
|
||||
});
|
||||
|
||||
grid.bind('onSelectChanged', (e, changes) => {
|
||||
this.renderSelected();
|
||||
});
|
||||
|
||||
grid.bind("onColumnWidthChanged", (e, columnItem) => {
|
||||
storeColumnWidth(gridId, columnItem)
|
||||
});
|
||||
|
||||
grid.bind('onClick', (e, d) => {
|
||||
const { rowItem } = d;
|
||||
const target = d.e.target;
|
||||
const mode = target.getAttribute("mode");
|
||||
if (mode === "install") {
|
||||
this.installModels([rowItem], target);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
grid.setOption({
|
||||
theme: 'dark',
|
||||
|
||||
selectVisible: true,
|
||||
selectMultiple: true,
|
||||
selectAllVisible: true,
|
||||
|
||||
textSelectable: true,
|
||||
scrollbarRound: true,
|
||||
|
||||
frozenColumn: 1,
|
||||
rowNotFound: "No Results",
|
||||
|
||||
rowHeight: 40,
|
||||
bindWindowResize: true,
|
||||
bindContainerResize: true,
|
||||
|
||||
cellResizeObserver: (rowItem, columnItem) => {
|
||||
const autoHeightColumns = ['name', 'description'];
|
||||
return autoHeightColumns.includes(columnItem.id)
|
||||
},
|
||||
|
||||
// updateGrid handler for filter and keywords
|
||||
rowFilter: (rowItem) => {
|
||||
|
||||
const searchableColumns = ["name", "type", "base", "description", "filename", "save_path"];
|
||||
|
||||
let shouldShown = grid.highlightKeywordsFilter(rowItem, searchableColumns, this.keywords);
|
||||
|
||||
if (shouldShown) {
|
||||
if(this.filter && rowItem.installed !== this.filter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if(this.type && rowItem.type !== this.type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if(this.base && rowItem.base !== this.base) {
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return shouldShown;
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
renderGrid() {
|
||||
|
||||
// update theme
|
||||
const colorPalette = this.app.ui.settings.settingsValues['Comfy.ColorPalette'];
|
||||
Array.from(this.element.classList).forEach(cn => {
|
||||
if (cn.startsWith("cmm-manager-")) {
|
||||
this.element.classList.remove(cn);
|
||||
}
|
||||
});
|
||||
this.element.classList.add(`cmm-manager-${colorPalette}`);
|
||||
|
||||
const options = {
|
||||
theme: colorPalette === "light" ? "" : "dark"
|
||||
};
|
||||
|
||||
const rows = this.modelList || [];
|
||||
|
||||
const columns = [{
|
||||
id: 'id',
|
||||
name: 'ID',
|
||||
width: 50,
|
||||
align: 'center'
|
||||
}, {
|
||||
id: 'name',
|
||||
name: 'Name',
|
||||
width: 200,
|
||||
minWidth: 100,
|
||||
maxWidth: 500,
|
||||
classMap: 'cmm-node-name',
|
||||
formatter: function(name, rowItem, columnItem, cellNode) {
|
||||
return `<a href=${rowItem.reference} target="_blank"><b>${name}</b></a>`;
|
||||
}
|
||||
}, {
|
||||
id: 'installed',
|
||||
name: 'Install',
|
||||
width: 130,
|
||||
minWidth: 110,
|
||||
maxWidth: 200,
|
||||
sortable: false,
|
||||
align: 'center',
|
||||
formatter: (installed, rowItem, columnItem) => {
|
||||
if (rowItem.refresh) {
|
||||
return `<font color="red">Refresh Required</span>`;
|
||||
}
|
||||
if (installed === "True") {
|
||||
return `<div class="cmm-icon-passed">${icons.passed}</div>`;
|
||||
}
|
||||
return `<button class="cmm-btn-install" mode="install">Install</button>`;
|
||||
}
|
||||
}, {
|
||||
id: 'url',
|
||||
name: '',
|
||||
width: 50,
|
||||
sortable: false,
|
||||
align: 'center',
|
||||
formatter: (url, rowItem, columnItem) => {
|
||||
return `<a class="cmm-btn-download" tooltip="Download file" href="${url}" target="_blank">${icons.download}</a>`;
|
||||
}
|
||||
}, {
|
||||
id: 'size',
|
||||
name: 'Size',
|
||||
width: 100,
|
||||
formatter: (size) => {
|
||||
if (typeof size === "number") {
|
||||
return this.formatSize(size);
|
||||
}
|
||||
return size;
|
||||
}
|
||||
}, {
|
||||
id: 'type',
|
||||
name: 'Type',
|
||||
width: 100
|
||||
}, {
|
||||
id: 'base',
|
||||
name: 'Base'
|
||||
}, {
|
||||
id: 'description',
|
||||
name: 'Description',
|
||||
width: 400,
|
||||
maxWidth: 5000,
|
||||
classMap: 'cmm-node-desc'
|
||||
}, {
|
||||
id: "save_path",
|
||||
name: 'Save Path',
|
||||
width: 200
|
||||
}, {
|
||||
id: 'filename',
|
||||
name: 'Filename',
|
||||
width: 200
|
||||
}];
|
||||
|
||||
restoreColumnWidth(gridId, columns);
|
||||
|
||||
this.grid.setData({
|
||||
options,
|
||||
rows,
|
||||
columns
|
||||
});
|
||||
|
||||
this.grid.render();
|
||||
|
||||
}
|
||||
|
||||
updateGrid() {
|
||||
if (this.grid) {
|
||||
this.grid.update();
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================================
|
||||
|
||||
renderSelected() {
|
||||
const selectedList = this.grid.getSelectedRows();
|
||||
if (!selectedList.length) {
|
||||
this.showSelection("");
|
||||
this.selectedModels = [];
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectedModels = selectedList;
|
||||
this.showSelection(`<span>Selected <b>${selectedList.length}</b> models <button class="cmm-btn-install" mode="install">Install</button>`);
|
||||
}
|
||||
|
||||
focusInstall(item) {
|
||||
const cellNode = this.grid.getCellNode(item, "installed");
|
||||
if (cellNode) {
|
||||
const cellBtn = cellNode.querySelector(`button[mode="install"]`);
|
||||
if (cellBtn) {
|
||||
cellBtn.classList.add("cmm-btn-loading");
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async installModels(list, btn) {
|
||||
let stats = await api.fetchApi('/v2/manager/queue/status');
|
||||
|
||||
stats = await stats.json();
|
||||
if(stats.is_processing) {
|
||||
customAlert(`[ComfyUI-Manager] There are already tasks in progress. Please try again after it is completed. (${stats.done_count}/${stats.total_count})`);
|
||||
return;
|
||||
}
|
||||
|
||||
btn.classList.add("cmm-btn-loading");
|
||||
this.showError("");
|
||||
|
||||
let needRefresh = false;
|
||||
let errorMsg = "";
|
||||
|
||||
await api.fetchApi('/v2/manager/queue/reset');
|
||||
|
||||
let target_items = [];
|
||||
|
||||
for (const item of list) {
|
||||
this.grid.scrollRowIntoView(item);
|
||||
target_items.push(item);
|
||||
|
||||
if (!this.focusInstall(item)) {
|
||||
this.grid.onNextUpdated(() => {
|
||||
this.focusInstall(item);
|
||||
});
|
||||
}
|
||||
|
||||
this.showStatus(`Install ${item.name} ...`);
|
||||
|
||||
const data = item.originalData;
|
||||
data.ui_id = item.hash;
|
||||
|
||||
const res = await api.fetchApi(`/v2/manager/queue/install_model`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (res.status != 200) {
|
||||
errorMsg = `'${item.name}': `;
|
||||
|
||||
if(res.status == 403) {
|
||||
errorMsg += `This action is not allowed with this security level configuration.\n`;
|
||||
} else {
|
||||
errorMsg += await res.text() + '\n';
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.install_context = {btn: btn, targets: target_items};
|
||||
|
||||
if(errorMsg) {
|
||||
this.showError(errorMsg);
|
||||
show_message("[Installation Errors]\n"+errorMsg);
|
||||
|
||||
// reset
|
||||
for(let k in target_items) {
|
||||
const item = target_items[k];
|
||||
this.grid.updateCell(item, "installed");
|
||||
}
|
||||
}
|
||||
else {
|
||||
await api.fetchApi('/v2/manager/queue/start');
|
||||
this.showStop();
|
||||
showTerminal();
|
||||
}
|
||||
}
|
||||
|
||||
async onQueueStatus(event) {
|
||||
let self = ModelManager.instance;
|
||||
|
||||
if(event.detail.status == 'in_progress' && event.detail.ui_target == 'model_manager') {
|
||||
const hash = event.detail.target;
|
||||
|
||||
const item = self.grid.getRowItemBy("hash", hash);
|
||||
|
||||
item.refresh = true;
|
||||
self.grid.setRowSelected(item, false);
|
||||
item.selectable = false;
|
||||
// self.grid.updateCell(item, "tg-column-select");
|
||||
self.grid.updateRow(item);
|
||||
}
|
||||
else if(event.detail.status == 'done') {
|
||||
self.hideStop();
|
||||
self.onQueueCompleted(event.detail);
|
||||
}
|
||||
}
|
||||
|
||||
async onQueueCompleted(info) {
|
||||
let result = info.model_result;
|
||||
|
||||
if(result.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let self = ModelManager.instance;
|
||||
|
||||
if(!self.install_context) {
|
||||
return;
|
||||
}
|
||||
|
||||
let btn = self.install_context.btn;
|
||||
|
||||
self.hideLoading();
|
||||
btn.classList.remove("cmm-btn-loading");
|
||||
|
||||
let errorMsg = "";
|
||||
|
||||
for(let hash in result){
|
||||
let v = result[hash];
|
||||
|
||||
if(v != 'success')
|
||||
errorMsg += v + '\n';
|
||||
}
|
||||
|
||||
for(let k in self.install_context.targets) {
|
||||
let item = self.install_context.targets[k];
|
||||
self.grid.updateCell(item, "installed");
|
||||
}
|
||||
|
||||
if (errorMsg) {
|
||||
self.showError(errorMsg);
|
||||
show_message("Installation Error:\n"+errorMsg);
|
||||
} else {
|
||||
self.showStatus(`Install ${result.length} models successfully`);
|
||||
}
|
||||
|
||||
self.showRefresh();
|
||||
self.showMessage(`To apply the installed model, please click the 'Refresh' button.`, "red")
|
||||
|
||||
infoToast('Tasks done', `[ComfyUI-Manager] All model downloading tasks in the queue have been completed.\n${info.done_count}/${info.total_count}`);
|
||||
self.install_context = undefined;
|
||||
}
|
||||
|
||||
getModelList(models) {
|
||||
const typeMap = new Map();
|
||||
const baseMap = new Map();
|
||||
|
||||
models.forEach((item, i) => {
|
||||
const { type, base, name, reference, installed } = item;
|
||||
item.originalData = JSON.parse(JSON.stringify(item));
|
||||
item.size = this.sizeToBytes(item.size);
|
||||
item.hash = md5(name + reference);
|
||||
item.id = i + 1;
|
||||
|
||||
if (installed === "True") {
|
||||
item.selectable = false;
|
||||
}
|
||||
|
||||
typeMap.set(type, type);
|
||||
baseMap.set(base, base);
|
||||
|
||||
});
|
||||
|
||||
const typeList = [];
|
||||
typeMap.forEach(type => {
|
||||
typeList.push({
|
||||
label: type,
|
||||
value: type
|
||||
});
|
||||
});
|
||||
typeList.sort((a,b)=> {
|
||||
const au = a.label.toUpperCase();
|
||||
const bu = b.label.toUpperCase();
|
||||
if (au !== bu) {
|
||||
return au > bu ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
this.typeList = [{
|
||||
label: "All",
|
||||
value: ""
|
||||
}].concat(typeList);
|
||||
|
||||
|
||||
const baseList = [];
|
||||
baseMap.forEach(base => {
|
||||
baseList.push({
|
||||
label: base,
|
||||
value: base
|
||||
});
|
||||
});
|
||||
baseList.sort((a,b)=> {
|
||||
const au = a.label.toUpperCase();
|
||||
const bu = b.label.toUpperCase();
|
||||
if (au !== bu) {
|
||||
return au > bu ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
this.baseList = [{
|
||||
label: "All",
|
||||
value: ""
|
||||
}].concat(baseList);
|
||||
|
||||
return models;
|
||||
}
|
||||
|
||||
// ===========================================================================================
|
||||
|
||||
async loadData() {
|
||||
|
||||
this.showLoading();
|
||||
|
||||
this.showStatus(`Loading external model list ...`);
|
||||
|
||||
const mode = manager_instance.datasrc_combo.value;
|
||||
|
||||
const res = await fetchData(`/v2/externalmodel/getlist?mode=${mode}`);
|
||||
if (res.error) {
|
||||
this.showError("Failed to get external model list.");
|
||||
this.hideLoading();
|
||||
return
|
||||
}
|
||||
|
||||
const { models } = res.data;
|
||||
|
||||
this.modelList = this.getModelList(models);
|
||||
// console.log("models", this.modelList);
|
||||
|
||||
this.updateFilter();
|
||||
|
||||
this.renderGrid();
|
||||
|
||||
this.hideLoading();
|
||||
|
||||
}
|
||||
|
||||
// ===========================================================================================
|
||||
|
||||
formatSize(v) {
|
||||
const base = 1000;
|
||||
const units = ['', 'K', 'M', 'G', 'T', 'P'];
|
||||
const space = '';
|
||||
const postfix = 'B';
|
||||
if (v <= 0) {
|
||||
return `0${space}${postfix}`;
|
||||
}
|
||||
for (let i = 0, l = units.length; i < l; i++) {
|
||||
const min = Math.pow(base, i);
|
||||
const max = Math.pow(base, i + 1);
|
||||
if (v > min && v <= max) {
|
||||
const unit = units[i];
|
||||
if (unit) {
|
||||
const n = v / min;
|
||||
const nl = n.toString().split('.')[0].length;
|
||||
const fl = Math.max(3 - nl, 1);
|
||||
v = n.toFixed(fl);
|
||||
}
|
||||
v = v + space + unit + postfix;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
// for size sort
|
||||
sizeToBytes(v) {
|
||||
if (typeof v === "number") {
|
||||
return v;
|
||||
}
|
||||
if (typeof v === "string") {
|
||||
const n = parseFloat(v);
|
||||
const unit = v.replace(/[0-9.B]+/g, "").trim().toUpperCase();
|
||||
if (unit === "K") {
|
||||
return n * 1000;
|
||||
}
|
||||
if (unit === "M") {
|
||||
return n * 1000 * 1000;
|
||||
}
|
||||
if (unit === "G") {
|
||||
return n * 1000 * 1000 * 1000;
|
||||
}
|
||||
if (unit === "T") {
|
||||
return n * 1000 * 1000 * 1000 * 1000;
|
||||
}
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
showSelection(msg) {
|
||||
this.element.querySelector(".cmm-manager-selection").innerHTML = msg;
|
||||
}
|
||||
|
||||
showError(err) {
|
||||
this.showMessage(err, "red");
|
||||
}
|
||||
|
||||
showMessage(msg, color) {
|
||||
if (color) {
|
||||
msg = `<font color="${color}">${msg}</font>`;
|
||||
}
|
||||
this.element.querySelector(".cmm-manager-message").innerHTML = msg;
|
||||
}
|
||||
|
||||
showStatus(msg, color) {
|
||||
if (color) {
|
||||
msg = `<font color="${color}">${msg}</font>`;
|
||||
}
|
||||
this.element.querySelector(".cmm-manager-status").innerHTML = msg;
|
||||
}
|
||||
|
||||
showLoading() {
|
||||
// this.setDisabled(true);
|
||||
if (this.grid) {
|
||||
this.grid.showLoading();
|
||||
this.grid.showMask({
|
||||
opacity: 0.05
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
hideLoading() {
|
||||
// this.setDisabled(false);
|
||||
if (this.grid) {
|
||||
this.grid.hideLoading();
|
||||
this.grid.hideMask();
|
||||
}
|
||||
}
|
||||
|
||||
setDisabled(disabled) {
|
||||
const $close = this.element.querySelector(".cmm-manager-close");
|
||||
const $refresh = this.element.querySelector(".cmm-manager-refresh");
|
||||
const $stop = this.element.querySelector(".cmm-manager-stop");
|
||||
|
||||
const list = [
|
||||
".cmm-manager-header input",
|
||||
".cmm-manager-header select",
|
||||
".cmm-manager-footer button",
|
||||
".cmm-manager-selection button"
|
||||
].map(s => {
|
||||
return Array.from(this.element.querySelectorAll(s));
|
||||
})
|
||||
.flat()
|
||||
.filter(it => {
|
||||
return it !== $close && it !== $refresh && it !== $stop;
|
||||
});
|
||||
|
||||
list.forEach($elem => {
|
||||
if (disabled) {
|
||||
$elem.setAttribute("disabled", "disabled");
|
||||
} else {
|
||||
$elem.removeAttribute("disabled");
|
||||
}
|
||||
});
|
||||
|
||||
Array.from(this.element.querySelectorAll(".cmm-btn-loading")).forEach($elem => {
|
||||
$elem.classList.remove("cmm-btn-loading");
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
showRefresh() {
|
||||
this.element.querySelector(".cmm-manager-refresh").style.display = "block";
|
||||
}
|
||||
|
||||
showStop() {
|
||||
this.element.querySelector(".cmm-manager-stop").style.display = "block";
|
||||
}
|
||||
|
||||
hideStop() {
|
||||
this.element.querySelector(".cmm-manager-stop").style.display = "none";
|
||||
}
|
||||
|
||||
setKeywords(keywords = "") {
|
||||
this.keywords = keywords;
|
||||
this.element.querySelector(".cmm-manager-keywords").value = keywords;
|
||||
}
|
||||
|
||||
show() {
|
||||
this.element.style.display = "flex";
|
||||
this.setKeywords("");
|
||||
this.showSelection("");
|
||||
this.showMessage("");
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
close() {
|
||||
this.element.style.display = "none";
|
||||
}
|
||||
}
|
||||
160
comfyui_manager/js/node_fixer.js
Normal file
160
comfyui_manager/js/node_fixer.js
Normal file
@@ -0,0 +1,160 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { api } from "../../scripts/api.js";
|
||||
|
||||
function addMenuHandler(nodeType, cb) {
|
||||
const getOpts = nodeType.prototype.getExtraMenuOptions;
|
||||
nodeType.prototype.getExtraMenuOptions = function () {
|
||||
const r = getOpts.apply(this, arguments);
|
||||
cb.apply(this, arguments);
|
||||
return r;
|
||||
};
|
||||
}
|
||||
|
||||
function distance(node1, node2) {
|
||||
let dx = (node1.pos[0] + node1.size[0]/2) - (node2.pos[0] + node2.size[0]/2);
|
||||
let dy = (node1.pos[1] + node1.size[1]/2) - (node2.pos[1] + node2.size[1]/2);
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
function lookup_nearest_nodes(node) {
|
||||
let nearest_distance = Infinity;
|
||||
let nearest_node = null;
|
||||
for(let other of app.graph._nodes) {
|
||||
if(other === node)
|
||||
continue;
|
||||
|
||||
let dist = distance(node, other);
|
||||
if (dist < nearest_distance && dist < 1000) {
|
||||
nearest_distance = dist;
|
||||
nearest_node = other;
|
||||
}
|
||||
}
|
||||
|
||||
return nearest_node;
|
||||
}
|
||||
|
||||
function lookup_nearest_inputs(node) {
|
||||
let input_map = {};
|
||||
|
||||
for(let i in node.inputs) {
|
||||
let input = node.inputs[i];
|
||||
|
||||
if(input.link || input_map[input.type])
|
||||
continue;
|
||||
|
||||
input_map[input.type] = {distance: Infinity, input_name: input.name, node: null, slot: null};
|
||||
}
|
||||
|
||||
let x = node.pos[0];
|
||||
let y = node.pos[1] + node.size[1]/2;
|
||||
|
||||
for(let other of app.graph._nodes) {
|
||||
if(other === node || !other.outputs)
|
||||
continue;
|
||||
|
||||
let dx = x - (other.pos[0] + other.size[0]);
|
||||
let dy = y - (other.pos[1] + other.size[1]/2);
|
||||
|
||||
if(dx < 0)
|
||||
continue;
|
||||
|
||||
let dist = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
for(let input_type in input_map) {
|
||||
for(let j in other.outputs) {
|
||||
let output = other.outputs[j];
|
||||
if(output.type == input_type) {
|
||||
if(input_map[input_type].distance > dist) {
|
||||
input_map[input_type].distance = dist;
|
||||
input_map[input_type].node = other;
|
||||
input_map[input_type].slot = parseInt(j);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let res = {};
|
||||
for (let i in input_map) {
|
||||
if (input_map[i].node) {
|
||||
res[i] = input_map[i];
|
||||
}
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
function connect_inputs(nearest_inputs, node) {
|
||||
for(let i in nearest_inputs) {
|
||||
let info = nearest_inputs[i];
|
||||
info.node.connect(info.slot, node.id, info.input_name);
|
||||
}
|
||||
}
|
||||
|
||||
function node_info_copy(src, dest, connect_both, copy_shape) {
|
||||
// copy input connections
|
||||
for(let i in src.inputs) {
|
||||
let input = src.inputs[i];
|
||||
if (input.widget !== undefined) {
|
||||
const destWidget = dest.widgets.find(x => x.name === input.widget.name);
|
||||
dest.convertWidgetToInput(destWidget);
|
||||
}
|
||||
if(input.link) {
|
||||
let link = app.graph.links[input.link];
|
||||
let src_node = app.graph.getNodeById(link.origin_id);
|
||||
src_node.connect(link.origin_slot, dest.id, input.name);
|
||||
}
|
||||
}
|
||||
|
||||
// copy output connections
|
||||
if(connect_both) {
|
||||
let output_links = {};
|
||||
for(let i in src.outputs) {
|
||||
let output = src.outputs[i];
|
||||
if(output.links) {
|
||||
let links = [];
|
||||
for(let j in output.links) {
|
||||
links.push(app.graph.links[output.links[j]]);
|
||||
}
|
||||
output_links[output.name] = links;
|
||||
}
|
||||
}
|
||||
|
||||
for(let i in dest.outputs) {
|
||||
let links = output_links[dest.outputs[i].name];
|
||||
if(links) {
|
||||
for(let j in links) {
|
||||
let link = links[j];
|
||||
let target_node = app.graph.getNodeById(link.target_id);
|
||||
dest.connect(parseInt(i), target_node, link.target_slot);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(copy_shape) {
|
||||
dest.color = src.color;
|
||||
dest.bgcolor = src.bgcolor;
|
||||
dest.size = max(src.size, dest.size);
|
||||
}
|
||||
|
||||
app.graph.afterChange();
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "Comfy.Legacy.Manager.NodeFixer",
|
||||
beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
addMenuHandler(nodeType, function (_, options) {
|
||||
options.push({
|
||||
content: "Fix node (recreate)",
|
||||
callback: () => {
|
||||
let new_node = LiteGraph.createNode(nodeType.comfyClass);
|
||||
new_node.pos = [this.pos[0], this.pos[1]];
|
||||
app.canvas.graph.add(new_node, false);
|
||||
node_info_copy(this, new_node, true);
|
||||
app.canvas.graph.remove(this);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
619
comfyui_manager/js/popover-helper.js
Normal file
619
comfyui_manager/js/popover-helper.js
Normal file
@@ -0,0 +1,619 @@
|
||||
const hasOwn = function(obj, key) {
|
||||
return Object.prototype.hasOwnProperty.call(obj, key);
|
||||
};
|
||||
|
||||
const isNum = function(num) {
|
||||
if (typeof num !== 'number' || isNaN(num)) {
|
||||
return false;
|
||||
}
|
||||
const isInvalid = function(n) {
|
||||
if (n === Number.MAX_VALUE || n === Number.MIN_VALUE || n === Number.NEGATIVE_INFINITY || n === Number.POSITIVE_INFINITY) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
if (isInvalid(num)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const toNum = (num) => {
|
||||
if (typeof (num) !== 'number') {
|
||||
num = parseFloat(num);
|
||||
}
|
||||
if (isNaN(num)) {
|
||||
num = 0;
|
||||
}
|
||||
num = Math.round(num);
|
||||
return num;
|
||||
};
|
||||
|
||||
const clamp = function(value, min, max) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
};
|
||||
|
||||
const isWindow = (obj) => {
|
||||
return Boolean(obj && obj === obj.window);
|
||||
};
|
||||
|
||||
const isDocument = (obj) => {
|
||||
return Boolean(obj && obj.nodeType === 9);
|
||||
};
|
||||
|
||||
const isElement = (obj) => {
|
||||
return Boolean(obj && obj.nodeType === 1);
|
||||
};
|
||||
|
||||
// ===========================================================================================
|
||||
|
||||
export const toRect = (obj) => {
|
||||
if (obj) {
|
||||
return {
|
||||
left: toNum(obj.left || obj.x),
|
||||
top: toNum(obj.top || obj.y),
|
||||
width: toNum(obj.width),
|
||||
height: toNum(obj.height)
|
||||
};
|
||||
}
|
||||
return {
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 0,
|
||||
height: 0
|
||||
};
|
||||
};
|
||||
|
||||
export const getElement = (selector) => {
|
||||
if (typeof selector === 'string' && selector) {
|
||||
if (selector.startsWith('#')) {
|
||||
return document.getElementById(selector.slice(1));
|
||||
}
|
||||
return document.querySelector(selector);
|
||||
}
|
||||
|
||||
if (isDocument(selector)) {
|
||||
return selector.body;
|
||||
}
|
||||
if (isElement(selector)) {
|
||||
return selector;
|
||||
}
|
||||
};
|
||||
|
||||
export const getRect = (target, fixed) => {
|
||||
if (!target) {
|
||||
return toRect();
|
||||
}
|
||||
|
||||
if (isWindow(target)) {
|
||||
return {
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight
|
||||
};
|
||||
}
|
||||
|
||||
const elem = getElement(target);
|
||||
if (!elem) {
|
||||
return toRect(target);
|
||||
}
|
||||
|
||||
const br = elem.getBoundingClientRect();
|
||||
const rect = toRect(br);
|
||||
|
||||
// fix offset
|
||||
if (!fixed) {
|
||||
rect.left += window.scrollX;
|
||||
rect.top += window.scrollY;
|
||||
}
|
||||
|
||||
rect.width = elem.offsetWidth;
|
||||
rect.height = elem.offsetHeight;
|
||||
|
||||
return rect;
|
||||
};
|
||||
|
||||
// ===========================================================================================
|
||||
|
||||
const calculators = {
|
||||
|
||||
bottom: (info, containerRect, targetRect) => {
|
||||
info.space = containerRect.top + containerRect.height - targetRect.top - targetRect.height - info.height;
|
||||
info.top = targetRect.top + targetRect.height;
|
||||
info.left = Math.round(targetRect.left + targetRect.width * 0.5 - info.width * 0.5);
|
||||
},
|
||||
|
||||
top: (info, containerRect, targetRect) => {
|
||||
info.space = targetRect.top - info.height - containerRect.top;
|
||||
info.top = targetRect.top - info.height;
|
||||
info.left = Math.round(targetRect.left + targetRect.width * 0.5 - info.width * 0.5);
|
||||
},
|
||||
|
||||
right: (info, containerRect, targetRect) => {
|
||||
info.space = containerRect.left + containerRect.width - targetRect.left - targetRect.width - info.width;
|
||||
info.top = Math.round(targetRect.top + targetRect.height * 0.5 - info.height * 0.5);
|
||||
info.left = targetRect.left + targetRect.width;
|
||||
},
|
||||
|
||||
left: (info, containerRect, targetRect) => {
|
||||
info.space = targetRect.left - info.width - containerRect.left;
|
||||
info.top = Math.round(targetRect.top + targetRect.height * 0.5 - info.height * 0.5);
|
||||
info.left = targetRect.left - info.width;
|
||||
}
|
||||
};
|
||||
|
||||
// with order
|
||||
export const getDefaultPositions = () => {
|
||||
return Object.keys(calculators);
|
||||
};
|
||||
|
||||
const calculateSpace = (info, containerRect, targetRect) => {
|
||||
const calculator = calculators[info.position];
|
||||
calculator(info, containerRect, targetRect);
|
||||
if (info.space >= 0) {
|
||||
info.passed += 1;
|
||||
}
|
||||
};
|
||||
|
||||
// ===========================================================================================
|
||||
|
||||
const calculateAlignOffset = (info, containerRect, targetRect, alignType, sizeType) => {
|
||||
|
||||
const popoverStart = info[alignType];
|
||||
const popoverSize = info[sizeType];
|
||||
|
||||
const containerStart = containerRect[alignType];
|
||||
const containerSize = containerRect[sizeType];
|
||||
|
||||
const targetStart = targetRect[alignType];
|
||||
const targetSize = targetRect[sizeType];
|
||||
|
||||
const targetCenter = targetStart + targetSize * 0.5;
|
||||
|
||||
// size overflow
|
||||
if (popoverSize > containerSize) {
|
||||
const overflow = (popoverSize - containerSize) * 0.5;
|
||||
info[alignType] = containerStart - overflow;
|
||||
info.offset = targetCenter - containerStart + overflow;
|
||||
return;
|
||||
}
|
||||
|
||||
const space1 = popoverStart - containerStart;
|
||||
const space2 = (containerStart + containerSize) - (popoverStart + popoverSize);
|
||||
|
||||
// both side passed, default to center
|
||||
if (space1 >= 0 && space2 >= 0) {
|
||||
if (info.passed) {
|
||||
info.passed += 2;
|
||||
}
|
||||
info.offset = popoverSize * 0.5;
|
||||
return;
|
||||
}
|
||||
|
||||
// one side passed
|
||||
if (info.passed) {
|
||||
info.passed += 1;
|
||||
}
|
||||
|
||||
if (space1 < 0) {
|
||||
const min = containerStart;
|
||||
info[alignType] = min;
|
||||
info.offset = targetCenter - min;
|
||||
return;
|
||||
}
|
||||
|
||||
// space2 < 0
|
||||
const max = containerStart + containerSize - popoverSize;
|
||||
info[alignType] = max;
|
||||
info.offset = targetCenter - max;
|
||||
|
||||
};
|
||||
|
||||
const calculateHV = (info, containerRect) => {
|
||||
if (['top', 'bottom'].includes(info.position)) {
|
||||
info.top = clamp(info.top, containerRect.top, containerRect.top + containerRect.height - info.height);
|
||||
return ['left', 'width'];
|
||||
}
|
||||
info.left = clamp(info.left, containerRect.left, containerRect.left + containerRect.width - info.width);
|
||||
return ['top', 'height'];
|
||||
};
|
||||
|
||||
const calculateOffset = (info, containerRect, targetRect) => {
|
||||
|
||||
const [alignType, sizeType] = calculateHV(info, containerRect);
|
||||
|
||||
calculateAlignOffset(info, containerRect, targetRect, alignType, sizeType);
|
||||
|
||||
info.offset = clamp(info.offset, 0, info[sizeType]);
|
||||
|
||||
};
|
||||
|
||||
// ===========================================================================================
|
||||
|
||||
const calculateDistance = (info, previousPositionInfo) => {
|
||||
if (!previousPositionInfo) {
|
||||
return;
|
||||
}
|
||||
// no change if position no change with previous
|
||||
if (info.position === previousPositionInfo.position) {
|
||||
return;
|
||||
}
|
||||
const ax = info.left + info.width * 0.5;
|
||||
const ay = info.top + info.height * 0.5;
|
||||
const bx = previousPositionInfo.left + previousPositionInfo.width * 0.5;
|
||||
const by = previousPositionInfo.top + previousPositionInfo.height * 0.5;
|
||||
const dx = Math.abs(ax - bx);
|
||||
const dy = Math.abs(ay - by);
|
||||
info.distance = Math.round(Math.sqrt(dx * dx + dy * dy));
|
||||
};
|
||||
|
||||
// ===========================================================================================
|
||||
|
||||
const calculatePositionInfo = (info, containerRect, targetRect, previousPositionInfo) => {
|
||||
calculateSpace(info, containerRect, targetRect);
|
||||
calculateOffset(info, containerRect, targetRect);
|
||||
calculateDistance(info, previousPositionInfo);
|
||||
};
|
||||
|
||||
// ===========================================================================================
|
||||
|
||||
const calculateBestPosition = (containerRect, targetRect, infoMap, withOrder, previousPositionInfo) => {
|
||||
|
||||
// position space: +1
|
||||
// align space:
|
||||
// two side passed: +2
|
||||
// one side passed: +1
|
||||
|
||||
const safePassed = 3;
|
||||
|
||||
if (previousPositionInfo) {
|
||||
const prevInfo = infoMap[previousPositionInfo.position];
|
||||
if (prevInfo) {
|
||||
calculatePositionInfo(prevInfo, containerRect, targetRect);
|
||||
if (prevInfo.passed >= safePassed) {
|
||||
return prevInfo;
|
||||
}
|
||||
prevInfo.calculated = true;
|
||||
}
|
||||
}
|
||||
|
||||
const positionList = [];
|
||||
Object.values(infoMap).forEach((info) => {
|
||||
if (!info.calculated) {
|
||||
calculatePositionInfo(info, containerRect, targetRect, previousPositionInfo);
|
||||
}
|
||||
positionList.push(info);
|
||||
});
|
||||
|
||||
positionList.sort((a, b) => {
|
||||
if (a.passed !== b.passed) {
|
||||
return b.passed - a.passed;
|
||||
}
|
||||
|
||||
if (withOrder && a.passed >= safePassed && b.passed >= safePassed) {
|
||||
return a.index - b.index;
|
||||
}
|
||||
|
||||
if (a.space !== b.space) {
|
||||
return b.space - a.space;
|
||||
}
|
||||
|
||||
return a.index - b.index;
|
||||
});
|
||||
|
||||
// logTable(positionList);
|
||||
|
||||
return positionList[0];
|
||||
};
|
||||
|
||||
// const logTable = (() => {
|
||||
// let time_id;
|
||||
// return (info) => {
|
||||
// clearTimeout(time_id);
|
||||
// time_id = setTimeout(() => {
|
||||
// console.table(info);
|
||||
// }, 10);
|
||||
// };
|
||||
// })();
|
||||
|
||||
// ===========================================================================================
|
||||
|
||||
const getAllowPositions = (positions, defaultAllowPositions) => {
|
||||
if (!positions) {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(positions)) {
|
||||
positions = positions.join(',');
|
||||
}
|
||||
positions = String(positions).split(',').map((it) => it.trim().toLowerCase()).filter((it) => it);
|
||||
positions = positions.filter((it) => defaultAllowPositions.includes(it));
|
||||
if (!positions.length) {
|
||||
return;
|
||||
}
|
||||
return positions;
|
||||
};
|
||||
|
||||
const isPositionChanged = (info, previousPositionInfo) => {
|
||||
if (!previousPositionInfo) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (info.left !== previousPositionInfo.left) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (info.top !== previousPositionInfo.top) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// ===========================================================================================
|
||||
|
||||
// const log = (name, time) => {
|
||||
// if (time > 0.1) {
|
||||
// console.log(name, time);
|
||||
// }
|
||||
// };
|
||||
|
||||
export const getBestPosition = (containerRect, targetRect, popoverRect, positions, previousPositionInfo) => {
|
||||
|
||||
const defaultAllowPositions = getDefaultPositions();
|
||||
let withOrder = true;
|
||||
let allowPositions = getAllowPositions(positions, defaultAllowPositions);
|
||||
if (!allowPositions) {
|
||||
allowPositions = defaultAllowPositions;
|
||||
withOrder = false;
|
||||
}
|
||||
|
||||
// console.log('withOrder', withOrder);
|
||||
|
||||
// const start_time = performance.now();
|
||||
|
||||
const infoMap = {};
|
||||
allowPositions.forEach((k, i) => {
|
||||
infoMap[k] = {
|
||||
position: k,
|
||||
index: i,
|
||||
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: popoverRect.width,
|
||||
height: popoverRect.height,
|
||||
|
||||
space: 0,
|
||||
|
||||
offset: 0,
|
||||
passed: 0,
|
||||
|
||||
distance: 0
|
||||
};
|
||||
});
|
||||
|
||||
// log('infoMap', performance.now() - start_time);
|
||||
|
||||
|
||||
const bestPosition = calculateBestPosition(containerRect, targetRect, infoMap, withOrder, previousPositionInfo);
|
||||
|
||||
// check left/top
|
||||
bestPosition.changed = isPositionChanged(bestPosition, previousPositionInfo);
|
||||
|
||||
return bestPosition;
|
||||
};
|
||||
|
||||
// ===========================================================================================
|
||||
|
||||
const getTemplatePath = (width, height, arrowOffset, arrowSize, borderRadius) => {
|
||||
const p = (px, py) => {
|
||||
return [px, py].join(',');
|
||||
};
|
||||
|
||||
const px = function(num, alignEnd) {
|
||||
const floor = Math.floor(num);
|
||||
let n = num < floor + 0.5 ? floor + 0.5 : floor + 1.5;
|
||||
if (alignEnd) {
|
||||
n -= 1;
|
||||
}
|
||||
return n;
|
||||
};
|
||||
|
||||
const pxe = function(num) {
|
||||
return px(num, true);
|
||||
};
|
||||
|
||||
const ls = [];
|
||||
|
||||
const innerLeft = px(arrowSize);
|
||||
const innerRight = pxe(width - arrowSize);
|
||||
arrowOffset = clamp(arrowOffset, innerLeft, innerRight);
|
||||
|
||||
const innerTop = px(arrowSize);
|
||||
const innerBottom = pxe(height - arrowSize);
|
||||
|
||||
const startPoint = p(innerLeft, innerTop + borderRadius);
|
||||
const arrowPoint = p(arrowOffset, 1);
|
||||
|
||||
const LT = p(innerLeft, innerTop);
|
||||
const RT = p(innerRight, innerTop);
|
||||
|
||||
const AOT = p(arrowOffset - arrowSize, innerTop);
|
||||
const RRT = p(innerRight - borderRadius, innerTop);
|
||||
|
||||
ls.push(`M${startPoint}`);
|
||||
ls.push(`V${innerBottom - borderRadius}`);
|
||||
ls.push(`Q${p(innerLeft, innerBottom)} ${p(innerLeft + borderRadius, innerBottom)}`);
|
||||
ls.push(`H${innerRight - borderRadius}`);
|
||||
ls.push(`Q${p(innerRight, innerBottom)} ${p(innerRight, innerBottom - borderRadius)}`);
|
||||
ls.push(`V${innerTop + borderRadius}`);
|
||||
|
||||
if (arrowOffset < innerLeft + arrowSize + borderRadius) {
|
||||
ls.push(`Q${RT} ${RRT}`);
|
||||
ls.push(`H${arrowOffset + arrowSize}`);
|
||||
ls.push(`L${arrowPoint}`);
|
||||
if (arrowOffset < innerLeft + arrowSize) {
|
||||
ls.push(`L${LT}`);
|
||||
ls.push(`L${startPoint}`);
|
||||
} else {
|
||||
ls.push(`L${AOT}`);
|
||||
ls.push(`Q${LT} ${startPoint}`);
|
||||
}
|
||||
} else if (arrowOffset > innerRight - arrowSize - borderRadius) {
|
||||
if (arrowOffset > innerRight - arrowSize) {
|
||||
ls.push(`L${RT}`);
|
||||
} else {
|
||||
ls.push(`Q${RT} ${p(arrowOffset + arrowSize, innerTop)}`);
|
||||
}
|
||||
ls.push(`L${arrowPoint}`);
|
||||
ls.push(`L${AOT}`);
|
||||
ls.push(`H${innerLeft + borderRadius}`);
|
||||
ls.push(`Q${LT} ${startPoint}`);
|
||||
} else {
|
||||
ls.push(`Q${RT} ${RRT}`);
|
||||
ls.push(`H${arrowOffset + arrowSize}`);
|
||||
ls.push(`L${arrowPoint}`);
|
||||
ls.push(`L${AOT}`);
|
||||
ls.push(`H${innerLeft + borderRadius}`);
|
||||
ls.push(`Q${LT} ${startPoint}`);
|
||||
}
|
||||
return ls.join('');
|
||||
};
|
||||
|
||||
const getPathData = function(position, width, height, arrowOffset, arrowSize, borderRadius) {
|
||||
|
||||
const handlers = {
|
||||
|
||||
bottom: () => {
|
||||
const d = getTemplatePath(width, height, arrowOffset, arrowSize, borderRadius);
|
||||
return {
|
||||
d,
|
||||
transform: ''
|
||||
};
|
||||
},
|
||||
|
||||
top: () => {
|
||||
const d = getTemplatePath(width, height, width - arrowOffset, arrowSize, borderRadius);
|
||||
return {
|
||||
d,
|
||||
transform: `rotate(180,${width * 0.5},${height * 0.5})`
|
||||
};
|
||||
},
|
||||
|
||||
left: () => {
|
||||
const d = getTemplatePath(height, width, arrowOffset, arrowSize, borderRadius);
|
||||
const x = (width - height) * 0.5;
|
||||
const y = (height - width) * 0.5;
|
||||
return {
|
||||
d,
|
||||
transform: `translate(${x} ${y}) rotate(90,${height * 0.5},${width * 0.5})`
|
||||
};
|
||||
},
|
||||
|
||||
right: () => {
|
||||
const d = getTemplatePath(height, width, height - arrowOffset, arrowSize, borderRadius);
|
||||
const x = (width - height) * 0.5;
|
||||
const y = (height - width) * 0.5;
|
||||
return {
|
||||
d,
|
||||
transform: `translate(${x} ${y}) rotate(-90,${height * 0.5},${width * 0.5})`
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return handlers[position]();
|
||||
};
|
||||
|
||||
// ===========================================================================================
|
||||
|
||||
// position style cache
|
||||
const styleCache = {
|
||||
// position: '',
|
||||
// top: {},
|
||||
// bottom: {},
|
||||
// left: {},
|
||||
// right: {}
|
||||
};
|
||||
|
||||
export const getPositionStyle = (info, options = {}) => {
|
||||
|
||||
const o = {
|
||||
bgColor: '#fff',
|
||||
borderColor: '#ccc',
|
||||
borderRadius: 5,
|
||||
arrowSize: 10
|
||||
};
|
||||
Object.keys(o).forEach((k) => {
|
||||
|
||||
if (hasOwn(options, k)) {
|
||||
const d = o[k];
|
||||
const v = options[k];
|
||||
|
||||
if (typeof d === 'string') {
|
||||
// string
|
||||
if (typeof v === 'string' && v) {
|
||||
o[k] = v;
|
||||
}
|
||||
} else {
|
||||
// number
|
||||
if (isNum(v) && v >= 0) {
|
||||
o[k] = v;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
const key = [
|
||||
info.width,
|
||||
info.height,
|
||||
info.offset,
|
||||
o.arrowSize,
|
||||
o.borderRadius,
|
||||
o.bgColor,
|
||||
o.borderColor
|
||||
].join('-');
|
||||
|
||||
const positionCache = styleCache[info.position];
|
||||
if (positionCache && key === positionCache.key) {
|
||||
const st = positionCache.style;
|
||||
st.changed = styleCache.position !== info.position;
|
||||
styleCache.position = info.position;
|
||||
return st;
|
||||
}
|
||||
|
||||
// console.log(options);
|
||||
|
||||
const data = getPathData(info.position, info.width, info.height, info.offset, o.arrowSize, o.borderRadius);
|
||||
// console.log(data);
|
||||
|
||||
const viewBox = [0, 0, info.width, info.height].join(' ');
|
||||
const svg = [
|
||||
`<svg viewBox="${viewBox}" xmlns="http://www.w3.org/2000/svg">`,
|
||||
`<path d="${data.d}" fill="${o.bgColor}" stroke="${o.borderColor}" transform="${data.transform}" />`,
|
||||
'</svg>'
|
||||
].join('');
|
||||
|
||||
// console.log(svg);
|
||||
const backgroundImage = `url("data:image/svg+xml;charset=utf8,${encodeURIComponent(svg)}")`;
|
||||
|
||||
const background = `${backgroundImage} center no-repeat`;
|
||||
|
||||
const padding = `${o.arrowSize + o.borderRadius}px`;
|
||||
|
||||
const style = {
|
||||
background,
|
||||
backgroundImage,
|
||||
padding,
|
||||
changed: true
|
||||
};
|
||||
|
||||
styleCache.position = info.position;
|
||||
styleCache[info.position] = {
|
||||
key,
|
||||
style
|
||||
};
|
||||
|
||||
return style;
|
||||
};
|
||||
300
comfyui_manager/js/snapshot.js
Normal file
300
comfyui_manager/js/snapshot.js
Normal file
@@ -0,0 +1,300 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { api } from "../../scripts/api.js"
|
||||
import { ComfyDialog, $el } from "../../scripts/ui.js";
|
||||
import { manager_instance, rebootAPI, show_message } from "./common.js";
|
||||
|
||||
|
||||
async function restore_snapshot(target) {
|
||||
if(SnapshotManager.instance) {
|
||||
try {
|
||||
const response = await api.fetchApi(`/v2/snapshot/restore?target=${target}`, { cache: "no-store" });
|
||||
|
||||
if(response.status == 403) {
|
||||
show_message('This action is not allowed with this security level configuration.');
|
||||
return false;
|
||||
}
|
||||
|
||||
if(response.status == 400) {
|
||||
show_message(`Restore snapshot failed: ${target.title} / ${exception}`);
|
||||
}
|
||||
|
||||
app.ui.dialog.close();
|
||||
return true;
|
||||
}
|
||||
catch(exception) {
|
||||
show_message(`Restore snapshot failed: ${target.title} / ${exception}`);
|
||||
return false;
|
||||
}
|
||||
finally {
|
||||
await SnapshotManager.instance.invalidateControl();
|
||||
SnapshotManager.instance.updateMessage("<BR>To apply the snapshot, please <button id='cm-reboot-button2' class='cm-small-button'>RESTART</button> ComfyUI. And refresh browser.", 'cm-reboot-button2');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function remove_snapshot(target) {
|
||||
if(SnapshotManager.instance) {
|
||||
try {
|
||||
const response = await api.fetchApi(`/v2/snapshot/remove?target=${target}`, { cache: "no-store" });
|
||||
|
||||
if(response.status == 403) {
|
||||
show_message('This action is not allowed with this security level configuration.');
|
||||
return false;
|
||||
}
|
||||
|
||||
if(response.status == 400) {
|
||||
show_message(`Remove snapshot failed: ${target.title} / ${exception}`);
|
||||
}
|
||||
|
||||
app.ui.dialog.close();
|
||||
return true;
|
||||
}
|
||||
catch(exception) {
|
||||
show_message(`Restore snapshot failed: ${target.title} / ${exception}`);
|
||||
return false;
|
||||
}
|
||||
finally {
|
||||
await SnapshotManager.instance.invalidateControl();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function save_current_snapshot() {
|
||||
try {
|
||||
const response = await api.fetchApi('/v2/snapshot/save', { cache: "no-store" });
|
||||
app.ui.dialog.close();
|
||||
return true;
|
||||
}
|
||||
catch(exception) {
|
||||
show_message(`Backup snapshot failed: ${exception}`);
|
||||
return false;
|
||||
}
|
||||
finally {
|
||||
await SnapshotManager.instance.invalidateControl();
|
||||
SnapshotManager.instance.updateMessage("<BR>Current snapshot saved.");
|
||||
}
|
||||
}
|
||||
|
||||
async function getSnapshotList() {
|
||||
const response = await api.fetchApi(`/v2/snapshot/getlist`);
|
||||
const data = await response.json();
|
||||
return data;
|
||||
}
|
||||
|
||||
export class SnapshotManager extends ComfyDialog {
|
||||
static instance = null;
|
||||
|
||||
restore_buttons = [];
|
||||
message_box = null;
|
||||
data = null;
|
||||
|
||||
clear() {
|
||||
this.restore_buttons = [];
|
||||
this.message_box = null;
|
||||
this.data = null;
|
||||
}
|
||||
|
||||
constructor(app, manager_dialog) {
|
||||
super();
|
||||
this.manager_dialog = manager_dialog;
|
||||
this.search_keyword = '';
|
||||
this.element = $el("div.comfy-modal", { parent: document.body }, []);
|
||||
}
|
||||
|
||||
async remove_item() {
|
||||
caller.disableButtons();
|
||||
|
||||
await caller.invalidateControl();
|
||||
}
|
||||
|
||||
createControls() {
|
||||
return [
|
||||
$el("button.cm-small-button", {
|
||||
type: "button",
|
||||
textContent: "Close",
|
||||
onclick: () => { this.close(); }
|
||||
})
|
||||
];
|
||||
}
|
||||
|
||||
startRestore(target) {
|
||||
const self = SnapshotManager.instance;
|
||||
|
||||
self.updateMessage(`<BR><font color="green">Restore snapshot '${target.name}'</font>`);
|
||||
|
||||
for(let i in self.restore_buttons) {
|
||||
self.restore_buttons[i].disabled = true;
|
||||
self.restore_buttons[i].style.backgroundColor = 'gray';
|
||||
}
|
||||
}
|
||||
|
||||
async invalidateControl() {
|
||||
this.clear();
|
||||
this.data = (await getSnapshotList()).items;
|
||||
|
||||
while (this.element.children.length) {
|
||||
this.element.removeChild(this.element.children[0]);
|
||||
}
|
||||
|
||||
await this.createGrid();
|
||||
await this.createBottomControls();
|
||||
}
|
||||
|
||||
updateMessage(msg, btn_id) {
|
||||
this.message_box.innerHTML = msg;
|
||||
if(btn_id) {
|
||||
const rebootButton = document.getElementById(btn_id);
|
||||
const self = this;
|
||||
rebootButton.onclick = function() {
|
||||
if(rebootAPI()) {
|
||||
self.close();
|
||||
self.manager_dialog.close();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async createGrid(models_json) {
|
||||
var grid = document.createElement('table');
|
||||
grid.setAttribute('id', 'snapshot-list-grid');
|
||||
|
||||
var thead = document.createElement('thead');
|
||||
var tbody = document.createElement('tbody');
|
||||
|
||||
var headerRow = document.createElement('tr');
|
||||
thead.style.position = "sticky";
|
||||
thead.style.top = "0px";
|
||||
thead.style.borderCollapse = "collapse";
|
||||
thead.style.tableLayout = "fixed";
|
||||
|
||||
var header1 = document.createElement('th');
|
||||
header1.innerHTML = ' ID ';
|
||||
header1.style.width = "20px";
|
||||
var header2 = document.createElement('th');
|
||||
header2.innerHTML = 'Datetime';
|
||||
header2.style.width = "100%";
|
||||
var header_button = document.createElement('th');
|
||||
header_button.innerHTML = 'Action';
|
||||
header_button.style.width = "100px";
|
||||
|
||||
thead.appendChild(headerRow);
|
||||
headerRow.appendChild(header1);
|
||||
headerRow.appendChild(header2);
|
||||
headerRow.appendChild(header_button);
|
||||
|
||||
headerRow.style.backgroundColor = "Black";
|
||||
headerRow.style.color = "White";
|
||||
headerRow.style.textAlign = "center";
|
||||
headerRow.style.width = "100%";
|
||||
headerRow.style.padding = "0";
|
||||
|
||||
grid.appendChild(thead);
|
||||
grid.appendChild(tbody);
|
||||
|
||||
this.grid_rows = {};
|
||||
|
||||
if(this.data)
|
||||
for (var i = 0; i < this.data.length; i++) {
|
||||
const data = this.data[i];
|
||||
var dataRow = document.createElement('tr');
|
||||
var data1 = document.createElement('td');
|
||||
data1.style.textAlign = "center";
|
||||
data1.innerHTML = i+1;
|
||||
var data2 = document.createElement('td');
|
||||
data2.innerHTML = ` ${data}`;
|
||||
var data_button = document.createElement('td');
|
||||
data_button.style.textAlign = "center";
|
||||
|
||||
var restoreBtn = document.createElement('button');
|
||||
restoreBtn.innerHTML = 'Restore';
|
||||
restoreBtn.style.width = "100px";
|
||||
restoreBtn.style.backgroundColor = 'blue';
|
||||
|
||||
restoreBtn.addEventListener('click', function() {
|
||||
restore_snapshot(data);
|
||||
});
|
||||
|
||||
var removeBtn = document.createElement('button');
|
||||
removeBtn.innerHTML = 'Remove';
|
||||
removeBtn.style.width = "100px";
|
||||
removeBtn.style.backgroundColor = 'red';
|
||||
|
||||
removeBtn.addEventListener('click', function() {
|
||||
remove_snapshot(data);
|
||||
});
|
||||
|
||||
data_button.appendChild(restoreBtn);
|
||||
data_button.appendChild(removeBtn);
|
||||
|
||||
dataRow.style.backgroundColor = "var(--bg-color)";
|
||||
dataRow.style.color = "var(--fg-color)";
|
||||
dataRow.style.textAlign = "left";
|
||||
|
||||
dataRow.appendChild(data1);
|
||||
dataRow.appendChild(data2);
|
||||
dataRow.appendChild(data_button);
|
||||
tbody.appendChild(dataRow);
|
||||
|
||||
this.grid_rows[i] = {data:data, control:dataRow};
|
||||
}
|
||||
|
||||
let self = this;
|
||||
const panel = document.createElement('div');
|
||||
panel.style.width = "100%";
|
||||
panel.appendChild(grid);
|
||||
|
||||
function handleResize() {
|
||||
const parentHeight = self.element.clientHeight;
|
||||
const gridHeight = parentHeight - 200;
|
||||
|
||||
grid.style.height = gridHeight + "px";
|
||||
}
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
grid.style.position = "relative";
|
||||
grid.style.display = "inline-block";
|
||||
grid.style.width = "100%";
|
||||
grid.style.height = "100%";
|
||||
grid.style.overflowY = "scroll";
|
||||
this.element.style.height = "85%";
|
||||
this.element.style.width = "80%";
|
||||
this.element.appendChild(panel);
|
||||
|
||||
handleResize();
|
||||
}
|
||||
|
||||
async createBottomControls() {
|
||||
var close_button = document.createElement("button");
|
||||
close_button.className = "cm-small-button";
|
||||
close_button.innerHTML = "Close";
|
||||
close_button.onclick = () => { this.close(); }
|
||||
close_button.style.display = "inline-block";
|
||||
|
||||
var save_button = document.createElement("button");
|
||||
save_button.className = "cm-small-button";
|
||||
save_button.innerHTML = "Save snapshot";
|
||||
save_button.onclick = () => { save_current_snapshot(); }
|
||||
save_button.style.display = "inline-block";
|
||||
save_button.style.horizontalAlign = "right";
|
||||
save_button.style.width = "170px";
|
||||
|
||||
this.message_box = $el('div', {id:'custom-download-message'}, [$el('br'), '']);
|
||||
this.message_box.style.height = '60px';
|
||||
this.message_box.style.verticalAlign = 'middle';
|
||||
|
||||
this.element.appendChild(this.message_box);
|
||||
this.element.appendChild(close_button);
|
||||
this.element.appendChild(save_button);
|
||||
}
|
||||
|
||||
async show() {
|
||||
try {
|
||||
this.invalidateControl();
|
||||
this.element.style.display = "block";
|
||||
this.element.style.zIndex = 1099;
|
||||
}
|
||||
catch(exception) {
|
||||
app.ui.dialog.show(`Failed to get external model list. / ${exception}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
1
comfyui_manager/js/turbogrid.esm.js
Normal file
1
comfyui_manager/js/turbogrid.esm.js
Normal file
File diff suppressed because one or more lines are too long
84
comfyui_manager/js/workflow-metadata.js
Normal file
84
comfyui_manager/js/workflow-metadata.js
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Attaches metadata to the workflow on save
|
||||
* - custom node pack version to all custom nodes used in the workflow
|
||||
*
|
||||
* Example metadata:
|
||||
* "nodes": {
|
||||
* "1": {
|
||||
* type: "CheckpointLoaderSimple",
|
||||
* ...
|
||||
* properties: {
|
||||
* cnr_id: "comfy-core",
|
||||
* version: "0.3.8",
|
||||
* },
|
||||
* },
|
||||
* }
|
||||
*
|
||||
* @typedef {Object} NodeInfo
|
||||
* @property {string} ver - Version (git hash or semantic version)
|
||||
* @property {string} cnr_id - ComfyRegistry node ID
|
||||
* @property {boolean} enabled - Whether the node is enabled
|
||||
*/
|
||||
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { api } from "../../scripts/api.js";
|
||||
|
||||
class WorkflowMetadataExtension {
|
||||
constructor() {
|
||||
this.name = "Comfy.CustomNodesManager.WorkflowMetadata";
|
||||
this.installedNodes = {};
|
||||
this.comfyCoreVersion = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the installed nodes info
|
||||
* @returns {Promise<Record<string, NodeInfo>>} The mapping from node name to its info.
|
||||
* ver can either be a git commit hash or a semantic version such as "1.0.0"
|
||||
* cnr_id is the id of the node in the ComfyRegistry
|
||||
* enabled is true if the node is enabled, false if it is disabled
|
||||
*/
|
||||
async getInstalledNodes() {
|
||||
const res = await api.fetchApi("/v2/customnode/installed");
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
async init() {
|
||||
this.installedNodes = await this.getInstalledNodes();
|
||||
this.comfyCoreVersion = (await api.getSystemStats()).system.comfyui_version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when any node is created
|
||||
* @param {LGraphNode} node The newly created node
|
||||
*/
|
||||
nodeCreated(node) {
|
||||
try {
|
||||
// nodeData doesn't exist if node is missing or node is frontend only node
|
||||
if (!node?.constructor?.nodeData?.python_module) return;
|
||||
|
||||
const nodeProperties = (node.properties ??= {});
|
||||
const modules = node.constructor.nodeData.python_module.split(".");
|
||||
const moduleType = modules[0];
|
||||
|
||||
if (moduleType === "custom_nodes") {
|
||||
const nodePackageName = modules[1];
|
||||
const { cnr_id, aux_id, ver } =
|
||||
this.installedNodes[nodePackageName] ??
|
||||
this.installedNodes[nodePackageName.toLowerCase()] ??
|
||||
{};
|
||||
|
||||
if (cnr_id === "comfy-core") return; // don't allow hijacking comfy-core name
|
||||
if (cnr_id) nodeProperties.cnr_id = cnr_id;
|
||||
else nodeProperties.aux_id = aux_id;
|
||||
if (ver) nodeProperties.ver = ver;
|
||||
} else if (["nodes", "comfy_extras"].includes(moduleType)) {
|
||||
nodeProperties.cnr_id = "comfy-core";
|
||||
nodeProperties.ver = this.comfyCoreVersion;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app.registerExtension(new WorkflowMetadataExtension());
|
||||
847
comfyui_manager/prestartup_script.py
Normal file
847
comfyui_manager/prestartup_script.py
Normal file
@@ -0,0 +1,847 @@
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import atexit
|
||||
import threading
|
||||
import re
|
||||
import locale
|
||||
import platform
|
||||
import json
|
||||
import ast
|
||||
import logging
|
||||
import traceback
|
||||
|
||||
from .glob import security_check
|
||||
from .glob import manager_util
|
||||
from .glob import cm_global
|
||||
from .glob import manager_downloader
|
||||
import folder_paths
|
||||
|
||||
manager_util.add_python_path_to_env()
|
||||
|
||||
import datetime as dt
|
||||
|
||||
if hasattr(dt, 'datetime'):
|
||||
from datetime import datetime as dt_datetime
|
||||
|
||||
def current_timestamp():
|
||||
return dt_datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]
|
||||
else:
|
||||
# NOTE: Occurs in some Mac environments.
|
||||
import time
|
||||
logging.error(f"[ComfyUI-Manager] fallback timestamp mode\n datetime module is invalid: '{dt.__file__}'")
|
||||
|
||||
def current_timestamp():
|
||||
return str(time.time()).split('.')[0]
|
||||
|
||||
security_check.security_check()
|
||||
|
||||
cm_global.pip_blacklist = {'torch', 'torchsde', 'torchvision'}
|
||||
cm_global.pip_downgrade_blacklist = ['torch', 'torchsde', 'torchvision', 'transformers', 'safetensors', 'kornia']
|
||||
|
||||
|
||||
def skip_pip_spam(x):
|
||||
return ('Requirement already satisfied:' in x) or ("DEPRECATION: Loading egg at" in x)
|
||||
|
||||
|
||||
message_collapses = [skip_pip_spam]
|
||||
import_failed_extensions = set()
|
||||
cm_global.variables['cm.on_revision_detected_handler'] = []
|
||||
enable_file_logging = True
|
||||
|
||||
|
||||
def register_message_collapse(f):
|
||||
global message_collapses
|
||||
message_collapses.append(f)
|
||||
|
||||
|
||||
def is_import_failed_extension(name):
|
||||
global import_failed_extensions
|
||||
return name in import_failed_extensions
|
||||
|
||||
|
||||
comfy_path = os.environ.get('COMFYUI_PATH')
|
||||
comfy_base_path = os.environ.get('COMFYUI_FOLDERS_BASE_PATH')
|
||||
|
||||
if comfy_path is None:
|
||||
try:
|
||||
comfy_path = os.path.abspath(os.path.dirname(sys.modules['__main__'].__file__))
|
||||
os.environ['COMFYUI_PATH'] = comfy_path
|
||||
except:
|
||||
print("[ComfyUI-Manager] environment variable 'COMFYUI_PATH' is not specified.")
|
||||
exit(-1)
|
||||
|
||||
if comfy_base_path is None:
|
||||
comfy_base_path = comfy_path
|
||||
|
||||
|
||||
sys.__comfyui_manager_register_message_collapse = register_message_collapse
|
||||
sys.__comfyui_manager_is_import_failed_extension = is_import_failed_extension
|
||||
cm_global.register_api('cm.register_message_collapse', register_message_collapse)
|
||||
cm_global.register_api('cm.is_import_failed_extension', is_import_failed_extension)
|
||||
|
||||
|
||||
comfyui_manager_path = os.path.abspath(os.path.dirname(__file__))
|
||||
|
||||
custom_nodes_base_path = folder_paths.get_folder_paths('custom_nodes')[0]
|
||||
manager_files_path = os.path.abspath(os.path.join(folder_paths.get_user_directory(), 'default', 'ComfyUI-Manager'))
|
||||
manager_pip_overrides_path = os.path.join(manager_files_path, "pip_overrides.json")
|
||||
manager_pip_blacklist_path = os.path.join(manager_files_path, "pip_blacklist.list")
|
||||
restore_snapshot_path = os.path.join(manager_files_path, "startup-scripts", "restore-snapshot.json")
|
||||
manager_config_path = os.path.join(manager_files_path, 'config.ini')
|
||||
|
||||
cm_cli_path = os.path.join(comfyui_manager_path, "cm-cli.py")
|
||||
|
||||
|
||||
default_conf = {}
|
||||
|
||||
def read_config():
|
||||
global default_conf
|
||||
try:
|
||||
import configparser
|
||||
config = configparser.ConfigParser(strict=False)
|
||||
config.read(manager_config_path)
|
||||
default_conf = config['default']
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def read_uv_mode():
|
||||
if 'use_uv' in default_conf:
|
||||
manager_util.use_uv = default_conf['use_uv'].lower() == 'true'
|
||||
|
||||
def check_file_logging():
|
||||
global enable_file_logging
|
||||
if 'file_logging' in default_conf and default_conf['file_logging'].lower() == 'false':
|
||||
enable_file_logging = False
|
||||
|
||||
|
||||
read_config()
|
||||
read_uv_mode()
|
||||
check_file_logging()
|
||||
|
||||
cm_global.pip_overrides = {'numpy': 'numpy<2'}
|
||||
if os.path.exists(manager_pip_overrides_path):
|
||||
with open(manager_pip_overrides_path, 'r', encoding="UTF-8", errors="ignore") as json_file:
|
||||
cm_global.pip_overrides = json.load(json_file)
|
||||
cm_global.pip_overrides['numpy'] = 'numpy<2'
|
||||
|
||||
|
||||
if os.path.exists(manager_pip_blacklist_path):
|
||||
with open(manager_pip_blacklist_path, 'r', encoding="UTF-8", errors="ignore") as f:
|
||||
for x in f.readlines():
|
||||
y = x.strip()
|
||||
if y != '':
|
||||
cm_global.pip_blacklist.add(y)
|
||||
|
||||
|
||||
def remap_pip_package(pkg):
|
||||
if pkg in cm_global.pip_overrides:
|
||||
res = cm_global.pip_overrides[pkg]
|
||||
print(f"[ComfyUI-Manager] '{pkg}' is remapped to '{res}'")
|
||||
return res
|
||||
else:
|
||||
return pkg
|
||||
|
||||
|
||||
std_log_lock = threading.Lock()
|
||||
|
||||
|
||||
def handle_stream(stream, prefix):
|
||||
stream.reconfigure(encoding=locale.getpreferredencoding(), errors='replace')
|
||||
for msg in stream:
|
||||
if prefix == '[!]' and ('it/s]' in msg or 's/it]' in msg) and ('%|' in msg or 'it [' in msg):
|
||||
if msg.startswith('100%'):
|
||||
print('\r' + msg, end="", file=sys.stderr),
|
||||
else:
|
||||
print('\r' + msg[:-1], end="", file=sys.stderr),
|
||||
else:
|
||||
if prefix == '[!]':
|
||||
print(prefix, msg, end="", file=sys.stderr)
|
||||
else:
|
||||
print(prefix, msg, end="")
|
||||
|
||||
|
||||
def process_wrap(cmd_str, cwd_path, handler=None, env=None):
|
||||
process = subprocess.Popen(cmd_str, cwd=cwd_path, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1)
|
||||
|
||||
if handler is None:
|
||||
handler = handle_stream
|
||||
|
||||
stdout_thread = threading.Thread(target=handler, args=(process.stdout, ""))
|
||||
stderr_thread = threading.Thread(target=handler, args=(process.stderr, "[!]"))
|
||||
|
||||
stdout_thread.start()
|
||||
stderr_thread.start()
|
||||
|
||||
stdout_thread.join()
|
||||
stderr_thread.join()
|
||||
|
||||
return process.wait()
|
||||
|
||||
|
||||
original_stdout = sys.stdout
|
||||
|
||||
|
||||
def try_get_custom_nodes(x):
|
||||
for custom_nodes_dir in folder_paths.get_folder_paths('custom_nodes'):
|
||||
if x.startswith(custom_nodes_dir):
|
||||
relative_path = os.path.relpath(x, custom_nodes_dir)
|
||||
next_segment = relative_path.split(os.sep)[0]
|
||||
if next_segment.lower() != 'comfyui-manager':
|
||||
return next_segment, os.path.join(custom_nodes_dir, next_segment)
|
||||
return None
|
||||
|
||||
|
||||
def extract_origin_module():
|
||||
stack = traceback.extract_stack()[:-2]
|
||||
for frame in reversed(stack):
|
||||
info = try_get_custom_nodes(frame.filename)
|
||||
if info is None:
|
||||
continue
|
||||
else:
|
||||
return info
|
||||
return None
|
||||
|
||||
def extract_origin_module_from_strings(file_paths):
|
||||
for filepath in file_paths:
|
||||
info = try_get_custom_nodes(filepath)
|
||||
if info is None:
|
||||
continue
|
||||
else:
|
||||
return info
|
||||
return None
|
||||
|
||||
|
||||
def finalize_startup():
|
||||
res = {}
|
||||
for k, v in cm_global.error_dict.items():
|
||||
if v['path'] in import_failed_extensions:
|
||||
res[k] = v
|
||||
|
||||
cm_global.error_dict = res
|
||||
|
||||
|
||||
try:
|
||||
if '--port' in sys.argv:
|
||||
port_index = sys.argv.index('--port')
|
||||
if port_index + 1 < len(sys.argv):
|
||||
port = int(sys.argv[port_index + 1])
|
||||
postfix = f"_{port}"
|
||||
else:
|
||||
postfix = ""
|
||||
else:
|
||||
postfix = ""
|
||||
|
||||
# Logger setup
|
||||
log_path_base = None
|
||||
if enable_file_logging:
|
||||
log_path_base = os.path.join(folder_paths.user_directory, 'comfyui')
|
||||
|
||||
if not os.path.exists(folder_paths.user_directory):
|
||||
os.makedirs(folder_paths.user_directory)
|
||||
|
||||
if os.path.exists(f"{log_path_base}{postfix}.log"):
|
||||
if os.path.exists(f"{log_path_base}{postfix}.prev.log"):
|
||||
if os.path.exists(f"{log_path_base}{postfix}.prev2.log"):
|
||||
os.remove(f"{log_path_base}{postfix}.prev2.log")
|
||||
os.rename(f"{log_path_base}{postfix}.prev.log", f"{log_path_base}{postfix}.prev2.log")
|
||||
os.rename(f"{log_path_base}{postfix}.log", f"{log_path_base}{postfix}.prev.log")
|
||||
|
||||
log_file = open(f"{log_path_base}{postfix}.log", "w", encoding="utf-8", errors="ignore")
|
||||
|
||||
log_lock = threading.Lock()
|
||||
|
||||
original_stdout = sys.stdout
|
||||
original_stderr = sys.stderr
|
||||
|
||||
if original_stdout.encoding.lower() == 'utf-8':
|
||||
write_stdout = original_stdout.write
|
||||
write_stderr = original_stderr.write
|
||||
else:
|
||||
def wrapper_stdout(msg):
|
||||
original_stdout.write(msg.encode('utf-8').decode(original_stdout.encoding, errors="ignore"))
|
||||
|
||||
def wrapper_stderr(msg):
|
||||
original_stderr.write(msg.encode('utf-8').decode(original_stderr.encoding, errors="ignore"))
|
||||
|
||||
write_stdout = wrapper_stdout
|
||||
write_stderr = wrapper_stderr
|
||||
|
||||
pat_tqdm = r'\d+%.*\[(.*?)\]'
|
||||
pat_import_fail = r'seconds \(IMPORT FAILED\):(.*)$'
|
||||
|
||||
is_start_mode = True
|
||||
|
||||
|
||||
class ComfyUIManagerLogger:
|
||||
def __init__(self, is_stdout):
|
||||
self.is_stdout = is_stdout
|
||||
self.encoding = "utf-8"
|
||||
self.last_char = ''
|
||||
|
||||
def fileno(self):
|
||||
try:
|
||||
if self.is_stdout:
|
||||
return original_stdout.fileno()
|
||||
else:
|
||||
return original_stderr.fileno()
|
||||
except AttributeError:
|
||||
# Handle error
|
||||
raise ValueError("The object does not have a fileno method")
|
||||
|
||||
def isatty(self):
|
||||
return False
|
||||
|
||||
def write(self, message):
|
||||
global is_start_mode
|
||||
|
||||
if any(f(message) for f in message_collapses):
|
||||
return
|
||||
|
||||
if is_start_mode:
|
||||
match = re.search(pat_import_fail, message)
|
||||
if match:
|
||||
import_failed_extensions.add(match.group(1).strip())
|
||||
|
||||
if not self.is_stdout:
|
||||
origin_info = extract_origin_module()
|
||||
if origin_info is not None:
|
||||
name, origin_path = origin_info
|
||||
|
||||
if name != 'comfyui-manager':
|
||||
if name not in cm_global.error_dict:
|
||||
cm_global.error_dict[name] = {'name': name, 'path': origin_path, 'msg': ''}
|
||||
|
||||
cm_global.error_dict[name]['msg'] += message
|
||||
|
||||
if not self.is_stdout:
|
||||
match = re.search(pat_tqdm, message)
|
||||
if match:
|
||||
message = re.sub(r'([#|])\d', r'\1▌', message)
|
||||
message = re.sub('#', '█', message)
|
||||
if '100%' in message:
|
||||
self.sync_write(message)
|
||||
else:
|
||||
write_stderr(message)
|
||||
original_stderr.flush()
|
||||
else:
|
||||
self.sync_write(message)
|
||||
else:
|
||||
self.sync_write(message)
|
||||
|
||||
def sync_write(self, message, file_only=False):
|
||||
with log_lock:
|
||||
timestamp = current_timestamp()
|
||||
if self.last_char != '\n':
|
||||
log_file.write(message)
|
||||
else:
|
||||
log_file.write(f"[{timestamp}] {message}")
|
||||
log_file.flush()
|
||||
self.last_char = message if message == '' else message[-1]
|
||||
|
||||
if not file_only:
|
||||
with std_log_lock:
|
||||
if self.is_stdout:
|
||||
write_stdout(message)
|
||||
original_stdout.flush()
|
||||
else:
|
||||
write_stderr(message)
|
||||
original_stderr.flush()
|
||||
|
||||
def flush(self):
|
||||
log_file.flush()
|
||||
|
||||
with std_log_lock:
|
||||
if self.is_stdout:
|
||||
original_stdout.flush()
|
||||
else:
|
||||
original_stderr.flush()
|
||||
|
||||
def close(self):
|
||||
self.flush()
|
||||
|
||||
def reconfigure(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
# You can close through sys.stderr.close_log()
|
||||
def close_log(self):
|
||||
sys.stderr = original_stderr
|
||||
sys.stdout = original_stdout
|
||||
log_file.close()
|
||||
|
||||
def close_log():
|
||||
sys.stderr = original_stderr
|
||||
sys.stdout = original_stdout
|
||||
log_file.close()
|
||||
|
||||
|
||||
if enable_file_logging:
|
||||
sys.stdout = ComfyUIManagerLogger(True)
|
||||
stderr_wrapper = ComfyUIManagerLogger(False)
|
||||
sys.stderr = stderr_wrapper
|
||||
|
||||
atexit.register(close_log)
|
||||
else:
|
||||
sys.stdout.close_log = lambda: None
|
||||
stderr_wrapper = None
|
||||
|
||||
|
||||
class LoggingHandler(logging.Handler):
|
||||
def emit(self, record):
|
||||
global is_start_mode
|
||||
|
||||
try:
|
||||
message = record.getMessage()
|
||||
except Exception as e:
|
||||
message = f"<<logging error>>: {record} - {e}"
|
||||
original_stderr.write(message)
|
||||
|
||||
if is_start_mode:
|
||||
match = re.search(pat_import_fail, message)
|
||||
if match:
|
||||
import_failed_extensions.add(match.group(1).strip())
|
||||
|
||||
if 'Traceback' in message:
|
||||
file_lists = self._extract_file_paths(message)
|
||||
origin_info = extract_origin_module_from_strings(file_lists)
|
||||
if origin_info is not None:
|
||||
name, origin_path = origin_info
|
||||
|
||||
if name != 'comfyui-manager':
|
||||
if name not in cm_global.error_dict:
|
||||
cm_global.error_dict[name] = {'name': name, 'path': origin_path, 'msg': ''}
|
||||
|
||||
cm_global.error_dict[name]['msg'] += message
|
||||
|
||||
if 'Starting server' in message:
|
||||
is_start_mode = False
|
||||
finalize_startup()
|
||||
|
||||
if stderr_wrapper:
|
||||
stderr_wrapper.sync_write(message+'\n', file_only=True)
|
||||
|
||||
def _extract_file_paths(self, msg):
|
||||
file_paths = []
|
||||
for line in msg.split('\n'):
|
||||
match = re.findall(r'File \"(.*?)\", line \d+', line)
|
||||
for x in match:
|
||||
if not x.startswith('<'):
|
||||
file_paths.extend(match)
|
||||
return file_paths
|
||||
|
||||
|
||||
logging.getLogger().addHandler(LoggingHandler())
|
||||
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ComfyUI-Manager] Logging failed: {e}")
|
||||
|
||||
|
||||
def ensure_dependencies():
|
||||
try:
|
||||
import git # noqa: F401
|
||||
import toml # noqa: F401
|
||||
import rich # noqa: F401
|
||||
import chardet # noqa: F401
|
||||
except ModuleNotFoundError:
|
||||
my_path = os.path.dirname(__file__)
|
||||
requirements_path = os.path.join(my_path, "requirements.txt")
|
||||
|
||||
print("## ComfyUI-Manager: installing dependencies. (GitPython)")
|
||||
try:
|
||||
subprocess.check_output(manager_util.make_pip_cmd(['install', '-r', requirements_path]))
|
||||
except subprocess.CalledProcessError:
|
||||
print("## [ERROR] ComfyUI-Manager: Attempting to reinstall dependencies using an alternative method.")
|
||||
try:
|
||||
subprocess.check_output(manager_util.make_pip_cmd(['install', '--user', '-r', requirements_path]))
|
||||
except subprocess.CalledProcessError:
|
||||
print("## [ERROR] ComfyUI-Manager: Failed to install the GitPython package in the correct Python environment. Please install it manually in the appropriate environment. (You can seek help at https://app.element.io/#/room/%23comfyui_space%3Amatrix.org)")
|
||||
|
||||
try:
|
||||
print("## ComfyUI-Manager: installing dependencies done.")
|
||||
except:
|
||||
# maybe we should sys.exit() here? there is at least two screens worth of error messages still being pumped after our error messages
|
||||
print("## [ERROR] ComfyUI-Manager: GitPython package seems to be installed, but failed to load somehow. Make sure you have a working git client installed")
|
||||
|
||||
ensure_dependencies()
|
||||
|
||||
|
||||
print("** ComfyUI startup time:", current_timestamp())
|
||||
print("** Platform:", platform.system())
|
||||
print("** Python version:", sys.version)
|
||||
print("** Python executable:", sys.executable)
|
||||
print("** ComfyUI Path:", comfy_path)
|
||||
print("** ComfyUI Base Folder Path:", comfy_base_path)
|
||||
print("** User directory:", folder_paths.user_directory)
|
||||
print("** ComfyUI-Manager config path:", manager_config_path)
|
||||
|
||||
|
||||
if log_path_base is not None:
|
||||
print("** Log path:", os.path.abspath(f'{log_path_base}.log'))
|
||||
else:
|
||||
print("** Log path: file logging is disabled")
|
||||
|
||||
|
||||
def read_downgrade_blacklist():
|
||||
try:
|
||||
if 'downgrade_blacklist' in default_conf:
|
||||
items = default_conf['downgrade_blacklist'].split(',')
|
||||
items = [x.strip() for x in items if x != '']
|
||||
cm_global.pip_downgrade_blacklist += items
|
||||
cm_global.pip_downgrade_blacklist = list(set(cm_global.pip_downgrade_blacklist))
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
read_downgrade_blacklist()
|
||||
|
||||
|
||||
def check_bypass_ssl():
|
||||
try:
|
||||
import ssl
|
||||
if 'bypass_ssl' in default_conf and default_conf['bypass_ssl'].lower() == 'true':
|
||||
print(f"[ComfyUI-Manager] WARN: Unsafe - SSL verification bypass option is Enabled. (see {manager_config_path})")
|
||||
ssl._create_default_https_context = ssl._create_unverified_context # SSL certificate error fix.
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
check_bypass_ssl()
|
||||
|
||||
|
||||
# Perform install
|
||||
processed_install = set()
|
||||
script_list_path = os.path.join(folder_paths.user_directory, "default", "ComfyUI-Manager", "startup-scripts", "install-scripts.txt")
|
||||
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, manager_files_path)
|
||||
|
||||
|
||||
def is_installed(name):
|
||||
name = name.strip()
|
||||
|
||||
if name.startswith('#'):
|
||||
return True
|
||||
|
||||
pattern = r'([^<>!~=]+)([<>!~=]=?)([0-9.a-zA-Z]*)'
|
||||
match = re.search(pattern, name)
|
||||
|
||||
if match:
|
||||
name = match.group(1)
|
||||
|
||||
if name in cm_global.pip_blacklist:
|
||||
return True
|
||||
|
||||
if name in cm_global.pip_downgrade_blacklist:
|
||||
pips = manager_util.get_installed_packages()
|
||||
|
||||
if match is None:
|
||||
if name in pips:
|
||||
return True
|
||||
elif match.group(2) in ['<=', '==', '<', '~=']:
|
||||
if name in pips:
|
||||
if manager_util.StrictVersion(pips[name]) >= manager_util.StrictVersion(match.group(3)):
|
||||
print(f"[ComfyUI-Manager] skip black listed pip installation: '{name}'")
|
||||
return True
|
||||
|
||||
pkg = manager_util.get_installed_packages().get(name.lower())
|
||||
if pkg is None:
|
||||
return False # update if not installed
|
||||
|
||||
if match is None:
|
||||
return True # don't update if version is not specified
|
||||
|
||||
if match.group(2) in ['>', '>=']:
|
||||
if manager_util.StrictVersion(pkg) < manager_util.StrictVersion(match.group(3)):
|
||||
return False
|
||||
elif manager_util.StrictVersion(pkg) > manager_util.StrictVersion(match.group(3)):
|
||||
print(f"[SKIP] Downgrading pip package isn't allowed: {name.lower()} (cur={pkg})")
|
||||
|
||||
if match.group(2) == '==':
|
||||
if manager_util.StrictVersion(pkg) < manager_util.StrictVersion(match.group(3)):
|
||||
return False
|
||||
|
||||
if match.group(2) == '~=':
|
||||
if manager_util.StrictVersion(pkg) == manager_util.StrictVersion(match.group(3)):
|
||||
return False
|
||||
|
||||
return True # prevent downgrade
|
||||
|
||||
|
||||
if os.path.exists(restore_snapshot_path):
|
||||
try:
|
||||
cloned_repos = []
|
||||
|
||||
def msg_capture(stream, prefix):
|
||||
stream.reconfigure(encoding=locale.getpreferredencoding(), errors='replace')
|
||||
for msg in stream:
|
||||
if msg.startswith("CLONE: "):
|
||||
cloned_repos.append(msg[7:])
|
||||
if prefix == '[!]':
|
||||
print(prefix, msg, end="", file=sys.stderr)
|
||||
else:
|
||||
print(prefix, msg, end="")
|
||||
|
||||
elif prefix == '[!]' and ('it/s]' in msg or 's/it]' in msg) and ('%|' in msg or 'it [' in msg):
|
||||
if msg.startswith('100%'):
|
||||
print('\r' + msg, end="", file=sys.stderr),
|
||||
else:
|
||||
print('\r'+msg[:-1], end="", file=sys.stderr),
|
||||
else:
|
||||
if prefix == '[!]':
|
||||
print(prefix, msg, end="", file=sys.stderr)
|
||||
else:
|
||||
print(prefix, msg, end="")
|
||||
|
||||
print("[ComfyUI-Manager] Restore snapshot.")
|
||||
new_env = os.environ.copy()
|
||||
if 'COMFYUI_FOLDERS_BASE_PATH' not in new_env:
|
||||
new_env["COMFYUI_FOLDERS_BASE_PATH"] = comfy_path
|
||||
|
||||
cmd_str = [sys.executable, cm_cli_path, 'restore-snapshot', restore_snapshot_path]
|
||||
exit_code = process_wrap(cmd_str, custom_nodes_base_path, handler=msg_capture, env=new_env)
|
||||
|
||||
if exit_code != 0:
|
||||
print("[ComfyUI-Manager] Restore snapshot failed.")
|
||||
else:
|
||||
print("[ComfyUI-Manager] Restore snapshot done.")
|
||||
|
||||
except Exception as e:
|
||||
print(e)
|
||||
print("[ComfyUI-Manager] Restore snapshot failed.")
|
||||
|
||||
os.remove(restore_snapshot_path)
|
||||
|
||||
|
||||
def execute_lazy_install_script(repo_path, executable):
|
||||
global processed_install
|
||||
|
||||
install_script_path = os.path.join(repo_path, "install.py")
|
||||
requirements_path = os.path.join(repo_path, "requirements.txt")
|
||||
|
||||
if os.path.exists(requirements_path):
|
||||
print(f"Install: pip packages for '{repo_path}'")
|
||||
|
||||
lines = manager_util.robust_readlines(requirements_path)
|
||||
for line in lines:
|
||||
package_name = remap_pip_package(line.strip())
|
||||
if package_name and not is_installed(package_name):
|
||||
if '--index-url' in package_name:
|
||||
s = package_name.split('--index-url')
|
||||
install_cmd = manager_util.make_pip_cmd(["install", s[0].strip(), '--index-url', s[1].strip()])
|
||||
else:
|
||||
install_cmd = manager_util.make_pip_cmd(["install", package_name])
|
||||
|
||||
process_wrap(install_cmd, repo_path)
|
||||
|
||||
if os.path.exists(install_script_path) and f'{repo_path}/install.py' not in processed_install:
|
||||
processed_install.add(f'{repo_path}/install.py')
|
||||
print(f"Install: install script for '{repo_path}'")
|
||||
install_cmd = [executable, "install.py"]
|
||||
|
||||
new_env = os.environ.copy()
|
||||
if 'COMFYUI_FOLDERS_BASE_PATH' not in new_env:
|
||||
new_env["COMFYUI_FOLDERS_BASE_PATH"] = comfy_path
|
||||
process_wrap(install_cmd, repo_path, env=new_env)
|
||||
|
||||
|
||||
def execute_lazy_cnr_switch(target, zip_url, from_path, to_path, no_deps, custom_nodes_path):
|
||||
import uuid
|
||||
import shutil
|
||||
|
||||
# 1. download
|
||||
archive_name = f"CNR_temp_{str(uuid.uuid4())}.zip" # should be unpredictable name - security precaution
|
||||
download_path = os.path.join(custom_nodes_path, archive_name)
|
||||
manager_downloader.download_url(zip_url, custom_nodes_path, archive_name)
|
||||
|
||||
# 2. extract files into <node_id>@<cur_ver>
|
||||
extracted = manager_util.extract_package_as_zip(download_path, from_path)
|
||||
os.remove(download_path)
|
||||
|
||||
if extracted is None:
|
||||
if len(os.listdir(from_path)) == 0:
|
||||
shutil.rmtree(from_path)
|
||||
|
||||
print(f'Empty archive file: {target}')
|
||||
return False
|
||||
|
||||
|
||||
# 3. calculate garbage files (.tracking - extracted)
|
||||
tracking_info_file = os.path.join(from_path, '.tracking')
|
||||
prev_files = set()
|
||||
with open(tracking_info_file, 'r') as f:
|
||||
for line in f:
|
||||
prev_files.add(line.strip())
|
||||
garbage = prev_files.difference(extracted)
|
||||
garbage = [os.path.join(custom_nodes_path, x) for x in garbage]
|
||||
|
||||
# 4-1. remove garbage files
|
||||
for x in garbage:
|
||||
if os.path.isfile(x):
|
||||
os.remove(x)
|
||||
|
||||
# 4-2. remove garbage dir if empty
|
||||
for x in garbage:
|
||||
if os.path.isdir(x):
|
||||
if not os.listdir(x):
|
||||
os.rmdir(x)
|
||||
|
||||
# 5. rename dir name <node_id>@<prev_ver> ==> <node_id>@<cur_ver>
|
||||
print(f"'{from_path}' is moved to '{to_path}'")
|
||||
shutil.move(from_path, to_path)
|
||||
|
||||
# 6. create .tracking file
|
||||
tracking_info_file = os.path.join(to_path, '.tracking')
|
||||
with open(tracking_info_file, "w", encoding='utf-8') as file:
|
||||
file.write('\n'.join(list(extracted)))
|
||||
|
||||
|
||||
script_executed = False
|
||||
|
||||
def execute_startup_script():
|
||||
global script_executed
|
||||
print("\n#######################################################################")
|
||||
print("[ComfyUI-Manager] Starting dependency installation/(de)activation for the extension\n")
|
||||
|
||||
custom_nodelist_cache = None
|
||||
|
||||
def get_custom_node_paths():
|
||||
nonlocal custom_nodelist_cache
|
||||
if custom_nodelist_cache is None:
|
||||
custom_nodelist_cache = set()
|
||||
for base in folder_paths.get_folder_paths('custom_nodes'):
|
||||
for x in os.listdir(base):
|
||||
fullpath = os.path.join(base, x)
|
||||
if os.path.isdir(fullpath):
|
||||
custom_nodelist_cache.add(fullpath)
|
||||
|
||||
return custom_nodelist_cache
|
||||
|
||||
def execute_lazy_delete(path):
|
||||
# Validate to prevent arbitrary paths from being deleted
|
||||
if path not in get_custom_node_paths():
|
||||
logging.error(f"## ComfyUI-Manager: The scheduled '{path}' is not a custom node path, so the deletion has been canceled.")
|
||||
return
|
||||
|
||||
if not os.path.exists(path):
|
||||
logging.info(f"## ComfyUI-Manager: SKIP-DELETE => '{path}' (already deleted)")
|
||||
return
|
||||
|
||||
try:
|
||||
shutil.rmtree(path)
|
||||
logging.info(f"## ComfyUI-Manager: DELETE => '{path}'")
|
||||
except Exception as e:
|
||||
logging.error(f"## ComfyUI-Manager: Failed to delete '{path}' ({e})")
|
||||
|
||||
executed = set()
|
||||
# Read each line from the file and convert it to a list using eval
|
||||
with open(script_list_path, 'r', encoding="UTF-8", errors="ignore") as file:
|
||||
for line in file:
|
||||
if line in executed:
|
||||
continue
|
||||
|
||||
executed.add(line)
|
||||
|
||||
try:
|
||||
script = ast.literal_eval(line)
|
||||
|
||||
if script[1].startswith('#') and script[1] != '#FORCE':
|
||||
if script[1] == "#LAZY-INSTALL-SCRIPT":
|
||||
execute_lazy_install_script(script[0], script[2])
|
||||
|
||||
elif script[1] == "#LAZY-CNR-SWITCH-SCRIPT":
|
||||
execute_lazy_cnr_switch(script[0], script[2], script[3], script[4], script[5], script[6])
|
||||
execute_lazy_install_script(script[3], script[7])
|
||||
|
||||
elif script[1] == "#LAZY-DELETE-NODEPACK":
|
||||
execute_lazy_delete(script[2])
|
||||
|
||||
elif os.path.exists(script[0]):
|
||||
if script[1] == "#FORCE":
|
||||
del script[1]
|
||||
else:
|
||||
if 'pip' in script[1:] and 'install' in script[1:] and is_installed(script[-1]):
|
||||
continue
|
||||
|
||||
print(f"\n## ComfyUI-Manager: EXECUTE => {script[1:]}")
|
||||
print(f"\n## Execute management script for '{script[0]}'")
|
||||
|
||||
new_env = os.environ.copy()
|
||||
if 'COMFYUI_FOLDERS_BASE_PATH' not in new_env:
|
||||
new_env["COMFYUI_FOLDERS_BASE_PATH"] = comfy_path
|
||||
exit_code = process_wrap(script[1:], script[0], env=new_env)
|
||||
|
||||
if exit_code != 0:
|
||||
print(f"management script failed: {script[0]}")
|
||||
else:
|
||||
print(f"\n## ComfyUI-Manager: CANCELED => {script[1:]}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Failed to execute management script: {line} / {e}")
|
||||
|
||||
# Remove the script_list_path file
|
||||
if os.path.exists(script_list_path):
|
||||
script_executed = True
|
||||
os.remove(script_list_path)
|
||||
|
||||
print("\n[ComfyUI-Manager] Startup script completed.")
|
||||
print("#######################################################################\n")
|
||||
|
||||
|
||||
# Check if script_list_path exists
|
||||
if os.path.exists(script_list_path):
|
||||
execute_startup_script()
|
||||
|
||||
|
||||
pip_fixer.fix_broken()
|
||||
|
||||
del processed_install
|
||||
del pip_fixer
|
||||
manager_util.clear_pip_cache()
|
||||
|
||||
if script_executed:
|
||||
# Restart
|
||||
print("[ComfyUI-Manager] Restarting to reapply dependency installation.")
|
||||
|
||||
if '__COMFY_CLI_SESSION__' in os.environ:
|
||||
with open(os.path.join(os.environ['__COMFY_CLI_SESSION__'] + '.reboot'), 'w'):
|
||||
pass
|
||||
|
||||
print("--------------------------------------------------------------------------\n")
|
||||
exit(0)
|
||||
else:
|
||||
sys_argv = sys.argv.copy()
|
||||
|
||||
if sys_argv[0].endswith("__main__.py"): # this is a python module
|
||||
module_name = os.path.basename(os.path.dirname(sys_argv[0]))
|
||||
cmds = [sys.executable, '-m', module_name] + sys_argv[1:]
|
||||
elif sys.platform.startswith('win32'):
|
||||
cmds = ['"' + sys.executable + '"', '"' + sys_argv[0] + '"'] + sys_argv[1:]
|
||||
else:
|
||||
cmds = [sys.executable] + sys_argv
|
||||
|
||||
print(f"Command: {cmds}", flush=True)
|
||||
print("--------------------------------------------------------------------------\n")
|
||||
|
||||
os.execv(sys.executable, cmds)
|
||||
|
||||
|
||||
def check_windows_event_loop_policy():
|
||||
try:
|
||||
import configparser
|
||||
config = configparser.ConfigParser(strict=False)
|
||||
config.read(manager_config_path)
|
||||
default_conf = config['default']
|
||||
|
||||
if 'windows_selector_event_loop_policy' in default_conf and default_conf['windows_selector_event_loop_policy'].lower() == 'true':
|
||||
try:
|
||||
import asyncio
|
||||
import asyncio.windows_events
|
||||
asyncio.set_event_loop_policy(asyncio.windows_events.WindowsSelectorEventLoopPolicy())
|
||||
print("[ComfyUI-Manager] Windows event loop policy mode enabled")
|
||||
except Exception as e:
|
||||
print(f"[ComfyUI-Manager] WARN: Windows initialization fail: {e}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
if platform.system() == 'Windows':
|
||||
check_windows_event_loop_policy()
|
||||
Reference in New Issue
Block a user