diff --git a/README.md b/README.md index 8bc8582..0e41b19 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Download, browse and delete models in ComfyUI. - View multiple models associated with a url. - Select a download directory. - Optionally also download a model preview image (a default image along side the model, from another url or locally uploaded). +- Optionally also download descriptions as a note (`.txt` file). - Civitai and HuggingFace API token configurable in `server_settings.yaml`. ### Models Tab @@ -22,7 +23,7 @@ Download, browse and delete models in ComfyUI. - Advanced keyword search using `"multiple words in quotes"` or a minus sign to `-exclude`. - Search `/`subdirectories of model directories based on your file structure (for example, `/0/1.5/styles/clothing`). - Add `/` at the start of the search bar to see auto-complete suggestions. -- Include models listed in ComfyUI's `extra_model_paths.yaml` or added in `ComfyUI/models`. +- Include models listed in ComfyUI's `extra_model_paths.yaml` or added in `ComfyUI/models/`. - Sort for models (Date Created, Date Modified, Name). ### Model Info View @@ -38,8 +39,9 @@ Download, browse and delete models in ComfyUI. - Button to add model to ComfyUI graph or embedding to selected nodes. (For small screens/low resolution.) - Right, left, top and bottom toggleable sidebar modes. - Drag a model onto the graph to add a new node. -- Drag a model onto an existing node to set the model field. -- Drag an embedding onto a text area to add it to the end. +- Drag a model onto an existing node to set the model field. (Must be exact on input if multiple inputs use model name text.) +- Drag an embedding onto a text area, or highlight any number of nodes, to add it to the end. +- Drag preview image in model info onto the graph to load embedded workflow. ### Settings Tab @@ -49,56 +51,7 @@ Download, browse and delete models in ComfyUI. - Show/Hide add embedding extension. - Colors follow ComfyUI's current theme. -## TODO +### Known Issues -
- - - -### Download Model - -- Checkbox to optionally save description in `.txt` file for Civitai. (what about "About Model"?) -- Server setting to enable creating new folders (on download, on move). - -### Download Model Info - -- Auto-save notes? (requires debounce and save confirmation) -- Load workflow from preview (Should be easy to add with ComfyUI built-in clipboard.) -- Default weights on add/drag? (optional override on drag?) -- Optional (re)download `πŸ“₯︎` model info from the internet and cache the text file locally. (requires checksum?) - - Radio buttons to swap between downloaded and server view. - -### Sidebar - -- Drag sidebar width/height dynamically. - -### Accessibility - -- Proper naming, labeling, alt text, etc. for html elements. -- Tool tips. -- Better error messages. - -### Image preview - -- Better placeholder preview. (with proper spelling!) -- Show preview images for videos. - - If ffmpeg or cv2 available, extract the first frame of the video and use as image preview. - -### Settings - -- Toggle exclusion of "hidden folders" with a `.` prefix. -- Sidebar default width/height. -- Toggle non-uniform preview sizes. (How to handle extreme aspect ratios?) - -### Search filtering and sort - -- Real-time search - - Check search code is optimized to avoid recalculation on every minor input change -- Filter directory dropdown - - Filter directory content in auto-suggest dropdown (not clear how this should be implemented) -- Filters dropdown - - Stable Diffusion model version, if applicable (Maybe dropdown list of "Base Models" is more pratical to impliment?) - - Favorites -- Swap between `and` and `or` keyword search? (currently `and`) - -
+- Pinch to Zoom can cause an invisible scrolling bug. +- After adding/renaming/deleting models, the webpage needs to be reloaded to update the model lists. (Can this be automated?) diff --git a/__init__.py b/__init__.py index c3f8154..fc4b28f 100644 --- a/__init__.py +++ b/__init__.py @@ -20,21 +20,51 @@ requests.packages.urllib3.disable_warnings() import folder_paths -config_loader_path = os.path.join(os.path.dirname(__file__), 'config_loader.py') +comfyui_model_uri = folder_paths.models_dir + +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) config_loader = importlib.util.module_from_spec(config_loader_spec) config_loader_spec.loader.exec_module(config_loader) -comfyui_model_uri = os.path.join(os.getcwd(), "models") -extension_uri = os.path.join(os.getcwd(), "custom_nodes" + os.path.sep + "ComfyUI-Model-Manager") no_preview_image = os.path.join(extension_uri, "no-preview.png") ui_settings_uri = os.path.join(extension_uri, "ui_settings.yaml") server_settings_uri = os.path.join(extension_uri, "server_settings.yaml") fallback_model_extensions = set([".bin", ".ckpt", ".onnx", ".pt", ".pth", ".safetensors"]) # TODO: magic values -image_extensions = (".apng", ".gif", ".jpeg", ".jpg", ".png", ".webp") # TODO: JavaScript does not know about this (x2 states) +image_extensions = ( + ".png", # order matters + ".webp", + ".jpeg", + ".jpg", + ".gif", + ".apng", +) +stable_diffusion_webui_civitai_helper_image_extensions = ( + ".preview.png", # order matters + ".preview.webp", + ".preview.jpeg", + ".preview.jpg", + ".preview.gif", + ".preview.apng", +) +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" #video_extensions = (".avi", ".mp4", ".webm") # TODO: Requires ffmpeg or cv2. Cache preview frame? +def split_valid_ext(s, *arg_exts): + sl = s.lower() + for exts in arg_exts: + for ext in exts: + if sl.endswith(ext.lower()): + return (s[:-len(ext)], ext) + return (s, "") + _folder_names_and_paths = None # dict[str, tuple[list[str], list[str]]] def folder_paths_folder_names_and_paths(refresh = False): global _folder_names_and_paths @@ -75,10 +105,9 @@ def folder_paths_get_supported_pt_extensions(folder_name, refresh = False): # Mi def search_path_to_system_path(model_path): sep = os.path.sep model_path = os.path.normpath(model_path.replace("/", sep)) + model_path = model_path.lstrip(sep) - isep0 = 0 if model_path[0] == sep else -1 - - isep1 = model_path.find(sep, isep0 + 1) + isep1 = model_path.find(sep, 0) if isep1 == -1 or isep1 == len(model_path): return (None, None) @@ -86,7 +115,7 @@ def search_path_to_system_path(model_path): if isep2 == -1 or isep2 - isep1 == 1: isep2 = len(model_path) - model_path_type = model_path[isep0 + 1:isep1] + model_path_type = model_path[0:isep1] paths = folder_paths_get_folder_paths(model_path_type) if len(paths) == 0: return (None, None) @@ -156,6 +185,7 @@ def ui_rules(): Rule("model-add-embedding-extension", False, bool), Rule("model-add-drag-strict-on-field", False, bool), Rule("model-add-offset", 25, int), + Rule("download-save-description-as-text-file", False, bool), ] @@ -169,6 +199,26 @@ def server_rules(): server_settings = config_loader.yaml_load(server_settings_uri, server_rules()) config_loader.yaml_save(server_settings_uri, server_rules(), server_settings) + +def get_def_headers(url=""): + def_headers = { + "User-Agent": "Mozilla/5.0 (iPad; CPU OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148", + } + + if url.startswith("https://civitai.com/"): + api_key = server_settings["civitai_api_key"] + if (api_key != ""): + 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 + elif url.startswith("https://huggingface.co/"): + api_key = server_settings["huggingface_api_key"] + if api_key != "": + def_headers["Authorization"] = f"Bearer {api_key}" + + return def_headers + + @server.PromptServer.instance.routes.get("/model-manager/settings/load") async def load_ui_settings(request): rules = ui_rules() @@ -183,58 +233,171 @@ async def save_ui_settings(request): rules = ui_rules() validated_settings = config_loader.validated(rules, settings) success = config_loader.yaml_save(ui_settings_uri, rules, validated_settings) + print("Saved file: " + ui_settings_uri) return web.json_response({ "success": success, "settings": validated_settings if success else "", }) +from PIL import Image, TiffImagePlugin +from PIL.PngImagePlugin import PngInfo +def PIL_cast_serializable(v): + # source: https://github.com/python-pillow/Pillow/issues/6199#issuecomment-1214854558 + if isinstance(v, TiffImagePlugin.IFDRational): + return float(v) + elif isinstance(v, tuple): + return tuple(PIL_cast_serializable(t) for t in v) + elif isinstance(v, bytes): + return v.decode(errors="replace") + elif isinstance(v, dict): + for kk, vv in v.items(): + v[kk] = PIL_cast_serializable(vv) + return v + else: + return v + + +def get_safetensors_image_bytes(path): + if not os.path.isfile(path): + raise RuntimeError("Path was invalid!") + header = get_safetensor_header(path) + metadata = header.get("__metadata__", None) + if metadata is None: + return None + thumbnail = metadata.get("modelspec.thumbnail", None) + if thumbnail is None: + return None + image_data = thumbnail.split(',')[1] + return base64.b64decode(image_data) + + @server.PromptServer.instance.routes.get("/model-manager/preview/get") async def get_model_preview(request): uri = request.query.get("uri") - image_path = no_preview_image - image_extension = "png" - image_data = None + image_type = "png" + file_name = os.path.split(no_preview_image)[1] if uri != "no-preview": sep = os.path.sep uri = uri.replace("/" if sep == "\\" else "/", sep) path, _ = search_path_to_system_path(uri) - head, extension = os.path.splitext(path) + head, extension = split_valid_ext(path, preview_extensions) if os.path.exists(path): - image_extension = extension[1:] image_path = path - elif os.path.exists(head) and os.path.splitext(head)[1] == ".safetensors": - image_extension = extension[1:] - header = get_safetensor_header(head) - metadata = header.get("__metadata__", None) - if metadata is not None: - thumbnail = metadata.get("modelspec.thumbnail", None) - if thumbnail is not None: - image_data = thumbnail.split(',')[1] - image_data = base64.b64decode(image_data) + image_type = extension.rsplit(".", 1)[1] + file_name = os.path.split(head)[1] + "." + image_type + elif os.path.exists(head) and head.endswith(".safetensors"): + image_path = head + image_type = extension.rsplit(".", 1)[1] + file_name = os.path.splitext(os.path.split(head)[1])[0] + "." + image_type - if image_data == None: - with open(image_path, "rb") as file: - image_data = file.read() + w = request.query.get("width") + h = request.query.get("height") + try: + w = int(w) + if w < 1: + w = None + except: + w = None + try: + h = int(h) + if w < 1: + h = None + except: + h = None - return web.Response(body=image_data, content_type="image/" + image_extension) + image_data = None + if w is None and h is None: # full size + if image_path.endswith(".safetensors"): + image_data = get_safetensors_image_bytes(image_path) + else: + with open(image_path, "rb") as image: + image_data = image.read() + else: + if image_path.endswith(".safetensors"): + image_data = get_safetensors_image_bytes(image_path) + fp = io.BytesIO(image_data) + else: + fp = image_path + + with Image.open(fp) as image: + w0, h0 = image.size + if w is None: + w = (h * w0) // h0 + elif h is None: + h = (w * h0) // w0 + + exif = image.getexif() + + metadata = None + if len(image.info) > 0: + metadata = PngInfo() + for (key, value) in image.info.items(): + value_str = str(PIL_cast_serializable(value)) # not sure if this is correct (sometimes includes exif) + metadata.add_text(key, value_str) + + image.thumbnail((w, h)) + + image_bytes = io.BytesIO() + image.save(image_bytes, format=image.format, exif=exif, pnginfo=metadata) + image_data = image_bytes.getvalue() + + return web.Response( + headers={ + "Content-Disposition": f"inline; filename={file_name}", + }, + body=image_data, + content_type="image/" + image_type, + ) + + +@server.PromptServer.instance.routes.get("/model-manager/image/extensions") +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: raise ("Invalid path!") - path, _ = search_path_to_system_path(path) - path_without_extension, _ = os.path.splitext(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) overwrite = formdata.get("overwrite", "true").lower() overwrite = True if overwrite == "true" else False image = formdata.get("image", None) if type(image) is str: - image_path = download_image(image, path, overwrite) - _, image_extension = os.path.splitext(image_path) + 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!") + image_path = path_without_extension + image_extension + download_file(image, image_path, overwrite) else: content_type = image.content_type if not content_type.startswith("image/"): @@ -251,7 +414,7 @@ def download_model_preview(formdata): with open(image_path, "wb") as f: f.write(image_data) - delete_same_name_files(path_without_extension, image_extensions, image_extension) + delete_same_name_files(path_without_extension, preview_extensions, image_extension) @server.PromptServer.instance.routes.post("/model-manager/preview/set") @@ -262,21 +425,29 @@ async def set_model_preview(request): return web.json_response({ "success": True }) except ValueError as e: print(e, file=sys.stderr, flush=True) - return web.json_response({ "success": False }) + return web.json_response({ + "success": False, + "alert": "Failed to set preview!\n\n" + str(e), + }) @server.PromptServer.instance.routes.post("/model-manager/preview/delete") async def delete_model_preview(request): + result = { "success": False } + model_path = request.query.get("path", None) if model_path is None: - return web.json_response({ "success": False }) + result["alert"] = "Missing model path!" + return web.json_response(result) model_path = urllib.parse.unquote(model_path) - file, _ = search_path_to_system_path(model_path) - path_and_name, _ = os.path.splitext(file) - delete_same_name_files(path_and_name, image_extensions) - - return web.json_response({ "success": True }) + model_path, model_type = search_path_to_system_path(model_path) + model_extensions = folder_paths_get_supported_pt_extensions(model_type) + path_and_name, _ = split_valid_ext(model_path, model_extensions) + delete_same_name_files(path_and_name, preview_extensions) + + result["success"] = True + return web.json_response(result) @server.PromptServer.instance.routes.get("/model-manager/models/list") @@ -297,29 +468,36 @@ async def get_model_list(request): for base_path_index, model_base_path in enumerate(folder_paths_get_folder_paths(model_type)): if not os.path.exists(model_base_path): # TODO: Bug in main code? ("ComfyUI\output\checkpoints", "ComfyUI\output\clip", "ComfyUI\models\t2i_adapter", "ComfyUI\output\vae") continue - for cwd, _subdirs, files in os.walk(model_base_path): + for cwd, subdirs, files in os.walk(model_base_path): dir_models = [] dir_images = [] for file in files: if file.lower().endswith(model_extensions): dir_models.append(file) - elif file.lower().endswith(image_extensions): + elif file.lower().endswith(preview_extensions): dir_images.append(file) for model in dir_models: - model_name, model_ext = os.path.splitext(model) + model_name, model_ext = split_valid_ext(model, model_extensions) image = None image_modified = None - for iImage in range(len(dir_images)-1, -1, -1): - image_name, _ = os.path.splitext(dir_images[iImage]) - if model_name == image_name: - image = end_swap_and_pop(dir_images, iImage) - img_abs_path = os.path.join(cwd, image) - image_modified = pathlib.Path(img_abs_path).stat().st_mtime_ns + for ext in preview_extensions: # order matters + for iImage in range(len(dir_images)-1, -1, -1): + image_name = dir_images[iImage] + if not image_name.lower().endswith(ext.lower()): + continue + image_name = image_name[:-len(ext)] + if model_name == image_name: + image = end_swap_and_pop(dir_images, iImage) + img_abs_path = os.path.join(cwd, image) + image_modified = pathlib.Path(img_abs_path).stat().st_mtime_ns + break + if image is not None: break abs_path = os.path.join(cwd, model) stats = pathlib.Path(abs_path).stat() + sizeBytes = stats.st_size model_modified = stats.st_mtime_ns model_created = stats.st_ctime_ns if use_safetensor_thumbnail and image is None and model_ext == ".safetensors": @@ -336,12 +514,21 @@ async def get_model_list(request): image = model + image_ext image_modified = model_modified rel_path = "" if cwd == model_base_path else os.path.relpath(cwd, model_base_path) - info = (model, image, base_path_index, rel_path, model_modified, model_created, image_modified) + info = ( + model, + image, + base_path_index, + rel_path, + model_modified, + model_created, + image_modified, + sizeBytes, + ) file_infos.append(info) - file_infos.sort(key=lambda tup: tup[4], reverse=True) # TODO: remove sort; sorted on client + #file_infos.sort(key=lambda tup: tup[4], reverse=True) # TODO: remove sort; sorted on client model_items = [] - for model, image, base_path_index, rel_path, model_modified, model_created, image_modified in file_infos: + for model, image, base_path_index, rel_path, model_modified, model_created, image_modified, sizeBytes in file_infos: item = { "name": model, "path": "/" + os.path.join(model_type, str(base_path_index), rel_path, model).replace(os.path.sep, "/"), # relative logical path @@ -350,6 +537,7 @@ async def get_model_list(request): "dateCreated": model_created, #"dateLastUsed": "", # TODO: track server-side, send increment client-side #"countUsed": 0, # TODO: track server-side, send increment client-side + "sizeBytes": sizeBytes, } if image is not None: raw_post = os.path.join(model_type, str(base_path_index), rel_path, image) @@ -378,7 +566,7 @@ def linear_directory_hierarchy(refresh = False): for dir_path_index, dir_path in enumerate(model_dirs): if not os.path.exists(dir_path) or os.path.isfile(dir_path): continue - + #dir_list.append({ "name": str(dir_path_index), "childIndex": None, "childCount": 0 }) dir_stack = [(dir_path, model_dir_child_index + dir_path_index)] while len(dir_stack) > 0: # DEPTH-FIRST @@ -403,8 +591,7 @@ def linear_directory_hierarchy(refresh = False): dir_child_count += 1 else: # file - _, file_extension = os.path.splitext(item_name) - if extension_whitelist is None or file_extension in extension_whitelist: + if extension_whitelist is None or split_valid_ext(item_name, extension_whitelist)[1] != "": dir_list.append({ "name": item_name }) dir_child_count += 1 if dir_child_count > 0: @@ -430,21 +617,15 @@ def download_file(url, filename, overwrite): filename_temp = filename + ".download" - def_headers = { - "User-Agent": "Mozilla/5.0 (iPad; CPU OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148", - } - - if url.startswith("https://civitai.com/"): - api_key = server_settings["civitai_api_key"] - if (api_key != ""): - 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 - elif url.startswith("https://huggingface.co/"): - api_key = server_settings["huggingface_api_key"] - if api_key != "": - def_headers["Authorization"] = f"Bearer {api_key}" - rh = requests.get(url=url, stream=True, verify=False, headers=def_headers, proxies=None, allow_redirects=False) + def_headers = get_def_headers(url) + rh = requests.get( + url=url, + stream=True, + verify=False, + headers=def_headers, + proxies=None, + allow_redirects=False, + ) if not rh.ok: raise ValueError( "Unable to download! Request header status code: " + @@ -457,8 +638,16 @@ def download_file(url, filename, overwrite): headers = {"Range": "bytes=%d-" % downloaded_size} headers["User-Agent"] = def_headers["User-Agent"] - - r = requests.get(url=url, stream=True, verify=False, headers=headers, proxies=None, allow_redirects=False) + headers["Authorization"] = def_headers.get("Authorization", None) + + r = requests.get( + url=url, + stream=True, + verify=False, + headers=headers, + proxies=None, + allow_redirects=False, + ) if rh.status_code == 307 and r.status_code == 307: # Civitai redirect redirect_url = r.content.decode("utf-8") @@ -482,7 +671,7 @@ def download_file(url, filename, overwrite): total_size = int(rh.headers.get("Content-Length", 0)) # TODO: pass in total size earlier - print("Download file: " + filename) + print("Downloading file: " + url) if total_size != 0: print("Download file size: " + str(total_size)) @@ -512,54 +701,63 @@ def download_file(url, filename, overwrite): if overwrite and os.path.isfile(filename): os.remove(filename) os.rename(filename_temp, filename) + print("Saved file: " + filename) -def download_image(image_uri, model_path, overwrite): - _, extension = os.path.splitext(image_uri) # TODO: doesn't work for https://civitai.com/images/... - if not extension in image_extensions: - raise ValueError("Invalid image type!") - path_without_extension, _ = os.path.splitext(model_path) - file = path_without_extension + extension - download_file(image_uri, file, overwrite) - return file +def bytes_to_size(total_bytes): + units = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"] + b = total_bytes + i = 0 + while True: + b = b >> 10 + if (b == 0): break + i = i + 1 + if i >= len(units) or i == 0: + return str(total_bytes) + " " + units[0] + 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): + result = { "success": False } + model_path = request.query.get("path", None) if model_path is None: - return web.json_response({ "success": False }) + result["alert"] = "Missing model path!" + return web.json_response(result) model_path = urllib.parse.unquote(model_path) - file, _ = search_path_to_system_path(model_path) - if file is None: - return web.json_response({}) + 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) info = {} - path, name = os.path.split(model_path) + comfyui_directory, name = os.path.split(model_path) info["File Name"] = name - info["File Directory"] = path - info["File Size"] = str(os.path.getsize(file)) + " bytes" - stats = pathlib.Path(file).stat() + 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) info["Date Modified"] = date_modified info["Date Created"] = datetime.fromtimestamp(stats.st_ctime).strftime(date_format) - file_name, _ = os.path.splitext(file) + model_extensions = folder_paths_get_supported_pt_extensions(model_type) + abs_name , _ = split_valid_ext(abs_path, model_extensions) - for extension in image_extensions: - maybe_image = file_name + extension - if os.path.isfile(maybe_image): - image_path, _ = os.path.splitext(model_path) - image_modified = pathlib.Path(maybe_image).stat().st_mtime_ns + for extension in preview_extensions: + maybe_preview = abs_name + extension + 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(image_path + extension), - "dateModified": urllib.parse.quote_plus(str(image_modified)), + "path": urllib.parse.quote_plus(preview_path + extension), + "dateModified": urllib.parse.quote_plus(str(preview_modified)), } break - header = get_safetensor_header(file) + header = get_safetensor_header(abs_path) metadata = header.get("__metadata__", None) #json.dump(metadata, sys.stdout, indent=4) #print() @@ -593,13 +791,14 @@ async def get_model_info(request): info["Base Training Model"] = metadata.get("ss_sd_model_name", "") info["Base Model"] = metadata.get("ss_base_model_version", "") - info["Architecture"] = metadata.get("modelspec.architecture", "") # "stable-diffusion-xl-v1-base" - + info["Architecture"] = metadata.get("modelspec.architecture", "") + info["Network Dimension"] = metadata.get("ss_network_dim", "") # features trained + info["Network Alpha"] = metadata.get("ss_network_alpha", "") # trained features applied + info["Model Sampling Type"] = metadata.get("modelspec.prediction_type", "") clip_skip = metadata.get("ss_clip_skip", "") - if clip_skip == "None": + if clip_skip == "None" or clip_skip == "1": # assume 1 means no clip skip clip_skip = "" - info["Clip Skip"] = clip_skip # default 1 (disable clip skip) - info["Model Sampling Type"] = metadata.get("modelspec.prediction_type", "") # "epsilon" + info["Clip Skip"] = clip_skip # it is unclear what these are #info["Hash SHA256"] = metadata.get("modelspec.hash_sha256", "") @@ -622,10 +821,10 @@ async def get_model_info(request): training_comment if training_comment != "None" else "" ).strip() - txt_file = file_name + ".txt" + info_text_file = abs_name + model_info_extension notes = "" - if os.path.isfile(txt_file): - with open(txt_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() info["Notes"] = notes @@ -656,7 +855,9 @@ async def get_model_info(request): tags.sort(key=lambda x: x[1], reverse=True) info["Tags"] = tags - return web.json_response(info) + result["success"] = True + result["info"] = info + return web.json_response(result) @server.PromptServer.instance.routes.get("/model-manager/system-separator") @@ -667,10 +868,7 @@ async def get_system_separator(request): @server.PromptServer.instance.routes.post("/model-manager/model/download") async def download_model(request): formdata = await request.post() - result = { - "success": False, - "invalid": None, - } + result = { "success": False } overwrite = formdata.get("overwrite", "false").lower() overwrite = True if overwrite == "true" else False @@ -678,25 +876,30 @@ async def download_model(request): model_path = formdata.get("path", "/0") directory, model_type = search_path_to_system_path(model_path) if directory is None: - result["invalid"] = "path" + result["alert"] = "Invalid save path!" return web.json_response(result) download_uri = formdata.get("download") if download_uri is None: - result["invalid"] = "download" + result["alert"] = "Invalid download url!" return web.json_response(result) name = formdata.get("name") - _, model_extension = os.path.splitext(name) - if not model_extension in folder_paths_get_supported_pt_extensions(model_type): - result["invalid"] = "name" + model_extensions = folder_paths_get_supported_pt_extensions(model_type) + name_head, model_extension = split_valid_ext(name, model_extensions) + name_without_extension = os.path.split(name_head)[1] + if name_without_extension == "": + result["alert"] = "Cannot have empty model name!" + return web.json_response(result) + if model_extension == "": + result["alert"] = "Unrecognized model extension!" return web.json_response(result) file_name = os.path.join(directory, name) try: download_file(download_uri, file_name, overwrite) except Exception as e: print(e, file=sys.stderr, flush=True) - result["invalid"] = "model" + result["alert"] = "Failed to download model!\n\n" + str(e) return web.json_response(result) image = formdata.get("image") @@ -709,7 +912,7 @@ async def download_model(request): }) except Exception as e: print(e, file=sys.stderr, flush=True) - result["invalid"] = "preview" + result["alert"] = "Failed to download preview!\n\n" + str(e) result["success"] = True return web.json_response(result) @@ -718,63 +921,86 @@ async def download_model(request): @server.PromptServer.instance.routes.post("/model-manager/model/move") async def move_model(request): body = await request.json() + result = { "success": False } old_file = body.get("oldFile", None) if old_file is None: - return web.json_response({ "success": False }) + result["alert"] = "No model was given!" + return web.json_response(result) old_file, old_model_type = search_path_to_system_path(old_file) if not os.path.isfile(old_file): - return web.json_response({ "success": False }) - _, model_extension = os.path.splitext(old_file) - if not model_extension in folder_paths_get_supported_pt_extensions(old_model_type): - # cannot move arbitrary files - return web.json_response({ "success": False }) + result["alert"] = "Model does not exist!" + return web.json_response(result) + old_model_extensions = folder_paths_get_supported_pt_extensions(old_model_type) + old_file_without_extension, model_extension = split_valid_ext(old_file, old_model_extensions) + if model_extension == "": + result["alert"] = "Invalid model extension!" + return web.json_response(result) new_file = body.get("newFile", None) if new_file is None or new_file == "": - # cannot have empty name - return web.json_response({ "success": False }) + result["alert"] = "New model name was invalid!" + return web.json_response(result) new_file, new_model_type = search_path_to_system_path(new_file) if not new_file.endswith(model_extension): - return web.json_response({ "success": False }) + result["alert"] = "Cannot change model extension!" + return web.json_response(result) if os.path.isfile(new_file): - # cannot overwrite existing file - return web.json_response({ "success": False }) - if not model_extension in folder_paths_get_supported_pt_extensions(new_model_type): - return web.json_response({ "success": False }) - new_file_dir, _ = os.path.split(new_file) + result["alert"] = "Cannot overwrite existing model!" + return web.json_response(result) + new_model_extensions = folder_paths_get_supported_pt_extensions(new_model_type) + new_file_without_extension, new_model_extension = split_valid_ext(new_file, new_model_extensions) + if model_extension != new_model_extension: + result["alert"] = "Cannot change model extension!" + return web.json_response(result) + new_file_dir, new_file_name = os.path.split(new_file) if not os.path.isdir(new_file_dir): - return web.json_response({ "success": False }) + result["alert"] = "Destination directory does not exist!" + return web.json_response(result) + new_name_without_extension = os.path.splitext(new_file_name)[0] + if new_file_name == new_name_without_extension or new_name_without_extension == "": + result["alert"] = "New model name was empty!" + return web.json_response(result) if old_file == new_file: - return web.json_response({ "success": False }) + # no-op + result["success"] = True + return web.json_response(result) try: shutil.move(old_file, new_file) + print("Moved file: " + new_file) except ValueError as e: print(e, file=sys.stderr, flush=True) - return web.json_response({ "success": False }) + result["alert"] = "Failed to move model!\n\n" + str(e) + return web.json_response(result) - old_file_without_extension, _ = os.path.splitext(old_file) - new_file_without_extension, _ = os.path.splitext(new_file) - - # TODO: this could overwrite existing files... - for extension in image_extensions + (".txt",): + # TODO: this could overwrite existing files in destination; do a check beforehand? + 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 try: - shutil.move(old_file, new_file_without_extension + extension) + shutil.move(old_file, new_file) + print("Moved file: " + new_file) except ValueError as e: print(e, file=sys.stderr, flush=True) + msg = result.get("alert","") + if msg == "": + result["alert"] = "Failed to move model resource file!\n\n" + str(e) + else: + result["alert"] = msg + "\n" + str(e) - return web.json_response({ "success": True }) + result["success"] = True + return web.json_response(result) def delete_same_name_files(path_without_extension, extensions, keep_extension=None): for extension in extensions: if extension == keep_extension: continue - image_file = path_without_extension + extension - if os.path.isfile(image_file): - os.remove(image_file) + file = path_without_extension + extension + if os.path.isfile(file): + os.remove(file) + print("Deleted file: " + file) @server.PromptServer.instance.routes.post("/model-manager/model/delete") @@ -783,29 +1009,27 @@ async def delete_model(request): 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) - - file, model_type = search_path_to_system_path(model_path) - if file is None: + model_path, model_type = search_path_to_system_path(model_path) + if model_path is None: + result["alert"] = "Invalid model path!" return web.json_response(result) - _, extension = os.path.splitext(file) - if not extension in folder_paths_get_supported_pt_extensions(model_type): - # cannot delete arbitrary files + model_extensions = folder_paths_get_supported_pt_extensions(model_type) + path_and_name, model_extension = split_valid_ext(model_path, model_extensions) + if model_extension == "": + result["alert"] = "Cannot delete file!" return web.json_response(result) - if os.path.isfile(file): - os.remove(file) + if os.path.isfile(model_path): + os.remove(model_path) result["success"] = True + print("Deleted file: " + model_path) - path_and_name, _ = os.path.splitext(file) - - delete_same_name_files(path_and_name, image_extensions) - - txt_file = path_and_name + ".txt" - if os.path.isfile(txt_file): - os.remove(txt_file) + delete_same_name_files(path_and_name, preview_extensions) + delete_same_name_files(path_and_name, (model_info_extension,)) return web.json_response(result) @@ -813,29 +1037,37 @@ async def delete_model(request): @server.PromptServer.instance.routes.post("/model-manager/notes/save") async def set_notes(request): body = await request.json() + result = { "success": False } text = body.get("notes", None) if type(text) is not str: - return web.json_response({ "success": False }) + result["alert"] = "Invalid note!" + return web.json_response(result) model_path = body.get("path", None) if type(model_path) is not str: - return web.json_response({ "success": False }) - model_path, _ = search_path_to_system_path(model_path) - file_path_without_extension, _ = os.path.splitext(model_path) - filename = os.path.normpath(file_path_without_extension + ".txt") + result["alert"] = "Missing model path!" + return web.json_response(result) + 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) if text.isspace() or text == "": if os.path.exists(filename): os.remove(filename) + print("Deleted file: " + filename) else: try: with open(filename, "w", encoding="utf-8") as f: f.write(text) + print("Saved file: " + filename) except ValueError as e: print(e, file=sys.stderr, flush=True) - web.json_response({ "success": False }) + result["alert"] = "Failed to save notes!\n\n" + str(e) + web.json_response(result) - return web.json_response({ "success": True }) + result["success"] = True + return web.json_response(result) WEB_DIRECTORY = "web" diff --git a/demo-tab-download.png b/demo-tab-download.png index 03e2ca0..2512b2c 100644 Binary files a/demo-tab-download.png and b/demo-tab-download.png differ diff --git a/demo-tab-models.png b/demo-tab-models.png index d8431fe..1138d51 100644 Binary files a/demo-tab-models.png and b/demo-tab-models.png differ diff --git a/no-preview.png b/no-preview.png index e2beb26..de224af 100644 Binary files a/no-preview.png and b/no-preview.png differ diff --git a/web/model-manager.css b/web/model-manager.css index df5109d..6c94a69 100644 --- a/web/model-manager.css +++ b/web/model-manager.css @@ -1,13 +1,26 @@ /* model manager */ .model-manager { + background-color: var(--comfy-menu-bg); box-sizing: border-box; - width: 100%; - height: 100%; - max-width: unset; - max-height: unset; - padding: 10px; color: var(--bg-color); + font-family: monospace; + font-size: 15px; + height: 100%; + padding: 8px; + position: fixed; + overflow: hidden; + top: 0; + left: 0; + width: 100%; z-index: 2000; + + /*override comfy-modal settings*/ + border-radius: 0; + box-shadow: none; + justify-content: unset; + max-height: unset; + max-width: unset; + transform: none; } .model-manager .comfy-modal-content { @@ -17,35 +30,42 @@ .model-manager.sidebar-left { width: 50%; - left: 25%; + left: 0%; } .model-manager.sidebar-top { height: 50%; - top: 25%; + top: 0%; } .model-manager.sidebar-bottom { height: 50%; - top: 75%; + top: 50%; } .model-manager.sidebar-right { width: 50%; - left: 75%; + left: 50%; +} + +.model-manager .sidebar-buttons .sidebar-button-active { + border-color: var(--fg-color); + color: var(--fg-color); + overflow: hidden; } /* common */ .model-manager h1 { min-width: 0; + overflow-wrap: break-word; } .model-manager textarea { - width: 100%; - font-size: 1.2em; border: solid 2px var(--border-color); border-radius: 8px; + font-size: 1.2em; resize: vertical; + width: 100%; } .model-manager input[type="file"] { @@ -72,11 +92,6 @@ width: 100%; } -.comfy-table a { - color: #007acc; - text-decoration: none; -} - .model-manager ::-webkit-scrollbar { width: 16px; } @@ -105,7 +120,7 @@ font-style: italic; } -.icon-button { +.model-manager .icon-button { height: 40px; width: 40px; line-height: 1.15; @@ -148,61 +163,61 @@ /* sidebar buttons */ .model-manager .sidebar-buttons { overflow: hidden; - padding-right: 10px; color: var(--input-text); + display: flex; + flex-direction: row-reverse; + flex-wrap: wrap; } -/* tabs */ -.model-manager .comfy-tabs { +/* main content */ +.model-manager .model-manager-panel { color: var(--fg-color); } -.model-manager .comfy-tabs-head { +.model-manager .model-manager-tabs { display: flex; - gap: 8px; - flex-wrap: wrap; - border-bottom: 2px solid var(--border-color); + gap: 4px; + height: 40px; } -.model-manager .comfy-tabs-head .head-item { - padding: 8px 12px; +.model-manager .model-manager-tabs .head-item { + background-color: var(--comfy-menu-bg); border: 2px solid var(--border-color); border-bottom: none; border-top-left-radius: 8px; border-top-right-radius: 8px; - background-color: var(--comfy-menu-bg); cursor: pointer; + padding: 8px 12px; margin-bottom: 0px; z-index: 1; } -.model-manager .comfy-tabs-head .head-item.active { - background-color: var(--comfy-input-bg); +.model-manager .model-manager-tabs .head-item.active { + background-color: var(--bg-color); cursor: default; position: relative; z-index: 1; } -.model-manager .comfy-tabs-body { +.model-manager .model-manager-body { background-color: var(--bg-color); border: 2px solid var(--border-color); - border-top: none; padding: 16px 0px; } -.model-manager .comfy-tabs { +.model-manager .model-manager-panel { flex: 1; display: flex; flex-direction: column; overflow: hidden; } -.model-manager .comfy-tabs-body { +.model-manager .model-manager-body { flex: 1; overflow: hidden; } -.model-manager .comfy-tabs-body > div { +.model-manager .model-manager-body > div { position: relative; height: 100%; width: auto; @@ -213,15 +228,13 @@ /* model info view */ .model-manager .model-info-view { background-color: var(--bg-color); - border: 2px solid var(--border-color); - box-sizing: border-box; display: flex; flex-direction: column; height: 100%; - margin-top: 40px; overflow-wrap: break-word; overflow-y: auto; padding: 20px; + position: relative; } .model-manager .model-info-container { @@ -233,17 +246,41 @@ /* download tab */ -.model-manager [data-name="Download"] summary { +.model-manager .download-model-infos { + display: flex; + flex-direction: column; + padding: 16px 0; + row-gap: 10px; +} + +.model-manager .download-details summary { + background-color: var(--comfy-menu-bg); + border-radius: 16px; padding: 16px; word-wrap: break-word; } -.model-manager [data-name="Download"] .download-settings { +.model-manager .download-details[open] summary { + background-color: var(--border-color); +} + +.model-manager .download-details > div { + column-gap: 8px; + display: flex; + flex-direction: row; + flex-wrap: wrap; + padding: 8px; + row-gap: 16px; +} + +.model-manager [data-name="Download"] .download-settings-wrapper { flex: 1; } -.model-manager .download-model-infos { - padding: 16px 0; +.model-manager [data-name="Download"] .download-settings { + display: flex; + flex-direction: column; + row-gap: 16px; } /* models tab */ @@ -394,11 +431,16 @@ } /* model preview select */ -.model-preview-select-radio-container { +.model-manager .model-preview-select-radio-container { min-width: 0; flex: 1; } +.model-manager .model-preview-select-radio-inputs > div { + height: 40px; + padding: 16px 0 8px 0; +} + .model-manager .model-preview-select-radio-container img { position: relative; width: 230px; @@ -411,23 +453,33 @@ /* topbar */ .model-manager .topbar-buttons { - position: absolute; display: flex; - top: 10px; - right: 10px; + float: right; } .model-manager .topbar-buttons button { - width: 33px; height: 33px; padding: 1px 6px; + width: 33px; +} + +.model-manager .model-manager-head .topbar-left { + display: flex; + float: left; +} + +.model-manager .model-manager-head .topbar-right { + column-gap: 4px; + display: flex; + flex-direction: row-reverse; + float: right; } /* search dropdown */ .model-manager .search-models { display: flex; - flex-direction: row; flex: 1; + flex-direction: row; min-width: 0; } @@ -450,13 +502,13 @@ } .model-manager .search-dropdown { - position: absolute; background-color: var(--bg-color); border: 2px var(--border-color) solid; + border-radius: 10px; color: var(--fg-color); max-height: 30vh; overflow: auto; - border-radius: 10px; + position: absolute; z-index: 1; } @@ -477,10 +529,15 @@ display: none; /* Safari and Chrome */ } -.model-manager .search-dropdown > p.search-dropdown-selected { +.model-manager .search-dropdown > p.search-dropdown-key-selected, +.model-manager .search-dropdown > p.search-dropdown-mouse-selected { background-color: var(--border-color); } +.model-manager .search-dropdown > p.search-dropdown-key-selected { + border-left: 1mm solid var(--input-text); +} + /* model manager settings */ .model-manager .model-manager-settings > div, .model-manager .model-manager-settings > label { @@ -500,6 +557,6 @@ width: 50px; } -.search-settings-text { +.model-manager .search-settings-text { width: 100%; } diff --git a/web/model-manager.js b/web/model-manager.js index 397a17d..ed0df15 100644 --- a/web/model-manager.js +++ b/web/model-manager.js @@ -35,7 +35,45 @@ const modelNodeType = { }; const MODEL_EXTENSIONS = [".bin", ".ckpt", ".onnx", ".pt", ".pth", ".safetensors"]; // TODO: ask server for? -const IMAGE_EXTENSIONS = [".apng", ".gif", ".jpeg", ".jpg", ".png", ".webp"]; // TODO: ask server for? +const IMAGE_EXTENSIONS = [ + ".png", + ".webp", + ".jpeg", + ".jpg", + ".gif", + ".apng", + + ".preview.png", + ".preview.webp", + ".preview.jpeg", + ".preview.jpg", + ".preview.gif", + ".preview.apng", +]; // TODO: /model-manager/image/extensions + +/** + * @param {string} s + * @param {string} prefix + * @returns {string} + */ +function removePrefix(s, prefix) { + if (s.length >= prefix.length && s.startsWith(prefix)){ + return s.substring(prefix.length); + } + return s; +} + +/** + * @param {string} s + * @param {string} suffix + * @returns {string} + */ +function removeSuffix(s, suffix) { + if (s.length >= suffix.length && s.endsWith(suffix)){ + return s.substring(0, s.length - suffix.length); + } + return s; +} class SearchPath { /** @@ -74,18 +112,28 @@ class SearchPath { /** * @param {string | undefined} [searchPath=undefined] * @param {string | undefined} [dateImageModified=undefined] - * + * @param {string | undefined} [width=undefined] + * @param {string | undefined} [height=undefined] * @returns {string} */ -function imageUri(imageSearchPath = undefined, dateImageModified = undefined) { +function imageUri(imageSearchPath = undefined, dateImageModified = undefined, width = undefined, height = undefined) { const path = imageSearchPath ?? "no-preview"; const date = dateImageModified; let uri = `/model-manager/preview/get?uri=${path}`; + if (width !== undefined && width !== null) { + uri += `&width=${width}`; + } + if (height !== undefined && height !== null) { + uri += `&height=${height}`; + } if (date !== undefined && date !== null) { uri += `&v=${date}`; } return uri; } +const PREVIEW_NONE_URI = imageUri(); +const PREVIEW_THUMBNAIL_WIDTH = 320; +const PREVIEW_THUMBNAIL_HEIGHT = 480; /** * @param {(...args) => void} callback @@ -127,82 +175,34 @@ function buttonAlert(element, success, successText = "", failureText = "", reset }, 1000, element, name, resetText); } -class Tabs { - /** @type {Record} */ - #head = {}; - /** @type {Record} */ - #body = {}; - - /** - * @param {HTMLDivElement[]} tabs - */ - constructor(tabs) { - const head = []; - const body = []; - - tabs.forEach((el, index) => { - const name = el.getAttribute("data-name"); - - /** @type {HTMLDivElement} */ - const tag = $el( - "div.head-item", - { onclick: () => this.active(name) }, - [name] - ); - - if (index === 0) { - this.#active = name; - } - - this.#head[name] = tag; - head.push(tag); - this.#body[name] = el; - body.push(el); - }); - - this.element = $el("div.comfy-tabs", [ - $el("div.comfy-tabs-head", head), - $el("div.comfy-tabs-body", body), - ]); - - this.active(this.#active); - } - - #active = undefined; - - /** - * @param {string} name - */ - active(name) { - this.#active = name; - Object.keys(this.#head).forEach((key) => { - if (name === key) { - this.#head[key].classList.add("active"); - this.#body[key].style.display = ""; - } else { - this.#head[key].classList.remove("active"); - this.#body[key].style.display = "none"; - } - }); - } -} - /** - * @param {Record} tabs - * @returns {HTMLDivElement[]} + * + * @param {string} modelPath + * @param {string} newValue + * @returns {Promise} */ -function $tabs(tabs) { - const instance = new Tabs(tabs); - return instance.element; -} - -/** - * @param {string} name - * @param {HTMLDivElement[]} el - * @returns {HTMLDivElement} - */ -function $tab(name, el) { - return $el("div", { dataset: { name } }, el); +async function saveNotes(modelPath, newValue) { + return await request( + "/model-manager/notes/save", + { + method: "POST", + body: JSON.stringify({ + "path": modelPath, + "notes": newValue, + }), + } + ).then((result) => { + const saved = result["success"]; + const message = result["alert"]; + if (message !== undefined) { + window.alert(message); + } + return saved; + }) + .catch((err) => { + console.warn(err); + return false; + }); } /** @@ -296,31 +296,54 @@ class ImageSelect { /** @type {string} */ #name = null; - /** @returns {string|File} */ - getImage() { + /** @returns {Promise | Promise} */ + async getImage() { const name = this.#name; const value = document.querySelector(`input[name="${name}"]:checked`).value; const elements = this.elements; switch (value) { case this.#PREVIEW_DEFAULT: const children = elements.defaultPreviews.children; - const noImage = imageUri(); + const noImage = PREVIEW_NONE_URI; + let url = ""; for (let i = 0; i < children.length; i++) { const child = children[i]; if (child.style.display !== "none" && child.nodeName === "IMG" && !child.src.endsWith(noImage) ) { - return child.src; + url = child.src; } } - return ""; + if (url.startsWith(Civitai.imageUrlPrefix())) { + url = await Civitai.getFullSizeImageUrl(url).catch((err) => { + console.warn(err); + return url; + }); + } + return url; case this.#PREVIEW_URL: - return elements.customUrl.value; + const value = elements.customUrl.value; + if (value.startsWith(Civitai.imagePostUrlPrefix())) { + try { + const imageInfo = await Civitai.getImageInfo(value); + const items = imageInfo["items"]; + if (items.length === 0) { + console.warn("Civitai /api/v1/images returned 0 items."); + return value; + } + return items[0]["url"]; + } + catch (error) { + console.error("Failed to get image info from Civitai!", error); + return value; + } + } + return value; case this.#PREVIEW_UPLOAD: return elements.uploadFile.files[0] ?? ""; case this.#PREVIEW_NONE: - return imageUri(); + return PREVIEW_NONE_URI; } return ""; } @@ -344,7 +367,7 @@ class ImageSelect { } } else { - el.src = imageUri(); + el.src = PREVIEW_NONE_URI; } }); this.checkDefault(); @@ -410,21 +433,21 @@ class ImageSelect { */ constructor(radioGroupName, defaultPreviews = []) { if (defaultPreviews === undefined | defaultPreviews === null | defaultPreviews.length === 0) { - defaultPreviews = [imageUri()]; + defaultPreviews = [PREVIEW_NONE_URI]; } this.#name = radioGroupName; const el_defaultUri = $el("div", { $: (el) => (this.elements.defaultUrl = el), style: { display: "none" }, - "data-noimage": imageUri(), + "data-noimage": PREVIEW_NONE_URI, }); const el_defaultPreviewNoImage = $el("img", { $: (el) => (this.elements.defaultPreviewNoImage = el), - src: imageUri(), + 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: PREVIEW_NONE_URI, style: { display: "none" }, - loading: "lazy", }); const el_defaultPreviews = $el("div", { @@ -436,11 +459,11 @@ class ImageSelect { }, (() => { 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" }, - loading: "lazy", onerror: (e) => { - e.target.src = el_defaultUri.dataset.noimage ?? imageUri(); + e.target.src = el_defaultUri.dataset.noimage ?? PREVIEW_NONE_URI; }, }); }); @@ -452,10 +475,10 @@ class ImageSelect { const el_uploadPreview = $el("img", { $: (el) => (this.elements.uploadPreview = el), - src: imageUri(), + src: PREVIEW_NONE_URI, style: { display : "none" }, onerror: (e) => { - e.target.src = el_defaultUri.dataset.noimage ?? imageUri(); + e.target.src = el_defaultUri.dataset.noimage ?? PREVIEW_NONE_URI; }, }); const el_uploadFile = $el("input", { @@ -480,12 +503,39 @@ class ImageSelect { el_uploadFile, ]); + /** + * @param {string} url + * @returns {Promise} + */ + const getCustomPreviewUrl = async (url) => { + if (url.startsWith(Civitai.imagePostUrlPrefix())) { + return await Civitai.getImageInfo(url) + .then((imageInfo) => { + const items = imageInfo["items"]; + if (items.length > 0) { + return items[0]["url"]; + } + else { + console.warn("Civitai /api/v1/images returned 0 items."); + return url; + } + }) + .catch((error) => { + console.error("Failed to get image info from Civitai!", error); + return url; + }); + } + else { + return url; + } + }; + const el_customUrlPreview = $el("img", { $: (el) => (this.elements.customUrlPreview = el), - src: imageUri(), + src: PREVIEW_NONE_URI, style: { display: "none" }, onerror: (e) => { - e.target.src = el_defaultUri.dataset.noimage ?? imageUri(); + e.target.src = el_defaultUri.dataset.noimage ?? PREVIEW_NONE_URI; }, }); const el_customUrl = $el("input.search-text-area", { @@ -494,6 +544,14 @@ class ImageSelect { name: "custom preview image url", autocomplete: "off", placeholder: "https://custom-image-preview.png", + onkeydown: async (e) => { + if (e.key === "Enter") { + const value = e.target.value; + el_customUrlPreview.src = await getCustomPreviewUrl(value); + e.stopPropagation(); + e.target.blur(); + } + }, }); const el_custom = $el("div.row.tab-header-flex-block", { $: (el) => (this.elements.custom = el), @@ -502,8 +560,11 @@ class ImageSelect { el_customUrl, $el("button.icon-button", { textContent: "πŸ”οΈŽ", - onclick: (e) => { - el_customUrlPreview.src = el_customUrl.value; + onclick: async (e) => { + const value = el_customUrl.value; + el_customUrlPreview.src = await getCustomPreviewUrl(value); + e.stopPropagation(); + el_customUrl.blur(); }, }), ]); @@ -598,7 +659,7 @@ class ImageSelect { $: (el) => (this.elements.radioGroup = el), }, [ $el("div.row.tab-header-flex-block", [el_radioButtons]), - $el("div", [ + $el("div.model-preview-select-radio-inputs", [ el_custom, el_upload, ]), @@ -607,13 +668,206 @@ class ImageSelect { } /** - * @typedef {Object} DirectoryItem - * @param {string} name - * @param {number | undefined} childCount - * @param {number | undefined} childIndex + * @typedef {Object} DirectoryItem + * @property {String} name + * @property {number | undefined} childCount + * @property {number | undefined} childIndex */ -const DROPDOWN_DIRECTORY_SELECTION_CLASS = "search-dropdown-selected"; +class ModelDirectories { + /** @type {DirectoryItem[]} */ + data = []; + + /** + * @returns {number} + */ + rootIndex() { + return 0; + } + + /** + * @param {any} index + * @returns {boolean} + */ + isValidIndex(index) { + return typeof index === "number" && 0 <= index && index < this.data.length; + } + + /** + * @param {number} index + * @returns {DirectoryItem} + */ + getItem(index) { + if (!this.isValidIndex(index)) { + throw new Error(`Index '${index}' is not valid!`); + } + return this.data[index]; + } + + /** + * @param {DirectoryItem | number} item + * @returns {boolean} + */ + isDirectory(item) { + if (typeof item === "number") { + item = this.getItem(item); + } + const childCount = item.childCount; + return childCount !== undefined && childCount != null; + } + + /** + * @param {DirectoryItem | number} item + * @returns {boolean} + */ + isEmpty(item) { + if (typeof item === "number") { + item = this.getItem(item); + } + if (!this.isDirectory(item)) { + throw new Error("Item is not a directory!"); + } + return item.childCount === 0; + } + + /** + * Returns a slice of children from the directory list. + * @param {DirectoryItem | number} item + * @returns {DirectoryItem[]} + */ + getChildren(item) { + if (typeof item === "number") { + item = this.getItem(item); + if (!this.isDirectory(item)) { + throw new Error("Item is not a directory!"); + } + } + else if (!this.isDirectory(item)) { + throw new Error("Item is not a directory!"); + } + const count = item.childCount; + const index = item.childIndex; + return this.data.slice(index, index + count); + } + + /** + * Returns index of child in parent directory. Returns -1 if DNE. + * @param {DirectoryItem | number} parent + * @param {string} name + * @returns {number} + */ + findChildIndex(parent, name) { + const item = this.getItem(parent); + if (!this.isDirectory(item)) { + throw new Error("Item is not a directory!"); + } + const start = item.childIndex; + const children = this.getChildren(item); + const index = children.findIndex((item) => { + return item.name === name; + }); + if (index === -1) { + return -1; + } + return index + start; + } + + /** + * Returns a list of matching search results and valid path. + * @param {string} filter + * @param {string} searchSeparator + * @param {boolean} directoriesOnly + * @returns {[string[], string]} + */ + search(filter, searchSeparator, directoriesOnly) { + let cwd = this.rootIndex(); + let indexLastWord = 1; + while (true) { + const indexNextWord = filter.indexOf(searchSeparator, indexLastWord); + if (indexNextWord === -1) { + // end of filter + break; + } + + const item = this.getItem(cwd); + if (!this.isDirectory(item) || this.isEmpty(item)) { + break; + } + + const word = filter.substring(indexLastWord, indexNextWord); + cwd = this.findChildIndex(cwd, word); + if (!this.isValidIndex(cwd)) { + return [[], ""]; + } + indexLastWord = indexNextWord + 1; + } + //const cwdPath = filter.substring(0, indexLastWord); + + const lastWord = filter.substring(indexLastWord); + const children = this.getChildren(cwd); + if (directoriesOnly) { + let indexPathEnd = indexLastWord; + const results = children.filter((child) => { + return ( + this.isDirectory(child) && + child.name.startsWith(lastWord) + ); + }).map((directory) => { + const children = this.getChildren(directory); + const hasChildren = children.some((item) => { + return this.isDirectory(item); + }); + const suffix = hasChildren ? searchSeparator : ""; + //const suffix = searchSeparator; + if (directory.name == lastWord) { + indexPathEnd += searchSeparator.length + directory.name.length + 1; + } + return directory.name + suffix; + }); + const path = filter.substring(0, indexPathEnd); + return [results, path]; + } + else { + let indexPathEnd = indexLastWord; + const results = children.filter((child) => { + return child.name.startsWith(lastWord); + }).map((item) => { + const isDir = this.isDirectory(item); + const isNonEmptyDirectory = isDir && item.childCount > 0; + const suffix = isNonEmptyDirectory ? searchSeparator : ""; + //const suffix = isDir ? searchSeparator : ""; + if (!isDir && item.name == lastWord) { + indexPathEnd += searchSeparator.length + item.name.length + 1; + } + return item.name + suffix; + }); + const path = filter.substring(0, indexPathEnd); + return [results, path]; + } + } +} + +const DROPDOWN_DIRECTORY_SELECTION_KEY_CLASS = "search-dropdown-key-selected"; +const DROPDOWN_DIRECTORY_SELECTION_MOUSE_CLASS = "search-dropdown-mouse-selected"; + + +class ModelData { + /** @type {string} */ + searchSeparator = "/"; // TODO: other client or server code may be assuming this to always be "/" + + /** @type {string} */ + systemSeparator = null; + + /** @type {Object} */ + models = {}; + + /** @type {ModelDirectories} */ + directories = null; + + constructor() { + this.directories = new ModelDirectories(); + } +} class DirectoryDropdown { /** @type {HTMLDivElement} */ @@ -625,9 +879,11 @@ class DirectoryDropdown { /** @type {HTMLInputElement} */ #input = null; - // TODO: remove this - /** @type {() => void} */ - #updateDropdown = null; + /** @type {() => string} */ + #getModelType = null; + + /** @type {ModelData} */ + #modelData = null; // READ ONLY /** @type {() => void} */ #updateCallback = null; @@ -635,15 +891,21 @@ class DirectoryDropdown { /** @type {() => Promise} */ #submitCallback = null; + /** @type {string} */ + #deepestPreviousPath = "/"; + + /** @type {Any} */ + #touchSelectionStart = null; + /** + * @param {ModelData} modelData * @param {HTMLInputElement} input - * @param {() => void} updateDropdown + * @param {Boolean} [showDirectoriesOnly=false] + * @param {() => string} [getModelType= () => { return ""; }] * @param {() => void} [updateCallback= () => {}] * @param {() => Promise} [submitCallback= () => {}] - * @param {String} [searchSeparator="/"] - * @param {Boolean} [showDirectoriesOnly=false] */ - constructor(input, updateDropdown, updateCallback = () => {}, submitCallback = () => {}, searchSeparator = "/", showDirectoriesOnly = false) { + constructor(modelData, input, showDirectoriesOnly = false, getModelType = () => { return ""; }, updateCallback = () => {}, submitCallback = () => {}) { /** @type {HTMLDivElement} */ const dropdown = $el("div.search-dropdown", { // TODO: change to `search-directory-dropdown` style: { @@ -651,23 +913,36 @@ class DirectoryDropdown { }, }); this.element = dropdown; + this.#modelData = modelData; this.#input = input; - this.#updateDropdown = updateDropdown; + this.#getModelType = getModelType; this.#updateCallback = updateCallback; this.#submitCallback = submitCallback; this.showDirectoriesOnly = showDirectoriesOnly; - input.addEventListener("input", () => updateDropdown()); - input.addEventListener("focus", () => updateDropdown()); + input.addEventListener("input", () => { + const path = this.#updateOptions(); + if (path !== undefined) { + this.#restoreSelectedOption(path); + this.#updateDeepestPath(path); + } + updateCallback(); + }); + input.addEventListener("focus", () => { + const path = this.#updateOptions(); + if (path !== undefined) { + this.#deepestPreviousPath = path; + this.#restoreSelectedOption(path); + } + updateCallback(); + }); input.addEventListener("blur", () => { dropdown.style.display = "none"; }); - input.addEventListener( - "keydown", - (e) => { + input.addEventListener("keydown", async(e) => { const options = dropdown.children; let iSelection; for (iSelection = 0; iSelection < options.length; iSelection++) { const selection = options[iSelection]; - if (selection.classList.contains(DROPDOWN_DIRECTORY_SELECTION_CLASS)) { + if (selection.classList.contains(DROPDOWN_DIRECTORY_SELECTION_KEY_CLASS)) { break; } } @@ -675,7 +950,7 @@ class DirectoryDropdown { e.stopPropagation(); if (iSelection < options.length) { const selection = options[iSelection]; - selection.classList.remove(DROPDOWN_DIRECTORY_SELECTION_CLASS); + selection.classList.remove(DROPDOWN_DIRECTORY_SELECTION_KEY_CLASS); } else { e.target.blur(); @@ -687,22 +962,26 @@ class DirectoryDropdown { e.stopPropagation(); e.preventDefault(); // prevent cursor move const input = e.target; - DirectoryDropdown.selectionToInput(input, selection, searchSeparator); - updateDropdown(); - //updateCallback(); - //submitCallback(); - /* - const options = dropdown.children; - if (options.length > 0) { - // arrow key navigation - options[0].classList.add(DROPDOWN_DIRECTORY_SELECTION_CLASS); + const searchSeparator = modelData.searchSeparator; + DirectoryDropdown.selectionToInput( + input, + selection, + searchSeparator, + DROPDOWN_DIRECTORY_SELECTION_KEY_CLASS + ); + const path = this.#updateOptions(); + if (path !== undefined) { + this.#restoreSelectedOption(path); + this.#updateDeepestPath(path); } - */ + updateCallback(); + //await submitCallback(); } } else if (e.key === "ArrowLeft" && dropdown.style.display !== "none") { const input = e.target; const oldFilterText = input.value; + const searchSeparator = modelData.searchSeparator; const iSep = oldFilterText.lastIndexOf(searchSeparator, oldFilterText.length - 2); const newFilterText = oldFilterText.substring(0, iSep + 1); if (oldFilterText !== newFilterText) { @@ -722,41 +1001,36 @@ class DirectoryDropdown { e.stopPropagation(); e.preventDefault(); // prevent cursor move input.value = newFilterText; - updateDropdown(); - //updateCallback(); - //submitCallback(); - /* - const options = dropdown.children; - let isSelected = false; - for (let i = 0; i < options.length; i++) { - const option = options[i]; - if (option.innerText.startsWith(delta)) { - option.classList.add(DROPDOWN_DIRECTORY_SELECTION_CLASS); - isSelected = true; - break; - } + const path = this.#updateOptions(); + if (path !== undefined) { + this.#restoreSelectedOption(path); + this.#updateDeepestPath(path); } - if (!isSelected) { - const options = dropdown.children; - if (options.length > 0) { - // arrow key navigation - options[0].classList.add(DROPDOWN_DIRECTORY_SELECTION_CLASS); - } - } - */ + updateCallback(); + //await submitCallback(); } } } else if (e.key === "Enter") { e.stopPropagation(); - const input = e.target - const selection = options[iSelection]; - if (selection !== undefined && selection !== null) { - DirectoryDropdown.selectionToInput(input, selection, searchSeparator); - updateDropdown(); - updateCallback(); + const input = e.target; + if (dropdown.style.display !== "none") { + const selection = options[iSelection]; + if (selection !== undefined && selection !== null) { + DirectoryDropdown.selectionToInput( + input, + selection, + modelData.searchSeparator, + DROPDOWN_DIRECTORY_SELECTION_KEY_CLASS + ); + const path = this.#updateOptions(); + if (path !== undefined) { + this.#updateDeepestPath(path); + } + updateCallback(); + } } - submitCallback(); + await submitCallback(); input.blur(); } else if ((e.key === "ArrowDown" || e.key === "ArrowUp") && dropdown.style.display !== "none") { @@ -765,36 +1039,33 @@ class DirectoryDropdown { let iNext = options.length; if (iSelection < options.length) { const selection = options[iSelection]; - selection.classList.remove(DROPDOWN_DIRECTORY_SELECTION_CLASS); + selection.classList.remove(DROPDOWN_DIRECTORY_SELECTION_KEY_CLASS); const delta = e.key === "ArrowDown" ? 1 : -1; iNext = iSelection + delta; - if (0 <= iNext && iNext < options.length) { - const selectionNext = options[iNext]; - selectionNext.classList.add(DROPDOWN_DIRECTORY_SELECTION_CLASS); + if (iNext < 0) { + iNext = options.length - 1; } + else if (iNext >= options.length) { + iNext = 0; + } + const selectionNext = options[iNext]; + selectionNext.classList.add(DROPDOWN_DIRECTORY_SELECTION_KEY_CLASS); } - else if (iSelection === options.length) { + else if (iSelection === options.length) { // none iNext = e.key === "ArrowDown" ? 0 : options.length-1; - const selection = options[iNext] - selection.classList.add(DROPDOWN_DIRECTORY_SELECTION_CLASS); + const selection = options[iNext]; + selection.classList.add(DROPDOWN_DIRECTORY_SELECTION_KEY_CLASS); } if (0 <= iNext && iNext < options.length) { - let dropdownTop = dropdown.scrollTop; - const dropdownHeight = dropdown.offsetHeight; - const selection = options[iNext]; - const selectionHeight = selection.offsetHeight; - const selectionTop = selection.offsetTop; - dropdownTop = Math.max(dropdownTop, selectionTop - dropdownHeight + selectionHeight); - dropdownTop = Math.min(dropdownTop, selectionTop); - dropdown.scrollTop = dropdownTop; + DirectoryDropdown.#clampDropdownScrollTop(dropdown, options[iNext]); } else { dropdown.scrollTop = 0; const options = dropdown.children; for (iSelection = 0; iSelection < options.length; iSelection++) { const selection = options[iSelection]; - if (selection.classList.contains(DROPDOWN_DIRECTORY_SELECTION_CLASS)) { - selection.classList.remove(DROPDOWN_DIRECTORY_SELECTION_CLASS); + if (selection.classList.contains(DROPDOWN_DIRECTORY_SELECTION_KEY_CLASS)) { + selection.classList.remove(DROPDOWN_DIRECTORY_SELECTION_KEY_CLASS); } } } @@ -802,182 +1073,207 @@ class DirectoryDropdown { }, ); } - + /** * @param {HTMLInputElement} input * @param {HTMLParagraphElement | undefined | null} selection * @param {String} searchSeparator + * @param {String} className + * @returns {boolean} changed */ - static selectionToInput(input, selection, searchSeparator) { - selection.classList.remove(DROPDOWN_DIRECTORY_SELECTION_CLASS); + static selectionToInput(input, selection, searchSeparator, className) { + selection.classList.remove(className); const selectedText = selection.innerText; const oldFilterText = input.value; const iSep = oldFilterText.lastIndexOf(searchSeparator); const previousPath = oldFilterText.substring(0, iSep + 1); - input.value = previousPath + selectedText; + const newFilterText = previousPath + selectedText; + input.value = newFilterText; + return newFilterText !== oldFilterText; } /** - * @param {DirectoryItem[]} directories - * @param {string} searchSeparator - * @param {string} [modelType = ""] + * @param {string} path */ - update(directories, searchSeparator, modelType = "") { + #updateDeepestPath = (path) => { + const deepestPath = this.#deepestPreviousPath; + if (path.length > deepestPath.length || !deepestPath.startsWith(path)) { + this.#deepestPreviousPath = path; + } + }; + + /** + * @param {HTMLDivElement} dropdown + * @param {HTMLParagraphElement} selection + */ + static #clampDropdownScrollTop = (dropdown, selection) => { + let dropdownTop = dropdown.scrollTop; + const dropdownHeight = dropdown.offsetHeight; + const selectionHeight = selection.offsetHeight; + const selectionTop = selection.offsetTop; + dropdownTop = Math.max(dropdownTop, selectionTop - dropdownHeight + selectionHeight); + dropdownTop = Math.min(dropdownTop, selectionTop); + dropdown.scrollTop = dropdownTop; + }; + + /** + * @param {string} path + */ + #restoreSelectedOption(path) { + const searchSeparator = this.#modelData.searchSeparator; + const deepest = this.#deepestPreviousPath; + if (deepest.length >= path.length && deepest.startsWith(path)) { + let name = deepest.substring(path.length); + name = removePrefix(name, searchSeparator); + const i1 = name.indexOf(searchSeparator); + if (i1 !== -1) { + name = name.substring(0, i1); + } + + const dropdown = this.element; + const options = dropdown.children; + let iSelection; + for (iSelection = 0; iSelection < options.length; iSelection++) { + const selection = options[iSelection]; + let text = removeSuffix(selection.innerText, searchSeparator); + if (text === name) { + selection.classList.add(DROPDOWN_DIRECTORY_SELECTION_KEY_CLASS); + dropdown.scrollTop = dropdown.scrollHeight; // snap to top + DirectoryDropdown.#clampDropdownScrollTop(dropdown, selection); + break; + } + } + if (iSelection === options.length) { + dropdown.scrollTop = 0; + } + } + } + + /** + * Returns path if update was successful. + * @returns {string | undefined} + */ + #updateOptions() { const dropdown = this.element; const input = this.#input; - const updateDropdown = this.#updateDropdown; - const updateCallback = this.#updateCallback; - const submitCallback = this.#submitCallback; - const showDirectoriesOnly = this.showDirectoriesOnly; + const searchSeparator = this.#modelData.searchSeparator; const filter = input.value; if (filter[0] !== searchSeparator) { dropdown.style.display = "none"; - return; + return undefined; } - let cwd = 0; - if (modelType !== "") { - const root = directories[0]; - const rootChildIndex = root["childIndex"]; - const rootChildCount = root["childCount"]; - cwd = null; - for (let i = rootChildIndex; i < rootChildIndex + rootChildCount; i++) { - const modelDir = directories[i]; - if (modelDir["name"] === modelType) { - cwd = i; - break; - } - } - } - - // TODO: directories === undefined? - let indexLastWord = 1; - while (true) { - const indexNextWord = filter.indexOf(searchSeparator, indexLastWord); - if (indexNextWord === -1) { - // end of filter - break; - } - - const item = directories[cwd]; - const childCount = item["childCount"]; - if (childCount === undefined) { - // file - break; - } - if (childCount === 0) { - // directory is empty - break; - } - const childIndex = item["childIndex"]; - const items = directories.slice(childIndex, childIndex + childCount); - - const word = filter.substring(indexLastWord, indexNextWord); - cwd = null; - for (let itemIndex = 0; itemIndex < items.length; itemIndex++) { - const itemName = items[itemIndex]["name"]; - if (itemName === word) { - // directory exists - cwd = childIndex + itemIndex; - break; - } - } - if (cwd === null) { - // directory does not exist - break; - } - indexLastWord = indexNextWord + 1; - } - if (cwd === null) { - dropdown.style.display = "none"; - return; - } - - let options = []; - const lastWord = filter.substring(indexLastWord); - const item = directories[cwd]; - if (item["childIndex"] !== undefined) { - const childIndex = item["childIndex"]; - const childCount = item["childCount"]; - const items = directories.slice(childIndex, childIndex + childCount); - for (let i = 0; i < items.length; i++) { - const child = items[i]; - const grandChildCount = child["childCount"]; - const isDir = grandChildCount !== undefined && grandChildCount !== null; - const itemName = child["name"]; - if (itemName.startsWith(lastWord) && (!showDirectoriesOnly || (showDirectoriesOnly && isDir))) { - options.push(itemName + (isDir ? searchSeparator : "")); - } - } - } - else if (!showDirectoriesOnly) { - const filename = item["name"]; - if (filename.startsWith(lastWord)) { - options.push(filename); - } - } + const modelType = this.#getModelType(); + const searchPrefix = modelType !== "" ? searchSeparator + modelType : ""; + const directories = this.#modelData.directories; + const [options, path] = directories.search( + searchPrefix + filter, + searchSeparator, + this.showDirectoriesOnly, + ); if (options.length === 0) { dropdown.style.display = "none"; - return; + return undefined; } - const selection_select = (e) => { + const mouse_selection_select = (e) => { const selection = e.target; if (e.movementX === 0 && e.movementY === 0) { return; } - if (!selection.classList.contains(DROPDOWN_DIRECTORY_SELECTION_CLASS)) { + if (!selection.classList.contains(DROPDOWN_DIRECTORY_SELECTION_MOUSE_CLASS)) { // assumes only one will ever selected at a time e.stopPropagation(); const children = dropdown.children; - let iChild; - for (iChild = 0; iChild < children.length; iChild++) { + for (let iChild = 0; iChild < children.length; iChild++) { const child = children[iChild]; - child.classList.remove(DROPDOWN_DIRECTORY_SELECTION_CLASS); + child.classList.remove(DROPDOWN_DIRECTORY_SELECTION_MOUSE_CLASS); } - selection.classList.add(DROPDOWN_DIRECTORY_SELECTION_CLASS); + selection.classList.add(DROPDOWN_DIRECTORY_SELECTION_MOUSE_CLASS); } }; - const selection_deselect = (e) => { + const mouse_selection_deselect = (e) => { e.stopPropagation(); - e.target.classList.remove(DROPDOWN_DIRECTORY_SELECTION_CLASS); + e.target.classList.remove(DROPDOWN_DIRECTORY_SELECTION_MOUSE_CLASS); }; - const selection_submit = (e) => { + const selection_submit = async(e) => { e.stopPropagation(); + e.preventDefault(); const selection = e.target; - DirectoryDropdown.selectionToInput(input, selection, searchSeparator); - updateDropdown(); - updateCallback();e.target - submitCallback(); + const changed = DirectoryDropdown.selectionToInput( + input, + selection, + searchSeparator, + DROPDOWN_DIRECTORY_SELECTION_MOUSE_CLASS + ); + if (!changed) { + dropdown.style.display = "none"; + input.blur(); + } + else { + const path = this.#updateOptions(); // TODO: is this needed? + if (path !== undefined) { + this.#updateDeepestPath(path); + } + } + this.#updateCallback(); }; - const innerHtml = options.map((text) => { + const touch_selection_select = async(e) => { + const [startX, startY] = this.#touchSelectionStart; + const [endX, endY] = [ + e.changedTouches[0].clientX, + e.changedTouches[0].clientY + ]; + if (startX === endX && startY === endY) { + const touch = e.changedTouches[0]; + const box = dropdown.getBoundingClientRect(); + if (touch.clientX >= box.left && + touch.clientX <= box.right && + touch.clientY >= box.top && + touch.clientY <= box.bottom) { + selection_submit(e); + } + } + }; + const touch_start = (e) => { + this.#touchSelectionStart = [ + e.changedTouches[0].clientX, + e.changedTouches[0].clientY + ]; + }; + dropdown.innerHTML = ""; + dropdown.append.apply(dropdown, options.map((text) => { /** @type {HTMLParagraphElement} */ const p = $el( "p", { - onmouseenter: (e) => selection_select(e), - onmousemove: (e) => selection_select(e), - onmouseleave: (e) => selection_deselect(e), + onmouseenter: (e) => mouse_selection_select(e), + onmousemove: (e) => mouse_selection_select(e), + onmouseleave: (e) => mouse_selection_deselect(e), onmousedown: (e) => selection_submit(e), + ontouchstart: (e) => touch_start(e), + ontouchmove: (e) => touch_move(e), + ontouchend: (e) => touch_selection_select(e), }, [ text ] ); return p; - }); - dropdown.innerHTML = ""; - dropdown.append.apply(dropdown, innerHtml); + })); // TODO: handle when dropdown is near the bottom of the window const inputRect = input.getBoundingClientRect(); dropdown.style.width = inputRect.width + "px"; dropdown.style.top = (input.offsetTop + inputRect.height) + "px"; dropdown.style.left = input.offsetLeft + "px"; dropdown.style.display = "block"; + + return path; } } const MODEL_SORT_DATE_CREATED = "dateCreated"; const MODEL_SORT_DATE_MODIFIED = "dateModified"; +const MODEL_SORT_SIZE_BYTES = "sizeBytes"; const MODEL_SORT_DATE_NAME = "name"; class ModelGrid { @@ -986,7 +1282,7 @@ class ModelGrid { * @returns {int} */ static modelWidgetIndex(nodeType) { - return 0; + return nodeType === undefined ? -1 : 0; } /** @@ -1041,7 +1337,7 @@ class ModelGrid { } /** - * In-place sort. Returns an arrat alias. + * In-place sort. Returns an array alias. * @param {Array} list * @param {string} sortBy * @param {bool} [reverse=false] @@ -1059,6 +1355,9 @@ class ModelGrid { case MODEL_SORT_DATE_CREATED: compareFn = (a, b) => { return b[MODEL_SORT_DATE_CREATED] - a[MODEL_SORT_DATE_CREATED]; }; break; + case MODEL_SORT_SIZE_BYTES: + compareFn = (a, b) => { return b[MODEL_SORT_SIZE_BYTES] - a[MODEL_SORT_SIZE_BYTES]; }; + break; default: console.warn("Invalid filter sort value: '" + sortBy + "'"); return list; @@ -1080,7 +1379,7 @@ class ModelGrid { const nodeType = modelNodeType[modelType]; const widgetIndex = ModelGrid.modelWidgetIndex(nodeType); let node = LiteGraph.createNode(nodeType, null, []); - if (node) { + if (widgetIndex !== -1 && node) { node.widgets[widgetIndex].value = path; const selectedNodes = app.canvas.selected_nodes; let isSelectedNode = false; @@ -1109,7 +1408,7 @@ class ModelGrid { const selectedNode = selectedNodes[i]; const nodeType = modelNodeType[modelType]; const widgetIndex = ModelGrid.modelWidgetIndex(nodeType); - const target = selectedNode.widgets[widgetIndex].element; + const target = selectedNode?.widgets[widgetIndex]?.element; if (target && target.type === "textarea") { target.value = ModelGrid.insertEmbeddingIntoText(target.value, embeddingFile, removeEmbeddingExtension); success = true; @@ -1122,41 +1421,72 @@ class ModelGrid { } buttonAlert(event.target, success, "βœ”", "βœ–", "✚"); } - + + static #getWidgetComboIndices(node, value) { + const widgetIndices = []; + node?.widgets?.forEach((widget, index) => { + if (widget.type === "combo" && widget.options.values?.includes(value)) { + widgetIndices.push(index); + } + }); + return widgetIndices; + } + /** * @param {Event} event * @param {string} modelType * @param {string} path * @param {boolean} removeEmbeddingExtension - * @param {boolean} strictDragToAdd + * @param {boolean} strictlyOnWidget */ - static #dragAddModel(event, modelType, path, removeEmbeddingExtension, strictDragToAdd) { + static #dragAddModel(event, modelType, path, removeEmbeddingExtension, strictlyOnWidget) { const target = document.elementFromPoint(event.x, event.y); if (modelType !== "embeddings" && target.id === "graph-canvas") { - const nodeType = modelNodeType[modelType]; - const widgetIndex = ModelGrid.modelWidgetIndex(nodeType); const pos = app.canvas.convertEventToCanvasOffset(event); - const nodeAtPos = app.graph.getNodeOnPos(pos[0], pos[1], app.canvas.visible_nodes); + const node = app.graph.getNodeOnPos(pos[0], pos[1], app.canvas.visible_nodes); - let draggedOnNode = nodeAtPos && nodeAtPos.type === nodeType; - if (strictDragToAdd) { - const draggedOnWidget = app.canvas.processNodeWidgets(nodeAtPos, pos, event) === nodeAtPos.widgets[widgetIndex]; - draggedOnNode = draggedOnNode && draggedOnWidget; + let widgetIndex = -1; + if (widgetIndex === -1) { + const widgetIndices = this.#getWidgetComboIndices(node, path); + if (widgetIndices.length === 0) { + widgetIndex = -1; + } + else if (widgetIndices.length === 1) { + widgetIndex = widgetIndices[0]; + if (strictlyOnWidget) { + const draggedWidget = app.canvas.processNodeWidgets(node, pos, event); + const widget = node.widgets[widgetIndex]; + if (draggedWidget != widget) { // != check NOT same object + widgetIndex = -1; + } + } + } + else { + // ambiguous widget (strictlyOnWidget always true) + const draggedWidget = app.canvas.processNodeWidgets(node, pos, event); + widgetIndex = widgetIndices.findIndex((index) => { + return draggedWidget == node.widgets[index]; // == check same object + }); + } } - if (draggedOnNode) { - let node = nodeAtPos; + if (widgetIndex !== -1) { node.widgets[widgetIndex].value = path; app.canvas.selectNode(node); } else { - let node = LiteGraph.createNode(nodeType, null, []); - if (node) { - node.pos[0] = pos[0]; - node.pos[1] = pos[1]; - node.widgets[widgetIndex].value = path; - app.graph.add(node, {doProcessChange: true}); - app.canvas.selectNode(node); + const expectedNodeType = modelNodeType[modelType]; + const newNode = LiteGraph.createNode(expectedNodeType, null, []); + let newWidgetIndex = ModelGrid.modelWidgetIndex(expectedNodeType); + if (newWidgetIndex === -1) { + newWidgetIndex = this.#getWidgetComboIndices(newNode, path)[0] ?? -1; + } + if (newNode !== undefined && newNode !== null && newWidgetIndex !== -1) { + newNode.pos[0] = pos[0]; + newNode.pos[1] = pos[1]; + newNode.widgets[newWidgetIndex].value = path; + app.graph.add(newNode, {doProcessChange: true}); + app.canvas.selectNode(newNode); } } event.stopPropagation(); @@ -1196,9 +1526,11 @@ class ModelGrid { else if (nodeType) { const node = LiteGraph.createNode(nodeType, null, []); const widgetIndex = ModelGrid.modelWidgetIndex(nodeType); - node.widgets[widgetIndex].value = path; - app.canvas.copyToClipboard([node]); - success = true; + if (widgetIndex !== -1) { + node.widgets[widgetIndex].value = path; + app.canvas.copyToClipboard([node]); + success = true; + } } else { console.warn(`Unable to copy unknown model type '${modelType}.`); @@ -1212,11 +1544,11 @@ class ModelGrid { * @param {Object.} settingsElements * @param {String} searchSeparator * @param {String} systemSeparator - * @param {(searchPath: string) => Promise} modelInfoCallback + * @param {(searchPath: string) => Promise} showModelInfo * @returns {HTMLElement[]} */ - static #generateInnerHtml(models, modelType, settingsElements, searchSeparator, systemSeparator, modelInfoCallback) { - // TODO: seperate text and model logic; getting too messy + static #generateInnerHtml(models, modelType, settingsElements, searchSeparator, systemSeparator, showModelInfo) { + // TODO: separate text and model logic; getting too messy // TODO: fallback on button failure to copy text? const canShowButtons = modelNodeType[modelType] !== undefined; const showAddButton = canShowButtons && settingsElements["model-show-add-button"].checked; @@ -1231,7 +1563,7 @@ class ModelGrid { const searchPath = item.path; const path = SearchPath.systemPath(searchPath, searchSeparator, systemSeparator); let buttons = []; - if (showAddButton) { + if (showAddButton && !(modelType === "embeddings" && !navigator.clipboard)) { buttons.push( $el("button.icon-button.model-button", { type: "button", @@ -1271,7 +1603,13 @@ class ModelGrid { ); return $el("div.item", {}, [ $el("img.model-preview", { - src: imageUri(previewInfo?.path, previewInfo?.dateModified), + 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, + PREVIEW_THUMBNAIL_WIDTH, + PREVIEW_THUMBNAIL_HEIGHT, + ), draggable: false, }), $el("div.model-preview-overlay", { @@ -1289,13 +1627,13 @@ class ModelGrid { $el("button.icon-button.model-button", { type: "button", textContent: "β“˜", - onclick: async() => modelInfoCallback(searchPath), + onclick: async() => { await showModelInfo(searchPath) }, draggable: false, }), ]), $el("div.model-label", { ondragend: (e) => dragAdd(e), - draggable: true, + draggable: false, }, [ $el("p", [showModelExtension ? item.name : SearchPath.splitExtension(item.name)[0]]) ]), @@ -1308,7 +1646,7 @@ class ModelGrid { /** * @param {HTMLDivElement} modelGrid - * @param {Object} models + * @param {ModelData} modelData * @param {HTMLSelectElement} modelSelect * @param {Object.<{value: string}>} previousModelType * @param {Object} settings @@ -1316,11 +1654,10 @@ class ModelGrid { * @param {boolean} reverseSort * @param {Array} previousModelFilters * @param {HTMLInputElement} modelFilter - * @param {String} searchSeparator - * @param {String} systemSeparator - * @param {(searchPath: string) => Promise} modelInfoCallback + * @param {(searchPath: string) => Promise} showModelInfo */ - static update(modelGrid, models, modelSelect, previousModelType, settings, sortBy, reverseSort, previousModelFilters, modelFilter, searchSeparator, systemSeparator, modelInfoCallback) { + static update(modelGrid, modelData, modelSelect, previousModelType, settings, sortBy, reverseSort, previousModelFilters, modelFilter, showModelInfo) { + const models = modelData.models; let modelType = modelSelect.value; if (models[modelType] === undefined) { modelType = "checkpoints"; // TODO: magic value @@ -1358,9 +1695,9 @@ class ModelGrid { modelList, modelType, settings, - searchSeparator, - systemSeparator, - modelInfoCallback, + modelData.searchSeparator, + modelData.systemSeparator, + showModelInfo, ); modelGrid.append.apply(modelGrid, modelGridModels); } @@ -1372,36 +1709,33 @@ class ModelInfoView { elements = { /** @type {HTMLDivElement} */ info: null, + /** @type {HTMLTextAreaElement} */ notes: null, /** @type {HTMLButtonElement} */ setPreviewButton: null, + /** @type {HTMLInputElement} */ moveDestinationInput: null, }; /** @type {ImageSelect} */ previewSelect = null; + /** @type {string} */ + #savedNotesValue = null; + /** - * @param {DirectoryItem[]} modelDirectories - Should be unique for every radio group. + * @param {ModelData} modelData * @param {() => Promise} updateModels - * @param {string} searchSeparator */ - constructor(modelDirectories, updateModels, searchSeparator) { + constructor(modelData, updateModels) { const moveDestinationInput = $el("input.search-text-area", { name: "move directory", autocomplete: "off", - placeholder: searchSeparator, + placeholder: modelData.searchSeparator, + value: modelData.searchSeparator, }); + this.elements.moveDestinationInput = moveDestinationInput; - let searchDropdown = null; - searchDropdown = new DirectoryDropdown( + const searchDropdown = new DirectoryDropdown( + modelData, moveDestinationInput, - () => { - searchDropdown.update( - modelDirectories, - searchSeparator, - ); - }, - () => {}, - () => {}, - searchSeparator, true, ); @@ -1413,14 +1747,14 @@ class ModelInfoView { $: (el) => (this.elements.setPreviewButton = el), textContent: "Set as Preview", onclick: async(e) => { - const confirmation = window.confirm("Change preview image PERMANENTLY?"); + const confirmation = window.confirm("Change preview image(s) PERMANENTLY?"); let updatedPreview = false; if (confirmation) { e.target.disabled = true; const container = this.elements.info; const path = container.dataset.path; - const imageUrl = previewSelect.getImage(); - if (imageUrl === imageUri()) { + const imageUrl = await previewSelect.getImage(); + if (imageUrl === PREVIEW_NONE_URI) { const encodedPath = encodeURIComponent(path); updatedPreview = await request( `/model-manager/preview/delete?path=${encodedPath}`, @@ -1430,6 +1764,10 @@ class ModelInfoView { } ) .then((result) => { + const message = result["alert"]; + if (message !== undefined) { + window.alert(message); + } return result["success"]; }) .catch((err) => { @@ -1449,6 +1787,10 @@ class ModelInfoView { } ) .then((result) => { + const message = result["alert"]; + if (message !== undefined) { + window.alert(message); + } return result["success"]; }) .catch((err) => { @@ -1458,7 +1800,7 @@ class ModelInfoView { if (updatedPreview) { updateModels(); const previewSelect = this.previewSelect; - previewSelect.elements.defaultUrl.dataset.noimage = imageUri(); + previewSelect.elements.defaultUrl.dataset.noimage = PREVIEW_NONE_URI; previewSelect.resetModelInfoPreview(); this.element.style.display = "none"; } @@ -1496,7 +1838,11 @@ class ModelInfoView { ) .then((result) => { const deleted = result["success"]; - if (deleted) + const message = result["alert"]; + if (message !== undefined) { + window.alert(message); + } + if (deleted) { container.innerHTML = ""; this.element.style.display = "none"; @@ -1528,7 +1874,7 @@ class ModelInfoView { const [oldFilePath, oldFileName] = SearchPath.split(oldFile); const newFile = ( moveDestinationInput.value + - searchSeparator + + modelData.searchSeparator + oldFileName ); moved = await request( @@ -1543,6 +1889,10 @@ class ModelInfoView { ) .then((result) => { const moved = result["success"]; + const message = result["alert"]; + if (message !== undefined) { + window.alert(message); + } if (moved) { moveDestinationInput.value = ""; @@ -1568,19 +1918,64 @@ class ModelInfoView { ]); } - /** @returns {boolean} */ - isVisible() { - return this.element.style.display === "none"; - } - /** @returns {void} */ show() { - this.element.removeAttribute("style"); + this.element.style = ""; + this.element.scrollTop = 0; } - /** @returns {void} */ - hide() { + /** + * @param {boolean} + * @returns {Promise} + */ + async trySave(promptUser) { + if (this.element.style.display === "none") { + return true; + } + + const noteValue = this.elements.notes.value; + const savedNotesValue = this.#savedNotesValue; + if (noteValue.trim() === savedNotesValue.trim()) { + return true; + } + const saveChanges = !promptUser || window.confirm("Save notes?"); + if (saveChanges) { + const path = this.elements.info.dataset.path; + const saved = await saveNotes(path, noteValue); + if (!saved) { + window.alert("Failed to save notes!"); + return false; + } + this.#savedNotesValue = noteValue; + } + else { + const discardChanges = window.confirm("Discard changes?"); + if (!discardChanges) { + return false; + } + else { + this.elements.notes.value = savedNotesValue; + } + } + return true; + } + + /** + * @param {boolean?} promptSave + * @returns {Promise} + */ + async tryHide(promptSave = true) { + const notes = this.elements.notes; + if (promptSave && notes !== undefined && notes !== null) { + const saved = await this.trySave(promptSave); + if (!saved) { + return false; + } + this.#savedNotesValue = ""; + this.elements.notes.value = ""; + } this.element.style.display = "none"; + return true; } /** @@ -1591,11 +1986,22 @@ class ModelInfoView { async update(searchPath, updateModels, searchSeparator) { const path = encodeURIComponent(searchPath); const info = await request(`/model-manager/model/info?path=${path}`) + .then((result) => { + const success = result["success"]; + const message = result["alert"]; + if (message !== undefined) { + window.alert(message); + } + if (!success) { + return undefined; + } + return result["info"]; + }) .catch((err) => { console.log(err); - return null; + return undefined; }); - if (info === null) { + if (info === undefined || info === null) { return; } const infoHtml = this.elements.info; @@ -1642,6 +2048,10 @@ class ModelInfoView { ) .then((result) => { const renamed = result["success"]; + const message = result["alert"]; + if (message !== undefined) { + window.alert(message); + } if (renamed) { container.innerHTML = ""; @@ -1663,6 +2073,16 @@ class ModelInfoView { ); } + const fileDirectory = info["File Directory"]; + if (fileDirectory !== undefined && fileDirectory !== null && fileDirectory !== "") { + this.elements.moveDestinationInput.placeholder = fileDirectory + this.elements.moveDestinationInput.value = fileDirectory; // TODO: noise vs convenience + } + else { + this.elements.moveDestinationInput.placeholder = searchSeparator; + this.elements.moveDestinationInput.value = searchSeparator; + } + const previewSelect = this.previewSelect; const defaultUrl = previewSelect.elements.defaultUrl; if (info["Preview"]) { @@ -1671,7 +2091,7 @@ class ModelInfoView { defaultUrl.dataset.noimage = imageUri(imagePath, imageDateModified); } else { - defaultUrl.dataset.noimage = imageUri(); + defaultUrl.dataset.noimage = PREVIEW_NONE_URI; } previewSelect.resetModelInfoPreview(); const setPreviewButton = this.elements.setPreviewButton; @@ -1719,33 +2139,21 @@ class ModelInfoView { else { if (key === "Notes") { elements.push($el("h2", [key + ":"])); - const noteArea = $el("textarea.comfy-multiline-input", { + const notes = $el("textarea.comfy-multiline-input", { name: "model notes", value: value, - rows: 10, + rows: 12, }); - elements.push(noteArea); + this.elements.notes = notes; + this.#savedNotesValue = value; elements.push($el("button", { textContent: "Save Notes", - onclick: (e) => { - const saved = request( - "/model-manager/notes/save", - { - method: "POST", - body: JSON.stringify({ - "path": this.elements.info.dataset.path, - "notes": noteArea.value, - }), - } - ).then((result) => { - return result["success"]; - }) - .catch((err) => { - return false; - }); + onclick: async (e) => { + const saved = await this.trySave(false); buttonAlert(e.target, saved); }, })); + elements.push(notes); } else if (key === "Description") { if (value !== "") { @@ -1779,7 +2187,7 @@ class Civitai { * @param {string} id - Model ID. * @param {string} apiPath - Civitai request subdirectory. "models" for 'model' urls. "model-version" for 'api' urls. * - * @returns {Promise} Dictionary containing recieved model info. Returns an empty if fails. + * @returns {Promise} Dictionary containing received model info. Returns an empty if fails. */ static async requestInfo(id, apiPath) { const url = "https://civitai.com/api/v1/" + apiPath + "/" + id; @@ -1793,15 +2201,15 @@ class Civitai { } /** - * Extract file information from the given model version infomation. + * Extract file information from the given model version information. * - * @param {Object} modelVersionInfo - Model version infomation. + * @param {Object} modelVersionInfo - Model version information. * @param {(string|null)} [type=null] - Optional select by model type. * @param {(string|null)} [fp=null] - Optional select by floating point quantization. * @param {(string|null)} [size=null] - Optional select by sizing. * @param {(string|null)} [format=null] - Optional select by file format. * - * @returns {Object} - Extracted list of infomation on each file of the given model version. + * @returns {Object} - Extracted list of information on each file of the given model version. */ static getModelFilesInfo(modelVersionInfo, type = null, fp = null, size = null, format = null) { const files = []; @@ -1842,12 +2250,11 @@ class Civitai { return image["url"]; }), "name": modelVersionInfo["name"], + "description": modelVersionInfo["description"] ?? "", }; } /** - * - * * @param {string} stringUrl - Model url. * * @returns {Promise} - Download information for the given url. @@ -1879,6 +2286,7 @@ class Civitai { return { "name": modelVersionInfo["model"]["name"], "type": modelVersionInfo["model"]["type"], + "description": modelVersionInfo["description"] ?? "", "versions": [filesInfo] } } @@ -1910,13 +2318,81 @@ class Civitai { return { "name": modelInfo["name"], "type": modelInfo["type"], - "versions": modelVersions + "description": modelInfo["description"] ?? "", + "versions": modelVersions, } } else { return {}; } } + + /** + * @returns {string} + */ + static imagePostUrlPrefix() { + return "https://civitai.com/images/"; + } + + /** + * @returns {string} + */ + static imageUrlPrefix() { + return "https://image.civitai.com/"; + } + + /** + * @param {string} stringUrl - https://civitai.com/images/{imageId}. + * + * @returns {Promise} - Image information. + */ + static async getImageInfo(stringUrl) { + const imagePostUrlPrefix = Civitai.imagePostUrlPrefix(); + if (!stringUrl.startsWith(imagePostUrlPrefix)) { + return {}; + } + const id = stringUrl.substring(imagePostUrlPrefix.length).match(/^\d+/)[0]; + const url = `https://civitai.com/api/v1/images?imageId=${id}`; + try { + return await request(url); + } + catch (error) { + console.error("Failed to get image info from Civitai!", error); + return {}; + } + } + + /** + * @param {string} stringUrl - https://image.civitai.com/... + * + * @returns {Promise} + */ + static async getFullSizeImageUrl(stringUrl) { + const imageUrlPrefix = Civitai.imageUrlPrefix(); + if (!stringUrl.startsWith(imageUrlPrefix)) { + return ""; + } + const i0 = stringUrl.lastIndexOf("/"); + const i1 = stringUrl.lastIndexOf("."); + if (i0 === -1 || i1 === -1) { + return ""; + } + const id = parseInt(stringUrl.substring(i0 + 1, i1)).toString(); + const url = `https://civitai.com/api/v1/images?imageId=${id}`; + try { + const imageInfo = await request(url); + const items = imageInfo["items"]; + if (items.length === 0) { + console.warn("Civitai /api/v1/images returned 0 items."); + return stringUrl; + } + return items[0]["url"]; + } + catch (error) { + console.error("Failed to get image info from Civitai!", error); + return stringUrl; + } + } } class HuggingFace { @@ -1926,7 +2402,7 @@ class HuggingFace { * @param {string} id - Model ID. * @param {string} apiPath - API path. * - * @returns {Promise} Dictionary containing recieved model info. Returns an empty if fails. + * @returns {Promise} Dictionary containing received model info. Returns an empty if fails. */ static async requestInfo(id, apiPath = "models") { const url = "https://huggingface.co/api/" + apiPath + "/" + id; @@ -2029,10 +2505,99 @@ class HuggingFace { "baseDownloadUrl": baseDownloadUrl, "modelFiles": modelFiles, "images": images, + "name": modelId, }; } } +/** + * @param {string} urlText + * @returns {Promise<[string, any[]]>} [name, modelInfos] + */ +async function getModelInfos(urlText) { + // TODO: class for proper return type + return await (async () => { + if (urlText.startsWith("https://civitai.com")) { + const civitaiInfo = await Civitai.getFilteredInfo(urlText); + if (Object.keys(civitaiInfo).length === 0) { + return ["", []]; + } + const name = civitaiInfo["name"]; + const infos = []; + const type = civitaiInfo["type"]; + const modelInfo = civitaiInfo["description"]?? ""; + civitaiInfo["versions"].forEach((version) => { + const images = version["images"]; + const versionDescription = version["description"]??""; + const description = (versionDescription + "\n\n" + modelInfo).trim().replace(/<[^>]+>/g, ""); // quick hack + version["files"].forEach((file) => { + infos.push({ + "images": images, + "fileName": file["name"], + "modelType": type, + "downloadUrl": file["downloadUrl"], + "downloadFilePath": "", + "description": description, + "details": { + "fileSizeKB": file["sizeKB"], + "fileType": file["type"], + "fp": file["fp"], + "quant": file["size"], + "fileFormat": file["format"], + }, + }); + }); + }); + return [name, infos]; + } + if (urlText.startsWith("https://huggingface.co")) { + const hfInfo = await HuggingFace.getFilteredInfo(urlText); + if (Object.keys(hfInfo).length === 0) { + return ["", []]; + } + const files = hfInfo["modelFiles"]; + if (files.length === 0) { + return ["", []]; + } + const name = hfInfo["name"]; + const baseDownloadUrl = hfInfo["baseDownloadUrl"]; + const infos = hfInfo["modelFiles"].map((file) => { + const indexSep = file.lastIndexOf("/"); + const filename = file.substring(indexSep + 1); + return { + "images": hfInfo["images"], + "fileName": filename, + "modelType": "", + "downloadUrl": baseDownloadUrl + "/" + file + "?download=true", + "downloadFilePath": file.substring(0, indexSep + 1), + "description": "", + "details": { + "fileSizeKB": undefined, // TODO: too hard? + }, + }; + }); + return [name, infos]; + } + if (urlText.endsWith(".json")) { + const indexInfo = await request(urlText).catch(() => []); + const name = urlText.substring(math.max(urlText.lastIndexOf("/"), 0)); + const infos = indexInfo.map((file) => { + return { + "images": [], + "fileName": file["name"], + "modelType": DownloadTab.modelTypeToComfyUiDirectory(file["type"], "") ?? "", + "downloadUrl": file["download"], + "downloadFilePath": "", + "description": file["description"], + "details": {}, + }; + }); + return [name, infos]; + } + return ["", []]; + })(); +} + class DownloadTab { /** @type {HTMLDivElement} */ element = null; @@ -2043,11 +2608,54 @@ class DownloadTab { /** @type {HTMLInputElement} */ overwrite: null, }; + /** @type {DOMParser} */ + #domParser = null; + /** @type {() => Promise} */ #updateModels = () => {}; /** - * Tries to return the related ComfyUI model directory if unambigious. + * @param {ModelData} modelData + * @param {any} settings + * @param {() => Promise} updateModels + */ + constructor(modelData, settings, updateModels) { + this.#domParser = new DOMParser(); + this.#updateModels = updateModels; + const update = async() => { await this.#update(modelData, settings); }; + $el("div.tab-header", { + $: (el) => (this.element = el), + }, [ + $el("div.row.tab-header-flex-block", [ + $el("input.search-text-area", { + $: (el) => (this.elements.url = el), + type: "text", + name: "model download url", + autocomplete: "off", + placeholder: "Search URL...", + onkeydown: async (e) => { + if (e.key === "Enter") { + e.stopPropagation(); + await update(); + e.target.blur(); + } + }, + }), + $el("button.icon-button", { + onclick: async () => { await update(); }, + textContent: "πŸ”οΈŽ", + }), + ]), + $el("div.download-model-infos", { + $: (el) => (this.elements.infos = el), + }, [ + $el("h1", ["Input a URL to select a model to download."]), + ]), + ]); + } + + /** + * Tries to return the related ComfyUI model directory if unambiguous. * * @param {string | undefined} modelType - Model type. * @param {string | undefined} [fileType] - File type. Relevant for "Diffusers". @@ -2089,131 +2697,124 @@ class DownloadTab { /** * @param {Object} info - * @param {String[]} modelTypes - * @param {DirectoryItem[]} modelDirectories - * @param {String} searchSeparator + * @param {ModelData} modelData * @param {int} id + * @param {any} settings * @returns {HTMLDivElement} */ - #modelInfo(info, modelTypes, modelDirectories, searchSeparator, id) { + #modelInfoHtml(info, modelData, id, settings) { const downloadPreviewSelect = new ImageSelect( "model-download-info-preview-model" + "-" + id, info["images"], ); - const el_modelTypeSelect = $el("select.model-select-dropdown", { - name: "model select dropdown", - }, (() => { - const options = [$el("option", { value: "" }, ["-- Model Type --"])]; - modelTypes.forEach((modelType) => { - options.push($el("option", { value: modelType }, [modelType])); - }); - return options; - })()); + const comfyUIModelType = ( + DownloadTab.modelTypeToComfyUiDirectory(info["details"]["fileType"]) ?? + DownloadTab.modelTypeToComfyUiDirectory(info["modelType"]) ?? + "" + ); + const searchSeparator = modelData.searchSeparator; + const defaultBasePath = searchSeparator + (comfyUIModelType === "" ? "" : comfyUIModelType + searchSeparator + "0"); const el_saveDirectoryPath = $el("input.search-text-area", { type: "text", name: "save directory", autocomplete: "off", - placeholder: searchSeparator + "0", - value: searchSeparator + "0", + placeholder: defaultBasePath, + value: defaultBasePath, }); - let searchDropdown = null; - searchDropdown = new DirectoryDropdown( + const searchDropdown = new DirectoryDropdown( + modelData, el_saveDirectoryPath, - () => { - const modelType = el_modelTypeSelect.value; - if (modelType === "") { return; } - searchDropdown.update( - modelDirectories, - searchSeparator, - modelType, - ); - }, - () => {}, - () => {}, - searchSeparator, true, ); + const default_name = (() => { + const filename = info["fileName"]; + // TODO: only remove valid model file extensions + const i = filename.lastIndexOf("."); + return i === - 1 ? filename : filename.substring(0, i); + })(); const el_filename = $el("input.plain-text-area", { type: "text", name: "model save file name", autocomplete: "off", - placeholder: (() => { - const filename = info["fileName"]; - // TODO: only remove valid model file extensions - const i = filename.lastIndexOf("."); - return i === - 1 ? filename : filename.substring(0, i); - })(), + placeholder: default_name, + value: default_name, + onkeydown: (e) => { + if (e.key === "Enter") { + e.stopPropagation(); + e.target.blur(); + } + }, }); const filepath = info["downloadFilePath"]; const modelInfo = $el("details.download-details", [ $el("summary", [filepath + info["fileName"]]), - $el("div", { - style: { display: "flex", "flex-wrap": "wrap", gap: "16px" }, - }, [ + $el("div", [ downloadPreviewSelect.elements.previews, - $el("div.download-settings", [ - $el("div", { - style: { "margin-top": "8px" } - }, [ - $el("div.row.tab-header-flex-block", [ - el_modelTypeSelect, - ]), + $el("div.download-settings-wrapper", [ + $el("div.download-settings", [ + $el("button.icon-button", { + textContent: "πŸ“₯︎", + onclick: async (e) => { + const pathDirectory = el_saveDirectoryPath.value; + const modelName = (() => { + const filename = info["fileName"]; + const name = el_filename.value; + if (name === "") { + return filename; + } + const ext = MODEL_EXTENSIONS.find((ext) => { + return filename.endsWith(ext); + }) ?? ""; + return name + ext; + })(); + const formData = new FormData(); + formData.append("download", info["downloadUrl"]); + formData.append("path", pathDirectory); + formData.append("name", modelName); + const image = await downloadPreviewSelect.getImage(); + formData.append("image", image === PREVIEW_NONE_URI ? "" : image); + formData.append("overwrite", this.elements.overwrite.checked); + e.target.disabled = true; + const [success, resultText] = await request( + "/model-manager/model/download", + { + method: "POST", + body: formData, + } + ).then((data) => { + const success = data["success"]; + const message = data["alert"]; + if (message !== undefined) { + window.alert(message); + } + return [success, success ? "βœ”" : "πŸ“₯︎"]; + }).catch((err) => { + return [false, "πŸ“₯︎"]; + }); + if (success) { + const description = info["description"]; + if (settings["download-save-description-as-text-file"].checked && description !== "") { + const modelPath = pathDirectory + searchSeparator + modelName; + const saved = await saveNotes(modelPath, description); + if (!saved) { + console.warn("Description was note saved as notes!"); + } + } + this.#updateModels(); + } + buttonAlert(e.target, success, "βœ”", "βœ–", resultText); + e.target.disabled = success; + }, + }), $el("div.row.tab-header-flex-block", [ el_saveDirectoryPath, searchDropdown.element, ]), $el("div.row.tab-header-flex-block", [ - $el("button.icon-button", { - textContent: "πŸ“₯︎", - onclick: async (e) => { - const formData = new FormData(); - formData.append("download", info["downloadUrl"]); - formData.append("path", - el_modelTypeSelect.value + - searchSeparator + // NOTE: this may add multiple separators (server should handle carefully) - el_saveDirectoryPath.value - ); - formData.append("name", (() => { - const filename = info["fileName"]; - const name = el_filename.value; - if (name === "") { - return filename; - } - const ext = MODEL_EXTENSIONS.find((ext) => { - return filename.endsWith(ext); - }) ?? ""; - return name + ext; - })()); - const image = downloadPreviewSelect.getImage(); - formData.append("image", image === imageUri() ? "" : image); - formData.append("overwrite", this.elements.overwrite.checked); - e.target.disabled = true; - const [success, resultText] = await request( - "/model-manager/model/download", - { - method: "POST", - body: formData, - } - ).then((data) => { - const success = data["success"]; - if (!success) { - console.warn(data["invalid"]); - } - return [success, success ? "βœ”" : "πŸ“₯︎"]; - }).catch((err) => { - return [false, "πŸ“₯︎"]; - }); - if (success) { - this.#updateModels(); - } - buttonAlert(e.target, success, "βœ”", "βœ–", resultText); - e.target.disabled = success; - }, - }), el_filename, ]), downloadPreviewSelect.elements.radioGroup, @@ -2222,176 +2823,51 @@ class DownloadTab { ]), ]); - el_modelTypeSelect.selectedIndex = 0; // reset - const comfyUIModelType = ( - DownloadTab.modelTypeToComfyUiDirectory(info["details"]["fileType"]) ?? - DownloadTab.modelTypeToComfyUiDirectory(info["modelType"]) ?? - null - ); - if (comfyUIModelType !== undefined && comfyUIModelType !== null) { - const modelTypeOptions = el_modelTypeSelect.children; - for (let i = 0; i < modelTypeOptions.length; i++) { - const option = modelTypeOptions[i]; - if (option.value === comfyUIModelType) { - el_modelTypeSelect.selectedIndex = i; - break; - } - } - } - return modelInfo; } - - /** - * @param {Object} models - * @param {DirectoryItem[]} modelDirectories - * @param {string} searchSeparator - */ - async search(models, modelDirectories, searchSeparator) { - const infosHtml = this.elements.infos; - infosHtml.innerHTML = ""; - const urlText = this.elements.url.value; - const modelInfos = await (async () => { - if (urlText.startsWith("https://civitai.com")) { - const civitaiInfo = await Civitai.getFilteredInfo(urlText); - if (Object.keys(civitaiInfo).length === 0) { - return []; - } - const infos = []; - const type = civitaiInfo["type"]; - civitaiInfo["versions"].forEach((version) => { - const images = version["images"]; - version["files"].forEach((file) => { - infos.push({ - "images": images, - "fileName": file["name"], - "modelType": type, - "downloadUrl": file["downloadUrl"], - "downloadFilePath": "", - "details": { - "fileSizeKB": file["sizeKB"], - "fileType": file["type"], - "fp": file["fp"], - "quant": file["size"], - "fileFormat": file["format"], - }, - }); - }); - }); - return infos; - } - if (urlText.startsWith("https://huggingface.co")) { - const hfInfo = await HuggingFace.getFilteredInfo(urlText); - if (Object.keys(hfInfo).length === 0) { - return []; - } - const files = hfInfo["modelFiles"]; - if (files.length === 0) { - return []; - } - - const baseDownloadUrl = hfInfo["baseDownloadUrl"]; - return hfInfo["modelFiles"].map((file) => { - const indexSep = file.lastIndexOf("/"); - const filename = file.substring(indexSep + 1); - return { - "images": hfInfo["images"], - "fileName": filename, - "modelType": "", - "downloadUrl": baseDownloadUrl + "/" + file + "?download=true", - "downloadFilePath": file.substring(0, indexSep + 1), - "details": { - "fileSizeKB": undefined, // TODO: too hard? - }, - }; - }); - } - if (urlText.endsWith(".json")) { - const indexInfo = await request(urlText).catch(() => []); - return indexInfo.map((file) => { - return { - "images": [], - "fileName": file["name"], - "modelType": DownloadTab.modelTypeToComfyUiDirectory(file["type"], "") ?? "", - "downloadUrl": file["download"], - "downloadFilePath": "", - "details": {}, - }; - }); - } - return []; - })(); - - const modelTypes = Object.keys(models); + /** + * @param {ModelData} modelData + * @param {any} settings + */ + async #update(modelData, settings) { + const [name, modelInfos] = await getModelInfos(this.elements.url.value); const modelInfosHtml = modelInfos.filter((modelInfo) => { const filename = modelInfo["fileName"]; return MODEL_EXTENSIONS.find((ext) => { return filename.endsWith(ext); }) ?? false; }).map((modelInfo, id) => { - return this.#modelInfo( + return this.#modelInfoHtml( modelInfo, - modelTypes, - modelDirectories, - searchSeparator, + modelData, id, + settings, ); }); - if (modelInfos.length === 0) { - modelInfosHtml.push($el("div", ["No results found."])); + if (modelInfosHtml.length === 0) { + modelInfosHtml.push($el("h1", ["No models found."])); } else { - if (modelInfos.length === 1) { + if (modelInfosHtml.length === 1) { modelInfosHtml[0].open = true; } - const label = $checkbox({ - $: (el) => { this.elements.overwrite = el; }, - textContent: "Overwrite Existing Files", - }); - modelInfosHtml.unshift(label); + + const header = $el("div", [ + $el("h1", [name]), + $checkbox({ + $: (el) => { this.elements.overwrite = el; }, + textContent: "Overwrite Existing Files.", + checked: false, + }), + ]); + modelInfosHtml.unshift(header); } + + const infosHtml = this.elements.infos; + infosHtml.innerHTML = ""; infosHtml.append.apply(infosHtml, modelInfosHtml); } - - /** - * @param {Object} models - * @param {DirectoryItem[]} modelDirectories - * @param {() => Promise} updateModels - * @param {string} searchSeparator - */ - constructor(models, modelDirectories, updateModels, searchSeparator) { - this.#updateModels = updateModels; - const search = async() => this.search(models, modelDirectories, searchSeparator); - $el("div.tab-header", { - $: (el) => (this.element = el), - }, [ - $el("div.row.tab-header-flex-block", [ - $el("input.search-text-area", { - $: (el) => (this.elements.url = el), - type: "text", - name: "model download url", - autocomplete: "off", - placeholder: "example: https://civitai.com/models/207992/stable-video-diffusion-svd", - onkeydown: (e) => { - if (e.key === "Enter") { - e.stopPropagation(); - search(); - } - }, - }), - $el("button.icon-button", { - onclick: () => search(), - textContent: "πŸ”οΈŽ", - }), - ]), - $el("div.download-model-infos", { - $: (el) => (this.elements.infos = el), - }, [ - $el("div", ["Input a URL to select a model to download."]), - ]), - ]); - } } class ModelTab { @@ -2405,36 +2881,83 @@ class ModelTab { /** @type {HTMLInputElement} */ modelContentFilter: null, }; + /** @type {Array} */ + previousModelFilters = []; + + /** @type {Object.<{value: string}>} */ + previousModelType = { value: null }; + /** @type {DirectoryDropdown} */ directoryDropdown = null; + /** @type {ModelData} */ + #modelData = null; + + /** @type {@param {() => Promise}} */ + #updateModels = null; + + /** */ + #settingsElements = null; + + /** @type {() => void} */ + updateModelGrid = () => {}; + /** - * @param {() => void} updateDirectoryDropdown - * @param {() => void} updatePreviousModelFilter - * @param {() => Promise} updateModelGrid * @param {() => Promise} updateModels - * @param {string} searchSeparator + * @param {ModelData} modelData + * @param {(searchPath: string) => Promise} showModelInfo + * @param {any} settingsElements */ - constructor(updateDirectoryDropdown, updatePreviousModelFilter, updateModelGrid, updateModels, searchSeparator) { + constructor(updateModels, modelData, showModelInfo, settingsElements) { /** @type {HTMLDivElement} */ const modelGrid = $el("div.comfy-grid"); this.elements.modelGrid = modelGrid; + this.#updateModels = updateModels; + this.#modelData = modelData; + this.#settingsElements = settingsElements; + const searchInput = $el("input.search-text-area", { $: (el) => (this.elements.modelContentFilter = el), type: "text", name: "model search", autocomplete: "off", - placeholder: "example: /0/1.5/styles/clothing -.pt", + placeholder: "/Search...", }); + const updatePreviousModelFilter = () => { + const modelType = this.elements.modelTypeSelect.value; + const value = this.elements.modelContentFilter.value; + this.previousModelFilters[modelType] = value; + }; + + const updateModelGrid = () => { + const sortValue = this.elements.modelSortSelect.value; + const reverseSort = sortValue[0] === "-"; + const sortBy = reverseSort ? sortValue.substring(1) : sortValue; + ModelGrid.update( + this.elements.modelGrid, + this.#modelData, + this.elements.modelTypeSelect, + this.previousModelType, + this.#settingsElements, + sortBy, + reverseSort, + this.previousModelFilters, + this.elements.modelContentFilter, + showModelInfo, + ); + this.element.parentElement.scrollTop = 0; + } + this.updateModelGrid = updateModelGrid; + const searchDropdown = new DirectoryDropdown( + modelData, searchInput, - updateDirectoryDropdown, + false, + () => { return this.elements.modelTypeSelect.value; }, updatePreviousModelFilter, updateModelGrid, - searchSeparator, - false, ); this.directoryDropdown = searchDropdown; @@ -2458,12 +2981,14 @@ class ModelTab { onchange: () => updateModelGrid(), }, [ - $el("option", { value: MODEL_SORT_DATE_CREATED }, ["Created (newest to oldest)"]), - $el("option", { value: "-" + MODEL_SORT_DATE_CREATED }, ["Created (oldest to newest)"]), - $el("option", { value: MODEL_SORT_DATE_MODIFIED }, ["Modified (newest to oldest)"]), - $el("option", { value: "-" + MODEL_SORT_DATE_MODIFIED }, ["Modified (oldest to newest)"]), + $el("option", { value: MODEL_SORT_DATE_CREATED }, ["Created (newest first)"]), + $el("option", { value: "-" + MODEL_SORT_DATE_CREATED }, ["Created (oldest first)"]), + $el("option", { value: MODEL_SORT_DATE_MODIFIED }, ["Modified (newest first)"]), + $el("option", { value: "-" + MODEL_SORT_DATE_MODIFIED }, ["Modified (oldest first)"]), $el("option", { value: MODEL_SORT_DATE_NAME }, ["Name (A-Z)"]), $el("option", { value: "-" + MODEL_SORT_DATE_NAME }, ["Name (Z-A)"]), + $el("option", { value: MODEL_SORT_SIZE_BYTES }, ["Size (largest first)"]), + $el("option", { value: "-" + MODEL_SORT_SIZE_BYTES }, ["Size (smallest first)"]), ], ), ]), @@ -2505,6 +3030,8 @@ class SettingsTab { /** @type {HTMLInputElement} */ "model-add-embedding-extension": null, /** @type {HTMLInputElement} */ "model-add-drag-strict-on-field": null, /** @type {HTMLInputElement} */ "model-add-offset": null, + + /** @type {HTMLInputElement} */ "download-save-description-as-text-file": null, }, }; @@ -2543,7 +3070,8 @@ class SettingsTab { */ async reload(updateModels) { const data = await request("/model-manager/settings/load"); - this.#setSettings(data["settings"], updateModels); + const settingsData = data["settings"]; + this.#setSettings(settingsData, updateModels); buttonAlert(this.elements.reloadButton, true); } @@ -2563,7 +3091,7 @@ class SettingsTab { } settingsData[setting] = value; } - + const data = await request( "/model-manager/settings/save", { @@ -2575,7 +3103,8 @@ class SettingsTab { }); const success = data["success"]; if (success) { - this.#setSettings(data["settings"], true); + const settingsData = data["settings"]; + this.#setSettings(settingsData, true); } buttonAlert(this.elements.saveButton, success); } @@ -2590,18 +3119,24 @@ class SettingsTab { $: (el) => (this.element = el), }, [ $el("h1", ["Settings"]), + $el("a", { + href: "https://github.com/hayden-fr/ComfyUI-Model-Manager/issues/" + }, [ + "File bugs and issues here." + ] + ), $el("div", [ $el("button", { $: (el) => (this.elements.reloadButton = el), type: "button", textContent: "Reload", // ⟳ - onclick: () => this.reload(true), + onclick: async () => { await this.reload(true); }, }), $el("button", { $: (el) => (this.elements.saveButton = el), type: "button", textContent: "Save", // πŸ’ΎοΈŽ - onclick: () => this.save(), + onclick: async () => { await this.save(); }, }), ]), /* @@ -2639,7 +3174,8 @@ class SettingsTab { $el("textarea.comfy-multiline-input", { $: (el) => (settings["model-search-always-append"] = el), name: "always include in model search", - placeholder: "example: -nsfw", + placeholder: "example: /0/sd1.5/styles \"pastel style\" -3d", + rows: "6", }), ]), ]), @@ -2681,6 +3217,11 @@ class SettingsTab { }), $el("p", ["Add model offset"]), ]), + $el("h2", ["Download"]), + $checkbox({ + $: (el) => (settings["download-save-description-as-text-file"] = el), + textContent: "Save descriptions as notes (in .txt file).", + }), ]); } } @@ -2703,14 +3244,20 @@ class SidebarButtons { const modelManager = this.#modelManager.element; const sidebarButtons = this.element.children; + const buttonActiveState = "sidebar-button-active"; + for (let i = 0; i < sidebarButtons.length; i++) { + sidebarButtons[i].classList.remove(buttonActiveState); + } + let buttonIndex; for (buttonIndex = 0; buttonIndex < sidebarButtons.length; buttonIndex++) { - if (sidebarButtons[buttonIndex] === button) { + const sidebarButton = sidebarButtons[buttonIndex]; + if (sidebarButton === button) { break; } } - const sidebarStates = ["sidebar-left", "sidebar-bottom", "sidebar-top", "sidebar-right"]; + const sidebarStates = ["sidebar-right", "sidebar-top", "sidebar-bottom", "sidebar-left"]; // TODO: magic numbers let stateIndex; for (stateIndex = 0; stateIndex < sidebarStates.length; stateIndex++) { const state = sidebarStates[stateIndex]; @@ -2723,6 +3270,8 @@ class SidebarButtons { if (stateIndex != buttonIndex) { const newSidebarState = sidebarStates[buttonIndex]; modelManager.classList.add(newSidebarState); + const sidebarButton = sidebarButtons[buttonIndex]; + sidebarButton.classList.add(buttonActiveState); } } @@ -2736,12 +3285,9 @@ class SidebarButtons { $: (el) => (this.element = el), }, [ + $el("button.icon-button", { - textContent: "β—§", - onclick: (event) => this.#setSidebar(event), - }), - $el("button.icon-button", { - textContent: "⬓", + textContent: "β—¨", onclick: (event) => this.#setSidebar(event), }), $el("button.icon-button", { @@ -2749,7 +3295,11 @@ class SidebarButtons { onclick: (event) => this.#setSidebar(event), }), $el("button.icon-button", { - textContent: "β—¨", + textContent: "⬓", + onclick: (event) => this.#setSidebar(event), + }), + $el("button.icon-button", { + textContent: "β—§", onclick: (event) => this.#setSidebar(event), }), ]); @@ -2757,18 +3307,11 @@ class SidebarButtons { } class ModelManager extends ComfyDialog { - #data = { - /** @type {Object} */ models: {}, - /** @type {DirectoryItem[]} */ modelDirectories: [], - /** @type {Array} */ previousModelFilters: [], - /** @type {Object.<{value: string}>} */ previousModelType: { value: null }, - }; + /** @type {HTMLDivElement} */ + element = null; - /** @type {string} */ - #searchSeparator = "/"; - - /** @type {string} */ - #systemSeparator = null; + /** @type {ModelData} */ + #modelData = null; /** @type {ModelInfoView} */ #modelInfoView = null; @@ -2782,146 +3325,204 @@ class ModelManager extends ComfyDialog { /** @type {SettingsTab} */ #settingsTab = null; - /** @type {SidebarButtons} */ - #sidebarButtons = null; + /** @type {HTMLDivElement} */ + #tabs = null; + + /** @type {HTMLDivElement} */ + #tabContents = null; + + /** @type {HTMLButtonElement} */ + #closeModelInfoButton = null; constructor() { super(); + + this.#modelData = new ModelData(); + const modelInfoView = new ModelInfoView( - this.#data.modelDirectories, - this.#modelTab_updateModels, - this.#searchSeparator, + this.#modelData, + this.#refreshModels, ); this.#modelInfoView = modelInfoView; - const downloadTab = new DownloadTab( - this.#data.models, - this.#data.modelDirectories, - this.#modelTab_updateModels, - this.#searchSeparator, - ); - this.#downloadTab = DownloadTab; - - const modelTab = new ModelTab( - this.#modelTab_updateDirectoryDropdown, - this.#modelTab_updatePreviousModelFilter, - this.#modelTab_updateModelGrid, - this.#modelTab_updateModels, - this.#searchSeparator, - ); - this.#modelTab = modelTab; - const settingsTab = new SettingsTab( - this.#modelTab_updateModels, + this.#refreshModels, ); this.#settingsTab = settingsTab; - const sidebarButtons = new SidebarButtons(this); - this.#sidebarButtons = sidebarButtons; + const ACTIVE_TAB_CLASS = "active"; - this.element = $el( + /** + * @param {searchPath: string} + * @return {Promise} + */ + const showModelInfo = async(searchPath) => { + await this.#modelInfoView.update( + searchPath, + this.#refreshModels, + this.#modelData.searchSeparator + ).then(() => { + this.#tabs.style.display = "none"; + this.#tabContents.style.display = "none"; + this.#closeModelInfoButton.style.display = ""; + this.#modelInfoView.show(); + }); + } + + const modelTab = new ModelTab( + this.#refreshModels, + this.#modelData, + showModelInfo, + this.#settingsTab.elements.settings, // TODO: decouple settingsData from elements? + ); + this.#modelTab = modelTab; + + const downloadTab = new DownloadTab( + this.#modelData, + this.#settingsTab.elements.settings, + this.#refreshModels, + ); + this.#downloadTab = downloadTab; + + const sidebarButtons = new SidebarButtons(this); + + /** @type {Record} */ + const head = {}; + + /** @type {Record} */ + const body = {}; + + /** @type {HTMLDivElement[]} */ + const contents = [ + $el("div", { dataset: { name: "Download" } }, [downloadTab.element]), + $el("div", { dataset: { name: "Models" } }, [modelTab.element]), + $el("div", { dataset: { name: "Settings" } }, [settingsTab.element]), + ]; + + const tabs = contents.map((content) => { + const name = content.getAttribute("data-name"); + /** @type {HTMLDivElement} */ + const tab = $el("div.head-item", { + onclick: () => { + Object.keys(head).forEach((key) => { + if (name === key) { + head[key].classList.add(ACTIVE_TAB_CLASS); + body[key].style.display = ""; + } else { + head[key].classList.remove(ACTIVE_TAB_CLASS); + body[key].style.display = "none"; + } + }); + }, + }, + [name], + ); + head[name] = tab; + body[name] = content; + return tab; + }); + tabs[0]?.click(); + + const closeManagerButton = $el("button.icon-button", { + textContent: "βœ–", + onclick: async() => { + const saved = await modelInfoView.trySave(true); + if (saved) { + this.close(); + } + } + }); + + const closeModelInfoButton = $el("button.icon-button", { + $: (el) => (this.#closeModelInfoButton = el), + style: { display: "none" }, + textContent: "β¬…", + onclick: async() => { await this.#tryHideModelInfo(true); }, + }); + + const modelManager = $el( "div.comfy-modal.model-manager", { + $: (el) => (this.element = el), parent: document.body, }, [ $el("div.comfy-modal-content", [ // TODO: settings.top_bar_left_to_right or settings.top_bar_right_to_left - modelInfoView.element, - $el("div.topbar-buttons", - [ - sidebarButtons.element, - $el("button.icon-button", { - textContent: "βœ–", - onclick: () => { - if (modelInfoView.isVisible()) { // TODO: decouple - this.close(); - } - else { - modelInfoView.hide(); - } - }, - }), - ] - ), - $tabs([ - $tab("Download", [downloadTab.element]), - $tab("Models", [modelTab.element]), - $tab("Settings", [settingsTab.element]), + $el("div.model-manager-panel", [ + $el("div.model-manager-head", [ + $el("div.topbar-right", [ + closeManagerButton, + closeModelInfoButton, + sidebarButtons.element, + ]), + $el("div.topbar-left", [ + $el("div.model-manager-tabs", { + $: (el) => (this.#tabs = el), + }, tabs), + ]), + ]), + $el("div.model-manager-body", [ + $el("div.model-manager-tab-contents", { + $: (el) => (this.#tabContents = el), + }, contents), + modelInfoView.element, + ]), ]), ]), ] ); + new ResizeObserver(() => { + if (modelManager.style.display === "none") { + return; + } + const minWidth = 768; // magic value (could easily break) + const managerRect = modelManager.getBoundingClientRect(); + const isNarrow = managerRect.width < minWidth; + let texts = isNarrow ? ["⬇️", "πŸ“", "βš™οΈ"] : ["Download", "Models", "Settings"]; // magic values + texts.forEach((text, i) => { + tabs[i].innerText = text; + }); + }).observe(modelManager); + this.#init(); } #init() { this.#settingsTab.reload(false); - this.#modelTab_updateModels(); + this.#refreshModels(); } - #modelTab_updateModelGrid = () => { - const modelTab = this.#modelTab; - const sortValue = modelTab.elements.modelSortSelect.value; - const reverseSort = sortValue[0] === "-"; - const sortBy = reverseSort ? sortValue.substring(1) : sortValue; - ModelGrid.update( - modelTab.elements.modelGrid, - this.#data.models, - modelTab.elements.modelTypeSelect, - this.#data.previousModelType, - this.#settingsTab.elements.settings, - sortBy, - reverseSort, - this.#data.previousModelFilters, - modelTab.elements.modelContentFilter, - this.#searchSeparator, - this.#systemSeparator, - this.#modelTab_showModelInfo, - ); - } - - #modelTab_updateModels = async() => { - this.#systemSeparator = await request("/model-manager/system-separator"); - + #refreshModels = async() => { + const modelData = this.#modelData; + modelData.systemSeparator = await request("/model-manager/system-separator"); const newModels = await request("/model-manager/models/list"); - Object.assign(this.#data.models, newModels); // NOTE: do NOT create a new object - + Object.assign(modelData.models, newModels); // NOTE: do NOT create a new object const newModelDirectories = await request("/model-manager/models/directory-list"); - this.#data.modelDirectories.splice(0, Infinity, ...newModelDirectories); // NOTE: do NOT create a new array + modelData.directories.data.splice(0, Infinity, ...newModelDirectories); // NOTE: do NOT create a new array - this.#modelTab_updateModelGrid(); - } - - #modelTab_updatePreviousModelFilter = () => { - const modelType = this.#modelTab.elements.modelTypeSelect.value; - const value = this.#modelTab.elements.modelContentFilter.value; - this.#data.previousModelFilters[modelType] = value; - }; - - #modelTab_updateDirectoryDropdown = () => { - this.#modelTab.directoryDropdown.update( - this.#data.modelDirectories, - this.#searchSeparator, - this.#modelTab.elements.modelTypeSelect.value, - ); - this.#modelTab_updatePreviousModelFilter(); + this.#modelTab.updateModelGrid(); + await this.#tryHideModelInfo(false); } /** - * @param {string} searchPath + * @param {boolean} promptSave + * @returns {Promise} */ - #modelTab_showModelInfo = async(searchPath) => { - this.#modelInfoView.update( - searchPath, - this.#modelTab_updateModels, - this.#searchSeparator - ).then(() => { - this.#modelInfoView.show(); - }); + #tryHideModelInfo = async(promptSave) => { + if (this.#tabContents.style.display === "none") { + if (!await this.#modelInfoView.tryHide(promptSave)) { + return false; + } + this.#closeModelInfoButton.style.display = "none"; + this.#tabs.style.display = ""; + this.#tabContents.style.display = ""; + } + return true; } } +/** @type {ModelManager | undefined} */ let instance; /** @@ -2950,7 +3551,16 @@ app.registerExtension({ id: "comfyui-model-manager-button", parent: document.querySelector(".comfy-menu"), textContent: "Models", - onclick: () => { getInstance().show(); }, + onclick: () => { + const modelManager = getInstance(); + const style = modelManager.element.style; + if (style.display === "" || style.display === "none") { + modelManager.show(); + } + else { + modelManager.close(); + } + }, }) ); },