Integrate Lora Loader MXD into MaxedOut
Add integrated Lora Loader MXD support and frontend. Introduces a new loraloader_mxd package (constants, logging, power node, prompt utils, server API routes, model info utils, server helpers, general utils, and userdata migration/IO). Adds many web frontend files for the MXD UI and registers the node in the top-level __init__.py. Updates README with merge/migration notes and adds userdata/ to .gitignore. Also includes minor comment/tuning change in maxedoutnodes.py and small updates to existing web/index.js and project metadata.
This commit is contained in:
@@ -30,6 +30,7 @@ logs/
|
||||
|
||||
# Local testing workspace
|
||||
testing/
|
||||
userdata/
|
||||
|
||||
# ComfyUI local cache & configs
|
||||
ComfyUI/output/
|
||||
|
||||
@@ -1,2 +1,22 @@
|
||||
# ComfyUI-MaxedOut
|
||||
Custom ComfyUI nodes used in Maxed Out workflows (SDXL, Flux, etc.)
|
||||
# ComfyUI-MaxedOut
|
||||
Custom ComfyUI nodes used in Maxed Out workflows (SDXL, Flux, Wan 2.2, etc.).
|
||||
|
||||
## Included Nodes
|
||||
- Core MaxedOut nodes from `maxedoutnodes.py`
|
||||
- Media comparer nodes from `mediacomparers.py`
|
||||
- WAN 2.2 nodes from `wan22nodes.py`
|
||||
- `Lora Loader MXD` (merged from `ComfyUI-LoraLoader-MXD`)
|
||||
|
||||
## Lora Loader MXD Merge
|
||||
`Lora Loader MXD` is now integrated directly into this repository.
|
||||
|
||||
Compatibility and behavior retained:
|
||||
- Node type remains `Lora Loader MXD`
|
||||
- API namespace remains `/loraloader-mxd/api/...`
|
||||
- Sidecar metadata compatibility remains `*.rgthree-info.json`
|
||||
- Frontend extension name remains `mxd.PowerLoraLoader`
|
||||
|
||||
## Migration Notes
|
||||
- Existing `ComfyUI-LoraLoader-MXD` installs are supported by a one-release compatibility shim.
|
||||
- During transition, keep both repos installed if needed; the standalone shim defers to this merged provider to avoid duplicate registration.
|
||||
- After the transition release window, the standalone repo can be removed.
|
||||
|
||||
+3
-2
@@ -1,4 +1,4 @@
|
||||
import importlib
|
||||
import importlib
|
||||
|
||||
WEB_DIRECTORY = "web"
|
||||
|
||||
@@ -19,7 +19,7 @@ def _get_mappings(mod):
|
||||
NODE_CLASS_MAPPINGS = {}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {}
|
||||
|
||||
for _name in ("maxedoutnodes", "mediacomparers", "wan22nodes"):
|
||||
for _name in ("maxedoutnodes", "mediacomparers", "wan22nodes", "loraloader_mxd"):
|
||||
_mod = _safe_import(_name)
|
||||
_class_map, _display_map = _get_mappings(_mod)
|
||||
NODE_CLASS_MAPPINGS.update(_class_map)
|
||||
@@ -30,3 +30,4 @@ __all__ = [
|
||||
"NODE_DISPLAY_NAME_MAPPINGS",
|
||||
"WEB_DIRECTORY",
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
from .power_lora_loader_mxd import MxdPowerLoraLoader
|
||||
from .server import routes_model_info as _routes_model_info # noqa: F401
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
MxdPowerLoraLoader.NAME: MxdPowerLoraLoader,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
MxdPowerLoraLoader.NAME: "Lora Loader MXD",
|
||||
}
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|
||||
@@ -0,0 +1,11 @@
|
||||
NAMESPACE = "mxd"
|
||||
|
||||
|
||||
def get_name(name: str):
|
||||
return f"{name} (MXD)"
|
||||
|
||||
|
||||
def get_category(sub_dirs=None):
|
||||
if sub_dirs is None:
|
||||
return "mxd"
|
||||
return "mxd/utils"
|
||||
@@ -0,0 +1,49 @@
|
||||
import time
|
||||
|
||||
NAME = "ComfyUI-MaxedOut/LoraLoader-MXD"
|
||||
|
||||
COLORS = {
|
||||
"RESET": "\33[0m",
|
||||
"YELLOW": "\33[33m",
|
||||
"CYAN": "\33[36m",
|
||||
"RED": "\33[31m",
|
||||
"BRIGHT_GREEN": "\33[92m",
|
||||
}
|
||||
|
||||
|
||||
def _log_node(color, node_name, message):
|
||||
prefix = node_name.replace(" (MXD)", "")
|
||||
log(message, color=color, prefix=prefix)
|
||||
|
||||
|
||||
def log_node_warn(node_name, message, msg_color="RESET"):
|
||||
_log_node("YELLOW", node_name, message)
|
||||
|
||||
|
||||
def log_node_info(node_name, message, msg_color="RESET"):
|
||||
_log_node("CYAN", node_name, message)
|
||||
|
||||
|
||||
def log_node_error(node_name, message, msg_color="RESET"):
|
||||
_log_node("RED", node_name, message)
|
||||
|
||||
|
||||
def log_node_success(node_name, message, msg_color="RESET"):
|
||||
_log_node("BRIGHT_GREEN", node_name, message)
|
||||
|
||||
|
||||
LOGGED = {}
|
||||
|
||||
|
||||
def log(message, color=None, msg_color=None, prefix=None, id=None, at_most_secs=None):
|
||||
now = int(time.time())
|
||||
if id:
|
||||
if at_most_secs is None:
|
||||
raise ValueError("at_most_secs should be set if an id is set.")
|
||||
if id in LOGGED and now < LOGGED[id] + at_most_secs:
|
||||
return
|
||||
LOGGED[id] = now
|
||||
color_code = COLORS.get(color or "BRIGHT_GREEN", COLORS["BRIGHT_GREEN"])
|
||||
msg_color_code = COLORS.get(msg_color or "RESET", "")
|
||||
pfx = f"[{prefix}]" if prefix else ""
|
||||
print(f"{color_code}[{NAME}]{pfx}{msg_color_code} {message}{COLORS['RESET']}")
|
||||
@@ -0,0 +1,94 @@
|
||||
import folder_paths
|
||||
|
||||
from typing import Union
|
||||
|
||||
from nodes import LoraLoader
|
||||
from .constants import get_category
|
||||
from .power_prompt_utils import get_lora_by_filename
|
||||
from .utils import FlexibleOptionalInputType, any_type
|
||||
from .server.utils_info import get_model_info_file_data
|
||||
from .log import log_node_warn
|
||||
|
||||
NODE_NAME = "Lora Loader MXD"
|
||||
|
||||
|
||||
class MxdPowerLoraLoader:
|
||||
"""Standalone Power LoRA Loader extracted from rgthree-comfy."""
|
||||
|
||||
NAME = NODE_NAME
|
||||
CATEGORY = get_category()
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls): # pylint: disable=invalid-name,missing-function-docstring
|
||||
return {
|
||||
"required": {},
|
||||
"optional": FlexibleOptionalInputType(type=any_type, data={
|
||||
"model": ("MODEL",),
|
||||
"clip": ("CLIP",),
|
||||
}),
|
||||
"hidden": {},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("MODEL", "CLIP")
|
||||
RETURN_NAMES = ("MODEL", "CLIP")
|
||||
FUNCTION = "load_loras"
|
||||
|
||||
def load_loras(self, model=None, clip=None, **kwargs):
|
||||
for key, value in kwargs.items():
|
||||
key = key.upper()
|
||||
if key.startswith("LORA_") and "on" in value and "lora" in value and "strength" in value:
|
||||
strength_model = value["strength"]
|
||||
strength_clip = value["strengthTwo"] if "strengthTwo" in value else None
|
||||
if clip is None:
|
||||
if strength_clip is not None and strength_clip != 0:
|
||||
log_node_warn(NODE_NAME, "Received clip strength even though no clip supplied.")
|
||||
strength_clip = 0
|
||||
else:
|
||||
strength_clip = strength_clip if strength_clip is not None else strength_model
|
||||
if value["on"] and (strength_model != 0 or strength_clip != 0):
|
||||
lora = get_lora_by_filename(value["lora"], log_node=self.NAME)
|
||||
if model is not None and lora is not None:
|
||||
model, clip = LoraLoader().load_lora(model, clip, lora, strength_model, strength_clip)
|
||||
|
||||
return (model, clip)
|
||||
|
||||
@classmethod
|
||||
def get_enabled_loras_from_prompt_node(
|
||||
cls,
|
||||
prompt_node: dict,
|
||||
) -> list[dict[str, Union[str, float]]]:
|
||||
result = []
|
||||
for name, lora in prompt_node["inputs"].items():
|
||||
if name.startswith("lora_") and lora["on"]:
|
||||
lora_file = get_lora_by_filename(lora["lora"], log_node=cls.NAME)
|
||||
if lora_file is not None:
|
||||
lora_dict = {
|
||||
"name": lora["lora"],
|
||||
"strength": lora["strength"],
|
||||
"path": folder_paths.get_full_path("loras", lora_file),
|
||||
}
|
||||
if "strengthTwo" in lora:
|
||||
lora_dict["strength_clip"] = lora["strengthTwo"]
|
||||
result.append(lora_dict)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def get_enabled_triggers_from_prompt_node(cls, prompt_node: dict, max_each: int = 1):
|
||||
loras = [l["name"] for l in cls.get_enabled_loras_from_prompt_node(prompt_node)]
|
||||
trained_words = []
|
||||
for lora in loras:
|
||||
info = get_model_info_file_data(lora, "loras", default={})
|
||||
if not info or not info.keys():
|
||||
log_node_warn(
|
||||
NODE_NAME,
|
||||
f"No info found for LoRA {lora} when grabbing triggers. Open the info dialog first.",
|
||||
)
|
||||
continue
|
||||
if "trainedWords" not in info or not info["trainedWords"]:
|
||||
log_node_warn(
|
||||
NODE_NAME,
|
||||
f"No trained words for LoRA {lora} when grabbing triggers.",
|
||||
)
|
||||
continue
|
||||
trained_words += [w for wi in info["trainedWords"][:max_each] if (wi and (w := wi["word"]))]
|
||||
return trained_words
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Utilities for Power Prompt nodes."""
|
||||
import re
|
||||
import os
|
||||
import folder_paths
|
||||
|
||||
from .log import log_node_warn, log_node_info
|
||||
|
||||
|
||||
def get_and_strip_loras(prompt, silent=False, log_node="Power Prompt"):
|
||||
"""Collects and strips lora tags from a prompt."""
|
||||
pattern = r'<lora:([^:>]*?)(?::(-?\d*(?:\.\d*)?))?>'
|
||||
lora_paths = folder_paths.get_filename_list('loras')
|
||||
|
||||
matches = re.findall(pattern, prompt)
|
||||
|
||||
loras = []
|
||||
unfound_loras = []
|
||||
skipped_loras = []
|
||||
for match in matches:
|
||||
tag_path = match[0]
|
||||
|
||||
strength = float(match[1] if len(match) > 1 and len(match[1]) else 1.0)
|
||||
if strength == 0:
|
||||
if not silent:
|
||||
log_node_info(log_node, f'Skipping "{tag_path}" with strength of zero')
|
||||
skipped_loras.append({'lora': tag_path, 'strength': strength})
|
||||
continue
|
||||
|
||||
lora_path = get_lora_by_filename(tag_path, lora_paths, log_node=None if silent else log_node)
|
||||
if lora_path is None:
|
||||
unfound_loras.append({'lora': tag_path, 'strength': strength})
|
||||
continue
|
||||
|
||||
loras.append({'lora': lora_path, 'strength': strength})
|
||||
|
||||
return (re.sub(pattern, '', prompt), loras, skipped_loras, unfound_loras)
|
||||
|
||||
|
||||
# pylint: disable = too-many-return-statements, too-many-branches
|
||||
def get_lora_by_filename(file_path, lora_paths=None, log_node=None):
|
||||
"""Returns a lora by filename, looking for exactl paths and then fuzzier matching."""
|
||||
lora_paths = lora_paths if lora_paths is not None else folder_paths.get_filename_list('loras')
|
||||
|
||||
if file_path in lora_paths:
|
||||
return file_path
|
||||
|
||||
lora_paths_no_ext = [os.path.splitext(x)[0] for x in lora_paths]
|
||||
|
||||
# See if we've entered the exact path, but without the extension
|
||||
if file_path in lora_paths_no_ext:
|
||||
found = lora_paths[lora_paths_no_ext.index(file_path)]
|
||||
return found
|
||||
|
||||
# Same check, but ensure file_path is without extension.
|
||||
file_path_force_no_ext = os.path.splitext(file_path)[0]
|
||||
if file_path_force_no_ext in lora_paths_no_ext:
|
||||
found = lora_paths[lora_paths_no_ext.index(file_path_force_no_ext)]
|
||||
return found
|
||||
|
||||
# See if we passed just the name, without paths.
|
||||
lora_filenames_only = [os.path.basename(x) for x in lora_paths]
|
||||
if file_path in lora_filenames_only:
|
||||
found = lora_paths[lora_filenames_only.index(file_path)]
|
||||
if log_node is not None:
|
||||
log_node_info(log_node, f'Matched Lora input "{file_path}" to "{found}".')
|
||||
return found
|
||||
|
||||
# Same, but force the input to be without paths
|
||||
file_path_force_filename = os.path.basename(file_path)
|
||||
lora_filenames_only = [os.path.basename(x) for x in lora_paths]
|
||||
if file_path_force_filename in lora_filenames_only:
|
||||
found = lora_paths[lora_filenames_only.index(file_path_force_filename)]
|
||||
if log_node is not None:
|
||||
log_node_info(log_node, f'Matched Lora input "{file_path}" to "{found}".')
|
||||
return found
|
||||
|
||||
# Check the filenames and without extension.
|
||||
lora_filenames_and_no_ext = [os.path.splitext(os.path.basename(x))[0] for x in lora_paths]
|
||||
if file_path in lora_filenames_and_no_ext:
|
||||
found = lora_paths[lora_filenames_and_no_ext.index(file_path)]
|
||||
if log_node is not None:
|
||||
log_node_info(log_node, f'Matched Lora input "{file_path}" to "{found}".')
|
||||
return found
|
||||
|
||||
# And, one last forcing the input to be the same
|
||||
file_path_force_filename_and_no_ext = os.path.splitext(os.path.basename(file_path))[0]
|
||||
if file_path_force_filename_and_no_ext in lora_filenames_and_no_ext:
|
||||
found = lora_paths[lora_filenames_and_no_ext.index(file_path_force_filename_and_no_ext)]
|
||||
if log_node is not None:
|
||||
log_node_info(log_node, f'Matched Lora input "{file_path}" to "{found}".')
|
||||
return found
|
||||
|
||||
# Finally, super fuzzy, we'll just check if the input exists in the path at all.
|
||||
for index, lora_path in enumerate(lora_paths):
|
||||
if file_path in lora_path:
|
||||
found = lora_paths[index]
|
||||
if log_node is not None:
|
||||
log_node_warn(log_node, f'Fuzzy-matched Lora input "{file_path}" to "{found}".')
|
||||
return found
|
||||
|
||||
if log_node is not None:
|
||||
log_node_warn(log_node, f'Lora "{file_path}" not found, skipping.')
|
||||
|
||||
return None
|
||||
@@ -0,0 +1 @@
|
||||
from . import routes_model_info # noqa: F401
|
||||
@@ -0,0 +1,200 @@
|
||||
import os
|
||||
import json
|
||||
from aiohttp import web
|
||||
|
||||
from ..log import log
|
||||
from server import PromptServer
|
||||
import folder_paths
|
||||
|
||||
from ..utils import abspath, path_exists
|
||||
from .utils_server import get_param, is_param_falsy
|
||||
from .utils_info import delete_model_info, get_model_info, set_model_info_partial, get_file_info
|
||||
|
||||
routes = PromptServer.instance.routes
|
||||
|
||||
|
||||
def _check_valid_model_type(request):
|
||||
model_type = request.match_info['type']
|
||||
if model_type not in ['loras', 'checkpoints']:
|
||||
return web.json_response({'status': 404, 'error': f'Invalid model type: {model_type}'})
|
||||
return None
|
||||
|
||||
|
||||
@routes.get('/loraloader-mxd/api/{type}')
|
||||
async def api_get_models_list(request):
|
||||
"""Returns a list of model types from user configuration.
|
||||
|
||||
By default, a list of filenames are provided. If `format=details` is specified, a list of objects
|
||||
with additional _file info_ is provided. This includes modigied time, hasInfoFile, and imageLocal
|
||||
among others.
|
||||
"""
|
||||
if _check_valid_model_type(request):
|
||||
return _check_valid_model_type(request)
|
||||
|
||||
model_type = request.match_info['type']
|
||||
files = folder_paths.get_filename_list(model_type)
|
||||
format_param = get_param(request, 'format')
|
||||
if format_param == 'details':
|
||||
response = []
|
||||
bad_files_first = None
|
||||
bad_files_num = 0
|
||||
for file in files:
|
||||
file_info = get_file_info(file, model_type)
|
||||
# Some folks were seeing null in this list, which is odd since it's coming from ComfyUI files.
|
||||
# See https://github.com/rgthree/rgthree-comfy/issues/574#issuecomment-3494629132 We'll check
|
||||
# and log if we haven't found, maybe someone will have more info.
|
||||
if file_info is not None:
|
||||
response.append(file_info)
|
||||
else:
|
||||
bad_files_num += 1
|
||||
if not bad_files_first:
|
||||
bad_files_first = file
|
||||
if bad_files_first:
|
||||
log(
|
||||
f"Couldn't get file info for {bad_files_first}"
|
||||
f"{f' and {bad_files_num} other {model_type}.' if bad_files_num > 1 else '.'} "
|
||||
"ComfyUI thinks they exist, but they were not found on the filesystem.",
|
||||
prefix="Power Lora Loader",
|
||||
color="YELLOW",
|
||||
id=f'no_file_details_{model_type}',
|
||||
at_most_secs=30
|
||||
)
|
||||
return web.json_response(response)
|
||||
|
||||
return web.json_response(list(files))
|
||||
|
||||
|
||||
@routes.get('/loraloader-mxd/api/{type}/info')
|
||||
async def api_get_models_info(request):
|
||||
"""Returns a list model info; either all or a specific ones if provided a 'files' param.
|
||||
|
||||
If a `light` param is specified and not falsy, no metadata will be fetched.
|
||||
"""
|
||||
if _check_valid_model_type(request):
|
||||
return _check_valid_model_type(request)
|
||||
|
||||
model_type = request.match_info['type']
|
||||
files_param = get_param(request, 'files')
|
||||
maybe_fetch_metadata = files_param is not None
|
||||
if not is_param_falsy(request, 'light'):
|
||||
maybe_fetch_metadata = False
|
||||
api_response = await models_info_response(
|
||||
request, model_type, maybe_fetch_metadata=maybe_fetch_metadata
|
||||
)
|
||||
return web.json_response(api_response)
|
||||
|
||||
|
||||
@routes.get('/loraloader-mxd/api/{type}/info/refresh')
|
||||
async def api_get_refresh_get_models_info(request):
|
||||
"""Refreshes model info; either all or specific ones if provided a 'files' param. """
|
||||
if _check_valid_model_type(request):
|
||||
return _check_valid_model_type(request)
|
||||
|
||||
model_type = request.match_info['type']
|
||||
api_response = await models_info_response(
|
||||
request, model_type, maybe_fetch_civitai=True, maybe_fetch_metadata=True
|
||||
)
|
||||
return web.json_response(api_response)
|
||||
|
||||
|
||||
@routes.get('/loraloader-mxd/api/{type}/info/clear')
|
||||
async def api_get_delete_model_info(request):
|
||||
"""Clears model info from the filesystem for the provided file."""
|
||||
if _check_valid_model_type(request):
|
||||
return _check_valid_model_type(request)
|
||||
|
||||
api_response = {'status': 200}
|
||||
model_type = request.match_info['type']
|
||||
files_param = get_param(request, 'files')
|
||||
if files_param is not None:
|
||||
files_param = files_param.split(',')
|
||||
del_info = not is_param_falsy(request, 'del_info')
|
||||
del_metadata = not is_param_falsy(request, 'del_metadata')
|
||||
del_civitai = not is_param_falsy(request, 'del_civitai')
|
||||
if not files_param:
|
||||
api_response['status'] = '404'
|
||||
api_response['error'] = f'No file provided. Please pass files=ALL to clear {model_type} info.'
|
||||
else:
|
||||
if len(files_param) == 1 and files_param[
|
||||
0] == "ALL": # Force the user to supply files=ALL to trigger all clearing.
|
||||
files_param = folder_paths.get_filename_list(model_type)
|
||||
for file_param in files_param:
|
||||
await delete_model_info(
|
||||
file_param,
|
||||
model_type,
|
||||
del_info=del_info,
|
||||
del_metadata=del_metadata,
|
||||
del_civitai=del_civitai
|
||||
)
|
||||
return web.json_response(api_response)
|
||||
|
||||
|
||||
@routes.post('/loraloader-mxd/api/{type}/info')
|
||||
async def api_post_save_model_data(request):
|
||||
"""Saves data to a model by name. """
|
||||
if _check_valid_model_type(request):
|
||||
return _check_valid_model_type(request)
|
||||
|
||||
model_type = request.match_info['type']
|
||||
api_response = {'status': 200}
|
||||
file_param = get_param(request, 'file')
|
||||
if file_param is None:
|
||||
api_response['status'] = '404'
|
||||
api_response['error'] = 'No model found at path'
|
||||
else:
|
||||
post = await request.post()
|
||||
await set_model_info_partial(file_param, model_type, json.loads(post.get("json")))
|
||||
info_data = await get_model_info(file_param, model_type)
|
||||
api_response['data'] = info_data
|
||||
return web.json_response(api_response)
|
||||
|
||||
|
||||
@routes.get('/loraloader-mxd/api/{type}/img')
|
||||
async def api_get_models_info_img(request):
|
||||
""" Returns an image response if one exists for the model. """
|
||||
if _check_valid_model_type(request):
|
||||
return _check_valid_model_type(request)
|
||||
|
||||
model_type = request.match_info['type']
|
||||
file_param = get_param(request, 'file')
|
||||
file_path = folder_paths.get_full_path(model_type, file_param)
|
||||
if not path_exists(file_path):
|
||||
file_path = abspath(file_path)
|
||||
img_path = None
|
||||
for ext in ['jpg', 'png', 'jpeg']:
|
||||
try_path = f'{os.path.splitext(file_path)[0]}.{ext}'
|
||||
if path_exists(try_path):
|
||||
img_path = try_path
|
||||
break
|
||||
|
||||
if not path_exists(img_path):
|
||||
api_response = {}
|
||||
api_response['status'] = '404'
|
||||
api_response['error'] = 'No model found at path'
|
||||
return web.json_response(api_response)
|
||||
|
||||
return web.FileResponse(img_path)
|
||||
|
||||
|
||||
async def models_info_response(
|
||||
request, model_type, maybe_fetch_civitai=False, maybe_fetch_metadata=False
|
||||
):
|
||||
"""Gets model info for all or a single model type."""
|
||||
api_response = {'status': 200, 'data': []}
|
||||
light = not is_param_falsy(request, 'light')
|
||||
files_param = get_param(request, 'files')
|
||||
if files_param is not None:
|
||||
files_param = files_param.split(',')
|
||||
else:
|
||||
files_param = folder_paths.get_filename_list(model_type)
|
||||
for file_param in files_param:
|
||||
info_data = await get_model_info(
|
||||
file_param,
|
||||
model_type,
|
||||
maybe_fetch_civitai=maybe_fetch_civitai,
|
||||
maybe_fetch_metadata=maybe_fetch_metadata,
|
||||
light=light
|
||||
)
|
||||
api_response['data'].append(info_data)
|
||||
return api_response
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
import requests
|
||||
|
||||
from server import PromptServer
|
||||
import folder_paths
|
||||
|
||||
from ..utils import abspath, get_dict_value, load_json_file, file_exists, remove_path, save_json_file
|
||||
from ..utils_userdata import read_userdata_json, save_userdata_json, delete_userdata_file
|
||||
|
||||
|
||||
def _get_info_cache_file(data_type: str, file_hash: str):
|
||||
return f'info/{file_hash}.{data_type}.json'
|
||||
|
||||
|
||||
async def delete_model_info(
|
||||
file: str, model_type, del_info=True, del_metadata=True, del_civitai=True
|
||||
):
|
||||
"""Delete the info json, and the civitai & metadata caches."""
|
||||
file_path = get_folder_path(file, model_type)
|
||||
if file_path is None:
|
||||
return
|
||||
if del_info:
|
||||
remove_path(get_info_file(file_path))
|
||||
if del_civitai or del_metadata:
|
||||
file_hash = _get_sha256_hash(file_path)
|
||||
if del_civitai:
|
||||
json_file_path = _get_info_cache_file(file_hash, 'civitai')
|
||||
delete_userdata_file(json_file_path)
|
||||
if del_metadata:
|
||||
json_file_path = _get_info_cache_file(file_hash, 'metadata')
|
||||
delete_userdata_file(json_file_path)
|
||||
|
||||
|
||||
def get_file_info(file: str, model_type):
|
||||
"""Gets basic file info, like created or modified date."""
|
||||
file_path = get_folder_path(file, model_type)
|
||||
if file_path is None:
|
||||
return None
|
||||
return {
|
||||
'file': file,
|
||||
'path': file_path,
|
||||
'modified': os.path.getmtime(file_path) * 1000, # millis
|
||||
'imageLocal': f'/loraloader-mxd/api/{model_type}/img?file={file}' if get_img_file(file_path) else None,
|
||||
'hasInfoFile': get_info_file(file_path) is not None,
|
||||
}
|
||||
|
||||
|
||||
def get_info_file(file_path: str, force=False):
|
||||
# Try to load a rgthree-info.json file next to the file.
|
||||
info_path = f'{file_path}.rgthree-info.json'
|
||||
return info_path if file_exists(info_path) or force else None
|
||||
|
||||
|
||||
def get_img_file(file_path: str, force=False):
|
||||
for ext in ['jpg', 'png', 'jpeg', 'webp']:
|
||||
try_path = f'{os.path.splitext(file_path)[0]}.{ext}'
|
||||
if file_exists(try_path):
|
||||
return try_path
|
||||
|
||||
|
||||
def get_model_info_file_data(file: str, model_type, default=None):
|
||||
"""Returns the data from the info file, or a default value if it doesn't exist."""
|
||||
file_path = get_folder_path(file, model_type)
|
||||
if file_path is None:
|
||||
return default
|
||||
return load_json_file(get_info_file(file_path), default=default)
|
||||
|
||||
|
||||
async def get_model_info(
|
||||
file: str,
|
||||
model_type,
|
||||
default=None,
|
||||
maybe_fetch_civitai=False,
|
||||
force_fetch_civitai=False,
|
||||
maybe_fetch_metadata=False,
|
||||
force_fetch_metadata=False,
|
||||
light=False
|
||||
):
|
||||
"""Compiles a model info given a stored file next to the model, and/or metadata/civitai."""
|
||||
|
||||
file_path = get_folder_path(file, model_type)
|
||||
if file_path is None:
|
||||
return default
|
||||
|
||||
should_save = False
|
||||
# basic data
|
||||
basic_data = get_file_info(file, model_type)
|
||||
# Try to load a rgthree-info.json file next to the file.
|
||||
info_data = get_model_info_file_data(file, model_type, default={})
|
||||
|
||||
for key in ['file', 'path', 'modified', 'imageLocal', 'hasInfoFile']:
|
||||
if key in basic_data and basic_data[key] and (
|
||||
key not in info_data or info_data[key] != basic_data[key]
|
||||
):
|
||||
info_data[key] = basic_data[key]
|
||||
should_save = True
|
||||
|
||||
# Check if we have an image next to the file and, if so, add it to the front of the images
|
||||
# (if it isn't already).
|
||||
img_next_to_file = basic_data['imageLocal']
|
||||
|
||||
if 'images' not in info_data:
|
||||
info_data['images'] = []
|
||||
should_save = True
|
||||
|
||||
if img_next_to_file:
|
||||
if len(info_data['images']) == 0 or info_data['images'][0]['url'] != img_next_to_file:
|
||||
info_data['images'].insert(0, {'url': img_next_to_file})
|
||||
should_save = True
|
||||
|
||||
# If we just want light data then bail now with just existing data, plus file, path and img if
|
||||
# next to the file.
|
||||
if light and not maybe_fetch_metadata and not force_fetch_metadata and not maybe_fetch_civitai and not force_fetch_civitai:
|
||||
return info_data
|
||||
|
||||
if 'raw' not in info_data:
|
||||
info_data['raw'] = {}
|
||||
should_save = True
|
||||
|
||||
should_save = _update_data(info_data) or should_save
|
||||
|
||||
should_fetch_civitai = force_fetch_civitai is True or (
|
||||
maybe_fetch_civitai is True and 'civitai' not in info_data['raw']
|
||||
)
|
||||
should_fetch_metadata = force_fetch_metadata is True or (
|
||||
maybe_fetch_metadata is True and 'metadata' not in info_data['raw']
|
||||
)
|
||||
|
||||
if should_fetch_metadata:
|
||||
data_meta = _get_model_metadata(file, model_type, default={}, refresh=force_fetch_metadata)
|
||||
should_save = _merge_metadata(info_data, data_meta) or should_save
|
||||
|
||||
if should_fetch_civitai:
|
||||
data_civitai = _get_model_civitai_data(
|
||||
file, model_type, default={}, refresh=force_fetch_civitai
|
||||
)
|
||||
should_save = _merge_civitai_data(info_data, data_civitai) or should_save
|
||||
|
||||
if 'sha256' not in info_data:
|
||||
file_hash = _get_sha256_hash(file_path)
|
||||
if file_hash is not None:
|
||||
info_data['sha256'] = file_hash
|
||||
should_save = True
|
||||
|
||||
if should_save:
|
||||
if 'trainedWords' in info_data:
|
||||
# Sort by count; if it doesn't exist, then assume it's a top item from civitai or elsewhere.
|
||||
info_data['trainedWords'] = sorted(
|
||||
info_data['trainedWords'],
|
||||
key=lambda w: w['count'] if 'count' in w else 99999,
|
||||
reverse=True
|
||||
)
|
||||
save_model_info(file, info_data, model_type)
|
||||
|
||||
# If we're saving, then the UI is likely waiting to see if the refreshed data is coming in.
|
||||
await PromptServer.instance.send(f"loraloader-mxd-refreshed-{model_type}-info", {"data": info_data})
|
||||
|
||||
return info_data
|
||||
|
||||
|
||||
def _update_data(info_data: dict) -> bool:
|
||||
"""Ports old data to new data if necessary."""
|
||||
should_save = False
|
||||
# If we have "triggerWords" then move them over to "trainedWords"
|
||||
if 'triggerWords' in info_data and len(info_data['triggerWords']) > 0:
|
||||
civitai_words = ','.join((
|
||||
get_dict_value(info_data, 'raw.civitai.triggerWords', default=[]) +
|
||||
get_dict_value(info_data, 'raw.civitai.trainedWords', default=[])
|
||||
))
|
||||
if 'trainedWords' not in info_data:
|
||||
info_data['trainedWords'] = []
|
||||
for trigger_word in info_data['triggerWords']:
|
||||
word_data = next((data for data in info_data['trainedWords'] if data['word'] == trigger_word),
|
||||
None)
|
||||
if word_data is None:
|
||||
word_data = {'word': trigger_word}
|
||||
info_data['trainedWords'].append(word_data)
|
||||
if trigger_word in civitai_words:
|
||||
word_data['civitai'] = True
|
||||
else:
|
||||
word_data['user'] = True
|
||||
|
||||
del info_data['triggerWords']
|
||||
should_save = True
|
||||
return should_save
|
||||
|
||||
|
||||
def _merge_metadata(info_data: dict, data_meta: dict) -> bool:
|
||||
"""Returns true if data was saved."""
|
||||
should_save = False
|
||||
|
||||
base_model_file = get_dict_value(data_meta, 'ss_sd_model_name', None)
|
||||
if base_model_file:
|
||||
info_data['baseModelFile'] = base_model_file
|
||||
|
||||
# Loop over metadata tags
|
||||
trained_words = {}
|
||||
if 'ss_tag_frequency' in data_meta and isinstance(data_meta['ss_tag_frequency'], dict):
|
||||
for bucket_value in data_meta['ss_tag_frequency'].values():
|
||||
if isinstance(bucket_value, dict):
|
||||
for tag, count in bucket_value.items():
|
||||
if tag not in trained_words:
|
||||
trained_words[tag] = {'word': tag, 'count': 0, 'metadata': True}
|
||||
trained_words[tag]['count'] = trained_words[tag]['count'] + count
|
||||
|
||||
if 'trainedWords' not in info_data:
|
||||
info_data['trainedWords'] = list(trained_words.values())
|
||||
should_save = True
|
||||
else:
|
||||
# We can't merge, because the list may have other data, like it's part of civitaidata.
|
||||
merged_dict = {}
|
||||
for existing_word_data in info_data['trainedWords']:
|
||||
merged_dict[existing_word_data['word']] = existing_word_data
|
||||
for new_key, new_word_data in trained_words.items():
|
||||
if new_key not in merged_dict:
|
||||
merged_dict[new_key] = {}
|
||||
merged_dict[new_key] = {**merged_dict[new_key], **new_word_data}
|
||||
info_data['trainedWords'] = list(merged_dict.values())
|
||||
should_save = True
|
||||
|
||||
# trained_words = list(trained_words.values())
|
||||
# info_data['meta_trained_words'] = trained_words
|
||||
info_data['raw']['metadata'] = data_meta
|
||||
should_save = True
|
||||
|
||||
if 'sha256' not in info_data and '_sha256' in data_meta:
|
||||
info_data['sha256'] = data_meta['_sha256']
|
||||
should_save = True
|
||||
|
||||
return should_save
|
||||
|
||||
|
||||
def _merge_civitai_data(info_data: dict, data_civitai: dict) -> bool:
|
||||
"""Returns true if data was saved."""
|
||||
should_save = False
|
||||
|
||||
if 'name' not in info_data:
|
||||
info_data['name'] = get_dict_value(data_civitai, 'model.name', '')
|
||||
should_save = True
|
||||
version_name = get_dict_value(data_civitai, 'name')
|
||||
if version_name is not None:
|
||||
info_data['name'] += f' - {version_name}'
|
||||
|
||||
if 'type' not in info_data:
|
||||
info_data['type'] = get_dict_value(data_civitai, 'model.type')
|
||||
should_save = True
|
||||
if 'baseModel' not in info_data:
|
||||
info_data['baseModel'] = get_dict_value(data_civitai, 'baseModel')
|
||||
should_save = True
|
||||
|
||||
# We always want to merge triggerword.
|
||||
civitai_trigger = get_dict_value(data_civitai, 'triggerWords', default=[])
|
||||
civitai_trained = get_dict_value(data_civitai, 'trainedWords', default=[])
|
||||
civitai_words = ','.join(civitai_trigger + civitai_trained)
|
||||
if civitai_words:
|
||||
civitai_words = re.sub(r"\s*,\s*", ",", civitai_words)
|
||||
civitai_words = re.sub(r",+", ",", civitai_words)
|
||||
civitai_words = re.sub(r"^,", "", civitai_words)
|
||||
civitai_words = re.sub(r",$", "", civitai_words)
|
||||
if civitai_words:
|
||||
civitai_words = civitai_words.split(',')
|
||||
if 'trainedWords' not in info_data:
|
||||
info_data['trainedWords'] = []
|
||||
for trigger_word in civitai_words:
|
||||
word_data = next(
|
||||
(data for data in info_data['trainedWords'] if data['word'] == trigger_word), None
|
||||
)
|
||||
if word_data is None:
|
||||
word_data = {'word': trigger_word}
|
||||
info_data['trainedWords'].append(word_data)
|
||||
word_data['civitai'] = True
|
||||
|
||||
if 'sha256' not in info_data:
|
||||
info_data['sha256'] = data_civitai['_sha256']
|
||||
should_save = True
|
||||
|
||||
if 'modelId' in data_civitai:
|
||||
info_data['links'] = info_data['links'] if 'links' in info_data else []
|
||||
civitai_link = f'https://civitai.com/models/{get_dict_value(data_civitai, "modelId")}'
|
||||
if get_dict_value(data_civitai, "id"):
|
||||
civitai_link += f'?modelVersionId={get_dict_value(data_civitai, "id")}'
|
||||
info_data['links'].append(civitai_link)
|
||||
info_data['links'].append(data_civitai['_civitai_api'])
|
||||
should_save = True
|
||||
|
||||
# Take images from civitai
|
||||
if 'images' in data_civitai:
|
||||
info_data_image_urls = list(
|
||||
map(lambda i: i['url'] if 'url' in i else None, info_data['images'])
|
||||
)
|
||||
for img in data_civitai['images']:
|
||||
img_url = get_dict_value(img, 'url')
|
||||
if img_url is not None and img_url not in info_data_image_urls:
|
||||
img_id = os.path.splitext(os.path.basename(img_url))[0] if img_url is not None else None
|
||||
img_data = {
|
||||
'url': img_url,
|
||||
'civitaiUrl': f'https://civitai.com/images/{img_id}' if img_id is not None else None,
|
||||
'width': get_dict_value(img, 'width'),
|
||||
'height': get_dict_value(img, 'height'),
|
||||
'type': get_dict_value(img, 'type'),
|
||||
'nsfwLevel': get_dict_value(img, 'nsfwLevel'),
|
||||
'seed': get_dict_value(img, 'meta.seed'),
|
||||
'positive': get_dict_value(img, 'meta.prompt'),
|
||||
'negative': get_dict_value(img, 'meta.negativePrompt'),
|
||||
'steps': get_dict_value(img, 'meta.steps'),
|
||||
'sampler': get_dict_value(img, 'meta.sampler'),
|
||||
'cfg': get_dict_value(img, 'meta.cfgScale'),
|
||||
'model': get_dict_value(img, 'meta.Model'),
|
||||
'resources': get_dict_value(img, 'meta.resources'),
|
||||
}
|
||||
info_data['images'].append(img_data)
|
||||
should_save = True
|
||||
|
||||
# The raw data
|
||||
if 'civitai' not in info_data['raw']:
|
||||
info_data['raw']['civitai'] = data_civitai
|
||||
should_save = True
|
||||
|
||||
return should_save
|
||||
|
||||
|
||||
def _get_model_civitai_data(file: str, model_type, default=None, refresh=False):
|
||||
"""Gets the civitai data, either cached from the user directory, or from civitai api."""
|
||||
file_hash = _get_sha256_hash(get_folder_path(file, model_type))
|
||||
if file_hash is None:
|
||||
return None
|
||||
|
||||
json_file_path = _get_info_cache_file(file_hash, 'civitai')
|
||||
|
||||
api_url = f'https://civitai.com/api/v1/model-versions/by-hash/{file_hash}'
|
||||
file_data = read_userdata_json(json_file_path)
|
||||
if file_data is None or refresh is True:
|
||||
try:
|
||||
response = requests.get(api_url, timeout=5000)
|
||||
data = response.json()
|
||||
save_userdata_json(
|
||||
json_file_path, {
|
||||
'url': api_url,
|
||||
'timestamp': datetime.now().timestamp(),
|
||||
'response': data
|
||||
}
|
||||
)
|
||||
file_data = read_userdata_json(json_file_path)
|
||||
except requests.exceptions.RequestException as e: # This is the correct syntax
|
||||
print(e)
|
||||
response = file_data['response'] if file_data is not None and 'response' in file_data else None
|
||||
if response is not None:
|
||||
response['_sha256'] = file_hash
|
||||
response['_civitai_api'] = api_url
|
||||
return response if response is not None else default
|
||||
|
||||
|
||||
def _get_model_metadata(file: str, model_type, default=None, refresh=False):
|
||||
"""Gets the metadata from the file itself."""
|
||||
file_path = get_folder_path(file, model_type)
|
||||
file_hash = _get_sha256_hash(file_path)
|
||||
if file_hash is None:
|
||||
return default
|
||||
|
||||
json_file_path = _get_info_cache_file(file_hash, 'metadata')
|
||||
|
||||
file_data = read_userdata_json(json_file_path)
|
||||
if file_data is None or refresh is True:
|
||||
data = _read_file_metadata_from_header(file_path)
|
||||
if data is not None:
|
||||
file_data = {'url': file, 'timestamp': datetime.now().timestamp(), 'response': data}
|
||||
save_userdata_json(json_file_path, file_data)
|
||||
response = file_data['response'] if file_data is not None and 'response' in file_data else None
|
||||
if response is not None:
|
||||
response['_sha256'] = file_hash
|
||||
return response if response is not None else default
|
||||
|
||||
|
||||
def _read_file_metadata_from_header(file_path: str) -> dict:
|
||||
"""Reads the file's header and returns a JSON dict metdata if available."""
|
||||
data = None
|
||||
try:
|
||||
if file_path.endswith('.safetensors'):
|
||||
with open(file_path, "rb") as file:
|
||||
# https://github.com/huggingface/safetensors#format
|
||||
# 8 bytes: N, an unsigned little-endian 64-bit integer, containing the size of the header
|
||||
header_size = int.from_bytes(file.read(8), "little", signed=False)
|
||||
|
||||
if header_size <= 0:
|
||||
raise BufferError("Invalid header size")
|
||||
|
||||
header = file.read(header_size)
|
||||
if header is None:
|
||||
raise BufferError("Invalid header")
|
||||
|
||||
header_json = json.loads(header)
|
||||
data = header_json["__metadata__"] if "__metadata__" in header_json else None
|
||||
|
||||
if data is not None:
|
||||
for key, value in data.items():
|
||||
if isinstance(value, str) and value.startswith('{') and value.endswith('}'):
|
||||
try:
|
||||
value_as_json = json.loads(value)
|
||||
data[key] = value_as_json
|
||||
except Exception:
|
||||
print(f'metdata for field {key} did not parse as json')
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(e)
|
||||
data = None
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def get_folder_path(file: str, model_type) -> str | None:
|
||||
"""Gets the file path ensuring it exists."""
|
||||
file_path = folder_paths.get_full_path(model_type, file)
|
||||
if not file_exists(file_path):
|
||||
file_path = abspath(file_path)
|
||||
if not file_exists(file_path):
|
||||
file_path = None
|
||||
return file_path
|
||||
|
||||
|
||||
def _get_sha256_hash(file_path: str | None):
|
||||
"""Returns the hash for the file."""
|
||||
if not file_path or not file_exists(file_path):
|
||||
return None
|
||||
BUF_SIZE = 1024 * 128 # lets read stuff in 64kb chunks!
|
||||
file_hash = None
|
||||
sha256_hash = hashlib.sha256()
|
||||
with open(file_path, "rb") as f:
|
||||
# Read and update hash string value in blocks of BUF_SIZE
|
||||
for byte_block in iter(lambda: f.read(BUF_SIZE), b""):
|
||||
sha256_hash.update(byte_block)
|
||||
file_hash = sha256_hash.hexdigest()
|
||||
return file_hash
|
||||
|
||||
|
||||
async def set_model_info_partial(file: str, model_type: str, info_data_partial):
|
||||
"""Sets partial data into the existing model info data."""
|
||||
info_data = await get_model_info(file, model_type, default={})
|
||||
info_data = {**info_data, **info_data_partial}
|
||||
save_model_info(file, info_data, model_type)
|
||||
|
||||
|
||||
def save_model_info(file: str, info_data, model_type):
|
||||
"""Saves the model info alongside the model itself."""
|
||||
file_path = get_folder_path(file, model_type)
|
||||
if file_path is None:
|
||||
return
|
||||
info_path = get_info_file(file_path, force=True)
|
||||
save_json_file(info_path, info_data)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import os
|
||||
from aiohttp import web
|
||||
|
||||
THIS_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
DIR_WEB = os.path.abspath(f'{THIS_DIR}/../../web/')
|
||||
|
||||
|
||||
def get_param(request, param, default=None):
|
||||
"""Gets a param from a request."""
|
||||
return request.rel_url.query[param] if param in request.rel_url.query else default
|
||||
|
||||
|
||||
def is_param_falsy(request, param):
|
||||
"""Determines if a param is explicitly 0 or false."""
|
||||
val = get_param(request, param)
|
||||
return val is not None and (val == "0" or val.upper() == "FALSE")
|
||||
|
||||
|
||||
def is_param_truthy(request, param):
|
||||
"""Determines if a param is explicitly 0 or false."""
|
||||
val = get_param(request, param)
|
||||
return val is not None and not is_param_falsy(request, param)
|
||||
|
||||
|
||||
def set_default_page_resources(path, routes):
|
||||
""" Sets up routes for handling static files under a path."""
|
||||
|
||||
@routes.get(f'/rgthree/{path}/{{file}}')
|
||||
async def get_resource(request):
|
||||
""" Returns a resource file. """
|
||||
return web.FileResponse(os.path.join(DIR_WEB, path, request.match_info['file']))
|
||||
|
||||
@routes.get(f'/rgthree/{path}/{{subdir}}/{{file}}')
|
||||
async def get_resource_subdir(request):
|
||||
""" Returns a resource file. """
|
||||
return web.FileResponse(
|
||||
os.path.join(DIR_WEB, path, request.match_info['subdir'], request.match_info['file']))
|
||||
|
||||
|
||||
def set_default_page_routes(path, routes):
|
||||
""" Sets default path handling for a hosted rgthree page. """
|
||||
|
||||
@routes.get(f'/rgthree/{path}')
|
||||
async def get_path_redir(request):
|
||||
""" Redirects to the path adding a trailing slash. """
|
||||
raise web.HTTPFound(f'{request.path}/')
|
||||
|
||||
@routes.get(f'/rgthree/{path}/')
|
||||
async def get_path_index(request):
|
||||
""" Handles the page's index loading. """
|
||||
html = ''
|
||||
with open(os.path.join(DIR_WEB, path, 'index.html'), 'r', encoding='UTF-8') as file:
|
||||
html = file.read()
|
||||
return web.Response(text=html, content_type='text/html')
|
||||
|
||||
set_default_page_resources(path, routes)
|
||||
@@ -0,0 +1,168 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
||||
from typing import Union
|
||||
|
||||
|
||||
class AnyType(str):
|
||||
"""A special class that is always equal in not equal comparisons. Credit to pythongosssss"""
|
||||
|
||||
def __ne__(self, __value: object) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
class FlexibleOptionalInputType(dict):
|
||||
"""A special class to make flexible nodes that pass data to our python handlers.
|
||||
|
||||
Enables both flexible/dynamic input types (like for Any Switch) or a dynamic number of inputs
|
||||
(like for Any Switch, Context Switch, Context Merge, Power Lora Loader, etc).
|
||||
|
||||
Initially, ComfyUI only needed to return True for `__contains__` below, which told ComfyUI that
|
||||
our node will handle the input, regardless of what it is.
|
||||
|
||||
However, after https://github.com/comfyanonymous/ComfyUI/pull/2666 ComdyUI's execution changed
|
||||
also checking the data for the key; specifcially, the type which is the first tuple entry. This
|
||||
type is supplied to our FlexibleOptionalInputType and returned for any non-data key. This can be a
|
||||
real type, or use the AnyType for additional flexibility.
|
||||
"""
|
||||
|
||||
def __init__(self, type, data: Union[dict, None] = None):
|
||||
"""Initializes the FlexibleOptionalInputType.
|
||||
|
||||
Args:
|
||||
type: The flexible type to use when ComfyUI retrieves an unknown key (via `__getitem__`).
|
||||
data: An optional dict to use as the basis. This is stored both in a `data` attribute, so we
|
||||
can look it up without hitting our overrides, as well as iterated over and adding its key
|
||||
and values to our `self` keys. This way, when looked at, we will appear to represent this
|
||||
data. When used in an "optional" INPUT_TYPES, these are the starting optional node types.
|
||||
"""
|
||||
self.type = type
|
||||
self.data = data
|
||||
if self.data is not None:
|
||||
for k, v in self.data.items():
|
||||
self[k] = v
|
||||
|
||||
def __getitem__(self, key):
|
||||
# If we have this key in the initial data, then return it. Otherwise return the tuple with our
|
||||
# flexible type.
|
||||
if self.data is not None and key in self.data:
|
||||
val = self.data[key]
|
||||
return val
|
||||
return (self.type,)
|
||||
|
||||
def __contains__(self, key):
|
||||
"""Always contain a key, and we'll always return the tuple above when asked for it."""
|
||||
return True
|
||||
|
||||
|
||||
any_type = AnyType("*")
|
||||
|
||||
|
||||
def is_dict_value_falsy(data: dict, dict_key: str):
|
||||
"""Checks if a dict value is falsy."""
|
||||
val = get_dict_value(data, dict_key)
|
||||
return not val
|
||||
|
||||
|
||||
def get_dict_value(data: dict, dict_key: str, default=None):
|
||||
"""Gets a deeply nested value given a dot-delimited key."""
|
||||
keys = dict_key.split('.')
|
||||
key = keys.pop(0) if len(keys) > 0 else None
|
||||
found = data[key] if key in data else None
|
||||
if found is not None and len(keys) > 0:
|
||||
return get_dict_value(found, '.'.join(keys), default)
|
||||
return found if found is not None else default
|
||||
|
||||
|
||||
def set_dict_value(data: dict, dict_key: str, value, create_missing_objects=True):
|
||||
"""Sets a deeply nested value given a dot-delimited key."""
|
||||
keys = dict_key.split('.')
|
||||
key = keys.pop(0) if len(keys) > 0 else None
|
||||
if key not in data:
|
||||
if create_missing_objects is False:
|
||||
return data
|
||||
data[key] = {}
|
||||
if len(keys) == 0:
|
||||
data[key] = value
|
||||
else:
|
||||
set_dict_value(data[key], '.'.join(keys), value, create_missing_objects)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def dict_has_key(data: dict, dict_key):
|
||||
"""Checks if a dict has a deeply nested dot-delimited key."""
|
||||
keys = dict_key.split('.')
|
||||
key = keys.pop(0) if len(keys) > 0 else None
|
||||
if key is None or key not in data:
|
||||
return False
|
||||
if len(keys) == 0:
|
||||
return True
|
||||
return dict_has_key(data[key], '.'.join(keys))
|
||||
|
||||
|
||||
def load_json_file(file: str, default=None):
|
||||
"""Reads a json file and returns the json dict, stripping out "//" comments first."""
|
||||
if path_exists(file):
|
||||
with open(file, 'r', encoding='UTF-8') as file:
|
||||
config = file.read()
|
||||
try:
|
||||
return json.loads(config)
|
||||
except json.decoder.JSONDecodeError:
|
||||
try:
|
||||
config = re.sub(r"^\s*//\s.*", "", config, flags=re.MULTILINE)
|
||||
return json.loads(config)
|
||||
except json.decoder.JSONDecodeError:
|
||||
try:
|
||||
config = re.sub(r"(?:^|\s)//.*", "", config, flags=re.MULTILINE)
|
||||
return json.loads(config)
|
||||
except json.decoder.JSONDecodeError:
|
||||
pass
|
||||
return default
|
||||
|
||||
|
||||
def save_json_file(file_path: str, data: dict):
|
||||
"""Saves a json file."""
|
||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||
with open(file_path, 'w+', encoding='UTF-8') as file:
|
||||
json.dump(data, file, sort_keys=False, indent=2, separators=(",", ": "))
|
||||
|
||||
|
||||
def path_exists(path):
|
||||
"""Checks if a path exists, accepting None type."""
|
||||
if path is not None:
|
||||
return os.path.exists(path)
|
||||
return False
|
||||
|
||||
|
||||
def file_exists(path):
|
||||
"""Checks if a file exists, accepting None type."""
|
||||
if path is not None:
|
||||
return os.path.isfile(path)
|
||||
return False
|
||||
|
||||
|
||||
def remove_path(path):
|
||||
"""Removes a path, if it exists."""
|
||||
if path_exists(path):
|
||||
os.remove(path)
|
||||
return True
|
||||
return False
|
||||
|
||||
def abspath(file_path: str):
|
||||
"""Resolves the abspath of a file, resolving symlinks and user dirs."""
|
||||
if file_path and not path_exists(file_path):
|
||||
maybe_path = os.path.abspath(os.path.realpath(os.path.expanduser(file_path)))
|
||||
file_path = maybe_path if path_exists(maybe_path) else file_path
|
||||
return file_path
|
||||
|
||||
class ByPassTypeTuple(tuple):
|
||||
"""A special class that will return additional "AnyType" strings beyond defined values.
|
||||
Credit to Trung0246
|
||||
"""
|
||||
|
||||
def __getitem__(self, index):
|
||||
if index > len(self) - 1:
|
||||
return AnyType("*")
|
||||
return super().__getitem__(index)
|
||||
@@ -0,0 +1,88 @@
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from .utils import load_json_file, path_exists, save_json_file
|
||||
|
||||
THIS_DIR = Path(__file__).resolve().parent
|
||||
MAXEDOUT_ROOT = THIS_DIR.parent
|
||||
USERDATA = MAXEDOUT_ROOT / "userdata" / "loraloader_mxd"
|
||||
LEGACY_USERDATA = MAXEDOUT_ROOT.parent / "ComfyUI-LoraLoader-MXD" / "userdata"
|
||||
MIGRATION_SENTINEL = USERDATA / ".migrated_from_standalone"
|
||||
|
||||
|
||||
def _log(message: str):
|
||||
print(f"[ComfyUI-MaxedOut][LoraLoader-MXD] {message}")
|
||||
|
||||
|
||||
def _migrate_legacy_userdata_once():
|
||||
if MIGRATION_SENTINEL.exists():
|
||||
return
|
||||
if not LEGACY_USERDATA.exists():
|
||||
return
|
||||
|
||||
copied = 0
|
||||
skipped = 0
|
||||
for src in LEGACY_USERDATA.rglob("*"):
|
||||
if not src.is_file():
|
||||
continue
|
||||
rel = src.relative_to(LEGACY_USERDATA)
|
||||
dest = USERDATA / rel
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
if dest.exists():
|
||||
skipped += 1
|
||||
continue
|
||||
shutil.copy2(src, dest)
|
||||
copied += 1
|
||||
|
||||
USERDATA.mkdir(parents=True, exist_ok=True)
|
||||
MIGRATION_SENTINEL.write_text("ok\n", encoding="utf-8")
|
||||
_log(f"Migrated legacy userdata files: copied={copied}, skipped_existing={skipped}")
|
||||
|
||||
|
||||
_migrate_legacy_userdata_once()
|
||||
|
||||
|
||||
def read_userdata_file(rel_path: str):
|
||||
"""Reads a file from the userdata directory."""
|
||||
file_path = clean_path(rel_path)
|
||||
if path_exists(file_path):
|
||||
with open(file_path, 'r', encoding='UTF-8') as file:
|
||||
return file.read()
|
||||
return None
|
||||
|
||||
|
||||
def save_userdata_file(rel_path: str, content: str):
|
||||
"""Saves a file from the userdata directory."""
|
||||
file_path = clean_path(rel_path)
|
||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||
with open(file_path, 'w+', encoding='UTF-8') as file:
|
||||
file.write(content)
|
||||
|
||||
|
||||
def delete_userdata_file(rel_path: str):
|
||||
"""Deletes a file from the userdata directory."""
|
||||
file_path = clean_path(rel_path)
|
||||
if os.path.isfile(file_path):
|
||||
os.remove(file_path)
|
||||
|
||||
|
||||
def read_userdata_json(rel_path: str):
|
||||
"""Reads a json file from the userdata directory."""
|
||||
file_path = clean_path(rel_path)
|
||||
return load_json_file(file_path)
|
||||
|
||||
|
||||
def save_userdata_json(rel_path: str, data: dict):
|
||||
"""Saves a json file from the userdata directory."""
|
||||
file_path = clean_path(rel_path)
|
||||
return save_json_file(file_path, data)
|
||||
|
||||
|
||||
def clean_path(rel_path: str):
|
||||
"""Cleans a relative path by splitting on forward slash and os.path.joining."""
|
||||
cleaned = USERDATA
|
||||
paths = rel_path.split('/')
|
||||
for path in paths:
|
||||
cleaned = cleaned / path
|
||||
return str(cleaned)
|
||||
+29
-54
@@ -221,24 +221,33 @@ class ZImageTurboEmptyLatentImage:
|
||||
TITLE = "Z-Image Turbo Empty Latent Image"
|
||||
CATEGORY = "MXD/Latent"
|
||||
|
||||
# Same resolutions as your original, just grouped like Flux
|
||||
# Tuned for Z-Image Turbo:
|
||||
# - Rule of 64: every dimension is a multiple of 64
|
||||
# - 1MP baseline: 1024x1024 in the standard tier
|
||||
# - Ceiling: keep presets below 6.5MP
|
||||
MAX_TOTAL_PIXELS = 6_500_000
|
||||
MIN_BLOCK = 64
|
||||
RESOLUTIONS = {
|
||||
"— High Resolutions —": None,
|
||||
"Square (1:1) 1536x1536": (1536, 1536),
|
||||
"Square (1:1) 1280x1280": (1280, 1280),
|
||||
"Photo (4:3) 1792x1344": (1792, 1344),
|
||||
"Landscape (3:2) 1920x1280": (1920, 1280),
|
||||
"Widescreen (16:9) 2048x1152": (2048, 1152),
|
||||
"Ultrawide (21:9) 2016x864": (2016, 864),
|
||||
"Ultrawide (21:9) 2304x1024": (2304, 1024),
|
||||
|
||||
"— Standard Resolutions —": None,
|
||||
"Square (1:1) 1024x1024": (1024, 1024),
|
||||
"Standard (3:2) 1536x1024": (1536, 1024),
|
||||
"Widescreen (16:9) 1920x1088": (1920, 1088),
|
||||
"Ultrawide (21:9) 1680x720": (1680, 720),
|
||||
"Photo (4:3) 1152x896": (1152, 896),
|
||||
"Landscape (3:2) 1280x832": (1280, 832),
|
||||
"Widescreen (16:9) 1344x768": (1344, 768),
|
||||
"Ultrawide (21:9) 1536x640": (1536, 640),
|
||||
|
||||
"— Low Resolutions —": None,
|
||||
"Square (1:1) 768x768": (768, 768),
|
||||
"Standard (3:2) 1216x832": (1216, 832),
|
||||
"Widescreen (16:9) 1280x720": (1280, 720),
|
||||
"Square (1:1) 512x512": (512, 512),
|
||||
"Photo (4:3) 576x448": (576, 448),
|
||||
"Landscape (3:2) 640x448": (640, 448),
|
||||
"Widescreen (16:9) 704x384": (704, 384),
|
||||
"Ultrawide (21:9) 768x320": (768, 320),
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
@@ -276,6 +285,15 @@ class ZImageTurboEmptyLatentImage:
|
||||
if vertical:
|
||||
width, height = height, width
|
||||
|
||||
if (width % self.MIN_BLOCK) != 0 or (height % self.MIN_BLOCK) != 0:
|
||||
raise ValueError(
|
||||
f"Invalid preset {width}x{height}. Z-Image Turbo requires multiples of {self.MIN_BLOCK}."
|
||||
)
|
||||
if (width * height) > self.MAX_TOTAL_PIXELS:
|
||||
raise ValueError(
|
||||
f"Invalid preset {width}x{height}. Z-Image Turbo presets must stay at or below {self.MAX_TOTAL_PIXELS:,} pixels."
|
||||
)
|
||||
|
||||
latent = torch.zeros([batch_size, 16, height // 8, width // 8], device=self.device)
|
||||
return ({"samples": latent},)
|
||||
|
||||
@@ -1377,32 +1395,8 @@ class SaveImage_MXD:
|
||||
# Strip UI previews so nothing shows up in the ComfyUI viewer.
|
||||
return {k: v for k, v in result.items() if k != "ui"}
|
||||
return result
|
||||
|
||||
|
||||
|
||||
########################################################################################################################
|
||||
# Dummy Node (for workflow missing-node testing)
|
||||
class DummyNodeMXD:
|
||||
DESCRIPTION = """Basic dummy node for missing-node workflow tests."""
|
||||
TITLE = "Dummy Node"
|
||||
CATEGORY = "MXD/Test"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls) -> dict:
|
||||
return {
|
||||
"required": {
|
||||
"text": ("STRING", {"default": "hello"}),
|
||||
"repeat": ("INT", {"default": 1, "min": 1, "max": 10}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
OUTPUT_TOOLTIPS = ("The input text repeated N times.",)
|
||||
FUNCTION = "run"
|
||||
|
||||
def run(self, text, repeat) -> tuple:
|
||||
return (text * int(repeat),)
|
||||
|
||||
|
||||
|
||||
# NODE MAPPING
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
@@ -1424,7 +1418,6 @@ NODE_CLASS_MAPPINGS = {
|
||||
"LoadImageWithPromptsMXD": LoadImageWithPromptsMXD,
|
||||
"ZImageTurboEmptyLatentImage": ZImageTurboEmptyLatentImage,
|
||||
"Save Image MXD": SaveImage_MXD,
|
||||
"Dummy Node MXD": DummyNodeMXD,
|
||||
}
|
||||
|
||||
if HAVE_COMFY_API:
|
||||
@@ -1450,9 +1443,8 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"Crop Image By Mask": "Crop Image by Mask MXD",
|
||||
"Load Image Batch MXD": "Load Image Batch MXD",
|
||||
"LoadImageWithPromptsMXD": "Load Image MXD",
|
||||
"ZImageTurboEmptyLatentImage": "ZImageTurbo Empty Latent Image MXD",
|
||||
"ZImageTurboEmptyLatentImage": "ZIT Empty Latent Image MXD",
|
||||
"Save Image MXD": "Save Image MXD",
|
||||
"Dummy Node MXD": "Dummy Node MXD",
|
||||
}
|
||||
|
||||
if HAVE_COMFY_API:
|
||||
@@ -1460,20 +1452,3 @@ if HAVE_COMFY_API:
|
||||
"QwenImageEditSingleMXD": "Qwen Image Edit + Latent MXD",
|
||||
"QwenImageEditTripleMXD": "Qwen Image Edit Prompt MXD (Triple)",
|
||||
})
|
||||
|
||||
def _add_mxd_aliases(class_map, display_map):
|
||||
alias_sources = {}
|
||||
for key in list(class_map.keys()):
|
||||
if "MXD" in key.upper():
|
||||
continue
|
||||
alias = f"{key} MXD"
|
||||
if alias in class_map:
|
||||
continue
|
||||
class_map[alias] = class_map[key]
|
||||
alias_sources[alias] = key
|
||||
for alias, source in alias_sources.items():
|
||||
if alias not in display_map:
|
||||
display_map[alias] = display_map.get(source, alias)
|
||||
return alias_sources
|
||||
|
||||
_add_mxd_aliases(NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS)
|
||||
|
||||
+3
-2
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
[project]
|
||||
name = "ComfyUI-MaxedOut"
|
||||
description = "Custom ComfyUI nodes used in Maxed Out workflows (SDXL, Flux, Wan 2.2, etc.)"
|
||||
version = "1.7.2"
|
||||
version = "1.8.0"
|
||||
license = {file = "LICENSE"}
|
||||
# classifiers = [
|
||||
# # For OS-independent nodes (works on all operating systems)
|
||||
@@ -34,3 +34,4 @@ Icon = "https://github.com/Maxed-Out-99/ComfyUI-MaxedOut/blob/main/assets/maxedo
|
||||
includes = []
|
||||
# "requires-comfyui" = ">=1.0.0" # ComfyUI version compatibility
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -1775,7 +1775,7 @@ if HAVE_COMFY_API:
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="Wan22FirstLastImageToVideoMXD",
|
||||
display_name="WAN 2.2 First&Last Image To Video MXD",
|
||||
display_name="WAN 2.2 First & Last I2V MXD",
|
||||
category="conditioning/video_models",
|
||||
inputs=[
|
||||
io.Conditioning.Input("positive"),
|
||||
|
||||
+3
-1
@@ -1,3 +1,5 @@
|
||||
import './js/betterCombos.js';
|
||||
import './js/betterCombos.js';
|
||||
import './js/image_comparer.js';
|
||||
import './addons/zip_loader/js/zip_loader.js';
|
||||
import './loraloader_mxd_entry.js';
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
import "./mxd_runtime.js";
|
||||
import "./power_lora_loader_mxd.js";
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
class MxdApi {
|
||||
constructor(baseUrl) {
|
||||
this.getLorasPromise = null;
|
||||
this.setBaseUrl(baseUrl);
|
||||
}
|
||||
|
||||
setBaseUrl(baseUrlArg) {
|
||||
let baseUrl = null;
|
||||
if (baseUrlArg) {
|
||||
baseUrl = baseUrlArg;
|
||||
} else if (window.location.pathname.includes("/loraloader-mxd/")) {
|
||||
const parts = window.location.pathname.split("/loraloader-mxd/")[1]?.split("/");
|
||||
if (parts && parts.length) {
|
||||
baseUrl = parts.map(() => "../").join("") + "loraloader-mxd/api";
|
||||
}
|
||||
}
|
||||
this.baseUrl = baseUrl || "./loraloader-mxd/api";
|
||||
const comfyBasePathname = location.pathname.includes("/loraloader-mxd/")
|
||||
? location.pathname.split("loraloader-mxd/")[0]
|
||||
: location.pathname;
|
||||
this.comfyBaseUrl = comfyBasePathname.split("/").slice(0, -1).join("/");
|
||||
}
|
||||
|
||||
apiURL(route) {
|
||||
return `${this.baseUrl}${route}`;
|
||||
}
|
||||
|
||||
fetchApi(route, options) {
|
||||
return fetch(this.apiURL(route), options);
|
||||
}
|
||||
|
||||
async fetchJson(route, options) {
|
||||
const r = await this.fetchApi(route, options);
|
||||
return await r.json();
|
||||
}
|
||||
|
||||
async postJson(route, json) {
|
||||
const body = new FormData();
|
||||
body.append("json", JSON.stringify(json));
|
||||
return await this.fetchJson(route, { method: "POST", body });
|
||||
}
|
||||
|
||||
getLoras(force = false) {
|
||||
if (!this.getLorasPromise || force) {
|
||||
this.getLorasPromise = this.fetchJson("/loras?format=details", { cache: "no-store" });
|
||||
}
|
||||
return this.getLorasPromise;
|
||||
}
|
||||
|
||||
async fetchApiJsonOrNull(route, options) {
|
||||
const response = await this.fetchJson(route, options);
|
||||
if (response.status === 200 && response.data) {
|
||||
return response.data || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async getModelsInfo(options) {
|
||||
const params = new URLSearchParams();
|
||||
if (options.files?.length) {
|
||||
params.set("files", options.files.join(","));
|
||||
}
|
||||
if (options.light) {
|
||||
params.set("light", "1");
|
||||
}
|
||||
if (options.format) {
|
||||
params.set("format", options.format);
|
||||
}
|
||||
const path = `/${options.type}/info?` + params.toString();
|
||||
return (await this.fetchApiJsonOrNull(path)) || [];
|
||||
}
|
||||
|
||||
async refreshModelsInfo(options) {
|
||||
const params = new URLSearchParams();
|
||||
if (options.files?.length) {
|
||||
params.set("files", options.files.join(","));
|
||||
}
|
||||
const path = `/${options.type}/info/refresh?` + params.toString();
|
||||
return await this.fetchApiJsonOrNull(path);
|
||||
}
|
||||
|
||||
async clearModelsInfo(options) {
|
||||
const params = new URLSearchParams();
|
||||
if (options.files?.length) {
|
||||
params.set("files", options.files.join(","));
|
||||
}
|
||||
const path = `/${options.type}/info/clear?` + params.toString();
|
||||
await this.fetchApiJsonOrNull(path);
|
||||
}
|
||||
|
||||
async saveModelInfo(type, file, data) {
|
||||
const body = new FormData();
|
||||
body.append("json", JSON.stringify(data));
|
||||
return await this.fetchApiJsonOrNull(`/${type}/info?file=${encodeURIComponent(file)}`, {
|
||||
cache: "no-store",
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
fetchComfyApi(route, options) {
|
||||
const url = this.comfyBaseUrl + "/api" + route;
|
||||
const opts = options || {};
|
||||
opts.headers = opts.headers || {};
|
||||
opts.cache = opts.cache || "no-cache";
|
||||
return fetch(url, opts);
|
||||
}
|
||||
}
|
||||
|
||||
export const mxdApi = new MxdApi();
|
||||
@@ -0,0 +1,217 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { ComfyWidgets } from "../../scripts/widgets.js";
|
||||
import { defineProperty, moveArrayItem } from "./mxd_shared_utils.js";
|
||||
|
||||
export class MxdBaseNode extends LGraphNode {
|
||||
static title = "__NEED_CLASS_TITLE__";
|
||||
static type = "__NEED_CLASS_TYPE__";
|
||||
static category = "mxd";
|
||||
static _category = "mxd";
|
||||
static exposedActions = [];
|
||||
|
||||
constructor(title = MxdBaseNode.title) {
|
||||
super(title);
|
||||
this.comfyClass = "__NEED_COMFY_CLASS__";
|
||||
this.isVirtualNode = false;
|
||||
this.isDropEnabled = false;
|
||||
this.removed = false;
|
||||
this.configuring = false;
|
||||
this._tempWidth = 0;
|
||||
this.__constructed__ = false;
|
||||
this.widgets = this.widgets || [];
|
||||
this.properties = this.properties || {};
|
||||
|
||||
defineProperty(this, "mode", {
|
||||
get: () => this._mxd_mode,
|
||||
set: (mode) => {
|
||||
if (this._mxd_mode !== mode) {
|
||||
const oldMode = this._mxd_mode;
|
||||
this._mxd_mode = mode;
|
||||
this.onModeChange(oldMode, mode);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
setTimeout(() => this.checkAndRunOnConstructed());
|
||||
}
|
||||
|
||||
checkAndRunOnConstructed() {
|
||||
if (!this.__constructed__) {
|
||||
this.onConstructed();
|
||||
}
|
||||
return this.__constructed__;
|
||||
}
|
||||
|
||||
onConstructed() {
|
||||
if (this.__constructed__) return false;
|
||||
this.type = this.type ?? undefined;
|
||||
this.__constructed__ = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
configure(info) {
|
||||
this.configuring = true;
|
||||
super.configure(info);
|
||||
for (const w of this.widgets || []) {
|
||||
w.last_y = w.last_y || 0;
|
||||
}
|
||||
this.configuring = false;
|
||||
}
|
||||
|
||||
clone() {
|
||||
const cloned = super.clone();
|
||||
if (cloned?.properties && window.structuredClone) {
|
||||
cloned.properties = structuredClone(cloned.properties);
|
||||
}
|
||||
return cloned;
|
||||
}
|
||||
|
||||
onModeChange(from, to) {}
|
||||
|
||||
removeWidget(widget) {
|
||||
if (typeof widget === "number") {
|
||||
widget = this.widgets[widget];
|
||||
}
|
||||
if (!widget) return;
|
||||
const index = this.widgets.indexOf(widget);
|
||||
if (index > -1) {
|
||||
this.widgets.splice(index, 1);
|
||||
}
|
||||
widget.onRemove?.();
|
||||
}
|
||||
|
||||
replaceWidget(widgetOrSlot, newWidget) {
|
||||
let index = null;
|
||||
if (widgetOrSlot != null) {
|
||||
index = typeof widgetOrSlot === "number" ? widgetOrSlot : this.widgets.indexOf(widgetOrSlot);
|
||||
this.removeWidget(this.widgets[index]);
|
||||
}
|
||||
index = index != null ? index : this.widgets.length - 1;
|
||||
if (this.widgets.includes(newWidget)) {
|
||||
moveArrayItem(this.widgets, newWidget, index);
|
||||
} else {
|
||||
this.widgets.splice(index, 0, newWidget);
|
||||
}
|
||||
}
|
||||
|
||||
defaultGetSlotMenuOptions(slot) {
|
||||
const menuInfo = [];
|
||||
if (slot?.output?.links?.length) {
|
||||
menuInfo.push({ content: "Disconnect Links", slot });
|
||||
}
|
||||
const inputOrOutput = slot.input || slot.output;
|
||||
if (inputOrOutput) {
|
||||
if (inputOrOutput.removable) {
|
||||
menuInfo.push(inputOrOutput.locked ? { content: "Cannot remove" } : { content: "Remove Slot", slot });
|
||||
}
|
||||
if (!inputOrOutput.nameLocked) {
|
||||
menuInfo.push({ content: "Rename Slot", slot });
|
||||
}
|
||||
}
|
||||
return menuInfo;
|
||||
}
|
||||
|
||||
onRemoved() {
|
||||
super.onRemoved?.();
|
||||
this.removed = true;
|
||||
}
|
||||
|
||||
static setUp() {}
|
||||
}
|
||||
|
||||
export class MxdBaseServerNode extends MxdBaseNode {
|
||||
static nodeType = null;
|
||||
static nodeData = null;
|
||||
static __registeredForOverride__ = false;
|
||||
|
||||
constructor(title) {
|
||||
super(title);
|
||||
this.isDropEnabled = true;
|
||||
this.serialize_widgets = true;
|
||||
this.setupFromServerNodeData();
|
||||
this.onConstructed();
|
||||
}
|
||||
|
||||
getWidgets() {
|
||||
return ComfyWidgets;
|
||||
}
|
||||
|
||||
async setupFromServerNodeData() {
|
||||
const nodeData = this.constructor.nodeData;
|
||||
if (!nodeData) throw Error("No node data");
|
||||
|
||||
this.comfyClass = nodeData.name;
|
||||
let inputs = nodeData.input.required;
|
||||
if (nodeData.input.optional != undefined) {
|
||||
inputs = Object.assign({}, inputs, nodeData.input.optional);
|
||||
}
|
||||
|
||||
const WIDGETS = this.getWidgets();
|
||||
const config = { minWidth: 1, minHeight: 1, widget: null };
|
||||
|
||||
for (const inputName in inputs) {
|
||||
const inputData = inputs[inputName];
|
||||
const type = inputData[0];
|
||||
if (inputData[1]?.forceInput) {
|
||||
this.addInput(inputName, type);
|
||||
} else {
|
||||
let widgetCreated = true;
|
||||
if (Array.isArray(type)) {
|
||||
Object.assign(config, WIDGETS.COMBO(this, inputName, inputData, app) || {});
|
||||
} else if (`${type}:${inputName}` in WIDGETS) {
|
||||
Object.assign(config, WIDGETS[`${type}:${inputName}`](this, inputName, inputData, app) || {});
|
||||
} else if (type in WIDGETS) {
|
||||
Object.assign(config, WIDGETS[type](this, inputName, inputData, app) || {});
|
||||
} else {
|
||||
this.addInput(inputName, type);
|
||||
widgetCreated = false;
|
||||
}
|
||||
|
||||
if (widgetCreated && inputData[1]?.forceInput && config?.widget) {
|
||||
if (!config.widget.options) config.widget.options = {};
|
||||
config.widget.options.forceInput = inputData[1].forceInput;
|
||||
}
|
||||
if (widgetCreated && inputData[1]?.defaultInput && config?.widget) {
|
||||
if (!config.widget.options) config.widget.options = {};
|
||||
config.widget.options.defaultInput = inputData[1].defaultInput;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const o in nodeData.output) {
|
||||
let output = nodeData.output[o];
|
||||
if (output instanceof Array) output = "COMBO";
|
||||
const outputName = nodeData.output_name[o] || output;
|
||||
const outputShape = nodeData.output_is_list[o] ? LiteGraph.GRID_SHAPE : LiteGraph.CIRCLE_SHAPE;
|
||||
this.addOutput(outputName, output, { shape: outputShape });
|
||||
}
|
||||
|
||||
const s = this.computeSize();
|
||||
s[0] = Math.max(config.minWidth ?? 1, s[0] * 1.5);
|
||||
s[1] = Math.max(config.minHeight ?? 1, s[1]);
|
||||
this.size = s;
|
||||
this.serialize_widgets = true;
|
||||
}
|
||||
|
||||
static registerForOverride(comfyClass, nodeData, mxdClass) {
|
||||
if (OVERRIDDEN_SERVER_NODES.has(comfyClass)) {
|
||||
throw Error(`Already have a class to override ${comfyClass.type || comfyClass.name || comfyClass.title}`);
|
||||
}
|
||||
OVERRIDDEN_SERVER_NODES.set(comfyClass, mxdClass);
|
||||
if (!mxdClass.__registeredForOverride__) {
|
||||
mxdClass.__registeredForOverride__ = true;
|
||||
mxdClass.nodeType = comfyClass;
|
||||
mxdClass.nodeData = nodeData;
|
||||
mxdClass.onRegisteredForOverride(comfyClass, mxdClass);
|
||||
}
|
||||
}
|
||||
|
||||
static onRegisteredForOverride(comfyClass, mxdClass) {}
|
||||
}
|
||||
|
||||
const OVERRIDDEN_SERVER_NODES = new Map();
|
||||
const oldRegisterNodeType = LiteGraph.registerNodeType;
|
||||
LiteGraph.registerNodeType = async function (nodeId, baseClass) {
|
||||
const clazz = OVERRIDDEN_SERVER_NODES.get(baseClass) || baseClass;
|
||||
return oldRegisterNodeType.call(LiteGraph, nodeId, clazz);
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
:not(#fakeid) .rgthree-button-reset {
|
||||
position: relative;
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:not(#fakeid) .rgthree-button {
|
||||
--padding-top: 7px;
|
||||
--padding-bottom: 9px;
|
||||
--padding-x: 16px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
border-radius: 0.33rem;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: white;
|
||||
font-family: system-ui, sans-serif;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
margin: 0.25rem;
|
||||
box-shadow: 0px 0px 2px rgb(0, 0, 0);
|
||||
background: #212121;
|
||||
transition: all 0.1s ease-in-out;
|
||||
padding: var(--padding-top) var(--padding-x) var(--padding-bottom);
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
:not(#fakeid) .rgthree-button::before, :not(#fakeid) .rgthree-button::after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
border-radius: 0.33rem;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-shadow: inset 1px 1px 0px rgba(255, 255, 255, 0.12), inset -1px -1px 0px rgba(0, 0, 0, 0.75);
|
||||
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.06), rgba(0, 0, 0, 0.15));
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
:not(#fakeid) .rgthree-button::after {
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
:not(#fakeid) .rgthree-button:hover {
|
||||
background: #303030;
|
||||
}
|
||||
:not(#fakeid) .rgthree-button:active {
|
||||
box-shadow: 0px 0px 0px rgba(0, 0, 0, 0);
|
||||
background: #121212;
|
||||
padding: calc(var(--padding-top) + 1px) calc(var(--padding-x) - 1px) calc(var(--padding-bottom) - 1px) calc(var(--padding-x) + 1px);
|
||||
}
|
||||
:not(#fakeid) .rgthree-button:active::before, :not(#fakeid) .rgthree-button:active::after {
|
||||
box-shadow: 1px 1px 0px rgba(255, 255, 255, 0.15), inset 1px 1px 0px rgba(0, 0, 0, 0.5), inset 1px 3px 5px rgba(0, 0, 0, 0.33);
|
||||
}
|
||||
:not(#fakeid) .rgthree-button.-blue {
|
||||
background: #346599 !important;
|
||||
}
|
||||
:not(#fakeid) .rgthree-button.-blue:hover {
|
||||
background: #3b77b8 !important;
|
||||
}
|
||||
:not(#fakeid) .rgthree-button.-blue:active {
|
||||
background: #1d5086 !important;
|
||||
}
|
||||
:not(#fakeid) .rgthree-button.-green {
|
||||
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.06), rgba(0, 0, 0, 0.15)), #14580b;
|
||||
}
|
||||
:not(#fakeid) .rgthree-button.-green:hover {
|
||||
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.06), rgba(0, 0, 0, 0.15)), #1a6d0f;
|
||||
}
|
||||
:not(#fakeid) .rgthree-button.-green:active {
|
||||
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.15), rgba(255, 255, 255, 0.06)), #0f3f09;
|
||||
}
|
||||
:not(#fakeid) .rgthree-button[disabled] {
|
||||
box-shadow: none;
|
||||
background: #666 !important;
|
||||
color: #aaa;
|
||||
pointer-events: none;
|
||||
}
|
||||
:not(#fakeid) .rgthree-button[disabled]::before, :not(#fakeid) .rgthree-button[disabled]::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:not(#fakeid) .rgthree-comfybar-top-button-group {
|
||||
font-size: 0;
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
:not(#fakeid) .rgthree-comfybar-top-button-group .rgthree-comfybar-top-button {
|
||||
margin: 0;
|
||||
flex: 1 1;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
border-radius: 0;
|
||||
background: var(--p-button-secondary-background);
|
||||
color: var(--p-button-secondary-color);
|
||||
}
|
||||
:not(#fakeid) .rgthree-comfybar-top-button-group .rgthree-comfybar-top-button.-primary {
|
||||
background: var(--p-button-primary-background);
|
||||
color: var(--p-button-primary-color);
|
||||
}
|
||||
:not(#fakeid) .rgthree-comfybar-top-button-group .rgthree-comfybar-top-button::before, :not(#fakeid) .rgthree-comfybar-top-button-group .rgthree-comfybar-top-button::after {
|
||||
border-radius: 0;
|
||||
}
|
||||
:not(#fakeid) .rgthree-comfybar-top-button-group .rgthree-comfybar-top-button svg {
|
||||
fill: currentColor;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
:not(#fakeid) .rgthree-comfybar-top-button-group .rgthree-comfybar-top-button:first-of-type,
|
||||
:not(#fakeid) .rgthree-comfybar-top-button-group .rgthree-comfybar-top-button:first-of-type::before,
|
||||
:not(#fakeid) .rgthree-comfybar-top-button-group .rgthree-comfybar-top-button:first-of-type::after {
|
||||
border-top-left-radius: 0.33rem;
|
||||
border-bottom-left-radius: 0.33rem;
|
||||
}
|
||||
:not(#fakeid) .rgthree-comfybar-top-button-group .rgthree-comfybar-top-button:last-of-type,
|
||||
:not(#fakeid) .rgthree-comfybar-top-button-group .rgthree-comfybar-top-button:last-of-type::before,
|
||||
:not(#fakeid) .rgthree-comfybar-top-button-group .rgthree-comfybar-top-button:last-of-type::after {
|
||||
border-top-right-radius: 0.33rem;
|
||||
border-bottom-right-radius: 0.33rem;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
@charset "UTF-8";
|
||||
.rgthree-dialog {
|
||||
outline: 0;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: #414141;
|
||||
color: #fff;
|
||||
box-shadow: inset 1px 1px 0px rgba(255, 255, 255, 0.05), inset -1px -1px 0px rgba(0, 0, 0, 0.5), 2px 2px 20px rgb(0, 0, 0);
|
||||
max-width: 800px;
|
||||
box-sizing: border-box;
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
font-size: 1rem;
|
||||
padding: 0;
|
||||
max-height: calc(100% - 32px);
|
||||
}
|
||||
.rgthree-dialog *, .rgthree-dialog *::before, .rgthree-dialog *::after {
|
||||
box-sizing: inherit;
|
||||
}
|
||||
|
||||
.rgthree-dialog-container > * {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
.rgthree-dialog-container > *:first-child {
|
||||
padding-top: 16px;
|
||||
}
|
||||
.rgthree-dialog-container > *:last-child {
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.rgthree-dialog.-iconed::after {
|
||||
content: "";
|
||||
font-size: 276px;
|
||||
position: absolute;
|
||||
right: 0px;
|
||||
bottom: 0px;
|
||||
opacity: 0.15;
|
||||
display: block;
|
||||
width: 237px;
|
||||
overflow: hidden;
|
||||
height: 186px;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.rgthree-dialog.-iconed.-help::after {
|
||||
content: "🛟";
|
||||
}
|
||||
|
||||
.rgthree-dialog.-iconed.-settings::after {
|
||||
content: "⚙️";
|
||||
}
|
||||
|
||||
@media (max-width: 832px) {
|
||||
.rgthree-dialog {
|
||||
max-width: calc(100% - 32px);
|
||||
}
|
||||
}
|
||||
.rgthree-dialog-container-title {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
.rgthree-dialog-container-title > svg:first-child {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.rgthree-dialog-container-title h2 {
|
||||
font-size: 1.375rem;
|
||||
margin: 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.rgthree-dialog-container-title h2 small {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: normal;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.rgthree-dialog-container-content {
|
||||
overflow: auto;
|
||||
max-height: calc(100vh - 200px); /* Arbitrary height to copensate for margin, title, and footer.*/
|
||||
}
|
||||
|
||||
.rgthree-dialog-container-content p {
|
||||
font-size: 0.8125rem;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.rgthree-dialog-container-content ul li p {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.rgthree-dialog-container-content ul li p + p {
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
|
||||
.rgthree-dialog-container-content ul li ul {
|
||||
margin-top: 0.5em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.rgthree-dialog-container-content p code {
|
||||
display: inline-block;
|
||||
padding: 2px 4px;
|
||||
margin: 0px 2px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.rgthree-dialog-container-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
body.rgthree-dialog-open > *:not(.rgthree-dialog):not(.rgthree-top-messages-container) {
|
||||
filter: blur(5px);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { createElement as $el, getClosestOrSelf, setAttributes } from "./mxd_utils_dom.js";
|
||||
export class MxdDialog extends EventTarget {
|
||||
constructor(options) {
|
||||
super();
|
||||
this.options = options;
|
||||
let container = $el("div.rgthree-dialog-container");
|
||||
this.element = $el("dialog", {
|
||||
classes: ["rgthree-dialog", options.class || ""],
|
||||
child: container,
|
||||
parent: document.body,
|
||||
events: {
|
||||
click: (event) => {
|
||||
if (!this.element.open ||
|
||||
event.target === container ||
|
||||
getClosestOrSelf(event.target, `.rgthree-dialog-container`) === container) {
|
||||
return;
|
||||
}
|
||||
return this.close();
|
||||
},
|
||||
},
|
||||
});
|
||||
this.element.addEventListener("close", (event) => {
|
||||
this.onDialogElementClose();
|
||||
});
|
||||
this.titleElement = $el("div.rgthree-dialog-container-title", {
|
||||
parent: container,
|
||||
children: !options.title
|
||||
? null
|
||||
: options.title instanceof Element || Array.isArray(options.title)
|
||||
? options.title
|
||||
: typeof options.title === "string"
|
||||
? !options.title.includes("<h2")
|
||||
? $el("h2", { html: options.title })
|
||||
: options.title
|
||||
: options.title,
|
||||
});
|
||||
this.contentElement = $el("div.rgthree-dialog-container-content", {
|
||||
parent: container,
|
||||
child: options.content,
|
||||
});
|
||||
const footerEl = $el("footer.rgthree-dialog-container-footer", { parent: container });
|
||||
for (const button of options.buttons || []) {
|
||||
$el("button", {
|
||||
text: button.label,
|
||||
className: button.className,
|
||||
disabled: !!button.disabled,
|
||||
parent: footerEl,
|
||||
events: {
|
||||
click: (e) => {
|
||||
var _a;
|
||||
(_a = button.callback) === null || _a === void 0 ? void 0 : _a.call(button, e);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (options.closeButtonLabel !== false) {
|
||||
$el("button", {
|
||||
text: options.closeButtonLabel || "Close",
|
||||
className: "rgthree-button",
|
||||
parent: footerEl,
|
||||
events: {
|
||||
click: (e) => {
|
||||
this.close(e);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
setTitle(content) {
|
||||
const title = typeof content !== "string" || content.includes("<h2")
|
||||
? content
|
||||
: $el("h2", { html: content });
|
||||
setAttributes(this.titleElement, { children: title });
|
||||
}
|
||||
setContent(content) {
|
||||
setAttributes(this.contentElement, { children: content });
|
||||
}
|
||||
show() {
|
||||
document.body.classList.add("rgthree-dialog-open");
|
||||
this.element.showModal();
|
||||
this.dispatchEvent(new CustomEvent("show"));
|
||||
return this;
|
||||
}
|
||||
async close(e) {
|
||||
if (this.options.onBeforeClose && !(await this.options.onBeforeClose())) {
|
||||
return;
|
||||
}
|
||||
this.element.close();
|
||||
}
|
||||
onDialogElementClose() {
|
||||
document.body.classList.remove("rgthree-dialog-open");
|
||||
this.element.remove();
|
||||
this.dispatchEvent(new CustomEvent("close", this.getCloseEventDetail()));
|
||||
}
|
||||
getCloseEventDetail() {
|
||||
return { detail: null };
|
||||
}
|
||||
}
|
||||
export class MxdHelpDialog extends MxdDialog {
|
||||
constructor(node, content, opts = {}) {
|
||||
const title = (node.type || node.title || "").replace(/\s*\(mxd\).*/, " <small>by mxd</small>");
|
||||
const options = Object.assign({}, opts, {
|
||||
class: "-iconed -help",
|
||||
title,
|
||||
content,
|
||||
});
|
||||
super(options);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
@import "./mxd_dialog.css";
|
||||
@import "./mxd_buttons.css";
|
||||
@import "./mxd_menu.css";
|
||||
|
||||
.mxd-top-messages-container {
|
||||
position: fixed;
|
||||
top: 8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 100000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mxd-top-message {
|
||||
background: rgba(20, 20, 20, 0.92);
|
||||
color: #fff;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 8px;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import { MxdDialog } from "./mxd_dialog.js";
|
||||
import {
|
||||
createElement as $el,
|
||||
empty,
|
||||
appendChildren,
|
||||
getClosestOrSelf,
|
||||
query,
|
||||
queryAll,
|
||||
setAttributes,
|
||||
} from "./mxd_utils_dom.js";
|
||||
import { logoCivitai, link, pencilColored, diskColored, dotdotdot } from "./mxd_svgs.js";
|
||||
import { CHECKPOINT_INFO_SERVICE, LORA_INFO_SERVICE } from "./mxd_model_info_service.js";
|
||||
import { mxdRuntime } from "./mxd_runtime.js";
|
||||
import { MenuButton } from "./mxd_menu.js";
|
||||
import { generateId, injectCss } from "./mxd_shared_utils.js";
|
||||
|
||||
const EXTENSION_BASE = new URL(".", import.meta.url).pathname.replace(/\/$/, "");
|
||||
class MxdInfoDialog extends MxdDialog {
|
||||
constructor(file) {
|
||||
const dialogOptions = {
|
||||
class: "rgthree-info-dialog",
|
||||
title: `<h2>Loading...</h2>`,
|
||||
content: "<center>Loading..</center>",
|
||||
onBeforeClose: () => true,
|
||||
};
|
||||
super(dialogOptions);
|
||||
this.modifiedModelData = false;
|
||||
this.modelInfo = null;
|
||||
this.init(file);
|
||||
}
|
||||
|
||||
async init(file) {
|
||||
await injectCss(`${EXTENSION_BASE}/mxd_dialog_base.css`);
|
||||
await injectCss(`${EXTENSION_BASE}/mxd_dialog_model_info.css`);
|
||||
this.modelInfo = await this.getModelInfo(file);
|
||||
this.setContent(this.getInfoContent());
|
||||
this.setTitle(this.modelInfo?.name || this.modelInfo?.file || "Unknown");
|
||||
this.attachEvents();
|
||||
}
|
||||
|
||||
getCloseEventDetail() {
|
||||
return { detail: { dirty: this.modifiedModelData } };
|
||||
}
|
||||
|
||||
attachEvents() {
|
||||
this.contentElement.addEventListener("click", async (e) => {
|
||||
const target = getClosestOrSelf(e.target, "[data-action]");
|
||||
const action = target?.getAttribute("data-action");
|
||||
if (!target || !action) return;
|
||||
await this.handleEventAction(action, target, e);
|
||||
});
|
||||
}
|
||||
|
||||
async handleEventAction(action, target, e) {
|
||||
const info = this.modelInfo;
|
||||
if (!info?.file) return;
|
||||
|
||||
if (action === "fetch-civitai") {
|
||||
this.modelInfo = await this.refreshModelInfo(info.file);
|
||||
this.setContent(this.getInfoContent());
|
||||
this.setTitle(this.modelInfo?.name || this.modelInfo?.file || "Unknown");
|
||||
} else if (action === "copy-trained-words") {
|
||||
const selected = queryAll(".-rgthree-is-selected", target.closest("tr"));
|
||||
const text = selected.map((el) => el.getAttribute("data-word")).join(", ");
|
||||
await navigator.clipboard.writeText(text);
|
||||
mxdRuntime.showMessage({
|
||||
id: "copy-trained-words-" + generateId(4),
|
||||
type: "success",
|
||||
message: `Copied ${selected.length} key word${selected.length === 1 ? "" : "s"}.`,
|
||||
timeout: 3000,
|
||||
});
|
||||
} else if (action === "toggle-trained-word") {
|
||||
target?.classList.toggle("-rgthree-is-selected");
|
||||
const tr = target.closest("tr");
|
||||
if (tr) {
|
||||
const span = query("td:first-child > *", tr);
|
||||
let small = query("small", span);
|
||||
if (!small) {
|
||||
small = $el("small", { parent: span });
|
||||
}
|
||||
const num = queryAll(".-rgthree-is-selected", tr).length;
|
||||
small.innerHTML = num ? `${num} selected | <span role="button" data-action="copy-trained-words">Copy</span>` : "";
|
||||
}
|
||||
} else if (action === "edit-row") {
|
||||
const tr = target.closest("tr");
|
||||
const td = query("td:nth-child(2)", tr);
|
||||
const input = td.querySelector("input,textarea");
|
||||
if (!input) {
|
||||
const fieldName = tr.dataset["fieldName"];
|
||||
tr.classList.add("-rgthree-editing");
|
||||
const isTextarea = fieldName === "userNote";
|
||||
const rowInput = $el(`${isTextarea ? "textarea" : 'input[type="text"]'}`, { value: td.textContent });
|
||||
rowInput.addEventListener("keydown", (evt) => {
|
||||
if (!isTextarea && evt.key === "Enter") {
|
||||
const modified = saveEditableRow(info, tr, true);
|
||||
this.modifiedModelData = this.modifiedModelData || modified;
|
||||
evt.stopPropagation();
|
||||
evt.preventDefault();
|
||||
} else if (evt.key === "Escape") {
|
||||
const modified = saveEditableRow(info, tr, false);
|
||||
this.modifiedModelData = this.modifiedModelData || modified;
|
||||
evt.stopPropagation();
|
||||
evt.preventDefault();
|
||||
}
|
||||
});
|
||||
appendChildren(empty(td), [rowInput]);
|
||||
rowInput.focus();
|
||||
} else if (target.nodeName.toLowerCase() === "button") {
|
||||
const modified = saveEditableRow(info, tr, true);
|
||||
this.modifiedModelData = this.modifiedModelData || modified;
|
||||
}
|
||||
e?.preventDefault();
|
||||
e?.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
getInfoContent() {
|
||||
const info = this.modelInfo || {};
|
||||
const civitaiLink = info.links?.find((i) => i.includes("civitai.com/models"));
|
||||
const html = `
|
||||
<ul class="rgthree-info-area">
|
||||
<li title="Type" class="rgthree-info-tag -type -type-${(info.type || "").toLowerCase()}"><span>${info.type || ""}</span></li>
|
||||
<li title="Base Model" class="rgthree-info-tag -basemodel -basemodel-${(info.baseModel || "").toLowerCase()}"><span>${info.baseModel || ""}</span></li>
|
||||
<li class="rgthree-info-menu" stub="menu"></li>
|
||||
</ul>
|
||||
|
||||
<table class="rgthree-info-table">
|
||||
${infoTableRow("File", info.file || "")}
|
||||
${infoTableRow("Hash (sha256)", info.sha256 || "")}
|
||||
${
|
||||
civitaiLink
|
||||
? infoTableRow("Civitai", `<a href="${civitaiLink}" target="_blank">${logoCivitai}View on Civitai</a>`)
|
||||
: info.raw?.civitai?.error === "Model not found"
|
||||
? infoTableRow("Civitai", `<i>Model not found</i>`)
|
||||
: info.raw?.civitai?.error
|
||||
? infoTableRow("Civitai", info.raw?.civitai?.error)
|
||||
: !info.raw?.civitai
|
||||
? infoTableRow("Civitai", `<button class="rgthree-button" data-action="fetch-civitai">Fetch info from civitai</button>`)
|
||||
: ""
|
||||
}
|
||||
${infoTableRow("Name", info.name || info.raw?.metadata?.ss_output_name || "", "Display name.", "name")}
|
||||
${
|
||||
!info.baseModel && !info.baseModelFile
|
||||
? ""
|
||||
: infoTableRow("Base Model", (info.baseModel || "") + (info.baseModelFile ? ` (${info.baseModelFile})` : ""))
|
||||
}
|
||||
${!info.trainedWords?.length ? "" : infoTableRow("Trained Words", getTrainedWordsMarkup(info.trainedWords) ?? "", "Click to select for copy.")}
|
||||
${
|
||||
!info.raw?.metadata?.ss_clip_skip || info.raw?.metadata?.ss_clip_skip == "None"
|
||||
? ""
|
||||
: infoTableRow("Clip Skip", info.raw?.metadata?.ss_clip_skip)
|
||||
}
|
||||
${infoTableRow("Strength Min", info.strengthMin ?? "", "Recommended minimum strength.", "strengthMin")}
|
||||
${infoTableRow("Strength Max", info.strengthMax ?? "", "Recommended maximum strength.", "strengthMax")}
|
||||
${infoTableRow("Additional Notes", info.userNote ?? "", "Local note.", "userNote")}
|
||||
</table>
|
||||
|
||||
<ul class="rgthree-info-images">${
|
||||
info.images?.map(
|
||||
(img) => `
|
||||
<li>
|
||||
<figure>${
|
||||
img.type === "video" ? `<video src="${img.url}" autoplay loop></video>` : `<img src="${img.url}" />`
|
||||
}
|
||||
<figcaption>${imgInfoField("", img.civitaiUrl ? `<a href="${img.civitaiUrl}" target="_blank">civitai${link}</a>` : undefined)}${imgInfoField("seed", img.seed)}${imgInfoField("steps", img.steps)}${imgInfoField("cfg", img.cfg)}${imgInfoField("sampler", img.sampler)}${imgInfoField("model", img.model)}${imgInfoField("positive", img.positive)}${imgInfoField("negative", img.negative)}</figcaption>
|
||||
</figure>
|
||||
</li>`,
|
||||
).join("") ?? ""
|
||||
}</ul>
|
||||
`;
|
||||
|
||||
const div = $el("div", { html });
|
||||
|
||||
setAttributes(query('[stub="menu"]', div), {
|
||||
children: [
|
||||
new MenuButton({
|
||||
icon: dotdotdot,
|
||||
options: [
|
||||
{ label: "More Actions", type: "title" },
|
||||
{
|
||||
label: "Open API JSON",
|
||||
callback: async () => {
|
||||
if (this.modelInfo?.file) {
|
||||
window.open(`/loraloader-mxd/api/loras/info?file=${encodeURIComponent(this.modelInfo.file)}`);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Clear all local info",
|
||||
callback: async () => {
|
||||
if (this.modelInfo?.file) {
|
||||
this.modelInfo = await LORA_INFO_SERVICE.clearFetchedInfo(this.modelInfo.file);
|
||||
this.setContent(this.getInfoContent());
|
||||
this.setTitle(this.modelInfo?.name || this.modelInfo?.file || "Unknown");
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
return div;
|
||||
}
|
||||
}
|
||||
|
||||
export class MxdLoraInfoDialog extends MxdInfoDialog {
|
||||
async getModelInfo(file) {
|
||||
return LORA_INFO_SERVICE.getInfo(file, false, false);
|
||||
}
|
||||
|
||||
async refreshModelInfo(file) {
|
||||
return LORA_INFO_SERVICE.refreshInfo(file);
|
||||
}
|
||||
}
|
||||
|
||||
export class MxdCheckpointInfoDialog extends MxdInfoDialog {
|
||||
async getModelInfo(file) {
|
||||
return CHECKPOINT_INFO_SERVICE.getInfo(file, false, false);
|
||||
}
|
||||
|
||||
async refreshModelInfo(file) {
|
||||
return CHECKPOINT_INFO_SERVICE.refreshInfo(file);
|
||||
}
|
||||
}
|
||||
|
||||
function infoTableRow(name, value, help = "", editableFieldName = "") {
|
||||
return `
|
||||
<tr class="${editableFieldName ? "editable" : ""}" ${editableFieldName ? `data-field-name="${editableFieldName}"` : ""}>
|
||||
<td><span>${name} ${help ? `<span class="-help" title="${help}"></span>` : ""}<span></td>
|
||||
<td ${editableFieldName ? "" : 'colspan="2"'}>${String(value).startsWith("<") ? value : `<span>${value}<span>`}</td>
|
||||
${editableFieldName ? `<td style="width: 24px;"><button class="rgthree-button-reset rgthree-button-edit" data-action="edit-row">${pencilColored}${diskColored}</button></td>` : ""}
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function getTrainedWordsMarkup(words) {
|
||||
let markup = `<ul class="rgthree-info-trained-words-list">`;
|
||||
for (const wordData of words || []) {
|
||||
markup += `<li title="${wordData.word}" data-word="${wordData.word}" class="rgthree-info-trained-words-list-item" data-action="toggle-trained-word">
|
||||
<span>${wordData.word}</span>
|
||||
${wordData.civitai ? logoCivitai : ""}
|
||||
${wordData.count != null ? `<small>${wordData.count}</small>` : ""}
|
||||
</li>`;
|
||||
}
|
||||
markup += `</ul>`;
|
||||
return markup;
|
||||
}
|
||||
|
||||
function saveEditableRow(info, tr, saving = true) {
|
||||
const fieldName = tr.dataset["fieldName"];
|
||||
const input = query("input,textarea", tr);
|
||||
let newValue = info[fieldName] ?? "";
|
||||
let modified = false;
|
||||
if (saving) {
|
||||
newValue = input.value;
|
||||
if (fieldName.startsWith("strength")) {
|
||||
if (Number.isNaN(Number(newValue))) {
|
||||
alert(`You must enter a number into the ${fieldName} field.`);
|
||||
return false;
|
||||
}
|
||||
newValue = (Math.round(Number(newValue) * 100) / 100).toFixed(2);
|
||||
}
|
||||
LORA_INFO_SERVICE.savePartialInfo(info.file, { [fieldName]: newValue });
|
||||
modified = true;
|
||||
}
|
||||
tr.classList.remove("-rgthree-editing");
|
||||
const td = query("td:nth-child(2)", tr);
|
||||
appendChildren(empty(td), [$el("span", { text: newValue })]);
|
||||
return modified;
|
||||
}
|
||||
|
||||
function imgInfoField(label, value) {
|
||||
return value != null ? `<span>${label ? `<label>${label} </label>` : ""}${value}</span>` : "";
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
.rgthree-info-dialog {
|
||||
width: 90vw;
|
||||
max-width: 960px;
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-area {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-area > li {
|
||||
display: inline-flex;
|
||||
margin: 0;
|
||||
vertical-align: top;
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-area > li + li {
|
||||
margin-left: 6px;
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-area > li:not(.-link) + li.-link {
|
||||
margin-left: auto;
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-area > li.rgthree-info-tag > * {
|
||||
min-height: 24px;
|
||||
border-radius: 4px;
|
||||
line-height: 1;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
background: rgb(69, 92, 85);
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
height: 1.6em;
|
||||
padding-left: 0.5em;
|
||||
padding-right: 0.5em;
|
||||
padding-bottom: 0.1em;
|
||||
align-content: center;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
box-shadow: inset 0px 0px 0 1px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-area > li.rgthree-info-tag > * > svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-area > li.rgthree-info-tag > * > svg:last-child {
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-area > li.rgthree-info-tag > *[href] {
|
||||
box-shadow: inset 0px 1px 0px rgba(255, 255, 255, 0.25), inset 0px -1px 0px rgba(0, 0, 0, 0.66);
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-area > li.rgthree-info-tag > *:empty {
|
||||
display: none;
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-area > li.-type > * {
|
||||
background: rgb(73, 54, 94);
|
||||
color: rgb(228, 209, 248);
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-area > li.rgthree-info-menu {
|
||||
margin-left: auto;
|
||||
}
|
||||
:not(#fakeid) .rgthree-info-dialog .rgthree-info-area > li.rgthree-info-menu .rgthree-button {
|
||||
margin: 0;
|
||||
min-height: 24px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-area > li.rgthree-info-menu svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-table {
|
||||
border-collapse: collapse;
|
||||
margin: 16px 0px;
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-table tr.editable button {
|
||||
display: flex;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-table tr.editable button svg + svg {
|
||||
display: none;
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-table tr.editable.-rgthree-editing button svg {
|
||||
display: none;
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-table tr.editable.-rgthree-editing button svg + svg {
|
||||
display: inline-block;
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-table td {
|
||||
position: relative;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
padding: 0;
|
||||
vertical-align: top;
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-table td:first-child {
|
||||
background: rgba(255, 255, 255, 0.075);
|
||||
width: 10px;
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-table td:first-child > *:first-child {
|
||||
white-space: nowrap;
|
||||
padding-right: 32px;
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-table td:first-child small {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-table td:first-child small > [data-action] {
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-table td:first-child small > [data-action]:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-table td a, .rgthree-info-dialog .rgthree-info-table td a:hover, .rgthree-info-dialog .rgthree-info-table td a:visited {
|
||||
color: inherit;
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-table td svg {
|
||||
width: 1.3333em;
|
||||
height: 1.3333em;
|
||||
vertical-align: -0.285em;
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-table td svg.logo-civitai {
|
||||
margin-right: 0.3333em;
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-table td > *:first-child {
|
||||
display: block;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-table td > input, .rgthree-info-dialog .rgthree-info-table td > textarea {
|
||||
padding: 5px 10px;
|
||||
border: 0;
|
||||
box-shadow: inset 1px 1px 5px 0px rgba(0, 0, 0, 0.5);
|
||||
font: inherit;
|
||||
appearance: none;
|
||||
background: #fff;
|
||||
color: #121212;
|
||||
resize: vertical;
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-table td > input:only-child, .rgthree-info-dialog .rgthree-info-table td > textarea:only-child {
|
||||
width: 100%;
|
||||
}
|
||||
:not(#fakeid) .rgthree-info-dialog .rgthree-info-table td .rgthree-button[data-action=fetch-civitai] {
|
||||
font-size: inherit;
|
||||
padding: 6px 16px;
|
||||
margin: 2px;
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-table tr[data-field-name=userNote] td > span:first-child {
|
||||
white-space: pre;
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-table tr.rgthree-info-table-break-row td {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 12px 4px 4px;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-table tr.rgthree-info-table-break-row td > small {
|
||||
font-style: italic;
|
||||
opacity: 0.66;
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-table tr.rgthree-info-table-break-row td:empty {
|
||||
padding: 4px;
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-table td .-help {
|
||||
border: 1px solid currentColor;
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
top: 6px;
|
||||
line-height: 1;
|
||||
font-size: 11px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-content: center;
|
||||
justify-content: center;
|
||||
cursor: help;
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-table td .-help::before {
|
||||
content: "?";
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-table td > ul.rgthree-info-trained-words-list {
|
||||
list-style: none;
|
||||
padding: 2px 8px;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
max-height: 15vh;
|
||||
overflow: auto;
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-table td > ul.rgthree-info-trained-words-list > li {
|
||||
display: inline-flex;
|
||||
margin: 2px;
|
||||
vertical-align: top;
|
||||
border-radius: 4px;
|
||||
line-height: 1;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
background: rgb(73, 91, 106);
|
||||
font-size: 1.2em;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
height: 1.6em;
|
||||
align-content: center;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
box-shadow: inset 0px 0px 0 1px rgba(0, 0, 0, 0.5);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
max-width: 183px;
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-table td > ul.rgthree-info-trained-words-list > li:hover {
|
||||
background: rgb(68, 109, 142);
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-table td > ul.rgthree-info-trained-words-list > li > svg {
|
||||
width: auto;
|
||||
height: 1.2em;
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-table td > ul.rgthree-info-trained-words-list > li > span {
|
||||
padding-left: 0.5em;
|
||||
padding-right: 0.5em;
|
||||
padding-bottom: 0.1em;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-table td > ul.rgthree-info-trained-words-list > li > small {
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 0.5em;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-table td > ul.rgthree-info-trained-words-list > li.-rgthree-is-selected {
|
||||
background: rgb(42, 126, 193);
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-images {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
scroll-snap-type: x mandatory;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
overflow: auto;
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-images > li {
|
||||
scroll-snap-align: start;
|
||||
max-width: 90%;
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
margin: 6px;
|
||||
font-size: 0;
|
||||
position: relative;
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-images > li figure {
|
||||
margin: 0;
|
||||
position: static;
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-images > li figure video, .rgthree-info-dialog .rgthree-info-images > li figure img {
|
||||
max-height: 45vh;
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-images > li figure figcaption {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
bottom: 0;
|
||||
padding: 12px;
|
||||
font-size: 12px;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
opacity: 0;
|
||||
transform: translateY(50px);
|
||||
transition: all 0.25s ease-in-out;
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-images > li figure figcaption > span {
|
||||
display: inline-block;
|
||||
padding: 2px 4px;
|
||||
margin: 2px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
word-break: break-word;
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-images > li figure figcaption > span label {
|
||||
display: inline;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-images > li figure figcaption > span a {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-images > li figure figcaption > span a:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-images > li figure figcaption > span a svg {
|
||||
height: 10px;
|
||||
margin-left: 4px;
|
||||
fill: currentColor;
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-images > li figure figcaption:empty {
|
||||
text-align: center;
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-images > li figure figcaption:empty::before {
|
||||
content: "No data.";
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-images > li:hover figure figcaption {
|
||||
opacity: 1;
|
||||
transform: translateY(0px);
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-images > li .rgthree-info-table {
|
||||
width: calc(100% - 16px);
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-civitai-link {
|
||||
margin: 8px;
|
||||
color: #eee;
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-civitai-link a, .rgthree-info-dialog .rgthree-info-civitai-link a:hover, .rgthree-info-dialog .rgthree-info-civitai-link a:visited {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.rgthree-info-dialog .rgthree-info-civitai-link > svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
.rgthree-menu {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
position: fixed;
|
||||
z-index: 999999;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.08s ease-in-out;
|
||||
color: #dde;
|
||||
background-color: #111;
|
||||
font-size: 12px;
|
||||
box-shadow: 0 0 10px black !important;
|
||||
}
|
||||
.rgthree-menu > li {
|
||||
position: relative;
|
||||
padding: 4px 6px;
|
||||
z-index: 9999;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rgthree-menu > li[role=button] {
|
||||
background-color: var(--comfy-menu-bg) !important;
|
||||
color: var(--input-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.rgthree-menu > li[role=button]:hover {
|
||||
filter: brightness(155%);
|
||||
}
|
||||
.rgthree-menu[state^=measuring] {
|
||||
display: block;
|
||||
opacity: 0;
|
||||
}
|
||||
.rgthree-menu[state=open] {
|
||||
display: block;
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.rgthree-top-menu {
|
||||
box-sizing: border-box;
|
||||
white-space: nowrap;
|
||||
background: var(--content-bg);
|
||||
color: var(--content-fg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.rgthree-top-menu * {
|
||||
box-sizing: inherit;
|
||||
}
|
||||
.rgthree-top-menu > li:not(#fakeid) {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
.rgthree-top-menu > li:not(#fakeid) > button {
|
||||
cursor: pointer;
|
||||
padding: 8px 12px 8px 8px;
|
||||
width: 100%;
|
||||
text-align: start;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: start;
|
||||
}
|
||||
.rgthree-top-menu > li:not(#fakeid) > button:hover {
|
||||
background-color: var(--comfy-input-bg);
|
||||
}
|
||||
.rgthree-top-menu > li:not(#fakeid) > button svg {
|
||||
height: 16px;
|
||||
width: auto;
|
||||
margin-inline-end: 0.6em;
|
||||
}
|
||||
.rgthree-top-menu > li:not(#fakeid) > button svg.github-star {
|
||||
fill: rgb(227, 179, 65);
|
||||
}
|
||||
.rgthree-top-menu > li:not(#fakeid).rgthree-message {
|
||||
min-height: 32px;
|
||||
}
|
||||
.rgthree-top-menu > li:not(#fakeid).rgthree-message > span {
|
||||
padding: 8px 12px;
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
font-size: 12px;
|
||||
}
|
||||
.rgthree-top-menu.-modal::after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: fixed;
|
||||
z-index: 1;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.0666666667);
|
||||
}
|
||||
|
||||
body.rgthree-modal-menu-open > *:not(.rgthree-menu):not(.rgthree-top-messages-container) {
|
||||
filter: blur(2px);
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
import { generateId, wait } from "./mxd_shared_utils.js";
|
||||
import { createElement as $el, getClosestOrSelf, setAttributes } from "./mxd_utils_dom.js";
|
||||
class Menu {
|
||||
constructor(options) {
|
||||
this.element = $el('menu.rgthree-menu');
|
||||
this.callbacks = new Map();
|
||||
this.handleWindowPointerDownBound = this.handleWindowPointerDown.bind(this);
|
||||
this.setOptions(options);
|
||||
this.element.addEventListener('pointerup', async (e) => {
|
||||
var _a, _b;
|
||||
const target = getClosestOrSelf(e.target, "[data-callback],menu");
|
||||
if (e.which !== 1) {
|
||||
return;
|
||||
}
|
||||
const callback = (_a = target === null || target === void 0 ? void 0 : target.dataset) === null || _a === void 0 ? void 0 : _a['callback'];
|
||||
if (callback) {
|
||||
const halt = await ((_b = this.callbacks.get(callback)) === null || _b === void 0 ? void 0 : _b(e));
|
||||
if (halt !== false) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
});
|
||||
}
|
||||
setOptions(options) {
|
||||
for (const option of options) {
|
||||
if (option.type === 'title') {
|
||||
this.element.appendChild($el(`li`, {
|
||||
html: option.label
|
||||
}));
|
||||
}
|
||||
else {
|
||||
const id = generateId(8);
|
||||
this.callbacks.set(id, async (e) => { var _a; return (_a = option === null || option === void 0 ? void 0 : option.callback) === null || _a === void 0 ? void 0 : _a.call(option, e); });
|
||||
this.element.appendChild($el(`li[role="button"][data-callback="${id}"]`, {
|
||||
html: option.label
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
toElement() {
|
||||
return this.element;
|
||||
}
|
||||
async open(e) {
|
||||
const parent = e.target.closest('div,dialog,body');
|
||||
parent.appendChild(this.element);
|
||||
setAttributes(this.element, {
|
||||
style: {
|
||||
left: `${e.clientX + 16}px`,
|
||||
top: `${e.clientY - 16}px`,
|
||||
}
|
||||
});
|
||||
this.element.setAttribute('state', 'measuring-open');
|
||||
await wait(16);
|
||||
const rect = this.element.getBoundingClientRect();
|
||||
if (rect.right > window.innerWidth) {
|
||||
this.element.style.left = `${e.clientX - rect.width - 16}px`;
|
||||
await wait(16);
|
||||
}
|
||||
this.element.setAttribute('state', 'open');
|
||||
setTimeout(() => {
|
||||
window.addEventListener('pointerdown', this.handleWindowPointerDownBound);
|
||||
});
|
||||
}
|
||||
handleWindowPointerDown(e) {
|
||||
if (!this.element.contains(e.target)) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
async close() {
|
||||
window.removeEventListener('pointerdown', this.handleWindowPointerDownBound);
|
||||
this.element.setAttribute('state', 'measuring-closed');
|
||||
await wait(16);
|
||||
this.element.setAttribute('state', 'closed');
|
||||
this.element.remove();
|
||||
}
|
||||
isOpen() {
|
||||
return (this.element.getAttribute('state') || '').includes('open');
|
||||
}
|
||||
}
|
||||
export class MenuButton {
|
||||
constructor(options) {
|
||||
this.element = $el('button.rgthree-button[data-action="open-menu"]');
|
||||
this.options = options;
|
||||
this.element.innerHTML = options.icon;
|
||||
this.menu = new Menu(options.options);
|
||||
this.element.addEventListener('pointerdown', (e) => {
|
||||
if (!this.menu.isOpen()) {
|
||||
this.menu.open(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
toElement() {
|
||||
return this.element;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { mxdApi } from "./mxd_api.js";
|
||||
import { api } from "../../scripts/api.js";
|
||||
|
||||
class BaseModelInfoService extends EventTarget {
|
||||
constructor() {
|
||||
super();
|
||||
this.fileToInfo = new Map();
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
api.addEventListener(this.apiRefreshEventString, this.handleAsyncUpdate.bind(this));
|
||||
}
|
||||
|
||||
async getInfo(file, refresh, light) {
|
||||
if (this.fileToInfo.has(file) && !refresh) {
|
||||
return this.fileToInfo.get(file);
|
||||
}
|
||||
return this.fetchInfo(file, refresh, light);
|
||||
}
|
||||
|
||||
async refreshInfo(file) {
|
||||
return this.fetchInfo(file, true);
|
||||
}
|
||||
|
||||
async clearFetchedInfo(file) {
|
||||
await mxdApi.clearModelsInfo({ type: this.modelInfoType, files: [file] });
|
||||
this.fileToInfo.delete(file);
|
||||
return null;
|
||||
}
|
||||
|
||||
async savePartialInfo(file, data) {
|
||||
const info = await mxdApi.saveModelInfo(this.modelInfoType, file, data);
|
||||
this.fileToInfo.set(file, info);
|
||||
return info;
|
||||
}
|
||||
|
||||
handleAsyncUpdate(event) {
|
||||
const info = event.detail?.data;
|
||||
if (info?.file) {
|
||||
this.fileToInfo.set(info.file, info);
|
||||
}
|
||||
}
|
||||
|
||||
async fetchInfo(file, refresh = false, light = false) {
|
||||
let info = null;
|
||||
if (!refresh) {
|
||||
info = await mxdApi.getModelsInfo({ type: this.modelInfoType, files: [file], light });
|
||||
} else {
|
||||
info = await mxdApi.refreshModelsInfo({ type: this.modelInfoType, files: [file] });
|
||||
}
|
||||
info = info?.[0] ?? null;
|
||||
if (!light) {
|
||||
this.fileToInfo.set(file, info);
|
||||
}
|
||||
return info;
|
||||
}
|
||||
}
|
||||
|
||||
class LoraInfoService extends BaseModelInfoService {
|
||||
apiRefreshEventString = "loraloader-mxd-refreshed-loras-info";
|
||||
modelInfoType = "loras";
|
||||
}
|
||||
|
||||
class CheckpointInfoService extends BaseModelInfoService {
|
||||
apiRefreshEventString = "loraloader-mxd-refreshed-checkpoints-info";
|
||||
modelInfoType = "checkpoints";
|
||||
}
|
||||
|
||||
export const LORA_INFO_SERVICE = new LoraInfoService();
|
||||
export const CHECKPOINT_INFO_SERVICE = new CheckpointInfoService();
|
||||
@@ -0,0 +1,97 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
|
||||
class LogSession {
|
||||
constructor(name) {
|
||||
this.name = name || "[mxd]";
|
||||
}
|
||||
|
||||
logParts(level, message, ...args) {
|
||||
const method = level === "error" ? "error" : level === "warn" ? "warn" : "log";
|
||||
return [method, [`${this.name} ${message}`, ...args]];
|
||||
}
|
||||
|
||||
errorParts(message, ...args) {
|
||||
return this.logParts("error", message, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
class MxdRuntime {
|
||||
constructor() {
|
||||
this.loadingApiJson = null;
|
||||
this.lastCanvasMouseEvent = null;
|
||||
this.canvasCurrentlyCopyingToClipboard = false;
|
||||
this.canvasCurrentlyCopyingToClipboardWithMultipleNodes = false;
|
||||
this.canvasCurrentlyPastingFromClipboard = false;
|
||||
this.initializeHooks();
|
||||
}
|
||||
|
||||
initializeHooks() {
|
||||
const runtime = this;
|
||||
|
||||
const loadApiJson = app.loadApiJson;
|
||||
app.loadApiJson = async function (apiData, fileName) {
|
||||
runtime.loadingApiJson = apiData;
|
||||
try {
|
||||
return await loadApiJson.apply(app, [...arguments]);
|
||||
} finally {
|
||||
runtime.loadingApiJson = null;
|
||||
}
|
||||
};
|
||||
|
||||
const adjustMouseEvent = LGraphCanvas.prototype.adjustMouseEvent;
|
||||
LGraphCanvas.prototype.adjustMouseEvent = function (e) {
|
||||
adjustMouseEvent.apply(this, [...arguments]);
|
||||
runtime.lastCanvasMouseEvent = e;
|
||||
};
|
||||
|
||||
const copyToClipboard = LGraphCanvas.prototype.copyToClipboard;
|
||||
LGraphCanvas.prototype.copyToClipboard = function (items) {
|
||||
runtime.canvasCurrentlyCopyingToClipboard = true;
|
||||
runtime.canvasCurrentlyCopyingToClipboardWithMultipleNodes =
|
||||
Object.values(items || this.selected_nodes || {}).length > 1;
|
||||
try {
|
||||
return copyToClipboard.apply(this, [...arguments]);
|
||||
} finally {
|
||||
runtime.canvasCurrentlyCopyingToClipboard = false;
|
||||
runtime.canvasCurrentlyCopyingToClipboardWithMultipleNodes = false;
|
||||
}
|
||||
};
|
||||
|
||||
const pasteFromClipboard = LGraphCanvas.prototype.pasteFromClipboard;
|
||||
LGraphCanvas.prototype.pasteFromClipboard = function () {
|
||||
runtime.canvasCurrentlyPastingFromClipboard = true;
|
||||
try {
|
||||
return pasteFromClipboard.apply(this, [...arguments]);
|
||||
} finally {
|
||||
runtime.canvasCurrentlyPastingFromClipboard = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
newLogSession(name) {
|
||||
return new LogSession(name);
|
||||
}
|
||||
|
||||
showMessage({ id, type, message, timeout }) {
|
||||
const msgId = id || `mxd-${Date.now()}`;
|
||||
let container = document.querySelector(".mxd-top-messages-container");
|
||||
if (!container) {
|
||||
container = document.createElement("div");
|
||||
container.className = "mxd-top-messages-container";
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
const node = document.createElement("div");
|
||||
node.className = `mxd-top-message ${type || "info"}`;
|
||||
node.setAttribute("msg-id", msgId);
|
||||
node.textContent = message || "";
|
||||
container.appendChild(node);
|
||||
setTimeout(() => node.remove(), timeout || 3000);
|
||||
}
|
||||
|
||||
isDevMode() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const mxdRuntime = new MxdRuntime();
|
||||
window.mxdRuntime = mxdRuntime;
|
||||
@@ -0,0 +1,416 @@
|
||||
export function getResolver(timeout = 5000) {
|
||||
const resolver = {};
|
||||
resolver.id = generateId(8);
|
||||
resolver.completed = false;
|
||||
resolver.resolved = false;
|
||||
resolver.rejected = false;
|
||||
resolver.promise = new Promise((resolve, reject) => {
|
||||
resolver.reject = (e) => {
|
||||
resolver.completed = true;
|
||||
resolver.rejected = true;
|
||||
reject(e);
|
||||
};
|
||||
resolver.resolve = (data) => {
|
||||
resolver.completed = true;
|
||||
resolver.resolved = true;
|
||||
resolve(data);
|
||||
};
|
||||
});
|
||||
resolver.timeout = setTimeout(() => {
|
||||
if (!resolver.completed) {
|
||||
resolver.reject();
|
||||
}
|
||||
}, timeout);
|
||||
return resolver;
|
||||
}
|
||||
const DEBOUNCE_FN_TO_PROMISE = new WeakMap();
|
||||
export function debounce(fn, ms = 64) {
|
||||
if (!DEBOUNCE_FN_TO_PROMISE.get(fn)) {
|
||||
DEBOUNCE_FN_TO_PROMISE.set(fn, wait(ms).then(() => {
|
||||
DEBOUNCE_FN_TO_PROMISE.delete(fn);
|
||||
fn();
|
||||
}));
|
||||
}
|
||||
return DEBOUNCE_FN_TO_PROMISE.get(fn);
|
||||
}
|
||||
export function check(value, msg = "", ...args) {
|
||||
if (!value) {
|
||||
console.error(msg, ...(args || []));
|
||||
throw new Error(msg || "Error");
|
||||
}
|
||||
}
|
||||
export function wait(ms = 16) {
|
||||
if (ms === 16) {
|
||||
return new Promise((resolve) => {
|
||||
requestAnimationFrame(() => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve();
|
||||
}, ms);
|
||||
});
|
||||
}
|
||||
export function deepFreeze(obj) {
|
||||
const propNames = Reflect.ownKeys(obj);
|
||||
for (const name of propNames) {
|
||||
const value = obj[name];
|
||||
if ((value && typeof value === "object") || typeof value === "function") {
|
||||
deepFreeze(value);
|
||||
}
|
||||
}
|
||||
return Object.freeze(obj);
|
||||
}
|
||||
function dec2hex(dec) {
|
||||
return dec.toString(16).padStart(2, "0");
|
||||
}
|
||||
export function generateId(length) {
|
||||
const arr = new Uint8Array(length / 2);
|
||||
crypto.getRandomValues(arr);
|
||||
return Array.from(arr, dec2hex).join("");
|
||||
}
|
||||
export function getObjectValue(obj, objKey, def) {
|
||||
if (!obj || !objKey)
|
||||
return def;
|
||||
const keys = objKey.split(".");
|
||||
const key = keys.shift();
|
||||
const found = obj[key];
|
||||
if (keys.length) {
|
||||
return getObjectValue(found, keys.join("."), def);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
export function setObjectValue(obj, objKey, value, createMissingObjects = true) {
|
||||
if (!obj || !objKey)
|
||||
return obj;
|
||||
const keys = objKey.split(".");
|
||||
const key = keys.shift();
|
||||
if (obj[key] === undefined) {
|
||||
if (!createMissingObjects) {
|
||||
return;
|
||||
}
|
||||
obj[key] = {};
|
||||
}
|
||||
if (!keys.length) {
|
||||
obj[key] = value;
|
||||
}
|
||||
else {
|
||||
if (typeof obj[key] != "object") {
|
||||
obj[key] = {};
|
||||
}
|
||||
setObjectValue(obj[key], keys.join("."), value, createMissingObjects);
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
export function moveArrayItem(arr, itemOrFrom, to) {
|
||||
const from = typeof itemOrFrom === "number" ? itemOrFrom : arr.indexOf(itemOrFrom);
|
||||
arr.splice(to, 0, arr.splice(from, 1)[0]);
|
||||
}
|
||||
export function removeArrayItem(arr, itemOrIndex) {
|
||||
const index = typeof itemOrIndex === "number" ? itemOrIndex : arr.indexOf(itemOrIndex);
|
||||
arr.splice(index, 1);
|
||||
}
|
||||
export function injectCss(href) {
|
||||
if (document.querySelector(`link[href^="${href}"]`)) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const link = document.createElement("link");
|
||||
link.setAttribute("rel", "stylesheet");
|
||||
link.setAttribute("type", "text/css");
|
||||
const timeout = setTimeout(resolve, 1000);
|
||||
link.addEventListener("load", (e) => {
|
||||
clearInterval(timeout);
|
||||
resolve();
|
||||
});
|
||||
link.href = href;
|
||||
document.head.appendChild(link);
|
||||
});
|
||||
}
|
||||
export function defineProperty(instance, property, desc) {
|
||||
var _a, _b, _c, _d, _e, _f;
|
||||
const existingDesc = Object.getOwnPropertyDescriptor(instance, property);
|
||||
if ((existingDesc === null || existingDesc === void 0 ? void 0 : existingDesc.configurable) === false) {
|
||||
throw new Error(`Error: rgthree-comfy cannot define un-configurable property "${property}"`);
|
||||
}
|
||||
if ((existingDesc === null || existingDesc === void 0 ? void 0 : existingDesc.get) && desc.get) {
|
||||
const descGet = desc.get;
|
||||
desc.get = () => {
|
||||
existingDesc.get.apply(instance, []);
|
||||
return descGet.apply(instance, []);
|
||||
};
|
||||
}
|
||||
if ((existingDesc === null || existingDesc === void 0 ? void 0 : existingDesc.set) && desc.set) {
|
||||
const descSet = desc.set;
|
||||
desc.set = (v) => {
|
||||
existingDesc.set.apply(instance, [v]);
|
||||
return descSet.apply(instance, [v]);
|
||||
};
|
||||
}
|
||||
desc.enumerable = (_b = (_a = desc.enumerable) !== null && _a !== void 0 ? _a : existingDesc === null || existingDesc === void 0 ? void 0 : existingDesc.enumerable) !== null && _b !== void 0 ? _b : true;
|
||||
desc.configurable = (_d = (_c = desc.configurable) !== null && _c !== void 0 ? _c : existingDesc === null || existingDesc === void 0 ? void 0 : existingDesc.configurable) !== null && _d !== void 0 ? _d : true;
|
||||
if (!desc.get && !desc.set) {
|
||||
desc.writable = (_f = (_e = desc.writable) !== null && _e !== void 0 ? _e : existingDesc === null || existingDesc === void 0 ? void 0 : existingDesc.writable) !== null && _f !== void 0 ? _f : true;
|
||||
}
|
||||
return Object.defineProperty(instance, property, desc);
|
||||
}
|
||||
export function areDataViewsEqual(a, b) {
|
||||
if (a.byteLength !== b.byteLength) {
|
||||
return false;
|
||||
}
|
||||
for (let i = 0; i < a.byteLength; i++) {
|
||||
if (a.getUint8(i) !== b.getUint8(i)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
function looksLikeBase64(source) {
|
||||
return source.length > 500 || source.startsWith("data:") || source.includes(";base64,");
|
||||
}
|
||||
export function areArrayBuffersEqual(a, b) {
|
||||
if (a == b || !a || !b) {
|
||||
return a == b;
|
||||
}
|
||||
return areDataViewsEqual(new DataView(a), new DataView(b));
|
||||
}
|
||||
export function newCanvas(widthOrPtOrImage, height) {
|
||||
let width;
|
||||
if (typeof widthOrPtOrImage !== "number") {
|
||||
width = widthOrPtOrImage.width;
|
||||
height = widthOrPtOrImage.height;
|
||||
}
|
||||
else {
|
||||
width = widthOrPtOrImage;
|
||||
height = height;
|
||||
}
|
||||
if (height == null) {
|
||||
throw new Error("Invalid height supplied when creating new canvas object.");
|
||||
}
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
if (widthOrPtOrImage instanceof HTMLImageElement) {
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.drawImage(widthOrPtOrImage, 0, 0, width, height);
|
||||
}
|
||||
return canvas;
|
||||
}
|
||||
export function getCanvasImageData(image) {
|
||||
const canvas = newCanvas(image);
|
||||
const ctx = canvas.getContext("2d");
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
return [canvas, ctx, imageData];
|
||||
}
|
||||
export async function convertToBase64(source) {
|
||||
if (source instanceof Promise) {
|
||||
source = await source;
|
||||
}
|
||||
if (typeof source === "string" && looksLikeBase64(source)) {
|
||||
return source;
|
||||
}
|
||||
if (typeof source === "string" || source instanceof Blob || source instanceof ArrayBuffer) {
|
||||
return convertToBase64(await loadImage(source));
|
||||
}
|
||||
if (source instanceof HTMLImageElement) {
|
||||
if (looksLikeBase64(source.src)) {
|
||||
return source.src;
|
||||
}
|
||||
const [canvas, ctx, imageData] = getCanvasImageData(source);
|
||||
return convertToBase64(canvas);
|
||||
}
|
||||
if (source instanceof HTMLCanvasElement) {
|
||||
return source.toDataURL("image/png");
|
||||
}
|
||||
throw Error("Unknown source to convert to base64.");
|
||||
}
|
||||
export async function convertToArrayBuffer(source) {
|
||||
if (source instanceof Promise) {
|
||||
source = await source;
|
||||
}
|
||||
if (source instanceof ArrayBuffer) {
|
||||
return source;
|
||||
}
|
||||
if (typeof source === "string") {
|
||||
if (looksLikeBase64(source)) {
|
||||
var binaryString = atob(source.replace(/^.*?;base64,/, ""));
|
||||
var bytes = new Uint8Array(binaryString.length);
|
||||
for (var i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
||||
return convertToArrayBuffer(await loadImage(source));
|
||||
}
|
||||
if (source instanceof HTMLImageElement) {
|
||||
const [canvas, ctx, imageData] = getCanvasImageData(source);
|
||||
return convertToArrayBuffer(canvas);
|
||||
}
|
||||
if (source instanceof HTMLCanvasElement) {
|
||||
return convertToArrayBuffer(source.toDataURL());
|
||||
}
|
||||
if (source instanceof Blob) {
|
||||
return source.arrayBuffer();
|
||||
}
|
||||
throw Error("Unknown source to convert to arraybuffer.");
|
||||
}
|
||||
export async function loadImage(source) {
|
||||
if (source instanceof Promise) {
|
||||
source = await source;
|
||||
}
|
||||
if (source instanceof HTMLImageElement) {
|
||||
return loadImage(source.src);
|
||||
}
|
||||
if (source instanceof Blob) {
|
||||
return loadImage(source.arrayBuffer());
|
||||
}
|
||||
if (source instanceof HTMLCanvasElement) {
|
||||
return loadImage(source.toDataURL());
|
||||
}
|
||||
if (source instanceof ArrayBuffer) {
|
||||
var binary = "";
|
||||
var bytes = new Uint8Array(source);
|
||||
var len = bytes.byteLength;
|
||||
for (var i = 0; i < len; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return loadImage(`data:${getMimeTypeFromArrayBuffer(bytes)};base64,${btoa(binary)}`);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.addEventListener("load", () => {
|
||||
resolve(img);
|
||||
});
|
||||
img.addEventListener("error", () => {
|
||||
reject(img);
|
||||
});
|
||||
img.src = source;
|
||||
});
|
||||
}
|
||||
function getMimeTypeFromArrayBuffer(buffer) {
|
||||
const len = 4;
|
||||
if (buffer.length >= len) {
|
||||
let signatureArr = new Array(len);
|
||||
for (let i = 0; i < len; i++)
|
||||
signatureArr[i] = buffer[i].toString(16);
|
||||
const signature = signatureArr.join("").toUpperCase();
|
||||
switch (signature) {
|
||||
case "89504E47":
|
||||
return "image/png";
|
||||
case "47494638":
|
||||
return "image/gif";
|
||||
case "25504446":
|
||||
return "application/pdf";
|
||||
case "FFD8FFDB":
|
||||
case "FFD8FFE0":
|
||||
return "image/jpeg";
|
||||
case "504B0304":
|
||||
return "application/zip";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
export class Broadcaster extends EventTarget {
|
||||
constructor(channelName) {
|
||||
super();
|
||||
this.queue = {};
|
||||
this.queue = {};
|
||||
this.channel = new BroadcastChannel(channelName);
|
||||
this.channel.addEventListener("message", (e) => {
|
||||
this.onMessage(e);
|
||||
});
|
||||
}
|
||||
getId() {
|
||||
let id;
|
||||
do {
|
||||
id = generateId(6);
|
||||
} while (this.queue[id]);
|
||||
return id;
|
||||
}
|
||||
async broadcastAndWait(action, payload, options) {
|
||||
const id = this.getId();
|
||||
this.queue[id] = getResolver(options === null || options === void 0 ? void 0 : options.timeout);
|
||||
this.channel.postMessage({
|
||||
id,
|
||||
action,
|
||||
payload,
|
||||
});
|
||||
let response;
|
||||
try {
|
||||
response = await this.queue[id].promise;
|
||||
}
|
||||
catch (e) {
|
||||
console.log("CAUGHT", e);
|
||||
response = [];
|
||||
}
|
||||
return response;
|
||||
}
|
||||
broadcast(action, payload) {
|
||||
this.channel.postMessage({
|
||||
id: this.getId(),
|
||||
action,
|
||||
payload,
|
||||
});
|
||||
}
|
||||
reply(replyId, action, payload) {
|
||||
this.channel.postMessage({
|
||||
id: this.getId(),
|
||||
replyId,
|
||||
action,
|
||||
payload,
|
||||
});
|
||||
}
|
||||
openWindowAndWaitForMessage(rgthreePath, windowName) {
|
||||
const id = this.getId();
|
||||
this.queue[id] = getResolver();
|
||||
const win = window.open(`/rgthree/${rgthreePath}#broadcastLoadMsgId=${id}`, windowName);
|
||||
return { window: win, promise: this.queue[id].promise };
|
||||
}
|
||||
onMessage(e) {
|
||||
var _a, _b;
|
||||
const msgId = ((_a = e.data) === null || _a === void 0 ? void 0 : _a.replyId) || "";
|
||||
const queueItem = this.queue[msgId];
|
||||
if (queueItem) {
|
||||
if (queueItem.completed) {
|
||||
console.error(`${msgId} already completed..`);
|
||||
}
|
||||
queueItem.deferment = queueItem.deferment || { data: [] };
|
||||
queueItem.deferment.data.push(e.data.payload);
|
||||
queueItem.deferment.timeout && clearTimeout(queueItem.deferment.timeout);
|
||||
queueItem.deferment.timeout = setTimeout(() => {
|
||||
queueItem.resolve(queueItem.deferment.data);
|
||||
}, 250);
|
||||
}
|
||||
else {
|
||||
this.dispatchEvent(new CustomEvent("rgthree-broadcast-message", {
|
||||
detail: Object.assign({ replyTo: (_b = e.data) === null || _b === void 0 ? void 0 : _b.id }, e.data),
|
||||
}));
|
||||
}
|
||||
}
|
||||
addMessageListener(callback, options) {
|
||||
return super.addEventListener("rgthree-broadcast-message", callback, options);
|
||||
}
|
||||
}
|
||||
const broadcastChannelMap = new Map();
|
||||
export function broadcastOnChannel(channel, action, payload) {
|
||||
let queue = broadcastChannelMap.get(channel);
|
||||
if (!queue) {
|
||||
broadcastChannelMap.set(channel, {});
|
||||
queue = broadcastChannelMap.get(channel);
|
||||
}
|
||||
let id;
|
||||
do {
|
||||
id = generateId(6);
|
||||
} while (queue[id]);
|
||||
queue[id] = getResolver();
|
||||
channel.postMessage({
|
||||
id,
|
||||
action,
|
||||
payload,
|
||||
});
|
||||
return queue[id].promise;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export const logoCivitai = `<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"><circle cx="12" cy="12" r="10" fill="#40a3ff"></circle><path d="M8 7h8l-4 10z" fill="#fff"></path></svg>`;
|
||||
export const link = `<svg viewBox="0 0 24 24" width="12" height="12" aria-hidden="true"><path d="M14 3h7v7h-2V6.4l-9.3 9.3-1.4-1.4L17.6 5H14V3z" fill="currentColor"></path><path d="M5 5h6v2H7v10h10v-4h2v6H5V5z" fill="currentColor"></path></svg>`;
|
||||
export const pencilColored = `<svg viewBox="0 0 24 24" width="16" height="16"><path fill="#f59e0b" d="M3 17.2V21h3.8l11-11-3.8-3.8-11 11z"></path><path fill="#d97706" d="M20.7 7.1a1 1 0 0 0 0-1.4l-2.4-2.4a1 1 0 0 0-1.4 0l-1.9 1.9 3.8 3.8 1.9-1.9z"></path></svg>`;
|
||||
export const diskColored = `<svg viewBox="0 0 24 24" width="16" height="16"><path fill="#22c55e" d="M4 4h16v16H4z"></path><path fill="#fff" d="M7 4h8v6H7zM8 14h8v4H8z"></path></svg>`;
|
||||
export const dotdotdot = `<svg viewBox="0 0 24 24" width="16" height="16"><circle cx="6" cy="12" r="2" fill="currentColor"></circle><circle cx="12" cy="12" r="2" fill="currentColor"></circle><circle cx="18" cy="12" r="2" fill="currentColor"></circle></svg>`;
|
||||
@@ -0,0 +1,724 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { api } from "../../scripts/api.js";
|
||||
import { getResolver, wait } from "./mxd_shared_utils.js";
|
||||
import { MxdHelpDialog } from "./mxd_dialog.js";
|
||||
const oldApiGetNodeDefs = api.getNodeDefs;
|
||||
api.getNodeDefs = async function () {
|
||||
const defs = await oldApiGetNodeDefs.call(api);
|
||||
this.dispatchEvent(new CustomEvent("fresh-node-defs", { detail: defs }));
|
||||
return defs;
|
||||
};
|
||||
export var IoDirection;
|
||||
(function (IoDirection) {
|
||||
IoDirection[IoDirection["INPUT"] = 0] = "INPUT";
|
||||
IoDirection[IoDirection["OUTPUT"] = 1] = "OUTPUT";
|
||||
})(IoDirection || (IoDirection = {}));
|
||||
const PADDING = 0;
|
||||
export const LAYOUT_LABEL_TO_DATA = {
|
||||
Left: [LiteGraph.LEFT, [0, 0.5], [PADDING, 0]],
|
||||
Right: [LiteGraph.RIGHT, [1, 0.5], [-PADDING, 0]],
|
||||
Top: [LiteGraph.UP, [0.5, 0], [0, PADDING]],
|
||||
Bottom: [LiteGraph.DOWN, [0.5, 1], [0, -PADDING]],
|
||||
};
|
||||
export const LAYOUT_LABEL_OPPOSITES = {
|
||||
Left: "Right",
|
||||
Right: "Left",
|
||||
Top: "Bottom",
|
||||
Bottom: "Top",
|
||||
};
|
||||
export const LAYOUT_CLOCKWISE = ["Top", "Right", "Bottom", "Left"];
|
||||
export function addMenuItem(node, _app, config, after = "Shape") {
|
||||
const oldGetExtraMenuOptions = node.prototype.getExtraMenuOptions;
|
||||
node.prototype.getExtraMenuOptions = function (canvas, menuOptions) {
|
||||
oldGetExtraMenuOptions && oldGetExtraMenuOptions.apply(this, [canvas, menuOptions]);
|
||||
addMenuItemOnExtraMenuOptions(this, config, menuOptions, after);
|
||||
};
|
||||
}
|
||||
let canvasResolver = null;
|
||||
export function waitForCanvas() {
|
||||
if (canvasResolver === null) {
|
||||
canvasResolver = getResolver();
|
||||
function _waitForCanvas() {
|
||||
if (!canvasResolver.completed) {
|
||||
if (app === null || app === void 0 ? void 0 : app.canvas) {
|
||||
canvasResolver.resolve(app.canvas);
|
||||
}
|
||||
else {
|
||||
requestAnimationFrame(_waitForCanvas);
|
||||
}
|
||||
}
|
||||
}
|
||||
_waitForCanvas();
|
||||
}
|
||||
return canvasResolver.promise;
|
||||
}
|
||||
let graphResolver = null;
|
||||
export function waitForGraph() {
|
||||
if (graphResolver === null) {
|
||||
graphResolver = getResolver();
|
||||
function _wait() {
|
||||
if (!graphResolver.completed) {
|
||||
if (app === null || app === void 0 ? void 0 : app.graph) {
|
||||
graphResolver.resolve(app.graph);
|
||||
}
|
||||
else {
|
||||
requestAnimationFrame(_wait);
|
||||
}
|
||||
}
|
||||
}
|
||||
_wait();
|
||||
}
|
||||
return graphResolver.promise;
|
||||
}
|
||||
export function addMenuItemOnExtraMenuOptions(node, config, menuOptions, after = "Shape") {
|
||||
let idx = menuOptions
|
||||
.slice()
|
||||
.reverse()
|
||||
.findIndex((option) => option === null || option === void 0 ? void 0 : option.isRgthree);
|
||||
if (idx == -1) {
|
||||
idx = menuOptions.findIndex((option) => { var _a; return (_a = option === null || option === void 0 ? void 0 : option.content) === null || _a === void 0 ? void 0 : _a.includes(after); }) + 1;
|
||||
if (!idx) {
|
||||
idx = menuOptions.length - 1;
|
||||
}
|
||||
menuOptions.splice(idx, 0, null);
|
||||
idx++;
|
||||
}
|
||||
else {
|
||||
idx = menuOptions.length - idx;
|
||||
}
|
||||
const subMenuOptions = typeof config.subMenuOptions === "function"
|
||||
? config.subMenuOptions(node)
|
||||
: config.subMenuOptions;
|
||||
menuOptions.splice(idx, 0, {
|
||||
content: typeof config.name == "function" ? config.name(node) : config.name,
|
||||
has_submenu: !!(subMenuOptions === null || subMenuOptions === void 0 ? void 0 : subMenuOptions.length),
|
||||
isRgthree: true,
|
||||
callback: (value, _options, event, parentMenu, _node) => {
|
||||
if (!!(subMenuOptions === null || subMenuOptions === void 0 ? void 0 : subMenuOptions.length)) {
|
||||
new LiteGraph.ContextMenu(subMenuOptions.map((option) => (option ? { content: option } : null)), {
|
||||
event,
|
||||
parentMenu,
|
||||
callback: (subValue, _options, _event, _parentMenu, _node) => {
|
||||
if (config.property) {
|
||||
node.properties = node.properties || {};
|
||||
node.properties[config.property] = config.prepareValue
|
||||
? config.prepareValue(subValue.content || "", node)
|
||||
: subValue.content || "";
|
||||
}
|
||||
config.callback && config.callback(node, subValue === null || subValue === void 0 ? void 0 : subValue.content);
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (config.property) {
|
||||
node.properties = node.properties || {};
|
||||
node.properties[config.property] = config.prepareValue
|
||||
? config.prepareValue(node.properties[config.property], node)
|
||||
: !node.properties[config.property];
|
||||
}
|
||||
config.callback && config.callback(node, value === null || value === void 0 ? void 0 : value.content);
|
||||
},
|
||||
});
|
||||
}
|
||||
export function addConnectionLayoutSupport(node, app, options = [
|
||||
["Left", "Right"],
|
||||
["Right", "Left"],
|
||||
], callback) {
|
||||
addMenuItem(node, app, {
|
||||
name: "Connections Layout",
|
||||
property: "connections_layout",
|
||||
subMenuOptions: options.map((option) => option[0] + (option[1] ? " -> " + option[1] : "")),
|
||||
prepareValue: (value, node) => {
|
||||
var _a;
|
||||
const values = String(value).split(" -> ");
|
||||
if (!values[1] && !((_a = node.outputs) === null || _a === void 0 ? void 0 : _a.length)) {
|
||||
values[1] = LAYOUT_LABEL_OPPOSITES[values[0]];
|
||||
}
|
||||
if (!LAYOUT_LABEL_TO_DATA[values[0]] || !LAYOUT_LABEL_TO_DATA[values[1]]) {
|
||||
throw new Error(`New Layout invalid: [${values[0]}, ${values[1]}]`);
|
||||
}
|
||||
return values;
|
||||
},
|
||||
callback: (node) => {
|
||||
var _a;
|
||||
callback && callback(node);
|
||||
(_a = node.graph) === null || _a === void 0 ? void 0 : _a.setDirtyCanvas(true, true);
|
||||
},
|
||||
});
|
||||
node.prototype.getConnectionPos = function (isInput, slotNumber, out) {
|
||||
return getConnectionPosForLayout(this, isInput, slotNumber, out);
|
||||
};
|
||||
node.prototype.getInputPos = function (slotNumber) {
|
||||
return getConnectionPosForLayout(this, true, slotNumber, [0, 0]);
|
||||
};
|
||||
node.prototype.getOutputPos = function (slotNumber) {
|
||||
return getConnectionPosForLayout(this, false, slotNumber, [0, 0]);
|
||||
};
|
||||
}
|
||||
export function setConnectionsLayout(node, newLayout) {
|
||||
var _a;
|
||||
newLayout = newLayout || node.defaultConnectionsLayout || ["Left", "Right"];
|
||||
if (!newLayout[1] && !((_a = node.outputs) === null || _a === void 0 ? void 0 : _a.length)) {
|
||||
newLayout[1] = LAYOUT_LABEL_OPPOSITES[newLayout[0]];
|
||||
}
|
||||
if (!LAYOUT_LABEL_TO_DATA[newLayout[0]] || !LAYOUT_LABEL_TO_DATA[newLayout[1]]) {
|
||||
throw new Error(`New Layout invalid: [${newLayout[0]}, ${newLayout[1]}]`);
|
||||
}
|
||||
node.properties = node.properties || {};
|
||||
node.properties["connections_layout"] = newLayout;
|
||||
}
|
||||
export function setConnectionsCollapse(node, collapseConnections = null) {
|
||||
node.properties = node.properties || {};
|
||||
collapseConnections =
|
||||
collapseConnections !== null ? collapseConnections : !node.properties["collapse_connections"];
|
||||
node.properties["collapse_connections"] = collapseConnections;
|
||||
}
|
||||
export function getConnectionPosForLayout(node, isInput, slotNumber, out) {
|
||||
var _a, _b, _c;
|
||||
out = out || new Float32Array(2);
|
||||
node.properties = node.properties || {};
|
||||
const layout = node.properties["connections_layout"] ||
|
||||
node.defaultConnectionsLayout || ["Left", "Right"];
|
||||
const collapseConnections = node.properties["collapse_connections"] || false;
|
||||
const offset = (_a = node.constructor.layout_slot_offset) !== null && _a !== void 0 ? _a : LiteGraph.NODE_SLOT_HEIGHT * 0.5;
|
||||
let side = isInput ? layout[0] : layout[1];
|
||||
const otherSide = isInput ? layout[1] : layout[0];
|
||||
let data = LAYOUT_LABEL_TO_DATA[side];
|
||||
const slotList = node[isInput ? "inputs" : "outputs"];
|
||||
const cxn = slotList[slotNumber];
|
||||
if (!cxn) {
|
||||
console.log("No connection found.. weird", isInput, slotNumber);
|
||||
return out;
|
||||
}
|
||||
if (cxn.disabled) {
|
||||
if (cxn.color_on !== "#666665") {
|
||||
cxn._color_on_org = cxn._color_on_org || cxn.color_on;
|
||||
cxn._color_off_org = cxn._color_off_org || cxn.color_off;
|
||||
}
|
||||
cxn.color_on = "#666665";
|
||||
cxn.color_off = "#666665";
|
||||
}
|
||||
else if (cxn.color_on === "#666665") {
|
||||
cxn.color_on = cxn._color_on_org || undefined;
|
||||
cxn.color_off = cxn._color_off_org || undefined;
|
||||
}
|
||||
const displaySlot = collapseConnections
|
||||
? 0
|
||||
: slotNumber -
|
||||
slotList.reduce((count, ioput, index) => {
|
||||
count += index < slotNumber && ioput.hidden ? 1 : 0;
|
||||
return count;
|
||||
}, 0);
|
||||
cxn.dir = data[0];
|
||||
const connections_dir = node.properties["connections_dir"];
|
||||
if ((node.size[0] == 10 || node.size[1] == 10) && connections_dir) {
|
||||
cxn.dir = connections_dir[isInput ? 0 : 1];
|
||||
}
|
||||
if (side === "Left") {
|
||||
if (node.flags.collapsed) {
|
||||
var w = node._collapsed_width || LiteGraph.NODE_COLLAPSED_WIDTH;
|
||||
out[0] = node.pos[0];
|
||||
out[1] = node.pos[1] - LiteGraph.NODE_TITLE_HEIGHT * 0.5;
|
||||
}
|
||||
else {
|
||||
toggleConnectionLabel(cxn, !isInput || collapseConnections || !!node.hideSlotLabels);
|
||||
out[0] = node.pos[0] + offset;
|
||||
if ((_b = node.constructor) === null || _b === void 0 ? void 0 : _b.type.includes("Reroute")) {
|
||||
out[1] = node.pos[1] + node.size[1] * 0.5;
|
||||
}
|
||||
else {
|
||||
out[1] =
|
||||
node.pos[1] +
|
||||
(displaySlot + 0.7) * LiteGraph.NODE_SLOT_HEIGHT +
|
||||
(node.constructor.slot_start_y || 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (side === "Right") {
|
||||
if (node.flags.collapsed) {
|
||||
var w = node._collapsed_width || LiteGraph.NODE_COLLAPSED_WIDTH;
|
||||
out[0] = node.pos[0] + w;
|
||||
out[1] = node.pos[1] - LiteGraph.NODE_TITLE_HEIGHT * 0.5;
|
||||
}
|
||||
else {
|
||||
toggleConnectionLabel(cxn, isInput || collapseConnections || !!node.hideSlotLabels);
|
||||
out[0] = node.pos[0] + node.size[0] + 1 - offset;
|
||||
if ((_c = node.constructor) === null || _c === void 0 ? void 0 : _c.type.includes("Reroute")) {
|
||||
out[1] = node.pos[1] + node.size[1] * 0.5;
|
||||
}
|
||||
else {
|
||||
out[1] =
|
||||
node.pos[1] +
|
||||
(displaySlot + 0.7) * LiteGraph.NODE_SLOT_HEIGHT +
|
||||
(node.constructor.slot_start_y || 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (side === "Top") {
|
||||
if (!cxn.has_old_label) {
|
||||
cxn.has_old_label = true;
|
||||
cxn.old_label = cxn.label;
|
||||
cxn.label = " ";
|
||||
}
|
||||
out[0] = node.pos[0] + node.size[0] * 0.5;
|
||||
out[1] = node.pos[1] + offset;
|
||||
}
|
||||
else if (side === "Bottom") {
|
||||
if (!cxn.has_old_label) {
|
||||
cxn.has_old_label = true;
|
||||
cxn.old_label = cxn.label;
|
||||
cxn.label = " ";
|
||||
}
|
||||
out[0] = node.pos[0] + node.size[0] * 0.5;
|
||||
out[1] = node.pos[1] + node.size[1] - offset;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function toggleConnectionLabel(cxn, hide = true) {
|
||||
if (hide) {
|
||||
if (!cxn.has_old_label) {
|
||||
cxn.has_old_label = true;
|
||||
cxn.old_label = cxn.label;
|
||||
}
|
||||
cxn.label = " ";
|
||||
}
|
||||
else if (!hide && cxn.has_old_label) {
|
||||
cxn.has_old_label = false;
|
||||
cxn.label = cxn.old_label;
|
||||
cxn.old_label = undefined;
|
||||
}
|
||||
return cxn;
|
||||
}
|
||||
export function addHelpMenuItem(node, content, menuOptions) {
|
||||
addMenuItemOnExtraMenuOptions(node, {
|
||||
name: "🛟 Node Help",
|
||||
callback: (node) => {
|
||||
if (node.showHelp) {
|
||||
node.showHelp();
|
||||
}
|
||||
else {
|
||||
new MxdHelpDialog(node, content).show();
|
||||
}
|
||||
},
|
||||
}, menuOptions, "Properties Panel");
|
||||
}
|
||||
export var PassThroughFollowing;
|
||||
(function (PassThroughFollowing) {
|
||||
PassThroughFollowing[PassThroughFollowing["ALL"] = 0] = "ALL";
|
||||
PassThroughFollowing[PassThroughFollowing["NONE"] = 1] = "NONE";
|
||||
PassThroughFollowing[PassThroughFollowing["REROUTE_ONLY"] = 2] = "REROUTE_ONLY";
|
||||
})(PassThroughFollowing || (PassThroughFollowing = {}));
|
||||
export function shouldPassThrough(node, passThroughFollowing = PassThroughFollowing.ALL) {
|
||||
var _a;
|
||||
const type = (_a = node === null || node === void 0 ? void 0 : node.constructor) === null || _a === void 0 ? void 0 : _a.type;
|
||||
if (!type || passThroughFollowing === PassThroughFollowing.NONE) {
|
||||
return false;
|
||||
}
|
||||
if (passThroughFollowing === PassThroughFollowing.REROUTE_ONLY) {
|
||||
return type.includes("Reroute");
|
||||
}
|
||||
return (type.includes("Reroute") || type.includes("Node Combiner") || type.includes("Node Collector"));
|
||||
}
|
||||
function filterOutPassthroughNodes(infos, passThroughFollowing = PassThroughFollowing.ALL) {
|
||||
return infos.filter((i) => !shouldPassThrough(i.node, passThroughFollowing));
|
||||
}
|
||||
export function getConnectedInputNodes(startNode, currentNode, slot, passThroughFollowing = PassThroughFollowing.ALL) {
|
||||
return getConnectedNodesInfo(startNode, IoDirection.INPUT, currentNode, slot, passThroughFollowing).map((n) => n.node);
|
||||
}
|
||||
export function getConnectedInputInfosAndFilterPassThroughs(startNode, currentNode, slot, passThroughFollowing = PassThroughFollowing.ALL) {
|
||||
return filterOutPassthroughNodes(getConnectedNodesInfo(startNode, IoDirection.INPUT, currentNode, slot, passThroughFollowing), passThroughFollowing);
|
||||
}
|
||||
export function getConnectedInputNodesAndFilterPassThroughs(startNode, currentNode, slot, passThroughFollowing = PassThroughFollowing.ALL) {
|
||||
return getConnectedInputInfosAndFilterPassThroughs(startNode, currentNode, slot, passThroughFollowing).map((n) => n.node);
|
||||
}
|
||||
export function getConnectedOutputNodes(startNode, currentNode, slot, passThroughFollowing = PassThroughFollowing.ALL) {
|
||||
return getConnectedNodesInfo(startNode, IoDirection.OUTPUT, currentNode, slot, passThroughFollowing).map((n) => n.node);
|
||||
}
|
||||
export function getConnectedOutputNodesAndFilterPassThroughs(startNode, currentNode, slot, passThroughFollowing = PassThroughFollowing.ALL) {
|
||||
return filterOutPassthroughNodes(getConnectedNodesInfo(startNode, IoDirection.OUTPUT, currentNode, slot, passThroughFollowing), passThroughFollowing).map((n) => n.node);
|
||||
}
|
||||
export function getConnectedNodesInfo(startNode, dir = IoDirection.INPUT, currentNode, slot, passThroughFollowing = PassThroughFollowing.ALL, originTravelFromSlot) {
|
||||
var _a, _b, _c, _d, _e, _f, _g, _h;
|
||||
currentNode = currentNode || startNode;
|
||||
let rootNodes = [];
|
||||
if (startNode === currentNode || shouldPassThrough(currentNode, passThroughFollowing)) {
|
||||
let linkIds;
|
||||
slot = slot != null && slot > -1 ? slot : undefined;
|
||||
if (dir == IoDirection.OUTPUT) {
|
||||
if (slot != null) {
|
||||
linkIds = [...(((_b = (_a = currentNode.outputs) === null || _a === void 0 ? void 0 : _a[slot]) === null || _b === void 0 ? void 0 : _b.links) || [])];
|
||||
}
|
||||
else {
|
||||
linkIds = ((_c = currentNode.outputs) === null || _c === void 0 ? void 0 : _c.flatMap((i) => i.links)) || [];
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (slot != null) {
|
||||
linkIds = [(_e = (_d = currentNode.inputs) === null || _d === void 0 ? void 0 : _d[slot]) === null || _e === void 0 ? void 0 : _e.link];
|
||||
}
|
||||
else {
|
||||
linkIds = ((_f = currentNode.inputs) === null || _f === void 0 ? void 0 : _f.map((i) => i.link)) || [];
|
||||
}
|
||||
}
|
||||
const graph = (_g = currentNode.graph) !== null && _g !== void 0 ? _g : app.graph;
|
||||
for (const linkId of linkIds) {
|
||||
let link = null;
|
||||
if (typeof linkId == "number") {
|
||||
link = (_h = graph.links[linkId]) !== null && _h !== void 0 ? _h : null;
|
||||
}
|
||||
if (!link) {
|
||||
continue;
|
||||
}
|
||||
const travelFromSlot = dir == IoDirection.OUTPUT ? link.origin_slot : link.target_slot;
|
||||
const connectedId = dir == IoDirection.OUTPUT ? link.target_id : link.origin_id;
|
||||
const travelToSlot = dir == IoDirection.OUTPUT ? link.target_slot : link.origin_slot;
|
||||
originTravelFromSlot = originTravelFromSlot != null ? originTravelFromSlot : travelFromSlot;
|
||||
const originNode = graph.getNodeById(connectedId);
|
||||
if (!link) {
|
||||
console.error("No connected node found... weird");
|
||||
continue;
|
||||
}
|
||||
if (rootNodes.some((n) => n.node == originNode)) {
|
||||
console.log(`${startNode.title} (${startNode.id}) seems to have two links to ${originNode.title} (${originNode.id}). One may be stale: ${linkIds.join(", ")}`);
|
||||
}
|
||||
else {
|
||||
rootNodes.push({ node: originNode, travelFromSlot, travelToSlot, originTravelFromSlot });
|
||||
if (shouldPassThrough(originNode, passThroughFollowing)) {
|
||||
for (const foundNode of getConnectedNodesInfo(startNode, dir, originNode, undefined, undefined, originTravelFromSlot)) {
|
||||
if (!rootNodes.map((n) => n.node).includes(foundNode.node)) {
|
||||
rootNodes.push(foundNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return rootNodes;
|
||||
}
|
||||
export function followConnectionUntilType(node, dir, slotNum, skipSelf = false) {
|
||||
const slots = dir === IoDirection.OUTPUT ? node.outputs : node.inputs;
|
||||
if (!slots || !slots.length) {
|
||||
return null;
|
||||
}
|
||||
let type = null;
|
||||
if (slotNum) {
|
||||
if (!slots[slotNum]) {
|
||||
return null;
|
||||
}
|
||||
type = getTypeFromSlot(slots[slotNum], dir, skipSelf);
|
||||
}
|
||||
else {
|
||||
for (const slot of slots) {
|
||||
type = getTypeFromSlot(slot, dir, skipSelf);
|
||||
if (type) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return type;
|
||||
}
|
||||
function getTypeFromSlot(slot, dir, skipSelf = false) {
|
||||
let graph = app.canvas.getCurrentGraph();
|
||||
let type = slot === null || slot === void 0 ? void 0 : slot.type;
|
||||
if (!skipSelf && type != null && type != "*") {
|
||||
return { type: type, label: slot === null || slot === void 0 ? void 0 : slot.label, name: slot === null || slot === void 0 ? void 0 : slot.name };
|
||||
}
|
||||
const links = getSlotLinks(slot);
|
||||
for (const link of links) {
|
||||
const connectedId = dir == IoDirection.OUTPUT ? link.link.target_id : link.link.origin_id;
|
||||
const connectedSlotNum = dir == IoDirection.OUTPUT ? link.link.target_slot : link.link.origin_slot;
|
||||
const connectedNode = graph.getNodeById(connectedId);
|
||||
const connectedSlots = dir === IoDirection.OUTPUT ? connectedNode.inputs : connectedNode.outputs;
|
||||
let connectedSlot = connectedSlots[connectedSlotNum];
|
||||
if ((connectedSlot === null || connectedSlot === void 0 ? void 0 : connectedSlot.type) != null && (connectedSlot === null || connectedSlot === void 0 ? void 0 : connectedSlot.type) != "*") {
|
||||
return {
|
||||
type: connectedSlot.type,
|
||||
label: connectedSlot === null || connectedSlot === void 0 ? void 0 : connectedSlot.label,
|
||||
name: connectedSlot === null || connectedSlot === void 0 ? void 0 : connectedSlot.name,
|
||||
};
|
||||
}
|
||||
else if ((connectedSlot === null || connectedSlot === void 0 ? void 0 : connectedSlot.type) == "*") {
|
||||
return followConnectionUntilType(connectedNode, dir);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
export async function replaceNode(existingNode, typeOrNewNode, inputNameMap) {
|
||||
const existingCtor = existingNode.constructor;
|
||||
const newNode = typeof typeOrNewNode === "string" ? LiteGraph.createNode(typeOrNewNode) : typeOrNewNode;
|
||||
if (existingNode.title != existingCtor.title) {
|
||||
newNode.title = existingNode.title;
|
||||
}
|
||||
newNode.pos = [...existingNode.pos];
|
||||
newNode.properties = { ...existingNode.properties };
|
||||
const oldComputeSize = [...existingNode.computeSize()];
|
||||
const oldSize = [
|
||||
existingNode.size[0] === oldComputeSize[0] ? null : existingNode.size[0],
|
||||
existingNode.size[1] === oldComputeSize[1] ? null : existingNode.size[1],
|
||||
];
|
||||
let setSizeIters = 0;
|
||||
const setSizeFn = () => {
|
||||
const newComputesize = newNode.computeSize();
|
||||
newNode.size[0] = Math.max(oldSize[0] || 0, newComputesize[0]);
|
||||
newNode.size[1] = Math.max(oldSize[1] || 0, newComputesize[1]);
|
||||
setSizeIters++;
|
||||
if (setSizeIters > 10) {
|
||||
requestAnimationFrame(setSizeFn);
|
||||
}
|
||||
};
|
||||
setSizeFn();
|
||||
const links = [];
|
||||
const graph = existingNode.graph || app.graph;
|
||||
for (const [index, output] of existingNode.outputs.entries()) {
|
||||
for (const linkId of output.links || []) {
|
||||
const link = graph.links[linkId];
|
||||
if (!link)
|
||||
continue;
|
||||
const targetNode = graph.getNodeById(link.target_id);
|
||||
links.push({ node: newNode, slot: output.name, targetNode, targetSlot: link.target_slot });
|
||||
}
|
||||
}
|
||||
for (const [index, input] of existingNode.inputs.entries()) {
|
||||
const linkId = input.link;
|
||||
if (linkId) {
|
||||
const link = graph.links[linkId];
|
||||
const originNode = graph.getNodeById(link.origin_id);
|
||||
links.push({
|
||||
node: originNode,
|
||||
slot: link.origin_slot,
|
||||
targetNode: newNode,
|
||||
targetSlot: (inputNameMap === null || inputNameMap === void 0 ? void 0 : inputNameMap.has(input.name))
|
||||
? inputNameMap.get(input.name)
|
||||
: input.name || index,
|
||||
});
|
||||
}
|
||||
}
|
||||
graph.add(newNode);
|
||||
await wait();
|
||||
for (const link of links) {
|
||||
link.node.connect(link.slot, link.targetNode, link.targetSlot);
|
||||
}
|
||||
await wait();
|
||||
graph.remove(existingNode);
|
||||
newNode.size = newNode.computeSize();
|
||||
newNode.setDirtyCanvas(true, true);
|
||||
return newNode;
|
||||
}
|
||||
export function getOriginNodeByLink(linkId) {
|
||||
let node = null;
|
||||
if (linkId != null) {
|
||||
const link = getLinkById(linkId);
|
||||
node = (link != null && getNodeById(link.origin_id)) || null;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
export function getLinkById(linkId) {
|
||||
var _a, _b, _c;
|
||||
if (linkId == null)
|
||||
return null;
|
||||
let link = (_a = app.graph.links[linkId]) !== null && _a !== void 0 ? _a : null;
|
||||
link = (_c = link !== null && link !== void 0 ? link : (_b = app.canvas.getCurrentGraph()) === null || _b === void 0 ? void 0 : _b.links[linkId]) !== null && _c !== void 0 ? _c : null;
|
||||
return link || findSomethingInAllSubgraphs((subgraph) => { var _a; return (_a = subgraph === null || subgraph === void 0 ? void 0 : subgraph.links[linkId]) !== null && _a !== void 0 ? _a : null; });
|
||||
}
|
||||
export function getNodeById(id) {
|
||||
var _a, _b;
|
||||
if (id == null)
|
||||
return null;
|
||||
let node = app.graph.getNodeById(id);
|
||||
node = (_b = node !== null && node !== void 0 ? node : (_a = app.canvas.getCurrentGraph()) === null || _a === void 0 ? void 0 : _a.getNodeById(id)) !== null && _b !== void 0 ? _b : null;
|
||||
return node || findSomethingInAllSubgraphs((subgraph) => { var _a; return (_a = subgraph === null || subgraph === void 0 ? void 0 : subgraph.getNodeById(id)) !== null && _a !== void 0 ? _a : null; });
|
||||
}
|
||||
export function findFromNodeForSubgraph(subgraphId) {
|
||||
var _a;
|
||||
const node = (_a = findSomethingInAllSubgraphs((subgraph) => subgraph.nodes
|
||||
.filter((node) => node.isSubgraphNode())
|
||||
.find((node) => node.subgraph.id === subgraphId))) !== null && _a !== void 0 ? _a : null;
|
||||
return node;
|
||||
}
|
||||
function findSomethingInAllSubgraphs(fn) {
|
||||
var _a, _b;
|
||||
const rootGraph = (_a = app.rootGraph) !== null && _a !== void 0 ? _a : app.graph.rootGraph;
|
||||
const subgraphs = [rootGraph, ...(_b = rootGraph.subgraphs) === null || _b === void 0 ? void 0 : _b.values()];
|
||||
for (const subgraph of subgraphs) {
|
||||
const thing = fn(subgraph);
|
||||
if (thing)
|
||||
return thing;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
export function applyMixins(original, constructors) {
|
||||
constructors.forEach((baseCtor) => {
|
||||
Object.getOwnPropertyNames(baseCtor.prototype).forEach((name) => {
|
||||
Object.defineProperty(original.prototype, name, Object.getOwnPropertyDescriptor(baseCtor.prototype, name) || Object.create(null));
|
||||
});
|
||||
});
|
||||
}
|
||||
export function getSlotLinks(inputOrOutput) {
|
||||
var _a;
|
||||
const links = [];
|
||||
if (!inputOrOutput) {
|
||||
return links;
|
||||
}
|
||||
if ((_a = inputOrOutput.links) === null || _a === void 0 ? void 0 : _a.length) {
|
||||
const output = inputOrOutput;
|
||||
for (const linkId of output.links || []) {
|
||||
const link = app.graph.links[linkId];
|
||||
if (link) {
|
||||
links.push({ id: linkId, link: link });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (inputOrOutput.link) {
|
||||
const input = inputOrOutput;
|
||||
const link = app.graph.links[input.link];
|
||||
if (link) {
|
||||
links.push({ id: input.link, link: link });
|
||||
}
|
||||
}
|
||||
return links;
|
||||
}
|
||||
export async function matchLocalSlotsToServer(node, direction, serverNodeData) {
|
||||
var _a, _b, _c;
|
||||
const serverSlotNames = direction == IoDirection.INPUT
|
||||
? Object.keys(((_a = serverNodeData.input) === null || _a === void 0 ? void 0 : _a.optional) || {})
|
||||
: serverNodeData.output_name;
|
||||
const serverSlotTypes = direction == IoDirection.INPUT
|
||||
? Object.values(((_b = serverNodeData.input) === null || _b === void 0 ? void 0 : _b.optional) || {}).map((i) => i[0])
|
||||
: serverNodeData.output;
|
||||
const slots = direction == IoDirection.INPUT ? node.inputs : node.outputs;
|
||||
let firstIndex = slots.findIndex((o, i) => i !== serverSlotNames.indexOf(o.name));
|
||||
if (firstIndex > -1) {
|
||||
const links = {};
|
||||
slots.map((slot) => {
|
||||
var _a;
|
||||
links[slot.name] = links[slot.name] || [];
|
||||
(_a = links[slot.name]) === null || _a === void 0 ? void 0 : _a.push(...getSlotLinks(slot));
|
||||
});
|
||||
for (const [index, serverSlotName] of serverSlotNames.entries()) {
|
||||
const currentNodeSlot = slots.map((s) => s.name).indexOf(serverSlotName);
|
||||
if (currentNodeSlot > -1) {
|
||||
if (currentNodeSlot != index) {
|
||||
const splicedItem = slots.splice(currentNodeSlot, 1)[0];
|
||||
slots.splice(index, 0, splicedItem);
|
||||
}
|
||||
}
|
||||
else if (currentNodeSlot === -1) {
|
||||
const splicedItem = {
|
||||
name: serverSlotName,
|
||||
type: serverSlotTypes[index],
|
||||
links: [],
|
||||
};
|
||||
slots.splice(index, 0, splicedItem);
|
||||
}
|
||||
}
|
||||
if (slots.length > serverSlotNames.length) {
|
||||
for (let i = slots.length - 1; i > serverSlotNames.length - 1; i--) {
|
||||
if (direction == IoDirection.INPUT) {
|
||||
node.disconnectInput(i);
|
||||
node.removeInput(i);
|
||||
}
|
||||
else {
|
||||
node.disconnectOutput(i);
|
||||
node.removeOutput(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [name, slotLinks] of Object.entries(links)) {
|
||||
let currentNodeSlot = slots.map((s) => s.name).indexOf(name);
|
||||
if (currentNodeSlot > -1) {
|
||||
for (const linkData of slotLinks) {
|
||||
if (direction == IoDirection.INPUT) {
|
||||
linkData.link.target_slot = currentNodeSlot;
|
||||
}
|
||||
else {
|
||||
linkData.link.origin_slot = currentNodeSlot;
|
||||
const nextNode = app.graph.getNodeById(linkData.link.target_id);
|
||||
if (nextNode && ((_c = nextNode.constructor) === null || _c === void 0 ? void 0 : _c.type.includes("Reroute"))) {
|
||||
nextNode.stabilize && nextNode.stabilize();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
export function isValidConnection(ioA, ioB) {
|
||||
if (!ioA || !ioB) {
|
||||
return false;
|
||||
}
|
||||
const typeA = String(ioA.type);
|
||||
const typeB = String(ioB.type);
|
||||
let isValid = LiteGraph.isValidConnection(typeA, typeB);
|
||||
if (!isValid) {
|
||||
let areCombos = (typeA.includes(",") && typeB === "COMBO") || (typeA === "COMBO" && typeB.includes(","));
|
||||
if (areCombos) {
|
||||
const nameA = ioA.name.toUpperCase().replace("_NAME", "").replace("CKPT", "MODEL");
|
||||
const nameB = ioB.name.toUpperCase().replace("_NAME", "").replace("CKPT", "MODEL");
|
||||
isValid = nameA.includes(nameB) || nameB.includes(nameA);
|
||||
}
|
||||
}
|
||||
return isValid;
|
||||
}
|
||||
const oldIsValidConnection = LiteGraph.isValidConnection;
|
||||
LiteGraph.isValidConnection = function (typeA, typeB) {
|
||||
let isValid = oldIsValidConnection.call(LiteGraph, typeA, typeB);
|
||||
if (!isValid) {
|
||||
typeA = String(typeA);
|
||||
typeB = String(typeB);
|
||||
let areCombos = (typeA.includes(",") && typeB === "COMBO") || (typeA === "COMBO" && typeB.includes(","));
|
||||
isValid = areCombos;
|
||||
}
|
||||
return isValid;
|
||||
};
|
||||
export function getOutputNodes(nodes) {
|
||||
return ((nodes === null || nodes === void 0 ? void 0 : nodes.filter((n) => {
|
||||
var _a;
|
||||
return (n.mode != LiteGraph.NEVER && ((_a = n.constructor.nodeData) === null || _a === void 0 ? void 0 : _a.output_node));
|
||||
})) || []);
|
||||
}
|
||||
export function changeModeOfNodes(nodeOrNodes, mode) {
|
||||
reduceNodesDepthFirst(nodeOrNodes, (n) => {
|
||||
n.mode = mode;
|
||||
});
|
||||
}
|
||||
export function reduceNodesDepthFirst(nodeOrNodes, reduceFn, reduceTo) {
|
||||
var _a;
|
||||
const nodes = Array.isArray(nodeOrNodes) ? nodeOrNodes : [nodeOrNodes];
|
||||
const stack = nodes.map((node) => ({ node }));
|
||||
while (stack.length > 0) {
|
||||
const { node } = stack.pop();
|
||||
const result = reduceFn(node, reduceTo);
|
||||
if (result !== undefined && result !== reduceTo) {
|
||||
reduceTo = result;
|
||||
}
|
||||
if (((_a = node.isSubgraphNode) === null || _a === void 0 ? void 0 : _a.call(node)) && node.subgraph) {
|
||||
const children = node.subgraph.nodes;
|
||||
for (let i = children.length - 1; i >= 0; i--) {
|
||||
stack.push({ node: children[i] });
|
||||
}
|
||||
}
|
||||
}
|
||||
return reduceTo;
|
||||
}
|
||||
export function getGroupNodes(group) {
|
||||
return Array.from(group._children).filter((c) => c instanceof LGraphNode);
|
||||
}
|
||||
export function getGraphDependantNodeKey(node) {
|
||||
var _a;
|
||||
const graph = (_a = node.graph) !== null && _a !== void 0 ? _a : app.graph;
|
||||
return `${graph.id}:${node.id}`;
|
||||
}
|
||||
export function getFullColor(color, liteGraphKey = "color") {
|
||||
if (!color) {
|
||||
return "";
|
||||
}
|
||||
if (LGraphCanvas.node_colors[color]) {
|
||||
color = LGraphCanvas.node_colors[color][liteGraphKey];
|
||||
}
|
||||
color = color.replace("#", "").toLocaleLowerCase();
|
||||
if (color.length === 3) {
|
||||
color = color.replace(/(.)(.)(.)/, "$1$1$2$2$3$3");
|
||||
}
|
||||
return `#${color}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
function binarySearch(max, getValue, match) {
|
||||
let min = 0;
|
||||
while (min <= max) {
|
||||
let guess = Math.floor((min + max) / 2);
|
||||
const compareVal = getValue(guess);
|
||||
if (compareVal === match)
|
||||
return guess;
|
||||
if (compareVal < match)
|
||||
min = guess + 1;
|
||||
else
|
||||
max = guess - 1;
|
||||
}
|
||||
return max;
|
||||
}
|
||||
export function fitString(ctx, str, maxWidth) {
|
||||
let width = ctx.measureText(str).width;
|
||||
const ellipsis = "…";
|
||||
const ellipsisWidth = measureText(ctx, ellipsis);
|
||||
if (width <= maxWidth || width <= ellipsisWidth) {
|
||||
return str;
|
||||
}
|
||||
const index = binarySearch(str.length, (guess) => measureText(ctx, str.substring(0, guess)), maxWidth - ellipsisWidth);
|
||||
return str.substring(0, index) + ellipsis;
|
||||
}
|
||||
export function measureText(ctx, str) {
|
||||
return ctx.measureText(str).width;
|
||||
}
|
||||
export function isLowQuality() {
|
||||
var _a;
|
||||
const canvas = app.canvas;
|
||||
return (((_a = canvas.ds) === null || _a === void 0 ? void 0 : _a.scale) || 1) <= 0.5;
|
||||
}
|
||||
export function drawNodeWidget(ctx, options) {
|
||||
const lowQuality = isLowQuality();
|
||||
const data = {
|
||||
width: options.size[0],
|
||||
height: options.size[1],
|
||||
posY: options.pos[1],
|
||||
lowQuality,
|
||||
margin: 15,
|
||||
colorOutline: LiteGraph.WIDGET_OUTLINE_COLOR,
|
||||
colorBackground: LiteGraph.WIDGET_BGCOLOR,
|
||||
colorText: LiteGraph.WIDGET_TEXT_COLOR,
|
||||
colorTextSecondary: LiteGraph.WIDGET_SECONDARY_TEXT_COLOR,
|
||||
};
|
||||
ctx.strokeStyle = options.colorStroke || data.colorOutline;
|
||||
ctx.fillStyle = options.colorBackground || data.colorBackground;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(data.margin, data.posY, data.width - data.margin * 2, data.height, lowQuality ? [0] : options.borderRadius ? [options.borderRadius] : [options.size[1] * 0.5]);
|
||||
ctx.fill();
|
||||
if (!lowQuality) {
|
||||
ctx.stroke();
|
||||
}
|
||||
return data;
|
||||
}
|
||||
export function drawRoundedRectangle(ctx, options) {
|
||||
const lowQuality = isLowQuality();
|
||||
options = { ...options };
|
||||
ctx.save();
|
||||
ctx.strokeStyle = options.colorStroke || LiteGraph.WIDGET_OUTLINE_COLOR;
|
||||
ctx.fillStyle = options.colorBackground || LiteGraph.WIDGET_BGCOLOR;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(...options.pos, ...options.size, lowQuality ? [0] : options.borderRadius ? [options.borderRadius] : [options.size[1] * 0.5]);
|
||||
ctx.fill();
|
||||
!lowQuality && ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
export function drawNumberWidgetPart(ctx, options) {
|
||||
const arrowWidth = 9;
|
||||
const arrowHeight = 10;
|
||||
const innerMargin = 3;
|
||||
const numberWidth = 32;
|
||||
const xBoundsArrowLess = [0, 0];
|
||||
const xBoundsNumber = [0, 0];
|
||||
const xBoundsArrowMore = [0, 0];
|
||||
ctx.save();
|
||||
let posX = options.posX;
|
||||
const { posY, height, value, textColor } = options;
|
||||
const midY = posY + height / 2;
|
||||
if (options.direction === -1) {
|
||||
posX = posX - arrowWidth - innerMargin - numberWidth - innerMargin - arrowWidth;
|
||||
}
|
||||
ctx.fill(new Path2D(`M ${posX} ${midY} l ${arrowWidth} ${arrowHeight / 2} l 0 -${arrowHeight} L ${posX} ${midY} z`));
|
||||
xBoundsArrowLess[0] = posX;
|
||||
xBoundsArrowLess[1] = arrowWidth;
|
||||
posX += arrowWidth + innerMargin;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
const oldTextcolor = ctx.fillStyle;
|
||||
if (textColor) {
|
||||
ctx.fillStyle = textColor;
|
||||
}
|
||||
ctx.fillText(fitString(ctx, value.toFixed(2), numberWidth), posX + numberWidth / 2, midY);
|
||||
ctx.fillStyle = oldTextcolor;
|
||||
xBoundsNumber[0] = posX;
|
||||
xBoundsNumber[1] = numberWidth;
|
||||
posX += numberWidth + innerMargin;
|
||||
ctx.fill(new Path2D(`M ${posX} ${midY - arrowHeight / 2} l ${arrowWidth} ${arrowHeight / 2} l -${arrowWidth} ${arrowHeight / 2} v -${arrowHeight} z`));
|
||||
xBoundsArrowMore[0] = posX;
|
||||
xBoundsArrowMore[1] = arrowWidth;
|
||||
ctx.restore();
|
||||
return [xBoundsArrowLess, xBoundsNumber, xBoundsArrowMore];
|
||||
}
|
||||
drawNumberWidgetPart.WIDTH_TOTAL = 9 + 3 + 32 + 3 + 9;
|
||||
export function drawTogglePart(ctx, options) {
|
||||
const lowQuality = isLowQuality();
|
||||
ctx.save();
|
||||
const { posX, posY, height, value } = options;
|
||||
const toggleRadius = height * 0.36;
|
||||
const toggleBgWidth = height * 1.5;
|
||||
if (!lowQuality) {
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(posX + 4, posY + 4, toggleBgWidth - 8, height - 8, [height * 0.5]);
|
||||
ctx.globalAlpha = app.canvas.editor_alpha * 0.25;
|
||||
ctx.fillStyle = "rgba(255,255,255,0.45)";
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = app.canvas.editor_alpha;
|
||||
}
|
||||
ctx.fillStyle = value === true ? "#89B" : "#888";
|
||||
const toggleX = lowQuality || value === false
|
||||
? posX + height * 0.5
|
||||
: value === true
|
||||
? posX + height
|
||||
: posX + height * 0.75;
|
||||
ctx.beginPath();
|
||||
ctx.arc(toggleX, posY + height * 0.5, toggleRadius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
return [posX, toggleBgWidth];
|
||||
}
|
||||
export function drawInfoIcon(ctx, x, y, size = 12) {
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x, y, size, size, [size * 0.1]);
|
||||
ctx.fillStyle = "#2f82ec";
|
||||
ctx.strokeStyle = "#0f2a5e";
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = "#FFF";
|
||||
ctx.lineWidth = 2;
|
||||
const midX = x + size / 2;
|
||||
const serifSize = size * 0.175;
|
||||
ctx.stroke(new Path2D(`
|
||||
M ${midX} ${y + size * 0.15}
|
||||
v 2
|
||||
M ${midX - serifSize} ${y + size * 0.45}
|
||||
h ${serifSize}
|
||||
v ${size * 0.325}
|
||||
h ${serifSize}
|
||||
h -${serifSize * 2}
|
||||
`));
|
||||
ctx.restore();
|
||||
}
|
||||
export function drawPlusIcon(ctx, x, midY, size = 12) {
|
||||
ctx.save();
|
||||
const s = size / 3;
|
||||
const plus = new Path2D(`
|
||||
M ${x} ${midY + s / 2}
|
||||
v-${s} h${s} v-${s} h${s}
|
||||
v${s} h${s} v${s} h-${s}
|
||||
v${s} h-${s} v-${s} h-${s}
|
||||
z
|
||||
`);
|
||||
ctx.lineJoin = "round";
|
||||
ctx.lineCap = "round";
|
||||
ctx.fillStyle = "#3a3";
|
||||
ctx.strokeStyle = "#383";
|
||||
ctx.fill(plus);
|
||||
ctx.stroke(plus);
|
||||
ctx.restore();
|
||||
}
|
||||
export function drawWidgetButton(ctx, options, text = null, isMouseDownedAndOver = false) {
|
||||
var _a;
|
||||
const borderRadius = isLowQuality() ? 0 : ((_a = options.borderRadius) !== null && _a !== void 0 ? _a : 4);
|
||||
ctx.save();
|
||||
if (!isLowQuality() && !isMouseDownedAndOver) {
|
||||
drawRoundedRectangle(ctx, {
|
||||
size: [options.size[0] - 2, options.size[1]],
|
||||
pos: [options.pos[0] + 1, options.pos[1] + 1],
|
||||
borderRadius,
|
||||
colorBackground: "#000000aa",
|
||||
colorStroke: "#000000aa",
|
||||
});
|
||||
}
|
||||
drawRoundedRectangle(ctx, {
|
||||
size: options.size,
|
||||
pos: [options.pos[0], options.pos[1] + (isMouseDownedAndOver ? 1 : 0)],
|
||||
borderRadius,
|
||||
colorBackground: isMouseDownedAndOver ? "#444" : LiteGraph.WIDGET_BGCOLOR,
|
||||
colorStroke: "transparent",
|
||||
});
|
||||
if (isLowQuality()) {
|
||||
ctx.restore();
|
||||
return;
|
||||
}
|
||||
if (!isMouseDownedAndOver) {
|
||||
drawRoundedRectangle(ctx, {
|
||||
size: [options.size[0] - 0.75, options.size[1] - 0.75],
|
||||
pos: options.pos,
|
||||
borderRadius: borderRadius - 0.5,
|
||||
colorBackground: "transparent",
|
||||
colorStroke: "#00000044",
|
||||
});
|
||||
drawRoundedRectangle(ctx, {
|
||||
size: [options.size[0] - 0.75, options.size[1] - 0.75],
|
||||
pos: [options.pos[0] + 0.75, options.pos[1] + 0.75],
|
||||
borderRadius: borderRadius - 0.5,
|
||||
colorBackground: "transparent",
|
||||
colorStroke: "#ffffff11",
|
||||
});
|
||||
}
|
||||
drawRoundedRectangle(ctx, {
|
||||
size: options.size,
|
||||
pos: [options.pos[0], options.pos[1] + (isMouseDownedAndOver ? 1 : 0)],
|
||||
borderRadius,
|
||||
colorBackground: "transparent",
|
||||
});
|
||||
if (!isLowQuality() && text) {
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
|
||||
ctx.fillText(text, options.size[0] / 2, options.pos[1] + options.size[1] / 2 + (isMouseDownedAndOver ? 1 : 0));
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
const DIRECT_ATTRIBUTE_MAP = {
|
||||
cellpadding: "cellPadding",
|
||||
cellspacing: "cellSpacing",
|
||||
colspan: "colSpan",
|
||||
frameborder: "frameBorder",
|
||||
height: "height",
|
||||
maxlength: "maxLength",
|
||||
nonce: "nonce",
|
||||
role: "role",
|
||||
rowspan: "rowSpan",
|
||||
type: "type",
|
||||
usemap: "useMap",
|
||||
valign: "vAlign",
|
||||
width: "width",
|
||||
};
|
||||
const RGX_NUMERIC_STYLE_UNIT = "px";
|
||||
const RGX_NUMERIC_STYLE = /^((max|min)?(width|height)|margin|padding|(margin|padding)?(left|top|bottom|right)|fontsize|borderwidth)$/i;
|
||||
const RGX_DEFAULT_VALUE_PROP = /input|textarea|select/i;
|
||||
function localAssertNotFalsy(input, errorMsg = `Input is not of type.`) {
|
||||
if (input == null) {
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
return input;
|
||||
}
|
||||
const RGX_STRING_VALID = "[a-z0-9_-]";
|
||||
const RGX_TAG = new RegExp(`^([a-z]${RGX_STRING_VALID}*)(\\.|\\[|\\#|$)`, "i");
|
||||
const RGX_ATTR_ID = new RegExp(`#(${RGX_STRING_VALID}+)`, "gi");
|
||||
const RGX_ATTR_CLASS = new RegExp(`(^|\\S)\\.([a-z0-9_\\-\\.]+)`, "gi");
|
||||
const RGX_STRING_CONTENT_TO_SQUARES = "(.*?)(\\[|\\])";
|
||||
const RGX_ATTRS_MAYBE_OPEN = new RegExp(`\\[${RGX_STRING_CONTENT_TO_SQUARES}`, "gi");
|
||||
const RGX_ATTRS_FOLLOW_OPEN = new RegExp(`^${RGX_STRING_CONTENT_TO_SQUARES}`, "gi");
|
||||
export function queryAll(selectors, parent = document) {
|
||||
return Array.from(parent.querySelectorAll(selectors)).filter((n) => !!n);
|
||||
}
|
||||
export function query(selectors, parent = document) {
|
||||
var _a;
|
||||
return (_a = parent.querySelector(selectors)) !== null && _a !== void 0 ? _a : null;
|
||||
}
|
||||
export function createText(text) {
|
||||
return document.createTextNode(text);
|
||||
}
|
||||
export function getClosestOrSelf(element, query) {
|
||||
const el = element;
|
||||
return ((el === null || el === void 0 ? void 0 : el.closest) && ((el.matches(query) && el) || el.closest(query))) || null;
|
||||
}
|
||||
export function containsOrSelf(parent, contained) {
|
||||
var _a;
|
||||
return (parent === contained || ((_a = parent === null || parent === void 0 ? void 0 : parent.contains) === null || _a === void 0 ? void 0 : _a.call(parent, contained)) || false);
|
||||
}
|
||||
export function createElement(selectorOrMarkup, attrs) {
|
||||
const frag = getHtmlFragment(selectorOrMarkup);
|
||||
let element = frag === null || frag === void 0 ? void 0 : frag.firstElementChild;
|
||||
let selector = "";
|
||||
if (!element) {
|
||||
selector = selectorOrMarkup.replace(/[\r\n]\s*/g, "");
|
||||
const tag = getSelectorTag(selector) || "div";
|
||||
element = document.createElement(tag);
|
||||
selector = selector.replace(RGX_TAG, "$2");
|
||||
const brackets = selector.match(/(\[[^\]]+\])/g) || [];
|
||||
for (const bracket of brackets) {
|
||||
selector = selector.replace(bracket, "");
|
||||
}
|
||||
selector = selector.replace(RGX_ATTR_ID, '[id="$1"]');
|
||||
selector = selector.replace(RGX_ATTR_CLASS, (match, p1, p2) => `${p1}[class="${p2.replace(/\./g, " ")}"]`);
|
||||
selector += brackets.join("");
|
||||
}
|
||||
const selectorAttrs = getSelectorAttributes(selector);
|
||||
if (selectorAttrs) {
|
||||
for (const attr of selectorAttrs) {
|
||||
let matches = attr.substring(1, attr.length - 1).split("=");
|
||||
let key = localAssertNotFalsy(matches.shift());
|
||||
let value = matches.join("=");
|
||||
if (value === undefined) {
|
||||
setAttribute(element, key, true);
|
||||
}
|
||||
else {
|
||||
value = value.replace(/^['"](.*)['"]$/, "$1");
|
||||
setAttribute(element, key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (attrs) {
|
||||
setAttributes(element, attrs);
|
||||
}
|
||||
return element;
|
||||
}
|
||||
export const $el = createElement;
|
||||
function getSelectorTag(str) {
|
||||
return tryMatch(str, RGX_TAG);
|
||||
}
|
||||
function getSelectorAttributes(selector) {
|
||||
RGX_ATTRS_MAYBE_OPEN.lastIndex = 0;
|
||||
let attrs = [];
|
||||
let result;
|
||||
while ((result = RGX_ATTRS_MAYBE_OPEN.exec(selector))) {
|
||||
let attr = result[0];
|
||||
if (attr.endsWith("]")) {
|
||||
attrs.push(attr);
|
||||
}
|
||||
else {
|
||||
attr =
|
||||
result[0] + getOpenAttributesRecursive(selector.substr(RGX_ATTRS_MAYBE_OPEN.lastIndex), 2);
|
||||
RGX_ATTRS_MAYBE_OPEN.lastIndex += attr.length - result[0].length;
|
||||
attrs.push(attr);
|
||||
}
|
||||
}
|
||||
return attrs;
|
||||
}
|
||||
function getOpenAttributesRecursive(selectorSubstring, openCount) {
|
||||
let matches = selectorSubstring.match(RGX_ATTRS_FOLLOW_OPEN);
|
||||
let result = "";
|
||||
if (matches && matches.length) {
|
||||
result = matches[0];
|
||||
openCount += result.endsWith("]") ? -1 : 1;
|
||||
if (openCount > 0) {
|
||||
result += getOpenAttributesRecursive(selectorSubstring.substr(result.length), openCount);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function tryMatch(str, rgx, index = 1) {
|
||||
var _a;
|
||||
let found = "";
|
||||
try {
|
||||
found = ((_a = str.match(rgx)) === null || _a === void 0 ? void 0 : _a[index]) || "";
|
||||
}
|
||||
catch (e) {
|
||||
found = "";
|
||||
}
|
||||
return found;
|
||||
}
|
||||
export function setAttributes(element, data) {
|
||||
let attr;
|
||||
for (attr in data) {
|
||||
if (data.hasOwnProperty(attr)) {
|
||||
setAttribute(element, attr, data[attr]);
|
||||
}
|
||||
}
|
||||
}
|
||||
function getHtmlFragment(value) {
|
||||
if (value.match(/^\s*<.*?>[\s\S]*<\/[a-z0-9]+>\s*$/)) {
|
||||
return document.createRange().createContextualFragment(value.trim());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function getChild(value) {
|
||||
if (value instanceof Node) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
let child = getHtmlFragment(value);
|
||||
if (child) {
|
||||
return child;
|
||||
}
|
||||
if (getSelectorTag(value)) {
|
||||
return createElement(value);
|
||||
}
|
||||
return createText(value);
|
||||
}
|
||||
if (value && typeof value.toElement === "function") {
|
||||
return value.toElement();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
export function setAttribute(element, attribute, value) {
|
||||
let isRemoving = value == null;
|
||||
if (attribute === "default") {
|
||||
attribute = RGX_DEFAULT_VALUE_PROP.test(element.nodeName) ? "value" : "text";
|
||||
}
|
||||
if (attribute === "text") {
|
||||
empty(element).appendChild(createText(value != null ? String(value) : ""));
|
||||
}
|
||||
else if (attribute === "html") {
|
||||
empty(element).innerHTML += value != null ? String(value) : "";
|
||||
}
|
||||
else if (attribute == "style") {
|
||||
if (typeof value === "string") {
|
||||
element.style.cssText = isRemoving ? "" : value != null ? String(value) : "";
|
||||
}
|
||||
else {
|
||||
for (const [styleKey, styleValue] of Object.entries(value)) {
|
||||
element.style[styleKey] = styleValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (attribute == "events") {
|
||||
for (const [key, fn] of Object.entries(value)) {
|
||||
addEvent(element, key, fn);
|
||||
}
|
||||
}
|
||||
else if (attribute === "parent") {
|
||||
value.appendChild(element);
|
||||
}
|
||||
else if (attribute === "child" || attribute === "children") {
|
||||
if (typeof value === "string" && /^\[[^\[\]]+\]$/.test(value)) {
|
||||
const parseable = value.replace(/^\[([^\[\]]+)\]$/, '["$1"]').replace(/,/g, '","');
|
||||
try {
|
||||
const parsed = JSON.parse(parseable);
|
||||
value = parsed;
|
||||
}
|
||||
catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
if (attribute === "children") {
|
||||
empty(element);
|
||||
}
|
||||
let children = value instanceof Array ? value : [value];
|
||||
for (let child of children) {
|
||||
child = getChild(child);
|
||||
if (child instanceof Node) {
|
||||
if (element instanceof HTMLTemplateElement) {
|
||||
element.content.appendChild(child);
|
||||
}
|
||||
else {
|
||||
element.appendChild(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (attribute == "for") {
|
||||
element.htmlFor = value != null ? String(value) : "";
|
||||
if (isRemoving) {
|
||||
element.removeAttribute("for");
|
||||
}
|
||||
}
|
||||
else if (attribute === "class" || attribute === "className" || attribute === "classes") {
|
||||
element.className = isRemoving ? "" : Array.isArray(value) ? value.join(" ") : String(value);
|
||||
}
|
||||
else if (attribute === "dataset") {
|
||||
if (typeof value !== "object") {
|
||||
console.error("Expecting an object for dataset");
|
||||
return;
|
||||
}
|
||||
for (const [key, val] of Object.entries(value)) {
|
||||
element.dataset[key] = String(val);
|
||||
}
|
||||
}
|
||||
else if (attribute.startsWith("on") && typeof value === "function") {
|
||||
element.addEventListener(attribute.substring(2), value);
|
||||
}
|
||||
else if (["checked", "disabled", "readonly", "required", "selected"].includes(attribute)) {
|
||||
element[attribute] = !!value;
|
||||
if (!value) {
|
||||
element.removeAttribute(attribute);
|
||||
}
|
||||
else {
|
||||
element.setAttribute(attribute, attribute);
|
||||
}
|
||||
}
|
||||
else if (DIRECT_ATTRIBUTE_MAP.hasOwnProperty(attribute)) {
|
||||
if (isRemoving) {
|
||||
element.removeAttribute(DIRECT_ATTRIBUTE_MAP[attribute]);
|
||||
}
|
||||
else {
|
||||
element.setAttribute(DIRECT_ATTRIBUTE_MAP[attribute], String(value));
|
||||
}
|
||||
}
|
||||
else if (isRemoving) {
|
||||
element.removeAttribute(attribute);
|
||||
}
|
||||
else {
|
||||
let oldVal = element.getAttribute(attribute);
|
||||
if (oldVal !== value) {
|
||||
element.setAttribute(attribute, String(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
function addEvent(element, key, fn) {
|
||||
element.addEventListener(key, fn);
|
||||
}
|
||||
function setStyles(element, styles = null) {
|
||||
if (styles) {
|
||||
for (let name in styles) {
|
||||
setStyle(element, name, styles[name]);
|
||||
}
|
||||
}
|
||||
return element;
|
||||
}
|
||||
export function setStyle(element, name, value) {
|
||||
name = name.indexOf("float") > -1 ? "cssFloat" : name;
|
||||
if (name.indexOf("-") != -1) {
|
||||
name = name.replace(/-\D/g, (match) => {
|
||||
return match.charAt(1).toUpperCase();
|
||||
});
|
||||
}
|
||||
if (value == String(Number(value)) && RGX_NUMERIC_STYLE.test(name)) {
|
||||
value = value + RGX_NUMERIC_STYLE_UNIT;
|
||||
}
|
||||
if (name === "display" && typeof value !== "string") {
|
||||
value = !!value ? null : "none";
|
||||
}
|
||||
element.style[name] = value === null ? null : String(value);
|
||||
return element;
|
||||
}
|
||||
export function empty(element) {
|
||||
while (element.firstChild) {
|
||||
element.removeChild(element.firstChild);
|
||||
}
|
||||
return element;
|
||||
}
|
||||
export function remove(element) {
|
||||
while (element.parentElement) {
|
||||
element.parentElement.removeChild(element);
|
||||
}
|
||||
return element;
|
||||
}
|
||||
export function replaceChild(oldChildNode, newNode) {
|
||||
oldChildNode.parentNode.replaceChild(newNode, oldChildNode);
|
||||
return newNode;
|
||||
}
|
||||
export function appendChildren(el, children) {
|
||||
children = !Array.isArray(children) ? [children] : children;
|
||||
for (let child of children) {
|
||||
child = getChild(child);
|
||||
if (child instanceof Node) {
|
||||
if (el instanceof HTMLTemplateElement) {
|
||||
el.content.appendChild(child);
|
||||
}
|
||||
else {
|
||||
el.appendChild(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
export function getActionEls(parent = document) {
|
||||
const els = Array.from(parent.querySelectorAll("[data-action],[on-action],[on]"));
|
||||
if (parent instanceof Element) {
|
||||
els.unshift(parent);
|
||||
}
|
||||
return els
|
||||
.map((actionEl) => {
|
||||
const actions = {};
|
||||
const actionSegments = (actionEl.getAttribute("data-action") ||
|
||||
actionEl.getAttribute("on-action") ||
|
||||
actionEl.getAttribute("on") ||
|
||||
"").split(";");
|
||||
for (let segment of actionSegments) {
|
||||
let actionsData = segment
|
||||
.trim()
|
||||
.split(/\s*:\s*/g)
|
||||
.filter((i) => !!i.trim());
|
||||
if (!actionsData.length)
|
||||
continue;
|
||||
if (actionsData.length === 1) {
|
||||
if (actionEl instanceof HTMLInputElement) {
|
||||
actionsData.unshift("input");
|
||||
}
|
||||
else {
|
||||
actionsData.unshift("click");
|
||||
}
|
||||
}
|
||||
if (actionsData[0] && actionsData[1]) {
|
||||
actions[actionsData[0]] = actionsData[1];
|
||||
}
|
||||
}
|
||||
return {
|
||||
el: actionEl,
|
||||
actions,
|
||||
};
|
||||
})
|
||||
.filter((el) => !!el);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { mxdApi } from "./mxd_api.js";
|
||||
|
||||
export async function showLoraChooser(event, callback, parentMenu, loras) {
|
||||
const canvas = app.canvas;
|
||||
if (!loras) {
|
||||
loras = ["None", ...(await mxdApi.getLoras().then((items) => items.map((l) => l.file)) )];
|
||||
}
|
||||
new LiteGraph.ContextMenu(loras, {
|
||||
event,
|
||||
parentMenu: parentMenu != null ? parentMenu : undefined,
|
||||
title: "Choose a LoRA",
|
||||
scale: Math.max(1, canvas.ds?.scale ?? 1),
|
||||
className: "dark",
|
||||
callback,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { drawNodeWidget, drawWidgetButton, fitString, isLowQuality } from "./mxd_utils_canvas.js";
|
||||
export function drawLabelAndValue(ctx, label, value, width, posY, height, options) {
|
||||
var _a;
|
||||
const outerMargin = 15;
|
||||
const innerMargin = 10;
|
||||
const midY = posY + height / 2;
|
||||
ctx.save();
|
||||
ctx.textAlign = "left";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillStyle = LiteGraph.WIDGET_SECONDARY_TEXT_COLOR;
|
||||
const labelX = outerMargin + innerMargin + ((_a = options === null || options === void 0 ? void 0 : options.offsetLeft) !== null && _a !== void 0 ? _a : 0);
|
||||
ctx.fillText(label, labelX, midY);
|
||||
const valueXLeft = labelX + ctx.measureText(label).width + 7;
|
||||
const valueXRight = width - (outerMargin + innerMargin);
|
||||
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
|
||||
ctx.textAlign = "right";
|
||||
ctx.fillText(fitString(ctx, value, valueXRight - valueXLeft), valueXRight, midY);
|
||||
ctx.restore();
|
||||
}
|
||||
export class MxdBaseWidget {
|
||||
constructor(name) {
|
||||
this.type = "custom";
|
||||
this.options = {};
|
||||
this.y = 0;
|
||||
this.last_y = 0;
|
||||
this.mouseDowned = null;
|
||||
this.isMouseDownedAndOver = false;
|
||||
this.hitAreas = {};
|
||||
this.downedHitAreasForMove = [];
|
||||
this.downedHitAreasForClick = [];
|
||||
this.name = name;
|
||||
}
|
||||
serializeValue(node, index) {
|
||||
return this.value;
|
||||
}
|
||||
clickWasWithinBounds(pos, bounds) {
|
||||
let xStart = bounds[0];
|
||||
let xEnd = xStart + (bounds.length > 2 ? bounds[2] : bounds[1]);
|
||||
const clickedX = pos[0] >= xStart && pos[0] <= xEnd;
|
||||
if (bounds.length === 2) {
|
||||
return clickedX;
|
||||
}
|
||||
return clickedX && pos[1] >= bounds[1] && pos[1] <= bounds[1] + bounds[3];
|
||||
}
|
||||
mouse(event, pos, node) {
|
||||
var _a, _b, _c;
|
||||
const canvas = app.canvas;
|
||||
if (event.type == "pointerdown") {
|
||||
this.mouseDowned = [...pos];
|
||||
this.isMouseDownedAndOver = true;
|
||||
this.downedHitAreasForMove.length = 0;
|
||||
this.downedHitAreasForClick.length = 0;
|
||||
let anyHandled = false;
|
||||
for (const part of Object.values(this.hitAreas)) {
|
||||
if (this.clickWasWithinBounds(pos, part.bounds)) {
|
||||
if (part.onMove) {
|
||||
this.downedHitAreasForMove.push(part);
|
||||
}
|
||||
if (part.onClick) {
|
||||
this.downedHitAreasForClick.push(part);
|
||||
}
|
||||
if (part.onDown) {
|
||||
const thisHandled = part.onDown.apply(this, [event, pos, node, part]);
|
||||
anyHandled = anyHandled || thisHandled == true;
|
||||
}
|
||||
part.wasMouseClickedAndIsOver = true;
|
||||
}
|
||||
}
|
||||
return (_a = this.onMouseDown(event, pos, node)) !== null && _a !== void 0 ? _a : anyHandled;
|
||||
}
|
||||
if (event.type == "pointerup") {
|
||||
if (!this.mouseDowned)
|
||||
return true;
|
||||
this.downedHitAreasForMove.length = 0;
|
||||
const wasMouseDownedAndOver = this.isMouseDownedAndOver;
|
||||
this.cancelMouseDown();
|
||||
let anyHandled = false;
|
||||
for (const part of Object.values(this.hitAreas)) {
|
||||
if (part.onUp && this.clickWasWithinBounds(pos, part.bounds)) {
|
||||
const thisHandled = part.onUp.apply(this, [event, pos, node, part]);
|
||||
anyHandled = anyHandled || thisHandled == true;
|
||||
}
|
||||
part.wasMouseClickedAndIsOver = false;
|
||||
}
|
||||
for (const part of this.downedHitAreasForClick) {
|
||||
if (this.clickWasWithinBounds(pos, part.bounds)) {
|
||||
const thisHandled = part.onClick.apply(this, [event, pos, node, part]);
|
||||
anyHandled = anyHandled || thisHandled == true;
|
||||
}
|
||||
}
|
||||
this.downedHitAreasForClick.length = 0;
|
||||
if (wasMouseDownedAndOver) {
|
||||
const thisHandled = this.onMouseClick(event, pos, node);
|
||||
anyHandled = anyHandled || thisHandled == true;
|
||||
}
|
||||
return (_b = this.onMouseUp(event, pos, node)) !== null && _b !== void 0 ? _b : anyHandled;
|
||||
}
|
||||
if (event.type == "pointermove") {
|
||||
this.isMouseDownedAndOver = !!this.mouseDowned;
|
||||
if (this.mouseDowned &&
|
||||
(pos[0] < 15 ||
|
||||
pos[0] > node.size[0] - 15 ||
|
||||
pos[1] < this.last_y ||
|
||||
pos[1] > this.last_y + LiteGraph.NODE_WIDGET_HEIGHT)) {
|
||||
this.isMouseDownedAndOver = false;
|
||||
}
|
||||
for (const part of Object.values(this.hitAreas)) {
|
||||
if (this.downedHitAreasForMove.includes(part)) {
|
||||
part.onMove.apply(this, [event, pos, node, part]);
|
||||
}
|
||||
if (this.downedHitAreasForClick.includes(part)) {
|
||||
part.wasMouseClickedAndIsOver = this.clickWasWithinBounds(pos, part.bounds);
|
||||
}
|
||||
}
|
||||
return (_c = this.onMouseMove(event, pos, node)) !== null && _c !== void 0 ? _c : true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
cancelMouseDown() {
|
||||
this.mouseDowned = null;
|
||||
this.isMouseDownedAndOver = false;
|
||||
this.downedHitAreasForMove.length = 0;
|
||||
}
|
||||
onMouseDown(event, pos, node) {
|
||||
return;
|
||||
}
|
||||
onMouseUp(event, pos, node) {
|
||||
return;
|
||||
}
|
||||
onMouseClick(event, pos, node) {
|
||||
return;
|
||||
}
|
||||
onMouseMove(event, pos, node) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
export class MxdBetterButtonWidget extends MxdBaseWidget {
|
||||
constructor(name, mouseClickCallback, label) {
|
||||
super(name);
|
||||
this.type = "custom";
|
||||
this.value = "";
|
||||
this.label = "";
|
||||
this.mouseClickCallback = mouseClickCallback;
|
||||
this.label = label || name;
|
||||
}
|
||||
draw(ctx, node, width, y, height) {
|
||||
drawWidgetButton(ctx, { size: [width - 30, height], pos: [15, y] }, this.label, this.isMouseDownedAndOver);
|
||||
}
|
||||
onMouseClick(event, pos, node) {
|
||||
return this.mouseClickCallback(event, pos, node);
|
||||
}
|
||||
}
|
||||
export class MxdBetterTextWidget extends MxdBaseWidget {
|
||||
constructor(name, value) {
|
||||
super(name);
|
||||
this.name = name;
|
||||
this.value = value;
|
||||
}
|
||||
draw(ctx, node, width, y, height) {
|
||||
const widgetData = drawNodeWidget(ctx, { size: [width, height], pos: [15, y] });
|
||||
if (!widgetData.lowQuality) {
|
||||
drawLabelAndValue(ctx, this.name, this.value, width, y, height);
|
||||
}
|
||||
}
|
||||
mouse(event, pos, node) {
|
||||
const canvas = app.canvas;
|
||||
if (event.type == "pointerdown") {
|
||||
canvas.prompt("Label", this.value, (v) => (this.value = v), event);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
export class MxdDividerWidget extends MxdBaseWidget {
|
||||
constructor(widgetOptions) {
|
||||
super("divider");
|
||||
this.value = {};
|
||||
this.options = { serialize: false };
|
||||
this.type = "custom";
|
||||
this.widgetOptions = {
|
||||
marginTop: 7,
|
||||
marginBottom: 7,
|
||||
marginLeft: 15,
|
||||
marginRight: 15,
|
||||
color: LiteGraph.WIDGET_OUTLINE_COLOR,
|
||||
thickness: 1,
|
||||
};
|
||||
Object.assign(this.widgetOptions, widgetOptions || {});
|
||||
}
|
||||
draw(ctx, node, width, posY, h) {
|
||||
if (this.widgetOptions.thickness) {
|
||||
ctx.strokeStyle = this.widgetOptions.color;
|
||||
const x = this.widgetOptions.marginLeft;
|
||||
const y = posY + this.widgetOptions.marginTop;
|
||||
const w = width - this.widgetOptions.marginLeft - this.widgetOptions.marginRight;
|
||||
ctx.stroke(new Path2D(`M ${x} ${y} h ${w}`));
|
||||
}
|
||||
}
|
||||
computeSize(width) {
|
||||
return [
|
||||
width,
|
||||
this.widgetOptions.marginTop + this.widgetOptions.marginBottom + this.widgetOptions.thickness,
|
||||
];
|
||||
}
|
||||
}
|
||||
export class MxdLabelWidget extends MxdBaseWidget {
|
||||
constructor(name, widgetOptions) {
|
||||
super(name);
|
||||
this.type = "custom";
|
||||
this.options = { serialize: false };
|
||||
this.value = "";
|
||||
this.widgetOptions = {};
|
||||
this.posY = 0;
|
||||
Object.assign(this.widgetOptions, widgetOptions);
|
||||
}
|
||||
update(widgetOptions) {
|
||||
Object.assign(this.widgetOptions, widgetOptions);
|
||||
}
|
||||
draw(ctx, node, width, posY, height) {
|
||||
var _a;
|
||||
this.posY = posY;
|
||||
ctx.save();
|
||||
let text = (_a = this.widgetOptions.text) !== null && _a !== void 0 ? _a : this.name;
|
||||
if (typeof text === "function") {
|
||||
text = text();
|
||||
}
|
||||
ctx.textAlign = this.widgetOptions.align || "left";
|
||||
ctx.fillStyle = this.widgetOptions.color || LiteGraph.WIDGET_TEXT_COLOR;
|
||||
const oldFont = ctx.font;
|
||||
if (this.widgetOptions.italic) {
|
||||
ctx.font = "italic " + ctx.font;
|
||||
}
|
||||
if (this.widgetOptions.size) {
|
||||
ctx.font = ctx.font.replace(/\d+px/, `${this.widgetOptions.size}px`);
|
||||
}
|
||||
const midY = posY + height / 2;
|
||||
ctx.textBaseline = "middle";
|
||||
if (this.widgetOptions.align === "center") {
|
||||
ctx.fillText(text, node.size[0] / 2, midY);
|
||||
}
|
||||
else {
|
||||
ctx.fillText(text, 15, midY);
|
||||
}
|
||||
ctx.font = oldFont;
|
||||
if (this.widgetOptions.actionLabel === "__PLUS_ICON__") {
|
||||
const plus = new Path2D(`M${node.size[0] - 15 - 2} ${posY + 7} v4 h-4 v4 h-4 v-4 h-4 v-4 h4 v-4 h4 v4 h4 z`);
|
||||
ctx.lineJoin = "round";
|
||||
ctx.lineCap = "round";
|
||||
ctx.fillStyle = "#3a3";
|
||||
ctx.strokeStyle = "#383";
|
||||
ctx.fill(plus);
|
||||
ctx.stroke(plus);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
mouse(event, nodePos, node) {
|
||||
if (event.type !== "pointerdown" ||
|
||||
isLowQuality() ||
|
||||
!this.widgetOptions.actionLabel ||
|
||||
!this.widgetOptions.actionCallback) {
|
||||
return false;
|
||||
}
|
||||
const pos = [nodePos[0], nodePos[1] - this.posY];
|
||||
const rightX = node.size[0] - 15;
|
||||
if (pos[0] > rightX || pos[0] < rightX - 16) {
|
||||
return false;
|
||||
}
|
||||
this.widgetOptions.actionCallback(event);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
export class MxdInvisibleWidget extends MxdBaseWidget {
|
||||
constructor(name, type, value, serializeValueFn) {
|
||||
super(name);
|
||||
this.type = "custom";
|
||||
this.value = value;
|
||||
this.serializeValueFn = serializeValueFn;
|
||||
}
|
||||
draw() {
|
||||
return;
|
||||
}
|
||||
computeSize(width) {
|
||||
return [0, 0];
|
||||
}
|
||||
serializeValue(node, index) {
|
||||
return this.serializeValueFn != null
|
||||
? this.serializeValueFn(node, index)
|
||||
: super.serializeValue(node, index);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,697 @@
|
||||
var _a;
|
||||
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { MxdBaseServerNode } from "./mxd_base_node.js";
|
||||
import { mxdRuntime } from "./mxd_runtime.js";
|
||||
import { addConnectionLayoutSupport } from "./mxd_utils.js";
|
||||
import {
|
||||
drawInfoIcon,
|
||||
drawNumberWidgetPart,
|
||||
drawRoundedRectangle,
|
||||
drawTogglePart,
|
||||
fitString,
|
||||
isLowQuality,
|
||||
} from "./mxd_utils_canvas.js";
|
||||
import {
|
||||
MxdBaseWidget,
|
||||
MxdBetterButtonWidget,
|
||||
MxdDividerWidget,
|
||||
} from "./mxd_utils_widgets.js";
|
||||
import { mxdApi } from "./mxd_api.js";
|
||||
import { showLoraChooser } from "./mxd_utils_menu.js";
|
||||
import { moveArrayItem, removeArrayItem } from "./mxd_shared_utils.js";
|
||||
import { MxdLoraInfoDialog } from "./mxd_dialog_info.js";
|
||||
import { LORA_INFO_SERVICE } from "./mxd_model_info_service.js";
|
||||
|
||||
const NODE_TYPE = "Lora Loader MXD";
|
||||
const PROP_LABEL_SHOW_STRENGTHS = "Show Strengths";
|
||||
const PROP_LABEL_SHOW_STRENGTHS_STATIC = `@${PROP_LABEL_SHOW_STRENGTHS}`;
|
||||
const PROP_VALUE_SHOW_STRENGTHS_SINGLE = "Single Strength";
|
||||
const PROP_VALUE_SHOW_STRENGTHS_SEPARATE = "Separate Model & Clip";
|
||||
|
||||
class MxdPowerLoraLoader extends MxdBaseServerNode {
|
||||
static title = NODE_TYPE;
|
||||
static type = NODE_TYPE;
|
||||
static comfyClass = NODE_TYPE;
|
||||
|
||||
static [PROP_LABEL_SHOW_STRENGTHS_STATIC] = {
|
||||
type: "combo",
|
||||
values: [PROP_VALUE_SHOW_STRENGTHS_SINGLE, PROP_VALUE_SHOW_STRENGTHS_SEPARATE],
|
||||
};
|
||||
|
||||
constructor(title = NODE_CLASS.title) {
|
||||
super(title);
|
||||
this.serialize_widgets = true;
|
||||
this.logger = mxdRuntime.newLogSession("[Power Lora Loader]");
|
||||
this.loraWidgetsCounter = 0;
|
||||
this.widgetButtonSpacer = null;
|
||||
this.properties[PROP_LABEL_SHOW_STRENGTHS] = PROP_VALUE_SHOW_STRENGTHS_SINGLE;
|
||||
|
||||
mxdApi.getLoras();
|
||||
|
||||
if (mxdRuntime.loadingApiJson) {
|
||||
const fullApiJson = mxdRuntime.loadingApiJson;
|
||||
setTimeout(() => {
|
||||
this.configureFromApiJson(fullApiJson);
|
||||
}, 16);
|
||||
}
|
||||
}
|
||||
|
||||
configureFromApiJson(fullApiJson) {
|
||||
if (this.id == null) {
|
||||
const [n, v] = this.logger.errorParts("Cannot load from API JSON without node id.");
|
||||
console[n]?.(...v);
|
||||
return;
|
||||
}
|
||||
const nodeData =
|
||||
fullApiJson[this.id] || fullApiJson[String(this.id)] || fullApiJson[Number(this.id)];
|
||||
if (nodeData == null) {
|
||||
const [n, v] = this.logger.errorParts(`No node found in API JSON for node id ${this.id}.`);
|
||||
console[n]?.(...v);
|
||||
return;
|
||||
}
|
||||
this.configure({
|
||||
widgets_values: Object.values(nodeData.inputs).filter((input) => typeof input?.["lora"] === "string"),
|
||||
});
|
||||
}
|
||||
|
||||
configure(info) {
|
||||
while (this.widgets?.length) this.removeWidget(0);
|
||||
this.widgetButtonSpacer = null;
|
||||
|
||||
const hasSerializedNodeData =
|
||||
info?.id != null ||
|
||||
[
|
||||
"pos",
|
||||
"size",
|
||||
"flags",
|
||||
"mode",
|
||||
"order",
|
||||
"properties",
|
||||
"color",
|
||||
"bgcolor",
|
||||
"title",
|
||||
"inputs",
|
||||
"outputs",
|
||||
"type",
|
||||
].some((key) => info?.[key] !== undefined);
|
||||
const serializedSize = Array.isArray(info?.size) ? [...info.size] : null;
|
||||
|
||||
if (hasSerializedNodeData) {
|
||||
super.configure(info);
|
||||
}
|
||||
|
||||
const baseWidth = this.size?.[0] ?? 0;
|
||||
const baseHeight = this.size?.[1] ?? 0;
|
||||
|
||||
for (const widgetValue of info.widgets_values || []) {
|
||||
if (widgetValue?.lora !== undefined) {
|
||||
const widget = this.addNewLoraWidget();
|
||||
widget.value = { ...widgetValue };
|
||||
}
|
||||
}
|
||||
|
||||
this.addNonLoraWidgets();
|
||||
|
||||
this.size = this.size || [0, 0];
|
||||
if (serializedSize) {
|
||||
this.size[0] = serializedSize[0];
|
||||
this.size[1] = serializedSize[1];
|
||||
} else {
|
||||
const computed = this.computeSize();
|
||||
this.size[0] = Math.max(baseWidth, computed[0]);
|
||||
this.size[1] = Math.max(baseHeight, computed[1]);
|
||||
}
|
||||
|
||||
this.setDirtyCanvas(true, true);
|
||||
}
|
||||
|
||||
onNodeCreated() {
|
||||
super.onNodeCreated?.();
|
||||
if (!this.widgets?.length) {
|
||||
this.addNonLoraWidgets();
|
||||
}
|
||||
if (!this.configuring && !mxdRuntime.canvasCurrentlyPastingFromClipboard) {
|
||||
const computed = this.computeSize();
|
||||
this.size = this.size || [0, 0];
|
||||
this.size[0] = Math.max(this.size[0], computed[0]);
|
||||
this.size[1] = Math.max(this.size[1], computed[1]);
|
||||
}
|
||||
this.setDirtyCanvas(true, true);
|
||||
}
|
||||
|
||||
addNewLoraWidget(lora) {
|
||||
this.loraWidgetsCounter++;
|
||||
const widget = this.addCustomWidget(new PowerLoraLoaderWidget("lora_" + this.loraWidgetsCounter));
|
||||
if (lora) widget.setLora(lora);
|
||||
if (this.widgetButtonSpacer) {
|
||||
moveArrayItem(this.widgets, widget, this.widgets.indexOf(this.widgetButtonSpacer));
|
||||
}
|
||||
return widget;
|
||||
}
|
||||
|
||||
addNonLoraWidgets() {
|
||||
moveArrayItem(
|
||||
this.widgets,
|
||||
this.addCustomWidget(new MxdDividerWidget({ marginTop: 4, marginBottom: 0, thickness: 0 })),
|
||||
0,
|
||||
);
|
||||
moveArrayItem(this.widgets, this.addCustomWidget(new PowerLoraLoaderHeaderWidget()), 1);
|
||||
|
||||
this.widgetButtonSpacer = this.addCustomWidget(
|
||||
new MxdDividerWidget({ marginTop: 4, marginBottom: 0, thickness: 0 }),
|
||||
);
|
||||
|
||||
this.addCustomWidget(
|
||||
new MxdBetterButtonWidget("+ Add Lora", (event, pos, node) => {
|
||||
mxdApi.getLoras().then((lorasDetails) => {
|
||||
const loras = lorasDetails.map((l) => l.file);
|
||||
showLoraChooser(
|
||||
event,
|
||||
(value) => {
|
||||
if (typeof value === "string" && value !== "NONE") {
|
||||
this.addNewLoraWidget(value);
|
||||
const computed = this.computeSize();
|
||||
this.size[1] = Math.max(this.size?.[1] ?? 15, computed[1]);
|
||||
this.setDirtyCanvas(true, true);
|
||||
}
|
||||
},
|
||||
null,
|
||||
[...loras],
|
||||
);
|
||||
});
|
||||
return true;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
getSlotInPosition(canvasX, canvasY) {
|
||||
const slot = super.getSlotInPosition(canvasX, canvasY);
|
||||
if (!slot) {
|
||||
let lastWidget = null;
|
||||
for (const widget of this.widgets) {
|
||||
if (!widget.last_y) return;
|
||||
if (canvasY > this.pos[1] + widget.last_y) {
|
||||
lastWidget = widget;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (lastWidget?.name?.startsWith("lora_")) {
|
||||
return { widget: lastWidget, output: { type: "LORA WIDGET" } };
|
||||
}
|
||||
}
|
||||
return slot;
|
||||
}
|
||||
|
||||
getSlotMenuOptions(slot) {
|
||||
if (slot?.widget?.name?.startsWith("lora_")) {
|
||||
const widget = slot.widget;
|
||||
const index = this.widgets.indexOf(widget);
|
||||
const canMoveUp = !!this.widgets[index - 1]?.name?.startsWith("lora_");
|
||||
const canMoveDown = !!this.widgets[index + 1]?.name?.startsWith("lora_");
|
||||
const menuItems = [
|
||||
{
|
||||
content: `Show Info`,
|
||||
callback: () => widget.showLoraInfoDialog(),
|
||||
},
|
||||
null,
|
||||
{
|
||||
content: `${widget.value.on ? "Disable" : "Enable"}`,
|
||||
callback: () => {
|
||||
widget.value.on = !widget.value.on;
|
||||
},
|
||||
},
|
||||
{
|
||||
content: `Move Up`,
|
||||
disabled: !canMoveUp,
|
||||
callback: () => {
|
||||
moveArrayItem(this.widgets, widget, index - 1);
|
||||
},
|
||||
},
|
||||
{
|
||||
content: `Move Down`,
|
||||
disabled: !canMoveDown,
|
||||
callback: () => {
|
||||
moveArrayItem(this.widgets, widget, index + 1);
|
||||
},
|
||||
},
|
||||
{
|
||||
content: `Remove`,
|
||||
callback: () => {
|
||||
removeArrayItem(this.widgets, widget);
|
||||
},
|
||||
},
|
||||
];
|
||||
new LiteGraph.ContextMenu(menuItems, {
|
||||
title: "LORA WIDGET",
|
||||
event: mxdRuntime.lastCanvasMouseEvent,
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
return this.defaultGetSlotMenuOptions(slot);
|
||||
}
|
||||
|
||||
refreshComboInNode(defs) {
|
||||
mxdApi.getLoras(true);
|
||||
}
|
||||
|
||||
hasLoraWidgets() {
|
||||
return !!this.widgets?.find((w) => w.name?.startsWith("lora_"));
|
||||
}
|
||||
|
||||
allLorasState() {
|
||||
let allOn = true;
|
||||
let allOff = true;
|
||||
for (const widget of this.widgets) {
|
||||
if (widget.name?.startsWith("lora_")) {
|
||||
const on = widget.value?.on;
|
||||
allOn = allOn && on === true;
|
||||
allOff = allOff && on === false;
|
||||
if (!allOn && !allOff) return null;
|
||||
}
|
||||
}
|
||||
return allOn && this.widgets?.length ? true : false;
|
||||
}
|
||||
|
||||
toggleAllLoras() {
|
||||
const allOn = this.allLorasState();
|
||||
const toggledTo = !allOn;
|
||||
for (const widget of this.widgets) {
|
||||
if (widget.name?.startsWith("lora_") && widget.value?.on != null) {
|
||||
widget.value.on = toggledTo;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static setUp(comfyClass, nodeData) {
|
||||
MxdBaseServerNode.registerForOverride(comfyClass, nodeData, NODE_CLASS);
|
||||
}
|
||||
|
||||
static onRegisteredForOverride(comfyClass, ctxClass) {
|
||||
addConnectionLayoutSupport(NODE_CLASS, app, [
|
||||
["Left", "Right"],
|
||||
["Right", "Left"],
|
||||
]);
|
||||
setTimeout(() => {
|
||||
NODE_CLASS.category = comfyClass.category;
|
||||
});
|
||||
}
|
||||
}
|
||||
_a = PROP_LABEL_SHOW_STRENGTHS_STATIC;
|
||||
MxdPowerLoraLoader[_a] = {
|
||||
type: "combo",
|
||||
values: [PROP_VALUE_SHOW_STRENGTHS_SINGLE, PROP_VALUE_SHOW_STRENGTHS_SEPARATE],
|
||||
};
|
||||
|
||||
class PowerLoraLoaderHeaderWidget extends MxdBaseWidget {
|
||||
constructor(name = "PowerLoraLoaderHeaderWidget") {
|
||||
super(name);
|
||||
this.value = { type: "PowerLoraLoaderHeaderWidget" };
|
||||
this.type = "custom";
|
||||
this.hitAreas = {
|
||||
toggle: { bounds: [0, 0], onDown: this.onToggleDown },
|
||||
};
|
||||
this.showModelAndClip = null;
|
||||
}
|
||||
|
||||
draw(ctx, node, w, posY, height) {
|
||||
if (!node.hasLoraWidgets()) return;
|
||||
|
||||
this.showModelAndClip = node.properties[PROP_LABEL_SHOW_STRENGTHS] === PROP_VALUE_SHOW_STRENGTHS_SEPARATE;
|
||||
const margin = 10;
|
||||
const innerMargin = margin * 0.33;
|
||||
const lowQuality = isLowQuality();
|
||||
const allLoraState = node.allLorasState();
|
||||
|
||||
posY += 2;
|
||||
const midY = posY + height * 0.5;
|
||||
let posX = 10;
|
||||
ctx.save();
|
||||
this.hitAreas.toggle.bounds = drawTogglePart(ctx, { posX, posY, height, value: allLoraState });
|
||||
|
||||
if (!lowQuality) {
|
||||
posX += this.hitAreas.toggle.bounds[1] + innerMargin;
|
||||
ctx.globalAlpha = app.canvas.editor_alpha * 0.55;
|
||||
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
|
||||
ctx.textAlign = "left";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText("Toggle All", posX, midY);
|
||||
|
||||
let rposX = node.size[0] - margin - innerMargin - innerMargin;
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText(this.showModelAndClip ? "Clip" : "Strength", rposX - drawNumberWidgetPart.WIDTH_TOTAL / 2, midY);
|
||||
if (this.showModelAndClip) {
|
||||
rposX = rposX - drawNumberWidgetPart.WIDTH_TOTAL - innerMargin * 2;
|
||||
ctx.fillText("Model", rposX - drawNumberWidgetPart.WIDTH_TOTAL / 2, midY);
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
onToggleDown(event, pos, node) {
|
||||
node.toggleAllLoras();
|
||||
this.cancelMouseDown();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_LORA_WIDGET_DATA = {
|
||||
on: true,
|
||||
lora: null,
|
||||
strength: 1,
|
||||
strengthTwo: null,
|
||||
};
|
||||
|
||||
class PowerLoraLoaderWidget extends MxdBaseWidget {
|
||||
constructor(name) {
|
||||
super(name);
|
||||
this.type = "custom";
|
||||
this.haveMouseMovedStrength = false;
|
||||
this.loraInfoPromise = null;
|
||||
this.loraInfo = null;
|
||||
this.showModelAndClip = null;
|
||||
this.hitAreas = {
|
||||
toggle: { bounds: [0, 0], onDown: this.onToggleDown },
|
||||
lora: { bounds: [0, 0], onClick: this.onLoraClick },
|
||||
info: { bounds: [0, 0], onDown: this.onInfoDown },
|
||||
remove: { bounds: [0, 0], onDown: this.onRemoveDown },
|
||||
strengthDec: { bounds: [0, 0], onClick: this.onStrengthDecDown },
|
||||
strengthVal: { bounds: [0, 0], onClick: this.onStrengthValUp },
|
||||
strengthInc: { bounds: [0, 0], onClick: this.onStrengthIncDown },
|
||||
strengthAny: { bounds: [0, 0], onMove: this.onStrengthAnyMove },
|
||||
strengthTwoDec: { bounds: [0, 0], onClick: this.onStrengthTwoDecDown },
|
||||
strengthTwoVal: { bounds: [0, 0], onClick: this.onStrengthTwoValUp },
|
||||
strengthTwoInc: { bounds: [0, 0], onClick: this.onStrengthTwoIncDown },
|
||||
strengthTwoAny: { bounds: [0, 0], onMove: this.onStrengthTwoAnyMove },
|
||||
};
|
||||
this._value = {
|
||||
on: true,
|
||||
lora: null,
|
||||
strength: 1,
|
||||
strengthTwo: null,
|
||||
};
|
||||
}
|
||||
|
||||
set value(v) {
|
||||
this._value = v;
|
||||
if (typeof this._value !== "object") {
|
||||
this._value = { ...DEFAULT_LORA_WIDGET_DATA };
|
||||
if (this.showModelAndClip) {
|
||||
this._value.strengthTwo = this._value.strength;
|
||||
}
|
||||
}
|
||||
this.getLoraInfo();
|
||||
}
|
||||
|
||||
get value() {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
setLora(lora) {
|
||||
this._value.lora = lora;
|
||||
this.getLoraInfo();
|
||||
}
|
||||
|
||||
draw(ctx, node, w, posY, height) {
|
||||
let currentShowModelAndClip = node.properties[PROP_LABEL_SHOW_STRENGTHS] === PROP_VALUE_SHOW_STRENGTHS_SEPARATE;
|
||||
if (this.showModelAndClip !== currentShowModelAndClip) {
|
||||
let oldShowModelAndClip = this.showModelAndClip;
|
||||
this.showModelAndClip = currentShowModelAndClip;
|
||||
if (this.showModelAndClip) {
|
||||
if (oldShowModelAndClip != null) {
|
||||
this.value.strengthTwo = this.value.strength ?? 1;
|
||||
}
|
||||
} else {
|
||||
this.value.strengthTwo = null;
|
||||
this.hitAreas.strengthTwoDec.bounds = [0, -1];
|
||||
this.hitAreas.strengthTwoVal.bounds = [0, -1];
|
||||
this.hitAreas.strengthTwoInc.bounds = [0, -1];
|
||||
this.hitAreas.strengthTwoAny.bounds = [0, -1];
|
||||
}
|
||||
}
|
||||
|
||||
ctx.save();
|
||||
const margin = 10;
|
||||
const innerMargin = margin * 0.33;
|
||||
const lowQuality = isLowQuality();
|
||||
const midY = posY + height * 0.5;
|
||||
|
||||
let posX = margin;
|
||||
|
||||
drawRoundedRectangle(ctx, { pos: [posX, posY], size: [node.size[0] - margin * 2, height] });
|
||||
this.hitAreas.toggle.bounds = drawTogglePart(ctx, { posX, posY, height, value: this.value.on });
|
||||
posX += this.hitAreas.toggle.bounds[1] + innerMargin;
|
||||
|
||||
if (lowQuality) {
|
||||
ctx.restore();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.value.on) {
|
||||
ctx.globalAlpha = app.canvas.editor_alpha * 0.4;
|
||||
}
|
||||
|
||||
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
|
||||
|
||||
let rposX = node.size[0] - margin - innerMargin - innerMargin;
|
||||
|
||||
const strengthValue = this.showModelAndClip ? (this.value.strengthTwo ?? 1) : (this.value.strength ?? 1);
|
||||
|
||||
let textColor = undefined;
|
||||
if (this.loraInfo?.strengthMax != null && strengthValue > this.loraInfo?.strengthMax) {
|
||||
textColor = "#c66";
|
||||
} else if (this.loraInfo?.strengthMin != null && strengthValue < this.loraInfo?.strengthMin) {
|
||||
textColor = "#c66";
|
||||
}
|
||||
|
||||
const [leftArrow, text, rightArrow] = drawNumberWidgetPart(ctx, {
|
||||
posX: node.size[0] - margin - innerMargin - innerMargin,
|
||||
posY,
|
||||
height,
|
||||
value: strengthValue,
|
||||
direction: -1,
|
||||
textColor,
|
||||
});
|
||||
|
||||
this.hitAreas.strengthDec.bounds = leftArrow;
|
||||
this.hitAreas.strengthVal.bounds = text;
|
||||
this.hitAreas.strengthInc.bounds = rightArrow;
|
||||
this.hitAreas.strengthAny.bounds = [leftArrow[0], rightArrow[0] + rightArrow[1] - leftArrow[0]];
|
||||
|
||||
rposX = leftArrow[0] - innerMargin;
|
||||
|
||||
if (this.showModelAndClip) {
|
||||
rposX -= innerMargin;
|
||||
this.hitAreas.strengthTwoDec.bounds = this.hitAreas.strengthDec.bounds;
|
||||
this.hitAreas.strengthTwoVal.bounds = this.hitAreas.strengthVal.bounds;
|
||||
this.hitAreas.strengthTwoInc.bounds = this.hitAreas.strengthInc.bounds;
|
||||
this.hitAreas.strengthTwoAny.bounds = this.hitAreas.strengthAny.bounds;
|
||||
|
||||
let textColor = undefined;
|
||||
if (this.loraInfo?.strengthMax != null && this.value.strength > this.loraInfo?.strengthMax) {
|
||||
textColor = "#c66";
|
||||
} else if (this.loraInfo?.strengthMin != null && this.value.strength < this.loraInfo?.strengthMin) {
|
||||
textColor = "#c66";
|
||||
}
|
||||
const [leftArrow2, text2, rightArrow2] = drawNumberWidgetPart(ctx, {
|
||||
posX: rposX,
|
||||
posY,
|
||||
height,
|
||||
value: this.value.strength ?? 1,
|
||||
direction: -1,
|
||||
textColor,
|
||||
});
|
||||
this.hitAreas.strengthDec.bounds = leftArrow2;
|
||||
this.hitAreas.strengthVal.bounds = text2;
|
||||
this.hitAreas.strengthInc.bounds = rightArrow2;
|
||||
this.hitAreas.strengthAny.bounds = [leftArrow2[0], rightArrow2[0] + rightArrow2[1] - leftArrow2[0]];
|
||||
rposX = leftArrow2[0] - innerMargin;
|
||||
}
|
||||
|
||||
const showInfoIcon = this.value?.lora && this.value?.lora !== "None";
|
||||
const infoIconSize = height * 0.66;
|
||||
const infoWidth = infoIconSize + innerMargin + innerMargin;
|
||||
if (showInfoIcon) {
|
||||
rposX -= innerMargin;
|
||||
drawInfoIcon(ctx, rposX - infoIconSize, posY + (height - infoIconSize) / 2, infoIconSize);
|
||||
this.hitAreas.info.bounds = [rposX - infoIconSize, infoWidth];
|
||||
rposX = rposX - infoIconSize - innerMargin;
|
||||
} else {
|
||||
this.hitAreas.info.bounds = [0, -1];
|
||||
}
|
||||
|
||||
const actionIconSize = infoIconSize;
|
||||
const actionWidth = actionIconSize + innerMargin;
|
||||
const drawAction = (key, label, color = LiteGraph.WIDGET_TEXT_COLOR) => {
|
||||
rposX -= actionWidth;
|
||||
const x = rposX;
|
||||
const y = posY + (height - actionIconSize) / 2;
|
||||
drawRoundedRectangle(ctx, {
|
||||
pos: [x, y],
|
||||
size: [actionIconSize, actionIconSize],
|
||||
borderRadius: actionIconSize * 0.15,
|
||||
colorBackground: "rgba(0,0,0,0.22)",
|
||||
colorStroke: "rgba(255,255,255,0.12)",
|
||||
});
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillText(label, x + actionIconSize * 0.5, y + actionIconSize * 0.52);
|
||||
this.hitAreas[key].bounds = [x, actionIconSize];
|
||||
rposX -= innerMargin * 0.25;
|
||||
};
|
||||
|
||||
drawAction("remove", "x", "#d88");
|
||||
|
||||
const loraWidth = rposX - posX;
|
||||
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
|
||||
ctx.textAlign = "left";
|
||||
ctx.textBaseline = "middle";
|
||||
const loraLabel = String(this.value?.lora || "None");
|
||||
ctx.fillText(fitString(ctx, loraLabel, loraWidth), posX, midY);
|
||||
|
||||
this.hitAreas.lora.bounds = [posX, loraWidth];
|
||||
posX += loraWidth + innerMargin;
|
||||
|
||||
ctx.globalAlpha = app.canvas.editor_alpha;
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
serializeValue(node, index) {
|
||||
const v = { ...this.value };
|
||||
if (!this.showModelAndClip) {
|
||||
delete v.strengthTwo;
|
||||
} else {
|
||||
this.value.strengthTwo = this.value.strengthTwo ?? 1;
|
||||
v.strengthTwo = this.value.strengthTwo;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
onToggleDown(event, pos, node) {
|
||||
this.value.on = !this.value.on;
|
||||
this.cancelMouseDown();
|
||||
return true;
|
||||
}
|
||||
|
||||
onInfoDown(event, pos, node) {
|
||||
this.showLoraInfoDialog();
|
||||
this.cancelMouseDown();
|
||||
return true;
|
||||
}
|
||||
|
||||
onRemoveDown(event, pos, node) {
|
||||
removeArrayItem(node.widgets, this);
|
||||
node.setDirtyCanvas(true, true);
|
||||
this.cancelMouseDown();
|
||||
return true;
|
||||
}
|
||||
|
||||
onLoraClick(event, pos, node) {
|
||||
showLoraChooser(event, (value) => {
|
||||
if (typeof value === "string") {
|
||||
this.value.lora = value;
|
||||
this.loraInfo = null;
|
||||
this.getLoraInfo();
|
||||
}
|
||||
node.setDirtyCanvas(true, true);
|
||||
});
|
||||
this.cancelMouseDown();
|
||||
}
|
||||
|
||||
onStrengthDecDown(event, pos, node) {
|
||||
this.stepStrength(-1, false);
|
||||
}
|
||||
|
||||
onStrengthIncDown(event, pos, node) {
|
||||
this.stepStrength(1, false);
|
||||
}
|
||||
|
||||
onStrengthTwoDecDown(event, pos, node) {
|
||||
this.stepStrength(-1, true);
|
||||
}
|
||||
|
||||
onStrengthTwoIncDown(event, pos, node) {
|
||||
this.stepStrength(1, true);
|
||||
}
|
||||
|
||||
onStrengthAnyMove(event, pos, node) {
|
||||
this.doOnStrengthAnyMove(event, false);
|
||||
}
|
||||
|
||||
onStrengthTwoAnyMove(event, pos, node) {
|
||||
this.doOnStrengthAnyMove(event, true);
|
||||
}
|
||||
|
||||
doOnStrengthAnyMove(event, isTwo = false) {
|
||||
if (event.deltaX) {
|
||||
let prop = isTwo ? "strengthTwo" : "strength";
|
||||
this.haveMouseMovedStrength = true;
|
||||
this.value[prop] = (this.value[prop] ?? 1) + event.deltaX * 0.05;
|
||||
}
|
||||
}
|
||||
|
||||
onStrengthValUp(event, pos, node) {
|
||||
this.doOnStrengthValUp(event, false);
|
||||
}
|
||||
|
||||
onStrengthTwoValUp(event, pos, node) {
|
||||
this.doOnStrengthValUp(event, true);
|
||||
}
|
||||
|
||||
doOnStrengthValUp(event, isTwo = false) {
|
||||
if (this.haveMouseMovedStrength) return;
|
||||
let prop = isTwo ? "strengthTwo" : "strength";
|
||||
const canvas = app.canvas;
|
||||
canvas.prompt("Value", this.value[prop], (v) => (this.value[prop] = Number(v)), event);
|
||||
}
|
||||
|
||||
onMouseUp(event, pos, node) {
|
||||
super.onMouseUp(event, pos, node);
|
||||
this.haveMouseMovedStrength = false;
|
||||
}
|
||||
|
||||
showLoraInfoDialog() {
|
||||
if (!this.value.lora || this.value.lora === "None") {
|
||||
return;
|
||||
}
|
||||
const infoDialog = new MxdLoraInfoDialog(this.value.lora).show();
|
||||
infoDialog.addEventListener("close", (e) => {
|
||||
if (e.detail.dirty) {
|
||||
this.getLoraInfo(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
stepStrength(direction, isTwo = false) {
|
||||
let step = 0.05;
|
||||
let prop = isTwo ? "strengthTwo" : "strength";
|
||||
let strength = (this.value[prop] ?? 1) + step * direction;
|
||||
this.value[prop] = Math.round(strength * 100) / 100;
|
||||
}
|
||||
|
||||
getLoraInfo(force = false) {
|
||||
if (!this.loraInfoPromise || force == true) {
|
||||
let promise;
|
||||
if (this.value.lora && this.value.lora != "None") {
|
||||
promise = LORA_INFO_SERVICE.getInfo(this.value.lora, force, true);
|
||||
} else {
|
||||
promise = Promise.resolve(null);
|
||||
}
|
||||
this.loraInfoPromise = promise.then((v) => (this.loraInfo = v));
|
||||
}
|
||||
return this.loraInfoPromise;
|
||||
}
|
||||
}
|
||||
|
||||
const NODE_CLASS = MxdPowerLoraLoader;
|
||||
|
||||
app.registerExtension({
|
||||
name: "mxd.PowerLoraLoader",
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name === NODE_CLASS.type) {
|
||||
NODE_CLASS.setUp(nodeType, nodeData);
|
||||
}
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user