Integrate Lora Loader MXD into MaxedOut

Add integrated Lora Loader MXD support and frontend. Introduces a new loraloader_mxd package (constants, logging, power node, prompt utils, server API routes, model info utils, server helpers, general utils, and userdata migration/IO). Adds many web frontend files for the MXD UI and registers the node in the top-level __init__.py. Updates README with merge/migration notes and adds userdata/ to .gitignore. Also includes minor comment/tuning change in maxedoutnodes.py and small updates to existing web/index.js and project metadata.
This commit is contained in:
Maxed-Out-99
2026-02-21 18:21:54 -08:00
parent c5847f70d3
commit a9fd90d608
39 changed files with 5738 additions and 62 deletions
+1
View File
@@ -30,6 +30,7 @@ logs/
# Local testing workspace
testing/
userdata/
# ComfyUI local cache & configs
ComfyUI/output/
+22 -2
View File
@@ -1,2 +1,22 @@
# ComfyUI-MaxedOut
Custom ComfyUI nodes used in Maxed Out workflows (SDXL, Flux, etc.)
# ComfyUI-MaxedOut
Custom ComfyUI nodes used in Maxed Out workflows (SDXL, Flux, Wan 2.2, etc.).
## Included Nodes
- Core MaxedOut nodes from `maxedoutnodes.py`
- Media comparer nodes from `mediacomparers.py`
- WAN 2.2 nodes from `wan22nodes.py`
- `Lora Loader MXD` (merged from `ComfyUI-LoraLoader-MXD`)
## Lora Loader MXD Merge
`Lora Loader MXD` is now integrated directly into this repository.
Compatibility and behavior retained:
- Node type remains `Lora Loader MXD`
- API namespace remains `/loraloader-mxd/api/...`
- Sidecar metadata compatibility remains `*.rgthree-info.json`
- Frontend extension name remains `mxd.PowerLoraLoader`
## Migration Notes
- Existing `ComfyUI-LoraLoader-MXD` installs are supported by a one-release compatibility shim.
- During transition, keep both repos installed if needed; the standalone shim defers to this merged provider to avoid duplicate registration.
- After the transition release window, the standalone repo can be removed.
+3 -2
View File
@@ -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",
]
+12
View File
@@ -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"]
+11
View File
@@ -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"
+49
View File
@@ -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']}")
+94
View File
@@ -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
+104
View File
@@ -0,0 +1,104 @@
"""Utilities for Power Prompt nodes."""
import re
import os
import folder_paths
from .log import log_node_warn, log_node_info
def get_and_strip_loras(prompt, silent=False, log_node="Power Prompt"):
"""Collects and strips lora tags from a prompt."""
pattern = r'<lora:([^:>]*?)(?::(-?\d*(?:\.\d*)?))?>'
lora_paths = folder_paths.get_filename_list('loras')
matches = re.findall(pattern, prompt)
loras = []
unfound_loras = []
skipped_loras = []
for match in matches:
tag_path = match[0]
strength = float(match[1] if len(match) > 1 and len(match[1]) else 1.0)
if strength == 0:
if not silent:
log_node_info(log_node, f'Skipping "{tag_path}" with strength of zero')
skipped_loras.append({'lora': tag_path, 'strength': strength})
continue
lora_path = get_lora_by_filename(tag_path, lora_paths, log_node=None if silent else log_node)
if lora_path is None:
unfound_loras.append({'lora': tag_path, 'strength': strength})
continue
loras.append({'lora': lora_path, 'strength': strength})
return (re.sub(pattern, '', prompt), loras, skipped_loras, unfound_loras)
# pylint: disable = too-many-return-statements, too-many-branches
def get_lora_by_filename(file_path, lora_paths=None, log_node=None):
"""Returns a lora by filename, looking for exactl paths and then fuzzier matching."""
lora_paths = lora_paths if lora_paths is not None else folder_paths.get_filename_list('loras')
if file_path in lora_paths:
return file_path
lora_paths_no_ext = [os.path.splitext(x)[0] for x in lora_paths]
# See if we've entered the exact path, but without the extension
if file_path in lora_paths_no_ext:
found = lora_paths[lora_paths_no_ext.index(file_path)]
return found
# Same check, but ensure file_path is without extension.
file_path_force_no_ext = os.path.splitext(file_path)[0]
if file_path_force_no_ext in lora_paths_no_ext:
found = lora_paths[lora_paths_no_ext.index(file_path_force_no_ext)]
return found
# See if we passed just the name, without paths.
lora_filenames_only = [os.path.basename(x) for x in lora_paths]
if file_path in lora_filenames_only:
found = lora_paths[lora_filenames_only.index(file_path)]
if log_node is not None:
log_node_info(log_node, f'Matched Lora input "{file_path}" to "{found}".')
return found
# Same, but force the input to be without paths
file_path_force_filename = os.path.basename(file_path)
lora_filenames_only = [os.path.basename(x) for x in lora_paths]
if file_path_force_filename in lora_filenames_only:
found = lora_paths[lora_filenames_only.index(file_path_force_filename)]
if log_node is not None:
log_node_info(log_node, f'Matched Lora input "{file_path}" to "{found}".')
return found
# Check the filenames and without extension.
lora_filenames_and_no_ext = [os.path.splitext(os.path.basename(x))[0] for x in lora_paths]
if file_path in lora_filenames_and_no_ext:
found = lora_paths[lora_filenames_and_no_ext.index(file_path)]
if log_node is not None:
log_node_info(log_node, f'Matched Lora input "{file_path}" to "{found}".')
return found
# And, one last forcing the input to be the same
file_path_force_filename_and_no_ext = os.path.splitext(os.path.basename(file_path))[0]
if file_path_force_filename_and_no_ext in lora_filenames_and_no_ext:
found = lora_paths[lora_filenames_and_no_ext.index(file_path_force_filename_and_no_ext)]
if log_node is not None:
log_node_info(log_node, f'Matched Lora input "{file_path}" to "{found}".')
return found
# Finally, super fuzzy, we'll just check if the input exists in the path at all.
for index, lora_path in enumerate(lora_paths):
if file_path in lora_path:
found = lora_paths[index]
if log_node is not None:
log_node_warn(log_node, f'Fuzzy-matched Lora input "{file_path}" to "{found}".')
return found
if log_node is not None:
log_node_warn(log_node, f'Lora "{file_path}" not found, skipping.')
return None
+1
View File
@@ -0,0 +1 @@
from . import routes_model_info # noqa: F401
+200
View File
@@ -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
+453
View File
@@ -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)
+56
View File
@@ -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)
+168
View File
@@ -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)
+88
View File
@@ -0,0 +1,88 @@
import os
import shutil
from pathlib import Path
from .utils import load_json_file, path_exists, save_json_file
THIS_DIR = Path(__file__).resolve().parent
MAXEDOUT_ROOT = THIS_DIR.parent
USERDATA = MAXEDOUT_ROOT / "userdata" / "loraloader_mxd"
LEGACY_USERDATA = MAXEDOUT_ROOT.parent / "ComfyUI-LoraLoader-MXD" / "userdata"
MIGRATION_SENTINEL = USERDATA / ".migrated_from_standalone"
def _log(message: str):
print(f"[ComfyUI-MaxedOut][LoraLoader-MXD] {message}")
def _migrate_legacy_userdata_once():
if MIGRATION_SENTINEL.exists():
return
if not LEGACY_USERDATA.exists():
return
copied = 0
skipped = 0
for src in LEGACY_USERDATA.rglob("*"):
if not src.is_file():
continue
rel = src.relative_to(LEGACY_USERDATA)
dest = USERDATA / rel
dest.parent.mkdir(parents=True, exist_ok=True)
if dest.exists():
skipped += 1
continue
shutil.copy2(src, dest)
copied += 1
USERDATA.mkdir(parents=True, exist_ok=True)
MIGRATION_SENTINEL.write_text("ok\n", encoding="utf-8")
_log(f"Migrated legacy userdata files: copied={copied}, skipped_existing={skipped}")
_migrate_legacy_userdata_once()
def read_userdata_file(rel_path: str):
"""Reads a file from the userdata directory."""
file_path = clean_path(rel_path)
if path_exists(file_path):
with open(file_path, 'r', encoding='UTF-8') as file:
return file.read()
return None
def save_userdata_file(rel_path: str, content: str):
"""Saves a file from the userdata directory."""
file_path = clean_path(rel_path)
os.makedirs(os.path.dirname(file_path), exist_ok=True)
with open(file_path, 'w+', encoding='UTF-8') as file:
file.write(content)
def delete_userdata_file(rel_path: str):
"""Deletes a file from the userdata directory."""
file_path = clean_path(rel_path)
if os.path.isfile(file_path):
os.remove(file_path)
def read_userdata_json(rel_path: str):
"""Reads a json file from the userdata directory."""
file_path = clean_path(rel_path)
return load_json_file(file_path)
def save_userdata_json(rel_path: str, data: dict):
"""Saves a json file from the userdata directory."""
file_path = clean_path(rel_path)
return save_json_file(file_path, data)
def clean_path(rel_path: str):
"""Cleans a relative path by splitting on forward slash and os.path.joining."""
cleaned = USERDATA
paths = rel_path.split('/')
for path in paths:
cleaned = cleaned / path
return str(cleaned)
+29 -54
View File
@@ -221,24 +221,33 @@ class ZImageTurboEmptyLatentImage:
TITLE = "Z-Image Turbo Empty Latent Image"
CATEGORY = "MXD/Latent"
# Same resolutions as your original, just grouped like Flux
# Tuned for Z-Image Turbo:
# - Rule of 64: every dimension is a multiple of 64
# - 1MP baseline: 1024x1024 in the standard tier
# - Ceiling: keep presets below 6.5MP
MAX_TOTAL_PIXELS = 6_500_000
MIN_BLOCK = 64
RESOLUTIONS = {
"— High Resolutions —": None,
"Square (1:1) 1536x1536": (1536, 1536),
"Square (1:1) 1280x1280": (1280, 1280),
"Photo (4:3) 1792x1344": (1792, 1344),
"Landscape (3:2) 1920x1280": (1920, 1280),
"Widescreen (16:9) 2048x1152": (2048, 1152),
"Ultrawide (21:9) 2016x864": (2016, 864),
"Ultrawide (21:9) 2304x1024": (2304, 1024),
"— Standard Resolutions —": None,
"Square (1:1) 1024x1024": (1024, 1024),
"Standard (3:2) 1536x1024": (1536, 1024),
"Widescreen (16:9) 1920x1088": (1920, 1088),
"Ultrawide (21:9) 1680x720": (1680, 720),
"Photo (4:3) 1152x896": (1152, 896),
"Landscape (3:2) 1280x832": (1280, 832),
"Widescreen (16:9) 1344x768": (1344, 768),
"Ultrawide (21:9) 1536x640": (1536, 640),
"— Low Resolutions —": None,
"Square (1:1) 768x768": (768, 768),
"Standard (3:2) 1216x832": (1216, 832),
"Widescreen (16:9) 1280x720": (1280, 720),
"Square (1:1) 512x512": (512, 512),
"Photo (4:3) 576x448": (576, 448),
"Landscape (3:2) 640x448": (640, 448),
"Widescreen (16:9) 704x384": (704, 384),
"Ultrawide (21:9) 768x320": (768, 320),
}
def __init__(self):
@@ -276,6 +285,15 @@ class ZImageTurboEmptyLatentImage:
if vertical:
width, height = height, width
if (width % self.MIN_BLOCK) != 0 or (height % self.MIN_BLOCK) != 0:
raise ValueError(
f"Invalid preset {width}x{height}. Z-Image Turbo requires multiples of {self.MIN_BLOCK}."
)
if (width * height) > self.MAX_TOTAL_PIXELS:
raise ValueError(
f"Invalid preset {width}x{height}. Z-Image Turbo presets must stay at or below {self.MAX_TOTAL_PIXELS:,} pixels."
)
latent = torch.zeros([batch_size, 16, height // 8, width // 8], device=self.device)
return ({"samples": latent},)
@@ -1377,32 +1395,8 @@ class SaveImage_MXD:
# Strip UI previews so nothing shows up in the ComfyUI viewer.
return {k: v for k, v in result.items() if k != "ui"}
return result
########################################################################################################################
# Dummy Node (for workflow missing-node testing)
class DummyNodeMXD:
DESCRIPTION = """Basic dummy node for missing-node workflow tests."""
TITLE = "Dummy Node"
CATEGORY = "MXD/Test"
@classmethod
def INPUT_TYPES(cls) -> dict:
return {
"required": {
"text": ("STRING", {"default": "hello"}),
"repeat": ("INT", {"default": 1, "min": 1, "max": 10}),
}
}
RETURN_TYPES = ("STRING",)
OUTPUT_TOOLTIPS = ("The input text repeated N times.",)
FUNCTION = "run"
def run(self, text, repeat) -> tuple:
return (text * int(repeat),)
# NODE MAPPING
NODE_CLASS_MAPPINGS = {
@@ -1424,7 +1418,6 @@ NODE_CLASS_MAPPINGS = {
"LoadImageWithPromptsMXD": LoadImageWithPromptsMXD,
"ZImageTurboEmptyLatentImage": ZImageTurboEmptyLatentImage,
"Save Image MXD": SaveImage_MXD,
"Dummy Node MXD": DummyNodeMXD,
}
if HAVE_COMFY_API:
@@ -1450,9 +1443,8 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"Crop Image By Mask": "Crop Image by Mask MXD",
"Load Image Batch MXD": "Load Image Batch MXD",
"LoadImageWithPromptsMXD": "Load Image MXD",
"ZImageTurboEmptyLatentImage": "ZImageTurbo Empty Latent Image MXD",
"ZImageTurboEmptyLatentImage": "ZIT Empty Latent Image MXD",
"Save Image MXD": "Save Image MXD",
"Dummy Node MXD": "Dummy Node MXD",
}
if HAVE_COMFY_API:
@@ -1460,20 +1452,3 @@ if HAVE_COMFY_API:
"QwenImageEditSingleMXD": "Qwen Image Edit + Latent MXD",
"QwenImageEditTripleMXD": "Qwen Image Edit Prompt MXD (Triple)",
})
def _add_mxd_aliases(class_map, display_map):
alias_sources = {}
for key in list(class_map.keys()):
if "MXD" in key.upper():
continue
alias = f"{key} MXD"
if alias in class_map:
continue
class_map[alias] = class_map[key]
alias_sources[alias] = key
for alias, source in alias_sources.items():
if alias not in display_map:
display_map[alias] = display_map.get(source, alias)
return alias_sources
_add_mxd_aliases(NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS)
+3 -2
View File
@@ -1,7 +1,7 @@
[project]
[project]
name = "ComfyUI-MaxedOut"
description = "Custom ComfyUI nodes used in Maxed Out workflows (SDXL, Flux, Wan 2.2, etc.)"
version = "1.7.2"
version = "1.8.0"
license = {file = "LICENSE"}
# classifiers = [
# # For OS-independent nodes (works on all operating systems)
@@ -34,3 +34,4 @@ Icon = "https://github.com/Maxed-Out-99/ComfyUI-MaxedOut/blob/main/assets/maxedo
includes = []
# "requires-comfyui" = ">=1.0.0" # ComfyUI version compatibility
+1 -1
View File
@@ -1775,7 +1775,7 @@ if HAVE_COMFY_API:
def define_schema(cls):
return io.Schema(
node_id="Wan22FirstLastImageToVideoMXD",
display_name="WAN 2.2 First&Last Image To Video MXD",
display_name="WAN 2.2 First & Last I2V MXD",
category="conditioning/video_models",
inputs=[
io.Conditioning.Input("positive"),
+3 -1
View File
@@ -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';
+2
View File
@@ -0,0 +1,2 @@
import "./mxd_runtime.js";
import "./power_lora_loader_mxd.js";
+110
View File
@@ -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();
+217
View File
@@ -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);
};
+130
View File
@@ -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;
}
+124
View File
@@ -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);
}
+111
View File
@@ -0,0 +1,111 @@
import { createElement as $el, getClosestOrSelf, setAttributes } from "./mxd_utils_dom.js";
export class MxdDialog extends EventTarget {
constructor(options) {
super();
this.options = options;
let container = $el("div.rgthree-dialog-container");
this.element = $el("dialog", {
classes: ["rgthree-dialog", options.class || ""],
child: container,
parent: document.body,
events: {
click: (event) => {
if (!this.element.open ||
event.target === container ||
getClosestOrSelf(event.target, `.rgthree-dialog-container`) === container) {
return;
}
return this.close();
},
},
});
this.element.addEventListener("close", (event) => {
this.onDialogElementClose();
});
this.titleElement = $el("div.rgthree-dialog-container-title", {
parent: container,
children: !options.title
? null
: options.title instanceof Element || Array.isArray(options.title)
? options.title
: typeof options.title === "string"
? !options.title.includes("<h2")
? $el("h2", { html: options.title })
: options.title
: options.title,
});
this.contentElement = $el("div.rgthree-dialog-container-content", {
parent: container,
child: options.content,
});
const footerEl = $el("footer.rgthree-dialog-container-footer", { parent: container });
for (const button of options.buttons || []) {
$el("button", {
text: button.label,
className: button.className,
disabled: !!button.disabled,
parent: footerEl,
events: {
click: (e) => {
var _a;
(_a = button.callback) === null || _a === void 0 ? void 0 : _a.call(button, e);
},
},
});
}
if (options.closeButtonLabel !== false) {
$el("button", {
text: options.closeButtonLabel || "Close",
className: "rgthree-button",
parent: footerEl,
events: {
click: (e) => {
this.close(e);
},
},
});
}
}
setTitle(content) {
const title = typeof content !== "string" || content.includes("<h2")
? content
: $el("h2", { html: content });
setAttributes(this.titleElement, { children: title });
}
setContent(content) {
setAttributes(this.contentElement, { children: content });
}
show() {
document.body.classList.add("rgthree-dialog-open");
this.element.showModal();
this.dispatchEvent(new CustomEvent("show"));
return this;
}
async close(e) {
if (this.options.onBeforeClose && !(await this.options.onBeforeClose())) {
return;
}
this.element.close();
}
onDialogElementClose() {
document.body.classList.remove("rgthree-dialog-open");
this.element.remove();
this.dispatchEvent(new CustomEvent("close", this.getCloseEventDetail()));
}
getCloseEventDetail() {
return { detail: null };
}
}
export class MxdHelpDialog extends MxdDialog {
constructor(node, content, opts = {}) {
const title = (node.type || node.title || "").replace(/\s*\(mxd\).*/, " <small>by mxd</small>");
const options = Object.assign({}, opts, {
class: "-iconed -help",
title,
content,
});
super(options);
}
}
+23
View File
@@ -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;
}
+277
View File
@@ -0,0 +1,277 @@
import { MxdDialog } from "./mxd_dialog.js";
import {
createElement as $el,
empty,
appendChildren,
getClosestOrSelf,
query,
queryAll,
setAttributes,
} from "./mxd_utils_dom.js";
import { logoCivitai, link, pencilColored, diskColored, dotdotdot } from "./mxd_svgs.js";
import { CHECKPOINT_INFO_SERVICE, LORA_INFO_SERVICE } from "./mxd_model_info_service.js";
import { mxdRuntime } from "./mxd_runtime.js";
import { MenuButton } from "./mxd_menu.js";
import { generateId, injectCss } from "./mxd_shared_utils.js";
const EXTENSION_BASE = new URL(".", import.meta.url).pathname.replace(/\/$/, "");
class MxdInfoDialog extends MxdDialog {
constructor(file) {
const dialogOptions = {
class: "rgthree-info-dialog",
title: `<h2>Loading...</h2>`,
content: "<center>Loading..</center>",
onBeforeClose: () => true,
};
super(dialogOptions);
this.modifiedModelData = false;
this.modelInfo = null;
this.init(file);
}
async init(file) {
await injectCss(`${EXTENSION_BASE}/mxd_dialog_base.css`);
await injectCss(`${EXTENSION_BASE}/mxd_dialog_model_info.css`);
this.modelInfo = await this.getModelInfo(file);
this.setContent(this.getInfoContent());
this.setTitle(this.modelInfo?.name || this.modelInfo?.file || "Unknown");
this.attachEvents();
}
getCloseEventDetail() {
return { detail: { dirty: this.modifiedModelData } };
}
attachEvents() {
this.contentElement.addEventListener("click", async (e) => {
const target = getClosestOrSelf(e.target, "[data-action]");
const action = target?.getAttribute("data-action");
if (!target || !action) return;
await this.handleEventAction(action, target, e);
});
}
async handleEventAction(action, target, e) {
const info = this.modelInfo;
if (!info?.file) return;
if (action === "fetch-civitai") {
this.modelInfo = await this.refreshModelInfo(info.file);
this.setContent(this.getInfoContent());
this.setTitle(this.modelInfo?.name || this.modelInfo?.file || "Unknown");
} else if (action === "copy-trained-words") {
const selected = queryAll(".-rgthree-is-selected", target.closest("tr"));
const text = selected.map((el) => el.getAttribute("data-word")).join(", ");
await navigator.clipboard.writeText(text);
mxdRuntime.showMessage({
id: "copy-trained-words-" + generateId(4),
type: "success",
message: `Copied ${selected.length} key word${selected.length === 1 ? "" : "s"}.`,
timeout: 3000,
});
} else if (action === "toggle-trained-word") {
target?.classList.toggle("-rgthree-is-selected");
const tr = target.closest("tr");
if (tr) {
const span = query("td:first-child > *", tr);
let small = query("small", span);
if (!small) {
small = $el("small", { parent: span });
}
const num = queryAll(".-rgthree-is-selected", tr).length;
small.innerHTML = num ? `${num} selected | <span role="button" data-action="copy-trained-words">Copy</span>` : "";
}
} else if (action === "edit-row") {
const tr = target.closest("tr");
const td = query("td:nth-child(2)", tr);
const input = td.querySelector("input,textarea");
if (!input) {
const fieldName = tr.dataset["fieldName"];
tr.classList.add("-rgthree-editing");
const isTextarea = fieldName === "userNote";
const rowInput = $el(`${isTextarea ? "textarea" : 'input[type="text"]'}`, { value: td.textContent });
rowInput.addEventListener("keydown", (evt) => {
if (!isTextarea && evt.key === "Enter") {
const modified = saveEditableRow(info, tr, true);
this.modifiedModelData = this.modifiedModelData || modified;
evt.stopPropagation();
evt.preventDefault();
} else if (evt.key === "Escape") {
const modified = saveEditableRow(info, tr, false);
this.modifiedModelData = this.modifiedModelData || modified;
evt.stopPropagation();
evt.preventDefault();
}
});
appendChildren(empty(td), [rowInput]);
rowInput.focus();
} else if (target.nodeName.toLowerCase() === "button") {
const modified = saveEditableRow(info, tr, true);
this.modifiedModelData = this.modifiedModelData || modified;
}
e?.preventDefault();
e?.stopPropagation();
}
}
getInfoContent() {
const info = this.modelInfo || {};
const civitaiLink = info.links?.find((i) => i.includes("civitai.com/models"));
const html = `
<ul class="rgthree-info-area">
<li title="Type" class="rgthree-info-tag -type -type-${(info.type || "").toLowerCase()}"><span>${info.type || ""}</span></li>
<li title="Base Model" class="rgthree-info-tag -basemodel -basemodel-${(info.baseModel || "").toLowerCase()}"><span>${info.baseModel || ""}</span></li>
<li class="rgthree-info-menu" stub="menu"></li>
</ul>
<table class="rgthree-info-table">
${infoTableRow("File", info.file || "")}
${infoTableRow("Hash (sha256)", info.sha256 || "")}
${
civitaiLink
? infoTableRow("Civitai", `<a href="${civitaiLink}" target="_blank">${logoCivitai}View on Civitai</a>`)
: info.raw?.civitai?.error === "Model not found"
? infoTableRow("Civitai", `<i>Model not found</i>`)
: info.raw?.civitai?.error
? infoTableRow("Civitai", info.raw?.civitai?.error)
: !info.raw?.civitai
? infoTableRow("Civitai", `<button class="rgthree-button" data-action="fetch-civitai">Fetch info from civitai</button>`)
: ""
}
${infoTableRow("Name", info.name || info.raw?.metadata?.ss_output_name || "", "Display name.", "name")}
${
!info.baseModel && !info.baseModelFile
? ""
: infoTableRow("Base Model", (info.baseModel || "") + (info.baseModelFile ? ` (${info.baseModelFile})` : ""))
}
${!info.trainedWords?.length ? "" : infoTableRow("Trained Words", getTrainedWordsMarkup(info.trainedWords) ?? "", "Click to select for copy.")}
${
!info.raw?.metadata?.ss_clip_skip || info.raw?.metadata?.ss_clip_skip == "None"
? ""
: infoTableRow("Clip Skip", info.raw?.metadata?.ss_clip_skip)
}
${infoTableRow("Strength Min", info.strengthMin ?? "", "Recommended minimum strength.", "strengthMin")}
${infoTableRow("Strength Max", info.strengthMax ?? "", "Recommended maximum strength.", "strengthMax")}
${infoTableRow("Additional Notes", info.userNote ?? "", "Local note.", "userNote")}
</table>
<ul class="rgthree-info-images">${
info.images?.map(
(img) => `
<li>
<figure>${
img.type === "video" ? `<video src="${img.url}" autoplay loop></video>` : `<img src="${img.url}" />`
}
<figcaption>${imgInfoField("", img.civitaiUrl ? `<a href="${img.civitaiUrl}" target="_blank">civitai${link}</a>` : undefined)}${imgInfoField("seed", img.seed)}${imgInfoField("steps", img.steps)}${imgInfoField("cfg", img.cfg)}${imgInfoField("sampler", img.sampler)}${imgInfoField("model", img.model)}${imgInfoField("positive", img.positive)}${imgInfoField("negative", img.negative)}</figcaption>
</figure>
</li>`,
).join("") ?? ""
}</ul>
`;
const div = $el("div", { html });
setAttributes(query('[stub="menu"]', div), {
children: [
new MenuButton({
icon: dotdotdot,
options: [
{ label: "More Actions", type: "title" },
{
label: "Open API JSON",
callback: async () => {
if (this.modelInfo?.file) {
window.open(`/loraloader-mxd/api/loras/info?file=${encodeURIComponent(this.modelInfo.file)}`);
}
},
},
{
label: "Clear all local info",
callback: async () => {
if (this.modelInfo?.file) {
this.modelInfo = await LORA_INFO_SERVICE.clearFetchedInfo(this.modelInfo.file);
this.setContent(this.getInfoContent());
this.setTitle(this.modelInfo?.name || this.modelInfo?.file || "Unknown");
}
},
},
],
}),
],
});
return div;
}
}
export class MxdLoraInfoDialog extends MxdInfoDialog {
async getModelInfo(file) {
return LORA_INFO_SERVICE.getInfo(file, false, false);
}
async refreshModelInfo(file) {
return LORA_INFO_SERVICE.refreshInfo(file);
}
}
export class MxdCheckpointInfoDialog extends MxdInfoDialog {
async getModelInfo(file) {
return CHECKPOINT_INFO_SERVICE.getInfo(file, false, false);
}
async refreshModelInfo(file) {
return CHECKPOINT_INFO_SERVICE.refreshInfo(file);
}
}
function infoTableRow(name, value, help = "", editableFieldName = "") {
return `
<tr class="${editableFieldName ? "editable" : ""}" ${editableFieldName ? `data-field-name="${editableFieldName}"` : ""}>
<td><span>${name} ${help ? `<span class="-help" title="${help}"></span>` : ""}<span></td>
<td ${editableFieldName ? "" : 'colspan="2"'}>${String(value).startsWith("<") ? value : `<span>${value}<span>`}</td>
${editableFieldName ? `<td style="width: 24px;"><button class="rgthree-button-reset rgthree-button-edit" data-action="edit-row">${pencilColored}${diskColored}</button></td>` : ""}
</tr>`;
}
function getTrainedWordsMarkup(words) {
let markup = `<ul class="rgthree-info-trained-words-list">`;
for (const wordData of words || []) {
markup += `<li title="${wordData.word}" data-word="${wordData.word}" class="rgthree-info-trained-words-list-item" data-action="toggle-trained-word">
<span>${wordData.word}</span>
${wordData.civitai ? logoCivitai : ""}
${wordData.count != null ? `<small>${wordData.count}</small>` : ""}
</li>`;
}
markup += `</ul>`;
return markup;
}
function saveEditableRow(info, tr, saving = true) {
const fieldName = tr.dataset["fieldName"];
const input = query("input,textarea", tr);
let newValue = info[fieldName] ?? "";
let modified = false;
if (saving) {
newValue = input.value;
if (fieldName.startsWith("strength")) {
if (Number.isNaN(Number(newValue))) {
alert(`You must enter a number into the ${fieldName} field.`);
return false;
}
newValue = (Math.round(Number(newValue) * 100) / 100).toFixed(2);
}
LORA_INFO_SERVICE.savePartialInfo(info.file, { [fieldName]: newValue });
modified = true;
}
tr.classList.remove("-rgthree-editing");
const td = query("td:nth-child(2)", tr);
appendChildren(empty(td), [$el("span", { text: newValue })]);
return modified;
}
function imgInfoField(label, value) {
return value != null ? `<span>${label ? `<label>${label} </label>` : ""}${value}</span>` : "";
}
+336
View File
@@ -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;
}
+103
View File
@@ -0,0 +1,103 @@
.rgthree-menu {
list-style: none;
padding: 0;
margin: 0;
position: fixed;
z-index: 999999;
pointer-events: none;
opacity: 0;
transition: opacity 0.08s ease-in-out;
color: #dde;
background-color: #111;
font-size: 12px;
box-shadow: 0 0 10px black !important;
}
.rgthree-menu > li {
position: relative;
padding: 4px 6px;
z-index: 9999;
white-space: nowrap;
}
.rgthree-menu > li[role=button] {
background-color: var(--comfy-menu-bg) !important;
color: var(--input-text);
cursor: pointer;
}
.rgthree-menu > li[role=button]:hover {
filter: brightness(155%);
}
.rgthree-menu[state^=measuring] {
display: block;
opacity: 0;
}
.rgthree-menu[state=open] {
display: block;
opacity: 1;
pointer-events: all;
}
.rgthree-top-menu {
box-sizing: border-box;
white-space: nowrap;
background: var(--content-bg);
color: var(--content-fg);
display: flex;
flex-direction: column;
list-style: none;
padding: 0;
margin: 0;
}
.rgthree-top-menu * {
box-sizing: inherit;
}
.rgthree-top-menu > li:not(#fakeid) {
list-style: none;
padding: 0;
margin: 0;
position: relative;
z-index: 2;
}
.rgthree-top-menu > li:not(#fakeid) > button {
cursor: pointer;
padding: 8px 12px 8px 8px;
width: 100%;
text-align: start;
display: flex;
flex-direction: row;
align-items: center;
justify-content: start;
}
.rgthree-top-menu > li:not(#fakeid) > button:hover {
background-color: var(--comfy-input-bg);
}
.rgthree-top-menu > li:not(#fakeid) > button svg {
height: 16px;
width: auto;
margin-inline-end: 0.6em;
}
.rgthree-top-menu > li:not(#fakeid) > button svg.github-star {
fill: rgb(227, 179, 65);
}
.rgthree-top-menu > li:not(#fakeid).rgthree-message {
min-height: 32px;
}
.rgthree-top-menu > li:not(#fakeid).rgthree-message > span {
padding: 8px 12px;
display: block;
width: 100%;
text-align: center;
font-style: italic;
font-size: 12px;
}
.rgthree-top-menu.-modal::after {
content: "";
display: block;
position: fixed;
z-index: 1;
inset: 0;
background: rgba(0, 0, 0, 0.0666666667);
}
body.rgthree-modal-menu-open > *:not(.rgthree-menu):not(.rgthree-top-messages-container) {
filter: blur(2px);
}
+100
View File
@@ -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;
}
}
+71
View File
@@ -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();
+97
View File
@@ -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;
+416
View File
@@ -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;
}
+5
View File
@@ -0,0 +1,5 @@
export const logoCivitai = `<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"><circle cx="12" cy="12" r="10" fill="#40a3ff"></circle><path d="M8 7h8l-4 10z" fill="#fff"></path></svg>`;
export const link = `<svg viewBox="0 0 24 24" width="12" height="12" aria-hidden="true"><path d="M14 3h7v7h-2V6.4l-9.3 9.3-1.4-1.4L17.6 5H14V3z" fill="currentColor"></path><path d="M5 5h6v2H7v10h10v-4h2v6H5V5z" fill="currentColor"></path></svg>`;
export const pencilColored = `<svg viewBox="0 0 24 24" width="16" height="16"><path fill="#f59e0b" d="M3 17.2V21h3.8l11-11-3.8-3.8-11 11z"></path><path fill="#d97706" d="M20.7 7.1a1 1 0 0 0 0-1.4l-2.4-2.4a1 1 0 0 0-1.4 0l-1.9 1.9 3.8 3.8 1.9-1.9z"></path></svg>`;
export const diskColored = `<svg viewBox="0 0 24 24" width="16" height="16"><path fill="#22c55e" d="M4 4h16v16H4z"></path><path fill="#fff" d="M7 4h8v6H7zM8 14h8v4H8z"></path></svg>`;
export const dotdotdot = `<svg viewBox="0 0 24 24" width="16" height="16"><circle cx="6" cy="12" r="2" fill="currentColor"></circle><circle cx="12" cy="12" r="2" fill="currentColor"></circle><circle cx="18" cy="12" r="2" fill="currentColor"></circle></svg>`;
+724
View File
@@ -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}`;
}
+225
View File
@@ -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();
}
+363
View File
@@ -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);
}
+17
View File
@@ -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,
});
}
+292
View File
@@ -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);
}
}
+697
View File
@@ -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);
}
},
});