diff --git a/README.md b/README.md index 41626f7..2e3f391 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Designed to support desktop, mobile and multi-screen devices. - Press the "copy" button to copy a model to ComfyUI's clipboard or copy the embedding to the system clipboard. (Copying the embedding to the system clipboard requires a secure http connection.) - Press the "add" button to add the model to the ComfyUI graph or append the embedding to one or more selected nodes. - Press the "load workflow" button to try and load a workflow embedded in a model's preview image. +- Press the "open model url" button to try and search the web and open a model's webpage. ### Download Tab @@ -55,6 +56,7 @@ Designed to support desktop, mobile and multi-screen devices. - Read, edit and save notes. (Saved as a `.txt` file beside the model). - `Ctrl+s` or `⌘+S` to save a note when the textarea is in focus. - Autosave can be enabled in settings. (Note: Once the model info view is closed, the undo history is lost.) + - Automatically search the web for model info and save as notes with a single button. - Change or remove a model's preview image. - View training tags and use the random tag generator to generate prompt ideas. (Inspired by the one in A1111.) diff --git a/__init__.py b/__init__.py index f63cc65..9d38800 100644 --- a/__init__.py +++ b/__init__.py @@ -8,6 +8,8 @@ import copy import importlib import re import base64 +import hashlib +import markdownify from aiohttp import web import server @@ -23,7 +25,7 @@ import folder_paths comfyui_model_uri = folder_paths.models_dir -extension_uri = os.path.dirname(__file__) +extension_uri = os.path.dirname(os.path.abspath(__file__)) config_loader_path = os.path.join(extension_uri, 'config_loader.py') config_loader_spec = importlib.util.spec_from_file_location('config_loader', config_loader_path) @@ -58,7 +60,8 @@ preview_extensions = ( # TODO: JavaScript does not know about this (x2 states) image_extensions + # order matters stable_diffusion_webui_civitai_helper_image_extensions ) -model_info_extension = ".txt" +model_notes_extension = ".txt" +model_info_extension = ".json" #video_extensions = (".avi", ".mp4", ".webm") # TODO: Requires ffmpeg or cv2. Cache preview frame? def split_valid_ext(s, *arg_exts): @@ -71,6 +74,7 @@ def split_valid_ext(s, *arg_exts): _folder_names_and_paths = None # dict[str, tuple[list[str], list[str]]] def folder_paths_folder_names_and_paths(refresh = False): + # TODO: "diffusers" extension whitelist is ["folder"] global _folder_names_and_paths if refresh or _folder_names_and_paths is None: _folder_names_and_paths = {} @@ -189,6 +193,7 @@ def ui_rules(): Rule("model-show-add-button", True, bool), Rule("model-show-copy-button", True, bool), Rule("model-show-load-workflow-button", True, bool), + Rule("model-show-open-model-url-button", False, bool), Rule("model-info-button-on-left", False, bool), Rule("model-buttons-only-on-hover", False, bool), @@ -232,6 +237,7 @@ def get_def_headers(url=""): if url.startswith("https://civitai.com/"): api_key = server_settings["civitai_api_key"] if (api_key != ""): + def_headers["Content-Type"] = f"application/json" def_headers["Authorization"] = f"Bearer {api_key}" url += "&" if "?" in url else "?" # not the most robust solution url += f"token={api_key}" # TODO: Authorization didn't work in the header @@ -243,6 +249,246 @@ def get_def_headers(url=""): return def_headers +def save_web_url(path, url): + with open(path, "w", encoding="utf-8") as f: + f.write(f"[InternetShortcut]\nURL={url}\n") + + +def try_load_web_url(path): + with open(path, "r", encoding="utf-8") as f: + if f.readline() != "[InternetShortcut]\n": return "" + url = f.readline() + if not url.startswith("URL="): return "" + if not url.endswith("\n"): return "" + return url[4:len(url)-1] + + +def hash_file(path, buffer_size=1024*1024): + sha256 = hashlib.sha256() + with open(path, 'rb') as f: + while True: + data = f.read(buffer_size) + if not data: break + sha256.update(data) + return sha256.hexdigest() + + +class Civitai: + IMAGE_URL_SUBDIRECTORY_PREFIX = "https://civitai.com/images/" + IMAGE_URL_DOMAIN_PREFIX = "'https://image.civitai.com/" + + @staticmethod + def image_subdirectory_url_to_image_url(image_url): + url_suffix = image_url[len(Civitai.IMAGE_URL_SUBDIRECTORY_PREFIX):] + image_id = re.search(r"^\d+", url_suffix).group(0) + image_id = str(int(image_id)) + image_info_url = f"https://civitai.com/api/v1/images?imageId={image_id}" + def_headers = get_def_headers(image_info_url) + response = requests.get( + url=image_info_url, + stream=False, + verify=False, + headers=def_headers, + proxies=None, + allow_redirects=False, + ) + if response.ok: + #content_type = response.headers.get("Content-Type") + info = response.json() + items = info["items"] + if len(items) == 0: + raise RuntimeError("Civitai /api/v1/images returned 0 items!") + return items[0]["url"] + else: + raise RuntimeError("Bad response from api/v1/images!") + + @staticmethod + def image_domain_url_full_size(url, width = None): + result = re.search("/width=(\d+)", url) + if width is None: + i0 = result.span()[0] + i1 = result.span()[1] + return url[0:i0] + url[i1:] + else: + w = int(result.group(1)) + return url.replace(str(w), str(width)) + + @staticmethod + def search_by_hash(sha256_hash): + url_api_hash = r"https://civitai.com/api/v1/model-versions/by-hash/" + sha256_hash + hash_response = requests.get(url_api_hash) + if hash_response.status_code != 200: + return {} + return hash_response.json() # model version info + + @staticmethod + def search_by_model_id(model_id): + url_api_model = r"https://civitai.com/api/v1/models/" + str(model_id) + model_response = requests.get(url_api_model) + if model_response.status_code != 200: + return {} + return model_response.json() # model group info + + @staticmethod + def get_model_url(model_version_info): + if len(model_version_info) == 0: return "" + model_id = model_version_info.get("modelId") + if model_id is None: + # there can be incomplete model info, so don't throw just in case + return "" + url = f"https://civitai.com/models/{model_id}" + version_id = model_version_info.get("id") + if version_id is not None: + url += f"?modelVersionId={version_id}" + return url + + @staticmethod + def get_preview_urls(model_version_info, full_size=False): + images = model_version_info.get("images", None) + if images is None: + return [] + preview_urls = [] + for image_info in images: + url = image_info["url"] + if full_size: + url = Civitai.image_domain_url_full_size(url, image_info.get("width", None)) + preview_urls.append(url) + return preview_urls + + @staticmethod + def search_notes(model_version_info): + if len(model_version_info) == 0: + return "" + model_name = None + if "modelId" in model_version_info and "id" in model_version_info: + model_id = model_version_info.get("modelId") + model_version_id = model_version_info.get("id") + + model_version_description = "" + model_trigger_words = [] + model_info = Civitai.search_by_model_id(model_id) + if len(model_info) == 0: # can happen if model download is disabled + print("Model Manager WARNING: Unable to find Civitai 'modelId' " + str(model_id) + ". Try deleting .json file and trying again later!") + return "" + model_name = model_info.get("name") + model_description = model_info.get("description") + for model_version in model_info["modelVersions"]: + if model_version["id"] == model_version_id: + model_version_description = model_version.get("description") + model_trigger_words = model_version.get("trainedWords") + break + elif "description" in model_version_info and "activation text" in model_version_info and "notes" in model_version_info: + # {'description': str, 'sd version': str, 'activation text': str, 'preferred weight': int, 'notes': str} + model_description = model_version_info.get("description") + activation_text = model_version_info.get("activation text") + if activation_text != "": + model_trigger_words = [activation_text] + else: + model_trigger_words = [] + model_version_description = model_version_info.get("notes") + else: + return "" + model_description = model_description if model_description is not None else "" + model_trigger_words = model_trigger_words if model_trigger_words is not None else [] + model_version_description = model_version_description if model_version_description is not None else "" + model_name = model_name if model_name is not None else "Model Description" + + notes = "" + if len(model_trigger_words) > 0: + notes += "# Trigger Words\n\n" + model_trigger_words = [re.sub(",$", "", s.strip()) for s in model_trigger_words] + join_separator = ', ' + for s in model_trigger_words: + if ',' in s: + join_separator = '\n' + break + if join_separator == '\n': + model_trigger_words = ["* " + s for s in model_trigger_words] + notes += join_separator.join(model_trigger_words) + if model_version_description != "": + if len(notes) > 0: notes += "\n\n" + notes += "# About this version\n\n" + notes += markdownify.markdownify(model_version_description) + if model_description != "": + if len(notes) > 0: notes += "\n\n" + notes += "# " + model_name + "\n\n" + notes += markdownify.markdownify(model_description) + return notes.strip() + + +class ModelInfo: + @staticmethod + def search_by_hash(sha256_hash): + model_info = Civitai.search_by_hash(sha256_hash) + if len(model_info) > 0: return model_info + # TODO: search other websites + return {} + + @staticmethod + def try_load_cached(model_path): + model_info_path = os.path.splitext(model_path)[0] + model_info_extension + if os.path.isfile(model_info_path): + with open(model_info_path, "r", encoding="utf-8") as f: + model_info = json.load(f) + return model_info + return {} + + @staticmethod + def get_hash(model_info): + model_info = Civitai.get_hash(model_info) + if len(model_info) > 0: return model_info + # TODO: search other websites + return {} + + @staticmethod + def search_info(model_path, cache=True, use_cached=True): + model_info = ModelInfo.try_load_cached(model_path) + if use_cached and len(model_info) > 0: + return model_info + + sha256_hash = hash_file(model_path) + model_info = ModelInfo.search_by_hash(sha256_hash) + if cache and len(model_info) > 0: + model_info_path = os.path.splitext(model_path)[0] + model_info_extension + with open(model_info_path, "w", encoding="utf-8") as f: + json.dump(model_info, f, indent=4) + print("Saved file: " + model_info_path) + + return model_info + + @staticmethod + def get_url(model_info): + if len(model_info) == 0: + return "" + model_url = Civitai.get_model_url(model_info) + if model_url != "": + return model_url + # TODO: huggingface has / formats + # TODO: support other websites + return "" + + @staticmethod + def search_notes(model_path): + assert(os.path.isfile(model_path)) + model_info = ModelInfo.search_info(model_path, cache=True, use_cached=True) # assume cached is correct; re-download elsewhere + if len(model_info) == 0: + return "" + notes = Civitai.search_notes(model_info) + if len(notes) > 0 and not notes.isspace(): + return notes + # TODO: search other websites + return "" + + @staticmethod + def get_web_preview_urls(model_info, full_size=False): + if len(model_info) == 0: + return [] + preview_urls = Civitai.get_preview_urls(model_info, full_size) + if len(preview_urls) > 0: + return preview_urls + # TODO: support other websites + return [] + @server.PromptServer.instance.routes.get("/model-manager/timestamp") async def get_timestamp(request): return web.json_response({ "timestamp": datetime.now().timestamp() }) @@ -327,9 +573,12 @@ def get_auto_thumbnail_format(original_format): return "JPEG" # default fallback -@server.PromptServer.instance.routes.get("/model-manager/preview/get") +@server.PromptServer.instance.routes.get("/model-manager/preview/get/{uri}") async def get_model_preview(request): - uri = request.query.get("uri") + uri = request.match_info["uri"] + if uri is None: # BUG: this should never happen + print(f"Invalid uri! Request url: {request.url}") + uri = "no-preview" quality = 75 response_image_format = request.query.get("image-format", None) if isinstance(response_image_format, str): @@ -451,42 +700,16 @@ async def get_image_extensions(request): return web.json_response(image_extensions) -def download_model_preview(formdata): - path = formdata.get("path", None) - if type(path) is not str: +def download_model_preview(path, image, overwrite): + if not os.path.isfile(path): raise ValueError("Invalid path!") - path, model_type = search_path_to_system_path(path) - model_type_extensions = folder_paths_get_supported_pt_extensions(model_type) - path_without_extension, _ = split_valid_ext(path, model_type_extensions) + path_without_extension = os.path.splitext(path)[0] - overwrite = formdata.get("overwrite", "true").lower() - overwrite = True if overwrite == "true" else False - - image = formdata.get("image", None) if type(image) is str: - civitai_image_url = "https://civitai.com/images/" - if image.startswith(civitai_image_url): - image_id = re.search(r"^\d+", image[len(civitai_image_url):]).group(0) - image_id = str(int(image_id)) - image_info_url = f"https://civitai.com/api/v1/images?imageId={image_id}" - def_headers = get_def_headers(image_info_url) - response = requests.get( - url=image_info_url, - stream=False, - verify=False, - headers=def_headers, - proxies=None, - allow_redirects=False, - ) - if response.ok: - content_type = response.headers.get("Content-Type") - info = response.json() - items = info["items"] - if len(items) == 0: - raise RuntimeError("Civitai /api/v1/images returned 0 items!") - image = items[0]["url"] - else: - raise RuntimeError("Bad response from api/v1/images!") + if image.startswith(Civitai.IMAGE_URL_SUBDIRECTORY_PREFIX): + image = Civitai.image_subdirectory_url_to_image_url(image) + if image.startswith(Civitai.IMAGE_URL_DOMAIN_PREFIX): + image = Civitai.image_domain_url_full_size(image) _, image_extension = split_valid_ext(image, image_extensions) if image_extension == "": raise ValueError("Invalid image type!") @@ -514,17 +737,23 @@ def download_model_preview(formdata): # detect (and try to fix) wrong file extension image_format = None - with Image.open(image_path) as image: - image_format = image.format - image_dir_and_name, image_ext = os.path.splitext(image_path) - if not image_format_is_equal(image_format, image_ext): - corrected_image_path = image_dir_and_name + "." + image_format.lower() - if os.path.exists(corrected_image_path) and not overwrite: - print("WARNING: '" + image_path + "' has wrong extension!") - else: - os.rename(image_path, corrected_image_path) - print("Saved file: " + corrected_image_path) - image_path = corrected_image_path + try: + with Image.open(image_path) as image: + image_format = image.format + image_dir_and_name, image_ext = os.path.splitext(image_path) + if not image_format_is_equal(image_format, image_ext): + corrected_image_path = image_dir_and_name + "." + image_format.lower() + if os.path.exists(corrected_image_path) and not overwrite: + print("WARNING: '" + image_path + "' has wrong extension!") + else: + os.rename(image_path, corrected_image_path) + print("Saved file: " + corrected_image_path) + image_path = corrected_image_path + except Image.UnidentifiedImageError as e: #TODO: handle case where "image" is actually video + print("WARNING: '" + image_path + "' image format was unknown!") + os.remove(image_path) + print("Deleted file: " + image_path) + image_path = "" return image_path # return in-case need corrected path @@ -532,7 +761,15 @@ def download_model_preview(formdata): async def set_model_preview(request): formdata = await request.post() try: - download_model_preview(formdata) + search_path = formdata.get("path", None) + model_path, model_type = search_path_to_system_path(search_path) + + image = formdata.get("image", None) + + overwrite = formdata.get("overwrite", "true").lower() + overwrite = True if overwrite == "true" else False + + download_model_preview(model_path, image, overwrite) return web.json_response({ "success": True }) except ValueError as e: print(e, file=sys.stderr, flush=True) @@ -710,8 +947,8 @@ async def get_model_list(request): if image is not None: raw_post = os.path.join(model_type, str(base_path_index), rel_path, image) item["preview"] = { - "path": urllib.parse.quote_plus(raw_post), - "dateModified": urllib.parse.quote_plus(str(image_modified)), + "path": raw_post, + "dateModified": str(image_modified), } model_items.append(item) @@ -779,6 +1016,116 @@ async def get_directory_list(request): return web.json_response(dir_list) +def try_download_and_save_model_info(model_file_path): + success = (0, 0, 0) #info, notes, url + head, _ = os.path.splitext(model_file_path) + model_info_path = head + model_info_extension + model_notes_path = head + model_notes_extension + model_url_path = head + ".url" + if os.path.exists(model_info_path) and os.path.exists(model_notes_path) and os.path.exists(model_url_path): + return success + print("Scanning " + model_file_path) + + model_info = {} + model_info = ModelInfo.search_info(model_file_path, cache=True, use_cached=True) + if len(model_info) == 0: + return success + success[0] = 1 + + if not os.path.exists(model_notes_path): + notes = ModelInfo.search_notes(model_file_path) + if not notes.isspace() and notes != "": + try: + with open(model_notes_path, "w", encoding="utf-8") as f: + f.write(notes) + print("Saved file: " + model_notes_path) + success[1] = 1 + except Exception as e: + print(f"Failed to save {model_notes_path}!") + print(e, file=sys.stderr, flush=True) + + if not os.path.exists(model_url_path): + web_url = ModelInfo.get_url(model_info) + if web_url is not None and web_url != "": + try: + save_web_url(model_url_path, web_url) + print("Saved file: " + model_url_path) + success[2] = 1 + except Exception as e: + print(f"Failed to save {model_url_path}!") + print(e, file=sys.stderr, flush=True) + return success + + +@server.PromptServer.instance.routes.post("/model-manager/models/scan") +async def try_scan_download(request): + refresh = request.query.get("refresh", None) is not None + response = { + "success": False, + "infoCount": 0, + "notesCount": 0, + "urlCount": 0, + } + model_paths = folder_paths_folder_names_and_paths(refresh) + for _, (model_dirs, model_extension_whitelist) in model_paths.items(): + for root_dir in model_dirs: + for root, dirs, files in os.walk(root_dir): + for file in files: + file_name, file_extension = os.path.splitext(file) + if file_extension not in model_extension_whitelist: + continue + model_file_path = root + os.path.sep + file + savedInfo, savedNotes, savedUrl = try_download_and_save_model_info(model_file_path) + response["infoCount"] += savedInfo + response["notesCount"] += savedNotes + response["urlCount"] += savedUrl + + response["success"] = True + return web.json_response(response) + +@server.PromptServer.instance.routes.post("/model-manager/preview/scan") +async def try_scan_download_previews(request): + refresh = request.query.get("refresh", None) is not None + response = { + "success": False, + "count": 0, + } + model_paths = folder_paths_folder_names_and_paths(refresh) + for _, (model_dirs, model_extension_whitelist) in model_paths.items(): + for root_dir in model_dirs: + for root, dirs, files in os.walk(root_dir): + for file in files: + file_name, file_extension = os.path.splitext(file) + if file_extension not in model_extension_whitelist: + continue + model_file_path = root + os.path.sep + file + model_file_head = os.path.splitext(model_file_path)[0] + + preview_exists = False + for preview_extension in preview_extensions: + preview_path = model_file_head + preview_extension + if os.path.isfile(preview_path): + preview_exists = True + break + if preview_exists: + continue + + model_info = ModelInfo.try_load_cached(model_file_path) # NOTE: model info must already be downloaded + web_previews = ModelInfo.get_web_preview_urls(model_info, True) + if len(web_previews) == 0: + continue + saved_image_path = download_model_preview( + model_file_path, + image=web_previews[0], + overwrite=False, + ) + if os.path.isfile(saved_image_path): + response["count"] += 1 + + response["success"] = True + return web.json_response(response) + + def download_file(url, filename, overwrite): if not overwrite and os.path.isfile(filename): raise ValueError("File already exists!") @@ -885,13 +1232,13 @@ def bytes_to_size(total_bytes): return "{:.2f}".format(total_bytes / (1 << (i * 10))) + " " + units[i] -@server.PromptServer.instance.routes.get("/model-manager/model/info") -async def get_model_info(request): +@server.PromptServer.instance.routes.get("/model-manager/model/info/{path}") +async def get_model_metadata(request): result = { "success": False } - model_path = request.query.get("path", None) + model_path = request.match_info["path"] if model_path is None: - result["alert"] = "Missing model path!" + result["alert"] = "Invalid model path!" return web.json_response(result) model_path = urllib.parse.unquote(model_path) @@ -900,16 +1247,16 @@ async def get_model_info(request): result["alert"] = "Invalid model path!" return web.json_response(result) - info = {} + data = {} comfyui_directory, name = os.path.split(model_path) - info["File Name"] = name - info["File Directory"] = comfyui_directory - info["File Size"] = bytes_to_size(os.path.getsize(abs_path)) + data["File Name"] = name + data["File Directory"] = comfyui_directory + data["File Size"] = bytes_to_size(os.path.getsize(abs_path)) stats = pathlib.Path(abs_path).stat() date_format = "%Y-%m-%d %H:%M:%S" date_modified = datetime.fromtimestamp(stats.st_mtime).strftime(date_format) - #info["Date Modified"] = date_modified - #info["Date Created"] = datetime.fromtimestamp(stats.st_ctime).strftime(date_format) + #data["Date Modified"] = date_modified + #data["Date Created"] = datetime.fromtimestamp(stats.st_ctime).strftime(date_format) model_extensions = folder_paths_get_supported_pt_extensions(model_type) abs_name , _ = split_valid_ext(abs_path, model_extensions) @@ -919,36 +1266,36 @@ async def get_model_info(request): if os.path.isfile(maybe_preview): preview_path, _ = split_valid_ext(model_path, model_extensions) preview_modified = pathlib.Path(maybe_preview).stat().st_mtime_ns - info["Preview"] = { - "path": urllib.parse.quote_plus(preview_path + extension), - "dateModified": urllib.parse.quote_plus(str(preview_modified)), + data["Preview"] = { + "path": preview_path + extension, + "dateModified": str(preview_modified), } break header = get_safetensor_header(abs_path) metadata = header.get("__metadata__", None) - if metadata is not None and info.get("Preview", None) is None: + if metadata is not None and data.get("Preview", None) is None: thumbnail = metadata.get("modelspec.thumbnail") if thumbnail is not None: i0 = thumbnail.find("/") + 1 i1 = thumbnail.find(";", i0) thumbnail_extension = "." + thumbnail[i0:i1] if thumbnail_extension in image_extensions: - info["Preview"] = { + data["Preview"] = { "path": request.query["path"] + thumbnail_extension, "dateModified": date_modified, } if metadata is not None: - info["Base Training Model"] = metadata.get("ss_sd_model_name", "") - info["Base Model Version"] = metadata.get("ss_base_model_version", "") - info["Network Dimension"] = metadata.get("ss_network_dim", "") - info["Network Alpha"] = metadata.get("ss_network_alpha", "") + data["Base Training Model"] = metadata.get("ss_sd_model_name", "") + data["Base Model Version"] = metadata.get("ss_base_model_version", "") + data["Network Dimension"] = metadata.get("ss_network_dim", "") + data["Network Alpha"] = metadata.get("ss_network_alpha", "") if metadata is not None: training_comment = metadata.get("ss_training_comment", "") - info["Description"] = ( + data["Description"] = ( metadata.get("modelspec.description", "") + "\n\n" + metadata.get("modelspec.usage_hint", "") + @@ -956,12 +1303,17 @@ async def get_model_info(request): training_comment if training_comment != "None" else "" ).strip() - info_text_file = abs_name + model_info_extension + notes_file = abs_name + model_notes_extension notes = "" - if os.path.isfile(info_text_file): - with open(info_text_file, 'r', encoding="utf-8") as f: + if os.path.isfile(notes_file): + with open(notes_file, 'r', encoding="utf-8") as f: notes = f.read() + web_url_file = abs_name + ".url" + web_url = "" + if os.path.isfile(web_url_file): + web_url = try_load_web_url(web_url_file) + if metadata is not None: img_buckets = metadata.get("ss_bucket_info", None) datasets = metadata.get("ss_datasets", None) @@ -983,7 +1335,7 @@ async def get_model_info(request): resolutions[str(x) + "x" + str(y)] = count resolutions = list(resolutions.items()) resolutions.sort(key=lambda x: x[1], reverse=True) - info["Bucket Resolutions"] = resolutions + data["Bucket Resolutions"] = resolutions tags = None if metadata is not None: @@ -997,21 +1349,82 @@ async def get_model_info(request): tags = list(tags.items()) tags.sort(key=lambda x: x[1], reverse=True) + model_info = ModelInfo.try_load_cached(abs_path) + web_previews = ModelInfo.get_web_preview_urls(model_info, True) + result["success"] = True - result["info"] = info + result["info"] = data if metadata is not None: result["metadata"] = metadata if tags is not None: result["tags"] = tags result["notes"] = notes + result["url"] = web_url + result["webPreviews"] = web_previews return web.json_response(result) +@server.PromptServer.instance.routes.get("/model-manager/model/web-url") +async def get_model_web_url(request): + result = { "success": False } + + model_path = request.query.get("path", None) + if model_path is None: + result["alert"] = "Invalid model path!" + return web.json_response(result) + model_path = urllib.parse.unquote(model_path) + + abs_path, model_type = search_path_to_system_path(model_path) + if abs_path is None: + result["alert"] = "Invalid model path!" + return web.json_response(result) + + url_path = os.path.splitext(abs_path)[0] + ".url" + if os.path.isfile(url_path): + web_url = try_load_web_url(url_path) + if web_url != "": + result["success"] = True + return web.json_response({ "url": web_url }) + + model_info = ModelInfo.search_info(abs_path) + if len(model_info) == 0: + result["alert"] = "Unable to find model info!" + return web.json_response(result) + web_url = ModelInfo.get_url(model_info) + if web_url != "" and web_url is not None: + save_web_url(url_path, web_url) + result["success"] = True + + return web.json_response({ "url": web_url }) + + @server.PromptServer.instance.routes.get("/model-manager/system-separator") async def get_system_separator(request): return web.json_response(os.path.sep) +@server.PromptServer.instance.routes.post("/model-manager/model/download/info") +async def download_model_info(request): + result = { "success": False } + + model_path = request.query.get("path", None) + if model_path is None: + result["alert"] = "Missing model path!" + return web.json_response(result) + model_path = urllib.parse.unquote(model_path) + + abs_path, model_type = search_path_to_system_path(model_path) + if abs_path is None: + result["alert"] = "Invalid model path!" + return web.json_response(result) + + model_info = ModelInfo.search_info(abs_path, cache=True, use_cached=False) + if len(model_info) > 0: + result["success"] = True + + return web.json_response(result) + + @server.PromptServer.instance.routes.post("/model-manager/model/download") async def download_model(request): formdata = await request.post() @@ -1026,6 +1439,7 @@ async def download_model(request): result["alert"] = "Invalid save path!" return web.json_response(result) + # download model download_uri = formdata.get("download") if download_uri is None: result["alert"] = "Invalid download url!" @@ -1049,14 +1463,24 @@ async def download_model(request): result["alert"] = "Failed to download model!\n\n" + str(e) return web.json_response(result) + # download model info + model_info = ModelInfo.search_info(file_name, cache=True) # save json + + # save url + url_file_path = os.path.splitext(file_name)[0] + ".url" + url = ModelInfo.get_url(model_info) + if url != "" and url is not None: + save_web_url(url_file_path, url) + + # save image as model preview image = formdata.get("image") if image is not None and image != "": try: - download_model_preview({ - "path": model_path + os.sep + name, - "image": image, - "overwrite": formdata.get("overwrite"), - }) + download_model_preview( + file_name, + image, + formdata.get("overwrite"), + ) except Exception as e: print(e, file=sys.stderr, flush=True) result["alert"] = "Failed to download preview!\n\n" + str(e) @@ -1122,7 +1546,7 @@ async def move_model(request): return web.json_response(result) # TODO: this could overwrite existing files in destination; do a check beforehand? - for extension in preview_extensions + (model_info_extension,): + for extension in preview_extensions + (model_notes_extension,) + (model_info_extension,): old_file = old_file_without_extension + extension if os.path.isfile(old_file): new_file = new_file_without_extension + extension @@ -1176,6 +1600,7 @@ async def delete_model(request): print("Deleted file: " + model_path) delete_same_name_files(path_and_name, preview_extensions) + delete_same_name_files(path_and_name, (model_notes_extension,)) delete_same_name_files(path_and_name, (model_info_extension,)) return web.json_response(result) @@ -1200,7 +1625,7 @@ async def set_notes(request): model_path, model_type = search_path_to_system_path(model_path) model_extensions = folder_paths_get_supported_pt_extensions(model_type) file_path_without_extension, _ = split_valid_ext(model_path, model_extensions) - filename = os.path.normpath(file_path_without_extension + model_info_extension) + filename = os.path.normpath(file_path_without_extension + model_notes_extension) if dt_epoch is not None and os.path.exists(filename) and os.path.getmtime(filename) > dt_epoch: # discard late save @@ -1221,12 +1646,52 @@ async def set_notes(request): except ValueError as e: print(e, file=sys.stderr, flush=True) result["alert"] = "Failed to save notes!\n\n" + str(e) - web.json_response(result) + return web.json_response(result) result["success"] = True return web.json_response(result) +@server.PromptServer.instance.routes.post("/model-manager/notes/download") +async def try_download_notes(request): + result = { "success": False } + + model_path = request.query.get("path", None) + if model_path is None: + result["alert"] = "Missing model path!" + return web.json_response(result) + model_path = urllib.parse.unquote(model_path) + + abs_path, model_type = search_path_to_system_path(model_path) + if abs_path is None: + result["alert"] = "Invalid model path!" + return web.json_response(result) + + overwrite = request.query.get("overwrite", None) + overwrite = not (overwrite == "False" or overwrite == "false" or overwrite == None) + notes_path = os.path.splitext(abs_path)[0] + ".txt" + if not overwrite and os.path.isfile(notes_path): + result["alert"] = "Notes already exist!" + return web.json_response(result) + + notes = ModelInfo.search_notes(abs_path) + if notes.isspace() or notes == "": + result["alert"] = "No notes found!" + return web.json_response(result) + + try: + with open(notes_path, "w", encoding="utf-8") as f: + f.write(notes) + result["success"] = True + except ValueError as e: + print(e, file=sys.stderr, flush=True) + result["alert"] = "Failed to save notes!\n\n" + str(e) + return web.json_response(result) + + result["notes"] = notes + return web.json_response(result) + + WEB_DIRECTORY = "web" NODE_CLASS_MAPPINGS = {} __all__ = ["NODE_CLASS_MAPPINGS"] diff --git a/demo/tab-model-preview-thumbnail-buttons-example.png b/demo/tab-model-preview-thumbnail-buttons-example.png index 8f96ba6..0eef0db 100644 Binary files a/demo/tab-model-preview-thumbnail-buttons-example.png and b/demo/tab-model-preview-thumbnail-buttons-example.png differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..06a83f1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +markdownify \ No newline at end of file diff --git a/web/model-manager.css b/web/model-manager.css index 394185e..07e1051 100644 --- a/web/model-manager.css +++ b/web/model-manager.css @@ -10,7 +10,7 @@ position: fixed; overflow: hidden; width: 100%; - z-index: 1100; + z-index: 1100; /*needs to be below the dialog modal element*/ /*override comfy-modal settings*/ border-radius: 0; @@ -23,6 +23,10 @@ touch-action: manipulation; } +.model-manager .model-manager-dialog { + z-index: 2001; /*needs to be above the model manager element*/ +} + .model-manager .comfy-modal-content { width: 100%; gap: 16px; @@ -249,6 +253,10 @@ user-select: none; } +.model-manager code { + text-wrap: wrap; +} + /* main content */ .model-manager .model-manager-panel { color: var(--fg-color); @@ -409,6 +417,11 @@ border-radius: 8px; } +.model-manager .model-info-container .item { + width: fit-content; + height: 50vh; +} + .model-manager .item img { width: 100%; height: 100%; @@ -416,15 +429,13 @@ border-radius: 8px; } -.model-manager .model-info-container .item { - width: auto; - height: auto; -} -.model-manager .model-info-container .item img { +.model-manager .model-info-container .item img, +.model-manager .model-preview-full { height: auto; width: auto; max-width: 100%; max-height: 50vh; + border-radius: 8px; } .model-manager .model-preview-button-left, diff --git a/web/model-manager.js b/web/model-manager.js index 4a8413b..347aefc 100644 --- a/web/model-manager.js +++ b/web/model-manager.js @@ -104,14 +104,59 @@ const IS_FIREFOX = navigator.userAgent.indexOf('Firefox') > -1; * @param {string} url */ async function loadWorkflow(url) { - const uri = new URL(url).searchParams.get('uri'); - const fileNameIndex = - Math.max(uri.lastIndexOf('/'), uri.lastIndexOf('\\')) + 1; - const fileName = uri.substring(fileNameIndex); - const response = await fetch(url); - const data = await response.blob(); - const file = new File([data], fileName, { type: data.type }); - app.handleFile(file); + const fileName = SearchPath.filename(decodeURIComponent(url)); + const response = await fetch(url); + const data = await response.blob(); + const file = new File([data], fileName, { type: data.type }); + app.handleFile(file); +} + +/** + * @param {string} modelSearchPath + * @returns {Promise} + */ +async function tryGetModelWebUrl(modelSearchPath) { + const encodedPath = encodeURIComponent(modelSearchPath); + const response = await comfyRequest(`/model-manager/model/web-url?path=${encodedPath}`); + const url = response.url; + return url !== undefined && url !== '' ? url : undefined; +} + +/** + * @param {string} url + * @param {string} name + * @returns {boolean} + */ +function tryOpenUrl(url, name="Url") { + try { + new URL(url); + } + catch (exception) { + return false; + } + try { + window.open(url, '_blank').focus(); + } + catch (exception) { + // browser or ad-blocker blocking opening new window + modelManagerDialog.show($el("span", + [ + $el("p", { + style: { color: "var(--input-text)" }, + }, [name]), + $el("a", { + href: url, + target: "_blank", + }, [ + $el("span", [ + url, + $el("i.mdi.mdi-open-in-new"), + ]) + ]), + ] + )); + } + return true; } const modelNodeType = { @@ -215,40 +260,57 @@ class SearchPath { const i2 = path.indexOf(searchSeparator, i1 + 1); return path.slice(i2 + 1).replaceAll(searchSeparator, systemSeparator); } + + /** + * @param {string} s search path or url + * @returns {string} + */ + static filename(s) { + let name = SearchPath.split(s)[1]; + const queryIndex = name.indexOf('?'); + if (queryIndex > -1) { + return name.substring(0, queryIndex); + } + return name; + } } /** - * @param {string | undefined} [searchPath=undefined] + * @param {string | undefined} [imageUriSearchPath=undefined] * @param {string | undefined} [dateImageModified=undefined] * @param {string | undefined} [width=undefined] * @param {string | undefined} [height=undefined] * @param {string | undefined} [imageFormat=undefined] * @returns {string} */ -function imageUri( - imageSearchPath = undefined, - dateImageModified = undefined, - width = undefined, - height = undefined, - imageFormat = undefined, -) { - const path = imageSearchPath ?? 'no-preview'; - const date = dateImageModified; - let uri = `/model-manager/preview/get?uri=${path}`; + function imageUri( + imageUriSearchPath = undefined, + dateImageModified = undefined, + width = undefined, + height = undefined, + imageFormat = undefined, + ) { + const params = []; if (width !== undefined && width !== null) { - uri += `&width=${width}`; + params.push(`width=${width}`); } if (height !== undefined && height !== null) { - uri += `&height=${height}`; + params.push(`height=${height}`); } - if (date !== undefined && date !== null) { - uri += `&v=${date}`; + if (dateImageModified !== undefined && dateImageModified !== null) { + params.push(`v=${dateImageModified}`); } if (imageFormat !== undefined && imageFormat !== null) { - uri += `&image-format=${imageFormat}`; + params.push(`image-format=${imageFormat}`); + } + + const path = imageUriSearchPath ?? 'no-preview'; + const uri = `/model-manager/preview/get/${path}`; + if (params.length > 0) { + return uri + '?' + params.join('&'); } return uri; -} + } const PREVIEW_NONE_URI = imageUri(); /** @@ -594,6 +656,7 @@ class ImageSelect { /** @type {HTMLImageElement} */ defaultPreviewNoImage: null, /** @type {HTMLDivElement} */ defaultPreviews: null, /** @type {HTMLDivElement} */ defaultUrl: null, + /** @type {HTMLDivElement} */ previewButtons: null, /** @type {HTMLImageElement} */ customUrlPreview: null, /** @type {HTMLInputElement} */ customUrl: null, @@ -658,11 +721,15 @@ class ImageSelect { case this.#PREVIEW_NONE: return PREVIEW_NONE_URI; } - return ''; + console.warn(`Invalid preview select type: ${value}`); + return PREVIEW_NONE_URI; } - /** @returns {void} */ - resetModelInfoPreview() { + /** + * @param {String[]} defaultPreviewUrls + * @returns {void} + */ + resetModelInfoPreview(defaultPreviewUrls = []) { let noimage = this.elements.defaultUrl.dataset.noimage; [ this.elements.defaultPreviewNoImage, @@ -681,6 +748,18 @@ class ImageSelect { el.src = PREVIEW_NONE_URI; } }); + const defaultPreviews = this.elements.defaultPreviews; + defaultPreviews.innerHTML = ''; + if (defaultPreviewUrls.length > 0) { + ImageSelect.generateDefaultPreviews(defaultPreviewUrls).forEach(previewElement => { + defaultPreviews.appendChild(previewElement); + }); + } + else { + const defaultImage = ImageSelect.generateDefaultPreviews([PREVIEW_NONE_URI]); + defaultPreviews.appendChild(defaultImage[0]); + } + this.elements.previewButtons.style.display = defaultPreviewUrls.length > 1 ? 'block' : 'none'; this.checkDefault(); this.elements.uploadFile.value = ''; this.elements.customUrl.value = ''; @@ -741,6 +820,28 @@ class ImageSelect { children[currentIndex].style.display = 'block'; } + /** + * @param {string[]|undefined} defaultPreviewUrls + * @returns {HTMLImageElement[]} + */ + static generateDefaultPreviews(defaultPreviewUrls) { + const imgs = defaultPreviewUrls.map((url) => { + return $el('img', { + loading: + 'lazy' /* `loading` BEFORE `src`; Known bug in Firefox 124.0.2 and Safari for iOS 17.4.1 (https://stackoverflow.com/a/76252772) */, + src: url, + style: { display: 'none' }, + onerror: (e) => { + e.target.src = PREVIEW_NONE_URI; + }, + }); + }); + if (imgs.length > 0) { + imgs[0].style.display = 'block'; + } + return imgs; + } + /** * @param {string} radioGroupName - Should be unique for every radio group. * @param {string[]|undefined} defaultPreviews @@ -778,23 +879,7 @@ class ImageSelect { height: '100%', }, }, - (() => { - const imgs = defaultPreviews.map((url) => { - return $el('img', { - loading: - 'lazy' /* `loading` BEFORE `src`; Known bug in Firefox 124.0.2 and Safari for iOS 17.4.1 (https://stackoverflow.com/a/76252772) */, - src: url, - style: { display: 'none' }, - onerror: (e) => { - e.target.src = el_defaultUri.dataset.noimage ?? PREVIEW_NONE_URI; - }, - }); - }); - if (imgs.length > 0) { - imgs[0].style.display = 'block'; - } - return imgs; - })(), + ImageSelect.generateDefaultPreviews(defaultPreviews), ); const el_uploadPreview = $el('img', { @@ -904,6 +989,7 @@ class ImageSelect { const el_previewButtons = $el( 'div.model-preview-overlay', { + $: (el) => (this.elements.previewButtons = el), style: { display: el_defaultPreviews.children.length > 1 ? 'block' : 'none', }, @@ -1828,6 +1914,7 @@ class ModelGrid { const widgetIndex = ModelGrid.modelWidgetIndex(nodeType); const target = selectedNode?.widgets[widgetIndex]?.element; if (target && target.type === 'textarea') { + // TODO: If the node has >1 text areas, the textarea element must be selected target.value = ModelGrid.insertEmbeddingIntoText( target.value, embeddingFile, @@ -1837,7 +1924,7 @@ class ModelGrid { } } if (!success) { - console.warn('Try selecting a node before adding the embedding.'); + window.alert('No selected nodes have a text area!'); } event.stopPropagation(); } @@ -2022,13 +2109,16 @@ class ModelGrid { // TODO: separate text and model logic; getting too messy // TODO: fallback on button failure to copy text? const canShowButtons = modelNodeType[modelType] !== undefined; + const shouldShowTryOpenModelUrl = + canShowButtons && + settingsElements['model-show-open-model-url-button'].checked; + const showLoadWorkflowButton = + canShowButtons && + settingsElements['model-show-load-workflow-button'].checked; const showAddButton = canShowButtons && settingsElements['model-show-add-button'].checked; const showCopyButton = canShowButtons && settingsElements['model-show-copy-button'].checked; - const showLoadWorkflowButton = - canShowButtons && - settingsElements['model-show-load-workflow-button'].checked; const strictDragToAdd = settingsElements['model-add-drag-strict-on-field'].checked; const addOffset = parseInt(settingsElements['model-add-offset'].value); @@ -2096,8 +2186,8 @@ class ModelGrid { loading: 'lazy' /* `loading` BEFORE `src`; Known bug in Firefox 124.0.2 and Safari for iOS 17.4.1 (https://stackoverflow.com/a/76252772) */, src: imageUri( - previewInfo?.path, - previewInfo?.dateModified, + previewInfo?.path ? encodeURIComponent(previewInfo.path) : undefined, + previewInfo?.dateModified ? encodeURIComponent(previewInfo.dateModified) : undefined, previewThumbnailWidth, previewThumbnailHeight, previewThumbnailFormat, @@ -2111,23 +2201,47 @@ class ModelGrid { systemSeparator, ); let actionButtons = []; - if (showCopyButton) { + if (shouldShowTryOpenModelUrl) { actionButtons.push( - new ComfyButton({ - icon: 'content-copy', - tooltip: 'Copy model to clipboard', - classList: 'comfyui-button icon-button model-button', - action: (e) => - ModelGrid.#copyModelToClipboard( - e, - modelType, - path, - removeEmbeddingExtension, - ), - }).element, + new ComfyButton({ + icon: 'open-in-new', + tooltip: 'Attempt to open model url page in a new tab.', + classList: 'comfyui-button icon-button model-button', + action: async (e) => { + const [button, icon, span] = comfyButtonDisambiguate(e.target); + button.disabled = true; + const webUrl = await tryGetModelWebUrl(searchPath); + const success = tryOpenUrl(webUrl, searchPath); + comfyButtonAlert(e.target, success, 'mdi-check-bold', 'mdi-close-thick'); + button.disabled = false; + }, + }).element ); - } - if (showAddButton && !(modelType === 'embeddings' && !navigator.clipboard)) { + } + if (showLoadWorkflowButton) { + actionButtons.push( + new ComfyButton({ + icon: 'arrow-bottom-left-bold-box-outline', + tooltip: 'Load preview workflow', + classList: 'comfyui-button icon-button model-button', + action: async (e) => { + const urlString = previewThumbnail.src; + const url = new URL(urlString); + const urlSearchParams = url.searchParams; + const uri = urlSearchParams.get('uri'); + const v = urlSearchParams.get('v'); + const urlFull = + urlString.substring(0, urlString.indexOf('?')) + + '?uri=' + + uri + + '&v=' + + v; + await loadWorkflow(urlFull); + }, + }).element, + ); + } + if (showAddButton) { actionButtons.push( new ComfyButton({ icon: 'plus-box-outline', @@ -2144,37 +2258,36 @@ class ModelGrid { }).element, ); } - if (showLoadWorkflowButton) { - actionButtons.push( - new ComfyButton({ - icon: 'arrow-bottom-left-bold-box-outline', - tooltip: 'Load preview workflow', - classList: 'comfyui-button icon-button model-button', - action: async (e) => { - const urlString = previewThumbnail.src; - const url = new URL(urlString); - const urlSearchParams = url.searchParams; - const uri = urlSearchParams.get('uri'); - const v = urlSearchParams.get('v'); - const urlFull = - urlString.substring(0, urlString.indexOf('?')) + - '?uri=' + - uri + - '&v=' + - v; - await loadWorkflow(urlFull); - }, - }).element, - ); + if ( + showCopyButton && + !(modelType === 'embeddings' && !navigator.clipboard) + ) { + actionButtons.push( + new ComfyButton({ + icon: 'content-copy', + tooltip: 'Copy model to clipboard', + classList: 'comfyui-button icon-button model-button', + action: (e) => + ModelGrid.#copyModelToClipboard( + e, + modelType, + path, + removeEmbeddingExtension, + ), + }).element, + ); } const infoButtons = [ new ComfyButton({ icon: 'information-outline', tooltip: 'View model information', classList: 'comfyui-button icon-button model-button', - action: async () => { + action: async(e) => { + const [button, icon, span] = comfyButtonDisambiguate(e.target); + button.disabled = true; await showModelInfo(searchPath); - }, + button.disabled = false; + }, }).element, ]; return $el('div.item', {}, [ @@ -2264,14 +2377,21 @@ class ModelGrid { previousModelType.value = modelType; } - let modelTypeOptions = []; - for (const [key, value] of Object.entries(models)) { - const el = $el('option', [key]); - modelTypeOptions.push(el); - } - modelSelect.innerHTML = ''; - modelTypeOptions.forEach((option) => modelSelect.add(option)); - modelSelect.value = modelType; + let modelTypeOptions = []; + for (const key of Object.keys(models)) { + const el = $el('option', [key]); + modelTypeOptions.push(el); + } + modelTypeOptions.sort((a, b) => + a.innerText.localeCompare( + b.innerText, + undefined, + {sensitivity : 'base'}, + ) + ); + modelSelect.innerHTML = ""; + modelTypeOptions.forEach(option => modelSelect.add(option)); + modelSelect.value = modelType; const searchAppend = settings['model-search-always-append'].value; const searchText = modelFilter.value + ' ' + searchAppend; @@ -2313,13 +2433,18 @@ class ModelInfo { /** @type {[HTMLElement][]} */ #settingsElements = null; + /** @type {() -> Promise} */ + #tryHideModelInfo = () => {}; + /** * @param {ModelData} modelData * @param {(withoutComfyRefresh?: boolean) => Promise} updateModels * @param {any} settingsElements + * @param {() => Promise} tryHideModelInfo */ - constructor(modelData, updateModels, settingsElements) { + constructor(modelData, updateModels, settingsElements, tryHideModelInfo) { this.#settingsElements = settingsElements; + this.#tryHideModelInfo = tryHideModelInfo; const moveDestinationInput = $el('input.search-text-area', { name: 'move directory', autocomplete: 'off', @@ -2405,11 +2530,6 @@ class ModelInfo { }, }).element; this.elements.setPreviewButton = setPreviewButton; - previewSelect.elements.radioButtons.addEventListener('change', (e) => { - setPreviewButton.style.display = previewSelect.defaultIsChecked() - ? 'none' - : 'block'; - }); this.element = $el( 'div', @@ -2542,9 +2662,9 @@ class ModelInfo { tabContent: this.element, }, { - name: 'Metadata', - icon: 'file-document-outline', - tabContent: $el('div', ['Metadata']), + name: 'Notes', + icon: 'pencil-outline', + tabContent: $el('div', ['Notes']), }, { name: 'Tags', @@ -2552,9 +2672,9 @@ class ModelInfo { tabContent: $el('div', ['Tags']), }, { - name: 'Notes', - icon: 'pencil-outline', - tabContent: $el('div', ['Notes']), + name: 'Metadata', + icon: 'file-document-outline', + tabContent: $el('div', ['Metadata']), }, ]); } @@ -2625,8 +2745,8 @@ class ModelInfo { */ async update(searchPath, updateModels, searchSeparator) { const path = encodeURIComponent(searchPath); - const [info, metadata, tags, noteText] = await comfyRequest( - `/model-manager/model/info?path=${path}`, + const [info, metadata, tags, noteText, url, webPreviews] = await comfyRequest( + `/model-manager/model/info/${path}`, ) .then((result) => { const success = result['success']; @@ -2642,6 +2762,8 @@ class ModelInfo { result['metadata'], result['tags'], result['notes'], + result['url'], + result['webPreviews'], ]; }) .catch((err) => { @@ -2748,38 +2870,131 @@ class ModelInfo { const previewSelect = this.previewSelect; const defaultUrl = previewSelect.elements.defaultUrl; if (info['Preview']) { - const imagePath = info['Preview']['path']; - const imageDateModified = info['Preview']['dateModified']; + const imagePath = encodeURIComponent(info['Preview']['path']); + const imageDateModified = encodeURIComponent(info['Preview']['dateModified']); defaultUrl.dataset.noimage = imageUri(imagePath, imageDateModified); } else { defaultUrl.dataset.noimage = PREVIEW_NONE_URI; } - previewSelect.resetModelInfoPreview(); - const setPreviewButton = this.elements.setPreviewButton; - setPreviewButton.style.display = previewSelect.defaultIsChecked() - ? 'none' - : 'block'; + previewSelect.resetModelInfoPreview(webPreviews); + const setPreviewDiv = $el('div.row.tab-header', { + style: { + display: "none" + } + }, [ + $el('div.row.tab-header-flex-block', [ + previewSelect.elements.radioGroup, + ]), + $el('div.row.tab-header-flex-block', [ + this.elements.setPreviewButton, + ]), + ]); + previewSelect.elements.previews.style.display = "none"; + + let previewUri; + if (info['Preview']) { + const imagePath = encodeURIComponent(info['Preview']['path']); + const imageDateModified = encodeURIComponent(info['Preview']['dateModified']); + previewUri = imageUri(imagePath, imageDateModified); + } else { + previewUri = PREVIEW_NONE_URI; + } + const previewImage = $el('img.model-preview-full', { + loading: + 'lazy' /* `loading` BEFORE `src`; Known bug in Firefox 124.0.2 and Safari for iOS 17.4.1 (https://stackoverflow.com/a/76252772) */, + src: previewUri, + }); innerHtml.push( $el('div', [ - previewSelect.elements.previews, - $el('div.row.tab-header', [ - $el('div', [ - new ComfyButton({ - content: 'Load Workflow', - tooltip: 'Attempt to load preview image workflow', - action: async () => { - const urlString = - previewSelect.elements.defaultPreviews.children[0].src; - await loadWorkflow(urlString); - }, - }).element, - ]), - $el('div.row.tab-header-flex-block', [ - previewSelect.elements.radioGroup, - ]), - $el('div.row.tab-header-flex-block', [setPreviewButton]), + $el('div.row.tab-header', { style: { "flex-direction": "row" } }, [ + new ComfyButton({ + icon: 'arrow-bottom-left-bold-box-outline', + tooltip: 'Attempt to load preview image workflow', + classList: 'comfyui-button icon-button', + action: async () => { + await loadWorkflow(previewImage.src); + }, + }).element, + new ComfyButton({ + icon: 'open-in-new', + tooltip: 'Attempt to open model url page in a new tab.', + classList: 'comfyui-button icon-button', + action: async (e) => { + const [button, icon, span] = comfyButtonDisambiguate(e.target); + button.disabled = true; + let webUrl; + if (url !== undefined && url !== "") { + webUrl = url; + } + else { + webUrl = await tryGetModelWebUrl(searchPath); + } + const success = tryOpenUrl(webUrl, searchPath); + comfyButtonAlert(e.target, success, "mdi-check-bold", "mdi-close-thick"); + button.disabled = false; + }, + }).element, + new ComfyButton({ + icon: 'earth-arrow-down', + tooltip: 'Hash model and try to download model info.', + classList: 'comfyui-button icon-button', + action: async(e) => { + const confirm = window.confirm('Overwrite model info?'); + if (!confirm) { + comfyButtonAlert(e.target, false, 'mdi-check-bold', 'mdi-close-thick'); + return; + } + const [button, icon, span] = comfyButtonDisambiguate(e.target); + button.disabled = true; + const success = await comfyRequest( + `/model-manager/model/download/info?path=${path}`, + { + method: 'POST', + body: {}, + } + ).then((data) => { + const success = data['success']; + const message = data['alert']; + if (message !== undefined) { + window.alert(message); + } + return success; + }).catch((err) => { + return false; + }); + if (success) { + this.#tryHideModelInfo(); + } + comfyButtonAlert(e.target, success, 'mdi-check-bold', 'mdi-close-thick'); + button.disabled = false; + }, + }).element, + new ComfyButton({ + icon: 'image-edit-outline', + tooltip: 'Open preview edit dialog.', + classList: 'comfyui-button icon-button', + action: () => { + // TODO: toggle button border highlight + if (previewImage.style.display === "none") { + setPreviewDiv.style.display = "none"; + previewSelect.elements.previews.style.display = "none"; + previewImage.style.display = ""; + } + else { + previewImage.style.display = "none"; + previewSelect.elements.previews.style.display = ""; + setPreviewDiv.style.display = ""; + if (previewSelect.elements.defaultPreviews.children[0].src.includes(PREVIEW_NONE_URI)) { + window.alert("No model previews found!\nTry downloading model info first!"); + } + } + }, + }).element, ]), + previewImage, + previewSelect.elements.previews, + setPreviewDiv, $el('h2', ['File Info:']), $el( 'div', @@ -2822,40 +3037,163 @@ class ModelInfo { infoHtml.append.apply(infoHtml, innerHtml); // TODO: set default value of dropdown and value to model type? - /** @type {HTMLDivElement} */ - const metadataElement = this.elements.tabContents[1]; // TODO: remove magic value - const isMetadata = - typeof metadata === 'object' && - metadata !== null && - Object.keys(metadata).length > 0; - metadataElement.innerHTML = ''; - metadataElement.append.apply(metadataElement, [ - $el('h1', ['Metadata']), - $el( - 'div', - (() => { - const tableRows = []; - if (isMetadata) { - for (const [key, value] of Object.entries(metadata)) { - if (value === undefined || value === null) { - continue; + // + // NOTES + // + + const saveIcon = 'content-save'; + const savingIcon = 'cloud-upload-outline'; + + const saveNotesButton = new ComfyButton({ + icon: saveIcon, + tooltip: 'Save note', + classList: 'comfyui-button icon-button', + action: async (e) => { + const [button, icon, span] = comfyButtonDisambiguate(e.target); + button.disabled = true; + const saved = await this.trySave(false); + comfyButtonAlert(e.target, saved); + button.disabled = false; + }, + }).element; + + const downloadNotesButton = new ComfyButton({ + icon: 'earth-arrow-down', + tooltip: 'Attempt to download model info from the internet.', + classList: 'comfyui-button icon-button', + action: async (e) => { + if (this.#savedNotesValue !== '') { + const overwriteNoteConfirmation = window.confirm('Overwrite note?'); + if (!overwriteNoteConfirmation) { + comfyButtonAlert(e.target, false, 'mdi-check-bold', 'mdi-close-thick'); + return; } - if (value !== '') { - tableRows.push( - $el('tr', [ - $el('th.model-metadata-key', [key]), - $el('th.model-metadata-value', [value]), - ]), - ); - } - } } - return $el('table.model-metadata', tableRows); - })(), - ), - ]); - const metadataButton = this.elements.tabButtons[1]; // TODO: remove magic value - metadataButton.style.display = isMetadata ? '' : 'none'; + + const [button, icon, span] = comfyButtonDisambiguate(e.target); + button.disabled = true; + const [success, downloadedNotesValue] = await comfyRequest( + `/model-manager/notes/download?path=${path}&overwrite=True`, + { + method: 'POST', + body: {}, + } + ).then((data) => { + const success = data['success']; + const message = data['alert']; + if (message !== undefined) { + window.alert(message); + } + return [success, data['notes']]; + }).catch((err) => { + return [false, '']; + }); + if (success) { + this.#savedNotesValue = downloadedNotesValue; + this.elements.notes.value = downloadedNotesValue; + this.elements.markdown.innerHTML = marked.parse(downloadedNotesValue); + } + comfyButtonAlert(e.target, success, 'mdi-check-bold', 'mdi-close-thick'); + button.disabled = false; + }, + }).element; + + const saveDebounce = debounce(async () => { + const saveIconClass = 'mdi-' + saveIcon; + const savingIconClass = 'mdi-' + savingIcon; + const iconElement = saveNotesButton.getElementsByTagName('i')[0]; + iconElement.classList.remove(saveIconClass); + iconElement.classList.add(savingIconClass); + const saved = await this.trySave(false); + iconElement.classList.remove(savingIconClass); + iconElement.classList.add(saveIconClass); + }, 1000); + + /** @type {HTMLDivElement} */ + const notesElement = this.elements.tabContents[1]; // TODO: remove magic value + notesElement.innerHTML = ''; + const markdown = $el('div', {}, ''); + markdown.innerHTML = marked.parse(noteText); + + notesElement.append.apply( + notesElement, + (() => { + const notes = $el('textarea.comfy-multiline-input', { + name: 'model notes', + value: noteText, + oninput: (e) => { + if (this.#settingsElements['model-info-autosave-notes'].checked) { + saveDebounce(); + } + }, + }); + + if (navigator.userAgent.includes('Mac')) { + new KeyComboListener(['MetaLeft', 'KeyS'], saveDebounce, notes); + new KeyComboListener(['MetaRight', 'KeyS'], saveDebounce, notes); + } else { + new KeyComboListener(['ControlLeft', 'KeyS'], saveDebounce, notes); + new KeyComboListener(['ControlRight', 'KeyS'], saveDebounce, notes); + } + + this.elements.notes = notes; + this.elements.markdown = markdown; + this.#savedNotesValue = noteText; + + const notesEditor = $el( + 'div', + { + style: { + display: noteText == '' ? 'flex' : 'none', + height: '100%', + 'min-height': '60px', + }, + }, + notes, + ); + const notesViewer = $el( + 'div', + { + style: { + display: noteText == '' ? 'none' : 'flex', + height: '100%', + 'min-height': '60px', + overflow: 'scroll', + 'overflow-wrap': 'anywhere', + }, + }, + markdown, + ); + + const editNotesButton = new ComfyButton({ + icon: 'pencil', + tooltip: 'Change file name', + classList: 'comfyui-button icon-button', + action: async () => { + notesEditor.style.display = + notesEditor.style.display == 'flex' ? 'none' : 'flex'; + notesViewer.style.display = + notesViewer.style.display == 'none' ? 'flex' : 'none'; + }, + }).element; + + return [ + $el( + 'div.row', + { + style: { 'align-items': 'center' }, + }, + [$el('h1', ['Notes']), downloadNotesButton, saveNotesButton, editNotesButton], + ), + notesEditor, + notesViewer, + ]; + })(), + ); + + // + // TAGS + // /** @type {HTMLDivElement} */ const tagsElement = this.elements.tabContents[2]; // TODO: remove magic value @@ -2969,114 +3307,44 @@ class ModelInfo { const tagButton = this.elements.tabButtons[2]; // TODO: remove magic value tagButton.style.display = isTags ? '' : 'none'; - const saveIcon = 'content-save'; - const savingIcon = 'cloud-upload-outline'; - - const saveNotesButton = new ComfyButton({ - icon: saveIcon, - tooltip: 'Save note', - classList: 'comfyui-button icon-button', - action: async (e) => { - const [button, icon, span] = comfyButtonDisambiguate(e.target); - button.disabled = true; - const saved = await this.trySave(false); - comfyButtonAlert(e.target, saved); - button.disabled = false; - }, - }).element; - - const saveDebounce = debounce(async () => { - const saveIconClass = 'mdi-' + saveIcon; - const savingIconClass = 'mdi-' + savingIcon; - const iconElement = saveNotesButton.getElementsByTagName('i')[0]; - iconElement.classList.remove(saveIconClass); - iconElement.classList.add(savingIconClass); - const saved = await this.trySave(false); - iconElement.classList.remove(savingIconClass); - iconElement.classList.add(saveIconClass); - }, 1000); + // + // METADATA + // /** @type {HTMLDivElement} */ - const notesElement = this.elements.tabContents[3]; // TODO: remove magic value - notesElement.innerHTML = ''; - const markdown = $el('div', {}, ''); - markdown.innerHTML = marked.parse(noteText); - - notesElement.append.apply( - notesElement, - (() => { - const notes = $el('textarea.comfy-multiline-input', { - name: 'model notes', - value: noteText, - oninput: (e) => { - if (this.#settingsElements['model-info-autosave-notes'].checked) { - saveDebounce(); + const metadataElement = this.elements.tabContents[3]; // TODO: remove magic value + const isMetadata = + typeof metadata === 'object' && + metadata !== null && + Object.keys(metadata).length > 0; + metadataElement.innerHTML = ''; + metadataElement.append.apply(metadataElement, [ + $el('h1', ['Metadata']), + $el( + 'div', + (() => { + const tableRows = []; + if (isMetadata) { + for (const [key, value] of Object.entries(metadata)) { + if (value === undefined || value === null) { + continue; + } + if (value !== '') { + tableRows.push( + $el('tr', [ + $el('th.model-metadata-key', [key]), + $el('th.model-metadata-value', [value]), + ]), + ); + } } - }, - }); - - if (navigator.userAgent.includes('Mac')) { - new KeyComboListener(['MetaLeft', 'KeyS'], saveDebounce, notes); - new KeyComboListener(['MetaRight', 'KeyS'], saveDebounce, notes); - } else { - new KeyComboListener(['ControlLeft', 'KeyS'], saveDebounce, notes); - new KeyComboListener(['ControlRight', 'KeyS'], saveDebounce, notes); - } - - this.elements.notes = notes; - this.elements.markdown = markdown; - this.#savedNotesValue = noteText; - - const notes_editor = $el( - 'div', - { - style: { - display: noteText == '' ? 'flex' : 'none', - height: '100%', - 'min-height': '60px', - }, - }, - notes, - ); - const notes_viewer = $el( - 'div', - { - style: { - display: noteText == '' ? 'none' : 'flex', - height: '100%', - 'min-height': '60px', - overflow: 'scroll', - 'overflow-wrap': 'anywhere', - }, - }, - markdown, - ); - - const editNotesButton = new ComfyButton({ - icon: 'pencil', - tooltip: 'Change file name', - classList: 'comfyui-button icon-button', - action: async () => { - notes_editor.style.display = - notes_editor.style.display == 'flex' ? 'none' : 'flex'; - notes_viewer.style.display = - notes_viewer.style.display == 'none' ? 'flex' : 'none'; - }, - }).element; - - return [ - $el( - 'div.row', - { - style: { 'align-items': 'center' }, - }, - [$el('h1', ['Notes']), saveNotesButton, editNotesButton], - ), - notes_editor, - notes_viewer, - ]; - })(), - ); + } + return $el('table.model-metadata', tableRows); + })(), + ), + ]); + const metadataButton = this.elements.tabButtons[3]; // TODO: remove magic value + metadataButton.style.display = isMetadata ? '' : 'none'; } static UniformTagSampling( @@ -3726,11 +3994,7 @@ class DownloadView { onkeydown: async (e) => { if (e.key === 'Enter') { e.stopPropagation(); - if (this.elements.url.value === '') { - reset(); - } else { - await update(); - } + searchButton.click(); e.target.blur(); } }, @@ -4375,6 +4639,7 @@ class SettingsView { /** @type {HTMLInputElement} */ 'model-show-add-button': null, /** @type {HTMLInputElement} */ 'model-show-copy-button': null, /** @type {HTMLInputElement} */ 'model-show-load-workflow-button': null, + /** @type {HTMLInputElement} */ 'model-show-open-model-url-button': null, /** @type {HTMLInputElement} */ 'model-info-button-on-left': null, /** @type {HTMLInputElement} */ 'model-buttons-only-on-hover': null, @@ -4402,7 +4667,7 @@ class SettingsView { }; /** @return {(withoutComfyRefresh?: boolean) => Promise} */ - #updateModels = () => {}; + #updateModels = async () => {}; /** @return {() => void} */ #updateSidebarSettings = () => {}; @@ -4544,7 +4809,7 @@ class SettingsView { this.elements.saveButton = saveButton; const correctPreviewsButton = new ComfyButton({ - content: 'Fix Extensions', + content: 'Fix Preview Extensions', tooltip: 'Correct image file extensions in all model directories', action: async (e) => { const [button, icon, span] = comfyButtonDisambiguate(e.target); @@ -4569,6 +4834,70 @@ class SettingsView { }, }).element; + const scanDownloadModelInfosButton = new ComfyButton({ + content: 'Download Model Info', + tooltip: 'Scans all model files and tries to download and save model info, notes and urls.', + action: async (e) => { + const confirmation = window.confirm( + 'WARNING: This may take a while and generate MANY server requests!\nUSE AT YOUR OWN RISK!', + ); + if (!confirmation) { + return; + } + + const [button, icon, span] = comfyButtonDisambiguate(e.target); + button.disabled = true; + const data = await comfyRequest('/model-manager/models/scan', { + method: 'POST', + body: JSON.stringify({}), + }).catch((err) => { + return { success: false }; + }); + const success = data['success']; + const successMessage = success ? "Scan Finished!" : "Scan Failed!"; + const infoCount = data['infoCount']; + const notesCount = data['notesCount']; + const urlCount = data['urlCount']; + window.alert(`${successMessage}\nScanned: ${infoCount}\nSaved Notes: ${notesCount}\nSaved Url: ${urlCount}`); + comfyButtonAlert(e.target, success); + if (infoCount > 0 || notesCount > 0 || urlCount > 0) { + await this.reload(true); + } + button.disabled = false; + }, + }).element; + + const scanDownloadPreviewsButton = new ComfyButton({ + content: 'Download Missing Previews', + tooltip: 'Downloads missing model previews from model info.\nRun model info scan first!', + action: async (e) => { + const confirmation = window.confirm( + 'WARNING: This may take a while and generate MANY server requests!\nUSE AT YOUR OWN RISK!', + ); + if (!confirmation) { + return; + } + + const [button, icon, span] = comfyButtonDisambiguate(e.target); + button.disabled = true; + const data = await comfyRequest('/model-manager/preview/scan', { + method: 'POST', + body: JSON.stringify({}), + }).catch((err) => { + return { success: false }; + }); + const success = data['success']; + const successMessage = success ? "Scan Finished!" : "Scan Failed!"; + const count = data['count']; + window.alert(`${successMessage}\nPreviews Downloaded: ${count}`); + comfyButtonAlert(e.target, success); + if (count > 0) { + await this.reload(true); + } + button.disabled = false; + }, + }).element; + $el( 'div.model-manager-settings', { @@ -4618,11 +4947,6 @@ class SettingsView { 'vae_approx', ], }), - $select({ - $: (el) => (settings['sidebar-default-state'] = el), - textContent: 'Default model manager position (on start up)', - options: ['Left', 'Right', 'Top', 'Bottom', 'None'], - }), $checkbox({ $: (el) => (settings['model-real-time-search'] = el), textContent: 'Real-time search', @@ -4686,6 +5010,9 @@ class SettingsView { $checkbox({ $: (el) => (settings['model-show-load-workflow-button'] = el), textContent: 'Show "Load Workflow" button', + }),$checkbox({ + $: (el) => (settings['model-show-open-model-url-button'] = el), + textContent: 'Show "Open Model Url" button', }), $checkbox({ $: (el) => (settings['model-info-button-on-left'] = el), @@ -4724,6 +5051,11 @@ class SettingsView { textContent: 'Save notes by default.', }), $el('h2', ['Window']), + $select({ + $: (el) => (settings['sidebar-default-state'] = el), + textContent: 'Default model manager position (on start up)', + options: ['None', 'Left', 'Bottom', 'Top', 'Right'], + }), sidebarControl, $el('label', [ 'Sidebar width (on start up)', @@ -4757,16 +5089,18 @@ class SettingsView { $: (el) => (settings['text-input-always-hide-clear-button'] = el), textContent: 'Always hide "Clear Search" buttons.', }), - $el('h2', ['Model Preview Images']), + $el('h2', ['Scan Files']), $el('div', [correctPreviewsButton]), + $el('div', [scanDownloadModelInfosButton]), + $el('div', [scanDownloadPreviewsButton]), $el('h2', ['Random Tag Generator']), $select({ $: (el) => (settings['tag-generator-sampler-method'] = el), - textContent: 'Default sampling method', + textContent: 'Sampling method', options: ['Frequency', 'Uniform'], }), $el('label', [ - 'Default count', + 'Generation count', $el('input', { $: (el) => (settings['tag-generator-count'] = el), type: 'number', @@ -4776,7 +5110,7 @@ class SettingsView { }), ]), $el('label', [ - 'Default minimum threshold', + 'Minimum frequency threshold', $el('input', { $: (el) => (settings['tag-generator-threshold'] = el), type: 'number', @@ -5027,6 +5361,7 @@ class ModelManager extends ComfyDialog { this.#modelData, this.#refreshModels, this.#settingsView.elements.settings, + () => this.#tryHideModelInfo(), ); this.#browseView = new BrowseView( @@ -5587,14 +5922,21 @@ class ModelManager extends ComfyDialog { /** @type {ModelManager | undefined} */ let instance; +/** @type {ComfyDialog | undefined} */ +let modelManagerDialog; + /** * @returns {ModelManager} */ function getInstance() { - if (!instance) { - instance = new ModelManager(); - } - return instance; + if (!instance) { + instance = new ModelManager(); + + modelManagerDialog = new ComfyDialog(); + modelManagerDialog.element.classList.add("model-manager-dialog"); + instance.element.appendChild(modelManagerDialog.element); + } + return instance; } const toggleModelManager = () => {