diff --git a/.gitignore b/.gitignore index b74de8b..20ed481 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ logs/ # Local testing workspace testing/ +userdata/ # ComfyUI local cache & configs ComfyUI/output/ diff --git a/README.md b/README.md index 0591b7f..1a41ebc 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/__init__.py b/__init__.py index 89b8e0e..8a96d49 100644 --- a/__init__.py +++ b/__init__.py @@ -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", ] + diff --git a/loraloader_mxd/__init__.py b/loraloader_mxd/__init__.py new file mode 100644 index 0000000..fea344a --- /dev/null +++ b/loraloader_mxd/__init__.py @@ -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"] diff --git a/loraloader_mxd/constants.py b/loraloader_mxd/constants.py new file mode 100644 index 0000000..a56e341 --- /dev/null +++ b/loraloader_mxd/constants.py @@ -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" diff --git a/loraloader_mxd/log.py b/loraloader_mxd/log.py new file mode 100644 index 0000000..896f45a --- /dev/null +++ b/loraloader_mxd/log.py @@ -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']}") diff --git a/loraloader_mxd/power_lora_loader_mxd.py b/loraloader_mxd/power_lora_loader_mxd.py new file mode 100644 index 0000000..f9bd944 --- /dev/null +++ b/loraloader_mxd/power_lora_loader_mxd.py @@ -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 diff --git a/loraloader_mxd/power_prompt_utils.py b/loraloader_mxd/power_prompt_utils.py new file mode 100644 index 0000000..c9cbd84 --- /dev/null +++ b/loraloader_mxd/power_prompt_utils.py @@ -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']*?)(?::(-?\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 diff --git a/loraloader_mxd/server/__init__.py b/loraloader_mxd/server/__init__.py new file mode 100644 index 0000000..75066b0 --- /dev/null +++ b/loraloader_mxd/server/__init__.py @@ -0,0 +1 @@ +from . import routes_model_info # noqa: F401 diff --git a/loraloader_mxd/server/routes_model_info.py b/loraloader_mxd/server/routes_model_info.py new file mode 100644 index 0000000..7f5f9af --- /dev/null +++ b/loraloader_mxd/server/routes_model_info.py @@ -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 + diff --git a/loraloader_mxd/server/utils_info.py b/loraloader_mxd/server/utils_info.py new file mode 100644 index 0000000..bd3bc42 --- /dev/null +++ b/loraloader_mxd/server/utils_info.py @@ -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) + diff --git a/loraloader_mxd/server/utils_server.py b/loraloader_mxd/server/utils_server.py new file mode 100644 index 0000000..b19d8a8 --- /dev/null +++ b/loraloader_mxd/server/utils_server.py @@ -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) diff --git a/loraloader_mxd/utils.py b/loraloader_mxd/utils.py new file mode 100644 index 0000000..9a6e906 --- /dev/null +++ b/loraloader_mxd/utils.py @@ -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) diff --git a/loraloader_mxd/utils_userdata.py b/loraloader_mxd/utils_userdata.py new file mode 100644 index 0000000..73c7add --- /dev/null +++ b/loraloader_mxd/utils_userdata.py @@ -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) diff --git a/maxedoutnodes.py b/maxedoutnodes.py index 98ccd66..ae8ac25 100644 --- a/maxedoutnodes.py +++ b/maxedoutnodes.py @@ -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) diff --git a/pyproject.toml b/pyproject.toml index 2065d25..00cab17 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 + diff --git a/wan22nodes.py b/wan22nodes.py index 69132bd..8d3d75e 100644 --- a/wan22nodes.py +++ b/wan22nodes.py @@ -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"), diff --git a/web/index.js b/web/index.js index 9e00ca9..c8cb7df 100644 --- a/web/index.js +++ b/web/index.js @@ -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'; + diff --git a/web/loraloader_mxd_entry.js b/web/loraloader_mxd_entry.js new file mode 100644 index 0000000..d07c525 --- /dev/null +++ b/web/loraloader_mxd_entry.js @@ -0,0 +1,2 @@ +import "./mxd_runtime.js"; +import "./power_lora_loader_mxd.js"; diff --git a/web/mxd_api.js b/web/mxd_api.js new file mode 100644 index 0000000..3b533e0 --- /dev/null +++ b/web/mxd_api.js @@ -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(); diff --git a/web/mxd_base_node.js b/web/mxd_base_node.js new file mode 100644 index 0000000..6dc4b10 --- /dev/null +++ b/web/mxd_base_node.js @@ -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); +}; diff --git a/web/mxd_buttons.css b/web/mxd_buttons.css new file mode 100644 index 0000000..2c9f13c --- /dev/null +++ b/web/mxd_buttons.css @@ -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; +} diff --git a/web/mxd_dialog.css b/web/mxd_dialog.css new file mode 100644 index 0000000..c9efdf6 --- /dev/null +++ b/web/mxd_dialog.css @@ -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); +} diff --git a/web/mxd_dialog.js b/web/mxd_dialog.js new file mode 100644 index 0000000..d710c9b --- /dev/null +++ b/web/mxd_dialog.js @@ -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(" { + 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("by mxd"); + const options = Object.assign({}, opts, { + class: "-iconed -help", + title, + content, + }); + super(options); + } +} + + diff --git a/web/mxd_dialog_base.css b/web/mxd_dialog_base.css new file mode 100644 index 0000000..ecebe25 --- /dev/null +++ b/web/mxd_dialog_base.css @@ -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; +} diff --git a/web/mxd_dialog_info.js b/web/mxd_dialog_info.js new file mode 100644 index 0000000..6e8e4a1 --- /dev/null +++ b/web/mxd_dialog_info.js @@ -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: `

Loading...

`, + content: "
Loading..
", + 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 | Copy` : ""; + } + } 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 = ` +
    +
  • ${info.type || ""}
  • +
  • ${info.baseModel || ""}
  • +
  • +
+ + + ${infoTableRow("File", info.file || "")} + ${infoTableRow("Hash (sha256)", info.sha256 || "")} + ${ + civitaiLink + ? infoTableRow("Civitai", `${logoCivitai}View on Civitai`) + : info.raw?.civitai?.error === "Model not found" + ? infoTableRow("Civitai", `Model not found`) + : info.raw?.civitai?.error + ? infoTableRow("Civitai", info.raw?.civitai?.error) + : !info.raw?.civitai + ? infoTableRow("Civitai", ``) + : "" + } + ${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")} +
+ +
    ${ + info.images?.map( + (img) => ` +
  • +
    ${ + img.type === "video" ? `` : `` + } +
    ${imgInfoField("", img.civitaiUrl ? `civitai${link}` : 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)}
    +
    +
  • `, + ).join("") ?? "" + }
+ `; + + 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 ` + + ${name} ${help ? `` : ""} + ${String(value).startsWith("<") ? value : `${value}`} + ${editableFieldName ? `` : ""} + `; +} + +function getTrainedWordsMarkup(words) { + let markup = `
    `; + for (const wordData of words || []) { + markup += `
  • + ${wordData.word} + ${wordData.civitai ? logoCivitai : ""} + ${wordData.count != null ? `${wordData.count}` : ""} +
  • `; + } + markup += `
`; + 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 ? `${label ? `` : ""}${value}` : ""; +} + + + diff --git a/web/mxd_dialog_model_info.css b/web/mxd_dialog_model_info.css new file mode 100644 index 0000000..5edee06 --- /dev/null +++ b/web/mxd_dialog_model_info.css @@ -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; +} diff --git a/web/mxd_menu.css b/web/mxd_menu.css new file mode 100644 index 0000000..426469e --- /dev/null +++ b/web/mxd_menu.css @@ -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); +} diff --git a/web/mxd_menu.js b/web/mxd_menu.js new file mode 100644 index 0000000..621a350 --- /dev/null +++ b/web/mxd_menu.js @@ -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; + } +} + + diff --git a/web/mxd_model_info_service.js b/web/mxd_model_info_service.js new file mode 100644 index 0000000..1539959 --- /dev/null +++ b/web/mxd_model_info_service.js @@ -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(); diff --git a/web/mxd_runtime.js b/web/mxd_runtime.js new file mode 100644 index 0000000..7f21c07 --- /dev/null +++ b/web/mxd_runtime.js @@ -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; diff --git a/web/mxd_shared_utils.js b/web/mxd_shared_utils.js new file mode 100644 index 0000000..61ba549 --- /dev/null +++ b/web/mxd_shared_utils.js @@ -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; +} diff --git a/web/mxd_svgs.js b/web/mxd_svgs.js new file mode 100644 index 0000000..8a4af52 --- /dev/null +++ b/web/mxd_svgs.js @@ -0,0 +1,5 @@ +export const logoCivitai = ``; +export const link = ``; +export const pencilColored = ``; +export const diskColored = ``; +export const dotdotdot = ``; diff --git a/web/mxd_utils.js b/web/mxd_utils.js new file mode 100644 index 0000000..a02bb67 --- /dev/null +++ b/web/mxd_utils.js @@ -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}`; +} + diff --git a/web/mxd_utils_canvas.js b/web/mxd_utils_canvas.js new file mode 100644 index 0000000..dc09972 --- /dev/null +++ b/web/mxd_utils_canvas.js @@ -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(); +} diff --git a/web/mxd_utils_dom.js b/web/mxd_utils_dom.js new file mode 100644 index 0000000..c07e876 --- /dev/null +++ b/web/mxd_utils_dom.js @@ -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); +} diff --git a/web/mxd_utils_menu.js b/web/mxd_utils_menu.js new file mode 100644 index 0000000..63cd458 --- /dev/null +++ b/web/mxd_utils_menu.js @@ -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, + }); +} diff --git a/web/mxd_utils_widgets.js b/web/mxd_utils_widgets.js new file mode 100644 index 0000000..1c31b4c --- /dev/null +++ b/web/mxd_utils_widgets.js @@ -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); + } +} + diff --git a/web/power_lora_loader_mxd.js b/web/power_lora_loader_mxd.js new file mode 100644 index 0000000..f3feed8 --- /dev/null +++ b/web/power_lora_loader_mxd.js @@ -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); + } + }, +});