diff --git a/README.md b/README.md index 2e3f391..41626f7 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,6 @@ 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 @@ -56,7 +55,6 @@ 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 9d38800..f63cc65 100644 --- a/__init__.py +++ b/__init__.py @@ -8,8 +8,6 @@ import copy import importlib import re import base64 -import hashlib -import markdownify from aiohttp import web import server @@ -25,7 +23,7 @@ import folder_paths comfyui_model_uri = folder_paths.models_dir -extension_uri = os.path.dirname(os.path.abspath(__file__)) +extension_uri = os.path.dirname(__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) @@ -60,8 +58,7 @@ preview_extensions = ( # TODO: JavaScript does not know about this (x2 states) image_extensions + # order matters stable_diffusion_webui_civitai_helper_image_extensions ) -model_notes_extension = ".txt" -model_info_extension = ".json" +model_info_extension = ".txt" #video_extensions = (".avi", ".mp4", ".webm") # TODO: Requires ffmpeg or cv2. Cache preview frame? def split_valid_ext(s, *arg_exts): @@ -74,7 +71,6 @@ 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 = {} @@ -193,7 +189,6 @@ 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), @@ -237,7 +232,6 @@ 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 @@ -249,246 +243,6 @@ 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() }) @@ -573,12 +327,9 @@ def get_auto_thumbnail_format(original_format): return "JPEG" # default fallback -@server.PromptServer.instance.routes.get("/model-manager/preview/get/{uri}") +@server.PromptServer.instance.routes.get("/model-manager/preview/get") async def get_model_preview(request): - 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" + uri = request.query.get("uri") quality = 75 response_image_format = request.query.get("image-format", None) if isinstance(response_image_format, str): @@ -700,16 +451,42 @@ async def get_image_extensions(request): return web.json_response(image_extensions) -def download_model_preview(path, image, overwrite): - if not os.path.isfile(path): +def download_model_preview(formdata): + path = formdata.get("path", None) + if type(path) is not str: raise ValueError("Invalid path!") - path_without_extension = os.path.splitext(path)[0] + 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) + overwrite = formdata.get("overwrite", "true").lower() + overwrite = True if overwrite == "true" else False + + image = formdata.get("image", None) if type(image) is str: - 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) + 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!") _, image_extension = split_valid_ext(image, image_extensions) if image_extension == "": raise ValueError("Invalid image type!") @@ -737,23 +514,17 @@ def download_model_preview(path, image, overwrite): # detect (and try to fix) wrong file extension image_format = None - 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 = "" + 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 return image_path # return in-case need corrected path @@ -761,15 +532,7 @@ def download_model_preview(path, image, overwrite): async def set_model_preview(request): formdata = await request.post() try: - 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) + download_model_preview(formdata) return web.json_response({ "success": True }) except ValueError as e: print(e, file=sys.stderr, flush=True) @@ -947,8 +710,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": raw_post, - "dateModified": str(image_modified), + "path": urllib.parse.quote_plus(raw_post), + "dateModified": urllib.parse.quote_plus(str(image_modified)), } model_items.append(item) @@ -1016,116 +779,6 @@ 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!") @@ -1232,13 +885,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/{path}") -async def get_model_metadata(request): +@server.PromptServer.instance.routes.get("/model-manager/model/info") +async def get_model_info(request): result = { "success": False } - model_path = request.match_info["path"] + model_path = request.query.get("path", None) if model_path is None: - result["alert"] = "Invalid model path!" + result["alert"] = "Missing model path!" return web.json_response(result) model_path = urllib.parse.unquote(model_path) @@ -1247,16 +900,16 @@ async def get_model_metadata(request): result["alert"] = "Invalid model path!" return web.json_response(result) - data = {} + info = {} comfyui_directory, name = os.path.split(model_path) - data["File Name"] = name - data["File Directory"] = comfyui_directory - data["File Size"] = bytes_to_size(os.path.getsize(abs_path)) + info["File Name"] = name + info["File Directory"] = comfyui_directory + info["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) - #data["Date Modified"] = date_modified - #data["Date Created"] = datetime.fromtimestamp(stats.st_ctime).strftime(date_format) + #info["Date Modified"] = date_modified + #info["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) @@ -1266,36 +919,36 @@ async def get_model_metadata(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 - data["Preview"] = { - "path": preview_path + extension, - "dateModified": str(preview_modified), + info["Preview"] = { + "path": urllib.parse.quote_plus(preview_path + extension), + "dateModified": urllib.parse.quote_plus(str(preview_modified)), } break header = get_safetensor_header(abs_path) metadata = header.get("__metadata__", None) - if metadata is not None and data.get("Preview", None) is None: + if metadata is not None and info.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: - data["Preview"] = { + info["Preview"] = { "path": request.query["path"] + thumbnail_extension, "dateModified": date_modified, } if metadata is not None: - 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", "") + 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", "") if metadata is not None: training_comment = metadata.get("ss_training_comment", "") - data["Description"] = ( + info["Description"] = ( metadata.get("modelspec.description", "") + "\n\n" + metadata.get("modelspec.usage_hint", "") + @@ -1303,17 +956,12 @@ async def get_model_metadata(request): training_comment if training_comment != "None" else "" ).strip() - notes_file = abs_name + model_notes_extension + info_text_file = abs_name + model_info_extension notes = "" - if os.path.isfile(notes_file): - with open(notes_file, 'r', encoding="utf-8") as f: + if os.path.isfile(info_text_file): + with open(info_text_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) @@ -1335,7 +983,7 @@ async def get_model_metadata(request): resolutions[str(x) + "x" + str(y)] = count resolutions = list(resolutions.items()) resolutions.sort(key=lambda x: x[1], reverse=True) - data["Bucket Resolutions"] = resolutions + info["Bucket Resolutions"] = resolutions tags = None if metadata is not None: @@ -1349,82 +997,21 @@ async def get_model_metadata(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"] = data + result["info"] = info 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() @@ -1439,7 +1026,6 @@ 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!" @@ -1463,24 +1049,14 @@ 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( - file_name, - image, - formdata.get("overwrite"), - ) + download_model_preview({ + "path": model_path + os.sep + name, + "image": image, + "overwrite": formdata.get("overwrite"), + }) except Exception as e: print(e, file=sys.stderr, flush=True) result["alert"] = "Failed to download preview!\n\n" + str(e) @@ -1546,7 +1122,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_notes_extension,) + (model_info_extension,): + for extension in preview_extensions + (model_info_extension,): old_file = old_file_without_extension + extension if os.path.isfile(old_file): new_file = new_file_without_extension + extension @@ -1600,7 +1176,6 @@ 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) @@ -1625,7 +1200,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_notes_extension) + filename = os.path.normpath(file_path_without_extension + model_info_extension) if dt_epoch is not None and os.path.exists(filename) and os.path.getmtime(filename) > dt_epoch: # discard late save @@ -1646,52 +1221,12 @@ 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) - return web.json_response(result) + 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 0eef0db..8f96ba6 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 deleted file mode 100644 index 06a83f1..0000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -markdownify \ No newline at end of file diff --git a/web/model-manager.css b/web/model-manager.css index 07e1051..394185e 100644 --- a/web/model-manager.css +++ b/web/model-manager.css @@ -10,7 +10,7 @@ position: fixed; overflow: hidden; width: 100%; - z-index: 1100; /*needs to be below the dialog modal element*/ + z-index: 1100; /*override comfy-modal settings*/ border-radius: 0; @@ -23,10 +23,6 @@ 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; @@ -253,10 +249,6 @@ user-select: none; } -.model-manager code { - text-wrap: wrap; -} - /* main content */ .model-manager .model-manager-panel { color: var(--fg-color); @@ -417,11 +409,6 @@ border-radius: 8px; } -.model-manager .model-info-container .item { - width: fit-content; - height: 50vh; -} - .model-manager .item img { width: 100%; height: 100%; @@ -429,13 +416,15 @@ border-radius: 8px; } -.model-manager .model-info-container .item img, -.model-manager .model-preview-full { +.model-manager .model-info-container .item { + width: auto; + height: auto; +} +.model-manager .model-info-container .item img { 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 347aefc..4a8413b 100644 --- a/web/model-manager.js +++ b/web/model-manager.js @@ -104,59 +104,14 @@ const IS_FIREFOX = navigator.userAgent.indexOf('Firefox') > -1; * @param {string} url */ async function loadWorkflow(url) { - 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 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 modelNodeType = { @@ -260,57 +215,40 @@ 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} [imageUriSearchPath=undefined] + * @param {string | undefined} [searchPath=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( - imageUriSearchPath = undefined, - dateImageModified = undefined, - width = undefined, - height = undefined, - imageFormat = undefined, - ) { - const params = []; +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}`; if (width !== undefined && width !== null) { - params.push(`width=${width}`); + uri += `&width=${width}`; } if (height !== undefined && height !== null) { - params.push(`height=${height}`); + uri += `&height=${height}`; } - if (dateImageModified !== undefined && dateImageModified !== null) { - params.push(`v=${dateImageModified}`); + if (date !== undefined && date !== null) { + uri += `&v=${date}`; } if (imageFormat !== undefined && imageFormat !== null) { - 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('&'); + uri += `&image-format=${imageFormat}`; } return uri; - } +} const PREVIEW_NONE_URI = imageUri(); /** @@ -656,7 +594,6 @@ 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, @@ -721,15 +658,11 @@ class ImageSelect { case this.#PREVIEW_NONE: return PREVIEW_NONE_URI; } - console.warn(`Invalid preview select type: ${value}`); - return PREVIEW_NONE_URI; + return ''; } - /** - * @param {String[]} defaultPreviewUrls - * @returns {void} - */ - resetModelInfoPreview(defaultPreviewUrls = []) { + /** @returns {void} */ + resetModelInfoPreview() { let noimage = this.elements.defaultUrl.dataset.noimage; [ this.elements.defaultPreviewNoImage, @@ -748,18 +681,6 @@ 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 = ''; @@ -820,28 +741,6 @@ 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 @@ -879,7 +778,23 @@ class ImageSelect { height: '100%', }, }, - ImageSelect.generateDefaultPreviews(defaultPreviews), + (() => { + 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; + })(), ); const el_uploadPreview = $el('img', { @@ -989,7 +904,6 @@ class ImageSelect { const el_previewButtons = $el( 'div.model-preview-overlay', { - $: (el) => (this.elements.previewButtons = el), style: { display: el_defaultPreviews.children.length > 1 ? 'block' : 'none', }, @@ -1914,7 +1828,6 @@ 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, @@ -1924,7 +1837,7 @@ class ModelGrid { } } if (!success) { - window.alert('No selected nodes have a text area!'); + console.warn('Try selecting a node before adding the embedding.'); } event.stopPropagation(); } @@ -2109,16 +2022,13 @@ 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); @@ -2186,8 +2096,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 ? encodeURIComponent(previewInfo.path) : undefined, - previewInfo?.dateModified ? encodeURIComponent(previewInfo.dateModified) : undefined, + previewInfo?.path, + previewInfo?.dateModified, previewThumbnailWidth, previewThumbnailHeight, previewThumbnailFormat, @@ -2201,47 +2111,23 @@ class ModelGrid { systemSeparator, ); let actionButtons = []; - if (shouldShowTryOpenModelUrl) { + if (showCopyButton) { actionButtons.push( - 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 + 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, ); - } - 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) { + } + if (showAddButton && !(modelType === 'embeddings' && !navigator.clipboard)) { actionButtons.push( new ComfyButton({ icon: 'plus-box-outline', @@ -2258,36 +2144,37 @@ class ModelGrid { }).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, - ); + 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, + ); } const infoButtons = [ new ComfyButton({ icon: 'information-outline', tooltip: 'View model information', classList: 'comfyui-button icon-button model-button', - action: async(e) => { - const [button, icon, span] = comfyButtonDisambiguate(e.target); - button.disabled = true; + action: async () => { await showModelInfo(searchPath); - button.disabled = false; - }, + }, }).element, ]; return $el('div.item', {}, [ @@ -2377,21 +2264,14 @@ class ModelGrid { previousModelType.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; + 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; const searchAppend = settings['model-search-always-append'].value; const searchText = modelFilter.value + ' ' + searchAppend; @@ -2433,18 +2313,13 @@ 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, tryHideModelInfo) { + constructor(modelData, updateModels, settingsElements) { this.#settingsElements = settingsElements; - this.#tryHideModelInfo = tryHideModelInfo; const moveDestinationInput = $el('input.search-text-area', { name: 'move directory', autocomplete: 'off', @@ -2530,6 +2405,11 @@ class ModelInfo { }, }).element; this.elements.setPreviewButton = setPreviewButton; + previewSelect.elements.radioButtons.addEventListener('change', (e) => { + setPreviewButton.style.display = previewSelect.defaultIsChecked() + ? 'none' + : 'block'; + }); this.element = $el( 'div', @@ -2662,9 +2542,9 @@ class ModelInfo { tabContent: this.element, }, { - name: 'Notes', - icon: 'pencil-outline', - tabContent: $el('div', ['Notes']), + name: 'Metadata', + icon: 'file-document-outline', + tabContent: $el('div', ['Metadata']), }, { name: 'Tags', @@ -2672,9 +2552,9 @@ class ModelInfo { tabContent: $el('div', ['Tags']), }, { - name: 'Metadata', - icon: 'file-document-outline', - tabContent: $el('div', ['Metadata']), + name: 'Notes', + icon: 'pencil-outline', + tabContent: $el('div', ['Notes']), }, ]); } @@ -2745,8 +2625,8 @@ class ModelInfo { */ async update(searchPath, updateModels, searchSeparator) { const path = encodeURIComponent(searchPath); - const [info, metadata, tags, noteText, url, webPreviews] = await comfyRequest( - `/model-manager/model/info/${path}`, + const [info, metadata, tags, noteText] = await comfyRequest( + `/model-manager/model/info?path=${path}`, ) .then((result) => { const success = result['success']; @@ -2762,8 +2642,6 @@ class ModelInfo { result['metadata'], result['tags'], result['notes'], - result['url'], - result['webPreviews'], ]; }) .catch((err) => { @@ -2870,131 +2748,38 @@ class ModelInfo { const previewSelect = this.previewSelect; const defaultUrl = previewSelect.elements.defaultUrl; if (info['Preview']) { - const imagePath = encodeURIComponent(info['Preview']['path']); - const imageDateModified = encodeURIComponent(info['Preview']['dateModified']); + const imagePath = info['Preview']['path']; + const imageDateModified = info['Preview']['dateModified']; defaultUrl.dataset.noimage = imageUri(imagePath, imageDateModified); } else { defaultUrl.dataset.noimage = PREVIEW_NONE_URI; } - 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, - }); + previewSelect.resetModelInfoPreview(); + const setPreviewButton = this.elements.setPreviewButton; + setPreviewButton.style.display = previewSelect.defaultIsChecked() + ? 'none' + : 'block'; innerHtml.push( $el('div', [ - $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('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('h2', ['File Info:']), $el( 'div', @@ -3037,163 +2822,40 @@ class ModelInfo { infoHtml.append.apply(infoHtml, innerHtml); // TODO: set default value of dropdown and value to model type? - // - // 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; - } - } - - 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(); + 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; + } + 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 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 - // + } + return $el('table.model-metadata', tableRows); + })(), + ), + ]); + const metadataButton = this.elements.tabButtons[1]; // TODO: remove magic value + metadataButton.style.display = isMetadata ? '' : 'none'; /** @type {HTMLDivElement} */ const tagsElement = this.elements.tabContents[2]; // TODO: remove magic value @@ -3307,44 +2969,114 @@ class ModelInfo { const tagButton = this.elements.tabButtons[2]; // TODO: remove magic value tagButton.style.display = isTags ? '' : 'none'; - // - // METADATA - // + 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); /** @type {HTMLDivElement} */ - 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]), - ]), - ); - } + 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(); } - } - return $el('table.model-metadata', tableRows); - })(), - ), - ]); - const metadataButton = this.elements.tabButtons[3]; // TODO: remove magic value - metadataButton.style.display = isMetadata ? '' : 'none'; + }, + }); + + 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, + ]; + })(), + ); } static UniformTagSampling( @@ -3994,7 +3726,11 @@ class DownloadView { onkeydown: async (e) => { if (e.key === 'Enter') { e.stopPropagation(); - searchButton.click(); + if (this.elements.url.value === '') { + reset(); + } else { + await update(); + } e.target.blur(); } }, @@ -4639,7 +4375,6 @@ 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, @@ -4667,7 +4402,7 @@ class SettingsView { }; /** @return {(withoutComfyRefresh?: boolean) => Promise} */ - #updateModels = async () => {}; + #updateModels = () => {}; /** @return {() => void} */ #updateSidebarSettings = () => {}; @@ -4809,7 +4544,7 @@ class SettingsView { this.elements.saveButton = saveButton; const correctPreviewsButton = new ComfyButton({ - content: 'Fix Preview Extensions', + content: 'Fix Extensions', tooltip: 'Correct image file extensions in all model directories', action: async (e) => { const [button, icon, span] = comfyButtonDisambiguate(e.target); @@ -4834,70 +4569,6 @@ 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', { @@ -4947,6 +4618,11 @@ 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', @@ -5010,9 +4686,6 @@ 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), @@ -5051,11 +4724,6 @@ 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)', @@ -5089,18 +4757,16 @@ class SettingsView { $: (el) => (settings['text-input-always-hide-clear-button'] = el), textContent: 'Always hide "Clear Search" buttons.', }), - $el('h2', ['Scan Files']), + $el('h2', ['Model Preview Images']), $el('div', [correctPreviewsButton]), - $el('div', [scanDownloadModelInfosButton]), - $el('div', [scanDownloadPreviewsButton]), $el('h2', ['Random Tag Generator']), $select({ $: (el) => (settings['tag-generator-sampler-method'] = el), - textContent: 'Sampling method', + textContent: 'Default sampling method', options: ['Frequency', 'Uniform'], }), $el('label', [ - 'Generation count', + 'Default count', $el('input', { $: (el) => (settings['tag-generator-count'] = el), type: 'number', @@ -5110,7 +4776,7 @@ class SettingsView { }), ]), $el('label', [ - 'Minimum frequency threshold', + 'Default minimum threshold', $el('input', { $: (el) => (settings['tag-generator-threshold'] = el), type: 'number', @@ -5361,7 +5027,6 @@ class ModelManager extends ComfyDialog { this.#modelData, this.#refreshModels, this.#settingsView.elements.settings, - () => this.#tryHideModelInfo(), ); this.#browseView = new BrowseView( @@ -5922,21 +5587,14 @@ class ModelManager extends ComfyDialog { /** @type {ModelManager | undefined} */ let instance; -/** @type {ComfyDialog | undefined} */ -let modelManagerDialog; - /** * @returns {ModelManager} */ function getInstance() { - if (!instance) { - instance = new ModelManager(); - - modelManagerDialog = new ComfyDialog(); - modelManagerDialog.element.classList.add("model-manager-dialog"); - instance.element.appendChild(modelManagerDialog.element); - } - return instance; + if (!instance) { + instance = new ModelManager(); + } + return instance; } const toggleModelManager = () => {