diff --git a/README.md b/README.md index a31d7ab..41626f7 100644 --- a/README.md +++ b/README.md @@ -2,55 +2,66 @@ Download, browse and delete models in ComfyUI. -
-
-
+
+
## Features
+### Node Graph
+
+
+
+- Drag a model thumbnail onto the graph to add a new node.
+- Drag a model thumbnail onto an existing node to set the input field.
+ - If there are multiple valid possible fields, then the drag must be exact.
+- Drag an embedding thumbnail onto a text area, or highlight any number of nodes, to append it onto the end of the text.
+- Drag the preview image in a model's info view onto the graph to load the embedded workflow (if it exists).
+
+
+
- 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`.
+- Select a save directory and input a filename.
+- Optionally set a model's preview image.
+- Optionally edit and save descriptions as a .txt note. (Default behavior can be set in the settings tab.)
+- Add Civitai and HuggingFace API tokens in `server_settings.yaml`.
### Models Tab
-- Search bar in models tab.
-- 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/`.
-- Sort for models (Date Created, Date Modified, Name).
+
+
+- Search in real-time for models using the search bar.
+- Use advance keyword search by typing `"multiple words in quotes"` or a minus sign before to `-exclude` a word or phrase.
+- Add `/` at the start of a search to view a dropdown list of subdirectories (for example, `/0/1.5/styles/clothing`).
+ - Any directory paths in ComfyUI's `extra_model_paths.yaml` or directories added in `ComfyUI/models/` will automatically be detected.
+- Sort models by "Date Created", "Date Modified", "Name" and "File Size".
### Model Info View
-- View model metadata, including training tags and bucket resolutions.
-- Read, edit and save notes in a `.txt` file beside the model.
-- Change or remove a model's preview image (add a different one using a url or local upload).
-- Rename, move or **permanently** remove models.
+
-### ComfyUI Node Graph
-
-- Button to copy a model to the ComfyUI clipboard or embedding to system clipboard. (Embedding copying requires secure http connection.)
-- 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. (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.
+- View file info and metadata.
+- Rename, move or **permanently** remove a model and all of it's related files.
+- Read, edit and save notes. (Saved as a `.txt` file beside the model).
+ - `Ctrl+s` or `β+S` to save a note when the textarea is in focus.
+ - Autosave can be enabled in settings. (Note: Once the model info view is closed, the undo history is lost.)
+- Change or remove a model's preview image.
+- View training tags and use the random tag generator to generate prompt ideas. (Inspired by the one in A1111.)
### Settings Tab
-- Settings tab saved in `ui_settings.yaml`.
- - Hide/Show 'add' and 'copy-to-clipboard' buttons.
- - Text to always search.
- - Show/Hide add embedding extension.
-- Colors follow ComfyUI's current theme.
+
-### Known Issues
-
-- Pinch to Zoom can cause an invisible scrolling bug.
+- Settings are saved to `ui_settings.yaml`.
+- Most settings should update immediately, but a few may require a page reload to take effect.
+- Press the "Fix Extensions" button to correct all image file extensions in the model directories. (Note: This may take a minute or so to complete.)
diff --git a/__init__.py b/__init__.py
index fc4b28f..6aadb9b 100644
--- a/__init__.py
+++ b/__init__.py
@@ -18,6 +18,7 @@ import json
import requests
requests.packages.urllib3.disable_warnings()
+import comfy.utils
import folder_paths
comfyui_model_uri = folder_paths.models_dir
@@ -33,12 +34,14 @@ 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
+fallback_model_extensions = set([".bin", ".ckpt", ".gguf", ".onnx", ".pt", ".pth", ".safetensors"]) # TODO: magic values
+jpeg_format_names = ["JPG", "JPEG", "JFIF"]
image_extensions = (
".png", # order matters
".webp",
".jpeg",
".jpg",
+ ".jfif",
".gif",
".apng",
)
@@ -47,6 +50,7 @@ stable_diffusion_webui_civitai_helper_image_extensions = (
".preview.webp",
".preview.jpeg",
".preview.jpg",
+ ".preview.jfif",
".preview.gif",
".preview.apng",
)
@@ -139,11 +143,9 @@ def search_path_to_system_path(model_path):
def get_safetensor_header(path):
try:
- with open(path, "rb") as f:
- length_of_header = struct.unpack("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) + return metadata + + +def image_format_is_equal(f1, f2): + if not isinstance(f1, str) or not isinstance(f2, str): + return False + if f1[0] == ".": f1 = f1[1:] + if f2[0] == ".": f2 = f2[1:] + f1 = f1.upper() + f2 = f2.upper() + return f1 == f2 or (f1 in jpeg_format_names and f2 in jpeg_format_names) + + +def get_auto_thumbnail_format(original_format): + if original_format in ["JPEG", "WEBP", "JPG"]: # JFIF? + return original_format + return "JPEG" # default fallback + + @server.PromptServer.instance.routes.get("/model-manager/preview/get") async def get_model_preview(request): uri = request.query.get("uri") + quality = 75 + response_image_format = request.query.get("image-format", None) + if isinstance(response_image_format, str): + response_image_format = response_image_format.upper() + image_path = no_preview_image - image_type = "png" file_name = os.path.split(no_preview_image)[1] if uri != "no-preview": sep = os.path.sep @@ -285,12 +340,10 @@ async def get_model_preview(request): head, extension = split_valid_ext(path, preview_extensions) if os.path.exists(path): image_path = path - image_type = extension.rsplit(".", 1)[1] - file_name = os.path.split(head)[1] + "." + image_type + file_name = os.path.split(head)[1] + extension 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 + file_name = os.path.splitext(os.path.split(head)[1])[0] + extension w = request.query.get("width") h = request.query.get("height") @@ -314,6 +367,22 @@ async def get_model_preview(request): else: with open(image_path, "rb") as image: image_data = image.read() + fp = io.BytesIO(image_data) + with Image.open(fp) as image: + image_format = image.format + if response_image_format is None: + response_image_format = image_format + elif response_image_format == "AUTO": + response_image_format = get_auto_thumbnail_format(image_format) + + if not image_format_is_equal(response_image_format, image_format): + exif = image.getexif() + metadata = get_image_info(image) + if response_image_format in jpeg_format_names: + image = image.convert('RGB') + image_bytes = io.BytesIO() + image.save(image_bytes, format=response_image_format, exif=exif, pnginfo=metadata, quality=quality) + image_data = image_bytes.getvalue() else: if image_path.endswith(".safetensors"): image_data = get_safetensors_image_bytes(image_path) @@ -322,6 +391,12 @@ async def get_model_preview(request): fp = image_path with Image.open(fp) as image: + image_format = image.format + if response_image_format is None: + response_image_format = image_format + elif response_image_format == "AUTO": + response_image_format = get_auto_thumbnail_format(image_format) + w0, h0 = image.size if w is None: w = (h * w0) // h0 @@ -329,26 +404,41 @@ async def get_model_preview(request): h = (w * h0) // w0 exif = image.getexif() + metadata = get_image_info(image) - 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) + ratio_original = w0 / h0 + ratio_thumbnail = w / h + if abs(ratio_original - ratio_thumbnail) < 0.01: + crop_box = (0, 0, w0, h0) + elif ratio_original > ratio_thumbnail: + crop_width_fp = h0 * w / h + x0 = int((w0 - crop_width_fp) / 2) + crop_box = (x0, 0, x0 + int(crop_width_fp), h0) + else: + crop_height_fp = w0 * h / w + y0 = int((h0 - crop_height_fp) / 2) + crop_box = (0, y0, w0, y0 + int(crop_height_fp)) + image = image.crop(crop_box) - image.thumbnail((w, h)) + if w < w0 and h < h0: + resampling_method = Image.Resampling.BOX + else: + resampling_method = Image.Resampling.BICUBIC + image.thumbnail((w, h), resample=resampling_method) + if not image_format_is_equal(image_format, response_image_format) and response_image_format in jpeg_format_names: + image = image.convert('RGB') image_bytes = io.BytesIO() - image.save(image_bytes, format=image.format, exif=exif, pnginfo=metadata) + image.save(image_bytes, format=response_image_format, exif=exif, pnginfo=metadata, quality=quality) image_data = image_bytes.getvalue() + response_file_name = os.path.splitext(file_name)[0] + '.' + response_image_format.lower() return web.Response( headers={ - "Content-Disposition": f"inline; filename={file_name}", + "Content-Disposition": f"inline; filename={response_file_name}", }, body=image_data, - content_type="image/" + image_type, + content_type="image/" + response_image_format.lower(), ) @@ -360,7 +450,7 @@ async def get_image_extensions(request): def download_model_preview(formdata): path = formdata.get("path", None) if type(path) is not str: - raise ("Invalid path!") + raise ValueError("Invalid path!") path, model_type = search_path_to_system_path(path) model_type_extensions = folder_paths_get_supported_pt_extensions(model_type) path_without_extension, _ = split_valid_ext(path, model_type_extensions) @@ -401,20 +491,37 @@ def download_model_preview(formdata): else: content_type = image.content_type if not content_type.startswith("image/"): - raise ("Invalid content type!") + raise RuntimeError("Invalid content type!") image_extension = "." + content_type[len("image/"):] if image_extension not in image_extensions: - raise ("Invalid extension!") + raise RuntimeError("Invalid extension!") image_path = path_without_extension + image_extension if not overwrite and os.path.isfile(image_path): - raise ("Image already exists!") + raise RuntimeError("Image already exists!") file: io.IOBase = image.file image_data = file.read() with open(image_path, "wb") as f: f.write(image_data) + print("Saved file: " + image_path) - delete_same_name_files(path_without_extension, preview_extensions, image_extension) + if overwrite: + delete_same_name_files(path_without_extension, preview_extensions, image_extension) + + # detect (and try to fix) wrong file extension + image_format = None + with Image.open(image_path) as image: + image_format = image.format + image_dir_and_name, image_ext = os.path.splitext(image_path) + if not image_format_is_equal(image_format, image_ext): + corrected_image_path = image_dir_and_name + "." + image_format.lower() + if os.path.exists(corrected_image_path) and not overwrite: + print("WARNING: '" + image_path + "' has wrong extension!") + else: + os.rename(image_path, corrected_image_path) + print("Saved file: " + corrected_image_path) + image_path = corrected_image_path + return image_path # return in-case need corrected path @server.PromptServer.instance.routes.post("/model-manager/preview/set") @@ -450,6 +557,63 @@ async def delete_model_preview(request): return web.json_response(result) +def correct_image_extensions(root_dir): + detected_image_count = 0 + corrected_image_count = 0 + for root, dirs, files in os.walk(root_dir): + for file_name in files: + file_path = root + os.path.sep + file_name + image_format = None + try: + with Image.open(file_path) as image: + image_format = image.format + except: + continue + image_path = file_path + image_dir_and_name, image_ext = os.path.splitext(image_path) + if not image_format_is_equal(image_format, image_ext): + detected_image_count += 1 + corrected_image_path = image_dir_and_name + "." + image_format.lower() + if os.path.exists(corrected_image_path): + print("WARNING: '" + image_path + "' has wrong extension!") + else: + try: + os.rename(image_path, corrected_image_path) + except: + print("WARNING: Unable to rename '" + image_path + "'!") + continue + ext0 = os.path.splitext(image_path)[1] + ext1 = os.path.splitext(corrected_image_path)[1] + print(f"({ext0} -> {ext1}): {corrected_image_path}") + corrected_image_count += 1 + return (detected_image_count, corrected_image_count) + + +@server.PromptServer.instance.routes.get("/model-manager/preview/correct-extensions") +async def correct_preview_extensions(request): + result = { "success": False } + + detected = 0 + corrected = 0 + + model_types = os.listdir(comfyui_model_uri) + model_types.remove("configs") + model_types.sort() + + for model_type in model_types: + 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 + d, c = correct_image_extensions(model_base_path) + detected += d + corrected += c + + result["success"] = True + result["detected"] = detected + result["corrected"] = corrected + return web.json_response(result) + + @server.PromptServer.instance.routes.get("/model-manager/models/list") async def get_model_list(request): use_safetensor_thumbnail = ( @@ -740,8 +904,8 @@ async def get_model_info(request): 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) + #info["Date Modified"] = date_modified + #info["Date Created"] = datetime.fromtimestamp(stats.st_ctime).strftime(date_format) model_extensions = folder_paths_get_supported_pt_extensions(model_type) abs_name , _ = split_valid_ext(abs_path, model_extensions) @@ -759,8 +923,6 @@ async def get_model_info(request): header = get_safetensor_header(abs_path) metadata = header.get("__metadata__", None) - #json.dump(metadata, sys.stdout, indent=4) - #print() if metadata is not None and info.get("Preview", None) is None: thumbnail = metadata.get("modelspec.thumbnail") @@ -775,41 +937,10 @@ async def get_model_info(request): } if metadata is not None: - train_end = metadata.get("modelspec.date", "").replace("T", " ") - train_start = metadata.get("ss_training_started_at", "") - if train_start != "": - try: - train_start = float(train_start) - train_start = datetime.fromtimestamp(train_start).strftime(date_format) - except: - train_start = "" - info["Date Trained"] = ( - train_start + - (" ... " if train_start != "" and train_end != "" else "") + - train_end - ) - 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", "") - 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" or clip_skip == "1": # assume 1 means no clip skip - clip_skip = "" - info["Clip Skip"] = clip_skip - - # it is unclear what these are - #info["Hash SHA256"] = metadata.get("modelspec.hash_sha256", "") - #info["SSHS Model Hash"] = metadata.get("sshs_model_hash", "") - #info["SSHS Legacy Hash"] = metadata.get("sshs_legacy_hash", "") - #info["New SD Model Hash"] = metadata.get("ss_new_sd_model_hash", "") - - #info["Output Name"] = metadata.get("ss_output_name", "") - #info["Title"] = metadata.get("modelspec.title", "") - info["Author"] = metadata.get("modelspec.author", "") - info["License"] = metadata.get("modelspec.license", "") + info["Base Model Version"] = metadata.get("ss_base_model_version", "") + info["Network Dimension"] = metadata.get("ss_network_dim", "") + info["Network Alpha"] = metadata.get("ss_network_alpha", "") if metadata is not None: training_comment = metadata.get("ss_training_comment", "") @@ -826,12 +957,18 @@ async def get_model_info(request): if os.path.isfile(info_text_file): with open(info_text_file, 'r', encoding="utf-8") as f: notes = f.read() - info["Notes"] = notes if metadata is not None: - img_buckets = metadata.get("ss_bucket_info", "{}") + img_buckets = metadata.get("ss_bucket_info", None) + datasets = metadata.get("ss_datasets", None) + if type(img_buckets) is str: img_buckets = json.loads(img_buckets) + elif type(datasets) is str: + datasets = json.loads(datasets) + if isinstance(datasets, list): + datasets = datasets[0] + img_buckets = datasets.get("bucket_info", None) resolutions = {} if img_buckets is not None: buckets = img_buckets.get("buckets", {}) @@ -844,6 +981,8 @@ async def get_model_info(request): resolutions.sort(key=lambda x: x[1], reverse=True) info["Bucket Resolutions"] = resolutions + tags = None + if metadata is not None: dir_tags = metadata.get("ss_tag_frequency", "{}") if type(dir_tags) is str: dir_tags = json.loads(dir_tags) @@ -853,10 +992,14 @@ async def get_model_info(request): tags[tag] = tags.get(tag, 0) + count tags = list(tags.items()) tags.sort(key=lambda x: x[1], reverse=True) - info["Tags"] = tags result["success"] = True result["info"] = info + if metadata is not None: + result["metadata"] = metadata + if tags is not None: + result["tags"] = tags + result["notes"] = notes return web.json_response(result) @@ -1039,6 +1182,8 @@ async def set_notes(request): body = await request.json() result = { "success": False } + dt_epoch = body.get("timestamp", None) + text = body.get("notes", None) if type(text) is not str: result["alert"] = "Invalid note!" @@ -1052,15 +1197,23 @@ async def set_notes(request): 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 dt_epoch is not None and os.path.exists(filename) and os.path.getmtime(filename) > dt_epoch: + # discard late save + result["success"] = True + return web.json_response(result) + if text.isspace() or text == "": if os.path.exists(filename): os.remove(filename) - print("Deleted file: " + filename) + #print("Deleted file: " + filename) # autosave -> too verbose else: try: with open(filename, "w", encoding="utf-8") as f: f.write(text) - print("Saved file: " + filename) + if dt_epoch is not None: + os.utime(filename, (dt_epoch, dt_epoch)) + #print("Saved file: " + filename) # autosave -> too verbose except ValueError as e: print(e, file=sys.stderr, flush=True) result["alert"] = "Failed to save notes!\n\n" + str(e) diff --git a/demo-tab-download.png b/demo-tab-download.png deleted file mode 100644 index 2512b2c..0000000 Binary files a/demo-tab-download.png and /dev/null differ diff --git a/demo-tab-models.png b/demo-tab-models.png deleted file mode 100644 index 1138d51..0000000 Binary files a/demo-tab-models.png and /dev/null differ diff --git a/demo/beta-menu-model-manager-button-settings-group.png b/demo/beta-menu-model-manager-button-settings-group.png new file mode 100644 index 0000000..e5f0d7e Binary files /dev/null and b/demo/beta-menu-model-manager-button-settings-group.png differ diff --git a/demo/tab-download.png b/demo/tab-download.png new file mode 100644 index 0000000..4e5f7a7 Binary files /dev/null and b/demo/tab-download.png differ diff --git a/demo/tab-model-drag-add.gif b/demo/tab-model-drag-add.gif new file mode 100644 index 0000000..897b474 Binary files /dev/null and b/demo/tab-model-drag-add.gif differ diff --git a/demo/tab-model-info-overview.png b/demo/tab-model-info-overview.png new file mode 100644 index 0000000..0637e75 Binary files /dev/null and b/demo/tab-model-info-overview.png differ diff --git a/demo/tab-model-preview-thumbnail-buttons-example.png b/demo/tab-model-preview-thumbnail-buttons-example.png new file mode 100644 index 0000000..8f96ba6 Binary files /dev/null and b/demo/tab-model-preview-thumbnail-buttons-example.png differ diff --git a/demo/tab-models-dropdown.png b/demo/tab-models-dropdown.png new file mode 100644 index 0000000..e23f763 Binary files /dev/null and b/demo/tab-models-dropdown.png differ diff --git a/demo/tab-models.png b/demo/tab-models.png new file mode 100644 index 0000000..672ecea Binary files /dev/null and b/demo/tab-models.png differ diff --git a/demo/tab-settings.png b/demo/tab-settings.png new file mode 100644 index 0000000..13ee3ef Binary files /dev/null and b/demo/tab-settings.png differ diff --git a/web/model-manager.css b/web/model-manager.css index 6c94a69..a205b26 100644 --- a/web/model-manager.css +++ b/web/model-manager.css @@ -9,8 +9,6 @@ padding: 8px; position: fixed; overflow: hidden; - top: 0; - left: 0; width: 100%; z-index: 2000; @@ -18,9 +16,11 @@ border-radius: 0; box-shadow: none; justify-content: unset; - max-height: unset; - max-width: unset; + max-height: 100vh; + max-width: 100vw; transform: none; + /*disable double-tap zoom on model manager*/ + touch-action: manipulation; } .model-manager .comfy-modal-content { @@ -28,32 +28,107 @@ gap: 16px; } -.model-manager.sidebar-left { - width: 50%; - left: 0%; +.model-manager .no-highlight { + user-select: none; + -moz-user-select: none; + -webkit-text-select: none; + -webkit-user-select: none; } -.model-manager.sidebar-top { - height: 50%; - top: 0%; +.model-manager label:has(> *){ + pointer-events: none; } -.model-manager.sidebar-bottom { - height: 50%; - top: 50%; +.model-manager label > * { + pointer-events: auto; } -.model-manager.sidebar-right { - width: 50%; - left: 50%; +/* sidebar */ + +.model-manager { + --model-manager-sidebar-width-left: 50vw; + --model-manager-sidebar-width-right: 50vw; + --model-manager-sidebar-height-top: 50vh; + --model-manager-sidebar-height-bottom: 50vh; + + --model-manager-left: 0; + --model-manager-right: 0; + --model-manager-top: 0; + --model-manager-bottom: 0; + + left: var(--model-manager-left); + top: var(--model-manager-right); + right: var(--model-manager-top); + bottom: var(--model-manager-bottom); } -.model-manager .sidebar-buttons .sidebar-button-active { +.model-manager.cursor-drag-left, +.model-manager.cursor-drag-right { + cursor: ew-resize; +} + +.model-manager.cursor-drag-top, +.model-manager.cursor-drag-bottom { + cursor: ns-resize; +} + +.model-manager.cursor-drag-top.cursor-drag-left, +.model-manager.cursor-drag-bottom.cursor-drag-right { + cursor: nwse-resize; +} + +.model-manager.cursor-drag-top.cursor-drag-right, +.model-manager.cursor-drag-bottom.cursor-drag-left { + cursor: nesw-resize; +} + +/* sidebar buttons */ +.model-manager .sidebar-buttons { + overflow: hidden; + color: var(--input-text); + display: flex; + flex-direction: row-reverse; + flex-wrap: wrap; +} + +.model-manager .sidebar-buttons .radio-button-group-active { border-color: var(--fg-color); color: var(--fg-color); overflow: hidden; } +.model-manager[data-sidebar-state="left"] { + width: var(--model-manager-sidebar-width-left); + max-width: 95vw; + min-width: 22vw; + right: auto; + border-right: solid var(--border-color) 2px; +} + +.model-manager[data-sidebar-state="top"] { + height: var(--model-manager-sidebar-height-top); + max-height: 95vh; + min-height: 22vh; + bottom: auto; + border-bottom: solid var(--border-color) 2px; +} + +.model-manager[data-sidebar-state="bottom"] { + height: var(--model-manager-sidebar-height-bottom); + max-height: 95vh; + min-height: 22vh; + top: auto; + border-top: solid var(--border-color) 2px; +} + +.model-manager[data-sidebar-state="right"] { + width: var(--model-manager-sidebar-width-right); + max-width: 95vw; + min-width: 22vw; + left: auto; + border-left: solid var(--border-color) 2px; +} + /* common */ .model-manager h1 { min-width: 0; @@ -72,12 +147,16 @@ width: 100%; } -.model-manager button, +.model-manager button { + margin: 0; + border: 2px solid var(--border-color); +} + +.model-manager button:not(.icon-button), .model-manager select, .model-manager input { padding: 4px 8px; margin: 0; - border: 2px solid var(--border-color); } .model-manager button:disabled, @@ -134,7 +213,7 @@ .model-manager .tab-header { display: flex; - padding: 8px 0; + padding: 8px 0px; flex-direction: column; background-color: var(--bg-color); } @@ -144,12 +223,12 @@ min-width: 0; } -.model-manager .button-success { +.model-manager .comfy-button-success { color: green; border-color: green; } -.model-manager .button-failure { +.model-manager .comfy-button-failure { color: darkred; border-color: darkred; } @@ -160,49 +239,39 @@ user-select: none; } -/* sidebar buttons */ -.model-manager .sidebar-buttons { - overflow: hidden; - color: var(--input-text); - display: flex; - flex-direction: row-reverse; - flex-wrap: wrap; -} - /* main content */ .model-manager .model-manager-panel { color: var(--fg-color); } -.model-manager .model-manager-tabs { +.model-manager .model-tab-group { display: flex; gap: 4px; height: 40px; } -.model-manager .model-manager-tabs .head-item { +.model-manager .model-tab-group .tab-button { 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; + border-radius: 8px 8px 0px 0px; cursor: pointer; padding: 8px 12px; margin-bottom: 0px; z-index: 1; } -.model-manager .model-manager-tabs .head-item.active { +.model-manager .model-tab-group .tab-button.active { background-color: var(--bg-color); cursor: default; position: relative; z-index: 1; + pointer-events: none; } .model-manager .model-manager-body { background-color: var(--bg-color); border: 2px solid var(--border-color); - padding: 16px 0px; } .model-manager .model-manager-panel { @@ -215,28 +284,28 @@ .model-manager .model-manager-body { flex: 1; overflow: hidden; + padding: 8px 0px 8px 16px; } -.model-manager .model-manager-body > div { +.model-manager .model-manager-body .tab-contents { position: relative; - height: 100%; - width: auto; - padding: 0 16px; - overflow-x: auto; -} - -/* model info view */ -.model-manager .model-info-view { - background-color: var(--bg-color); display: flex; flex-direction: column; height: 100%; - overflow-wrap: break-word; - overflow-y: auto; - padding: 20px; - position: relative; + width: auto; + overflow-x: auto; + overflow-y: hidden; } +.model-manager .model-manager-body .tab-content { + display: flex; + flex-direction: column; + height: 100%; + overflow-y: auto; + padding-right: 16px; +} + +/* model info view */ .model-manager .model-info-container { background-color: var(--bg-color); border-radius: 16px; @@ -244,12 +313,37 @@ width: auto; } +.model-manager .model-metadata { + table-layout: fixed; + text-align: left; + width: 100%; +} + +.model-manager .model-metadata-key { + overflow-wrap: break-word; + width: 20%; +} + +.model-manager .model-metadata-value { + overflow-wrap: anywhere; + width: 80%; +} + +.model-manager table { + border-collapse: collapse; +} + +.model-manager th { + border: 1px solid; + padding: 4px 8px; +} + /* download tab */ .model-manager .download-model-infos { display: flex; flex-direction: column; - padding: 16px 0; + padding: 0; row-gap: 10px; } @@ -283,6 +377,10 @@ row-gap: 16px; } +.model-manager .download-button { + max-width: fit-content; +} + /* models tab */ .model-manager [data-name="Models"] .row { position: sticky; @@ -293,8 +391,8 @@ /* preview image */ .model-manager .item { position: relative; - width: 230px; - height: 345px; + width: 240px; + height: 360px; text-align: center; overflow: hidden; border-radius: 8px; @@ -304,6 +402,18 @@ width: 100%; height: 100%; object-fit: cover; + border-radius: 8px; +} + +.model-manager .model-info-container .item { + width: auto; + height: auto; +} +.model-manager .model-info-container .item img { + height: auto; + width: auto; + max-width: 100%; + max-height: 50vh; } .model-manager .model-preview-button-left, @@ -340,7 +450,7 @@ } .model-manager .comfy-grid .model-label { - background-color: #000a; + background-color: rgb(from var(--content-hover-bg) r g b / 0.5); width: 100%; height: 2.2rem; position: absolute; @@ -437,7 +547,6 @@ } .model-manager .model-preview-select-radio-inputs > div { - height: 40px; padding: 16px 0 8px 0; } @@ -475,7 +584,23 @@ float: right; } +.model-manager .model-manager-head .topbar-right select { + position: relative; + top: 0; + bottom: 0; + font-size: 24px; + -o-appearance: none; + -ms-appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; +} + /* search dropdown */ +.model-manager .input-dropdown-container { + position: relative; +} + .model-manager .search-models { display: flex; flex: 1; @@ -501,46 +626,54 @@ min-height: 40px; } -.model-manager .search-dropdown { +.model-manager .search-directory-dropdown { background-color: var(--bg-color); border: 2px var(--border-color) solid; border-radius: 10px; color: var(--fg-color); - max-height: 30vh; + max-height: 40vh; overflow: auto; position: absolute; z-index: 1; } -.model-manager .search-dropdown:empty { +@media (pointer:none), (pointer:coarse) { + .model-manager .search-directory-dropdown { + max-height: 17.5vh; + } +} + +.model-manager .search-directory-dropdown:empty { display: none; } -.model-manager .search-dropdown > p { +.model-manager .search-directory-dropdown > p { margin: 0; padding: 0.85em 20px; min-width: 0; } -.model-manager .search-dropdown > p { +.model-manager .search-directory-dropdown > p { -ms-overflow-style: none; /* Internet Explorer 10+ */ scrollbar-width: none; /* Firefox */ } -.model-manager .search-dropdown > p::-webkit-scrollbar { +.model-manager .search-directory-dropdown > p::-webkit-scrollbar { display: none; /* Safari and Chrome */ } -.model-manager .search-dropdown > p.search-dropdown-key-selected, -.model-manager .search-dropdown > p.search-dropdown-mouse-selected { +.model-manager .search-directory-dropdown > p.search-directory-dropdown-key-selected, +.model-manager .search-directory-dropdown > p.search-directory-dropdown-mouse-selected { background-color: var(--border-color); } -.model-manager .search-dropdown > p.search-dropdown-key-selected { +.model-manager .search-directory-dropdown > p.search-directory-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 { +.model-manager .model-manager-settings > label, +.model-manager .tag-generator-settings > label, +.model-manager .tag-generator-settings > div { display: flex; flex-direction: row; align-items: center; @@ -550,10 +683,12 @@ .model-manager .model-manager-settings button { height: 40px; - width: 120px; + min-width: 120px; + justify-content: center; } -.model-manager .model-manager-settings input[type="number"] { +.model-manager .model-manager-settings input[type="number"], +.model-manager .tag-generator-settings input[type="number"]{ width: 50px; } diff --git a/web/model-manager.js b/web/model-manager.js index 5009b0c..a9246a3 100644 --- a/web/model-manager.js +++ b/web/model-manager.js @@ -1,13 +1,18 @@ import { app } from "../../scripts/app.js"; import { api } from "../../scripts/api.js"; import { ComfyDialog, $el } from "../../scripts/ui.js"; +import { ComfyButton } from "../../scripts/ui/components/button.js"; + +function clamp(x, min, max) { + return Math.min(Math.max(x, min), max); +} /** * @param {string} url * @param {any} [options=undefined] * @returns {Promise} */ -function request(url, options = undefined) { +function comfyRequest(url, options = undefined) { return new Promise((resolve, reject) => { api.fetchApi(url, options) .then((response) => response.json()) @@ -16,6 +21,124 @@ function request(url, options = undefined) { }); } +/** + * @param {(...args) => Promise} callback + * @param {number | undefined} delay + * @returns {(...args) => void} + */ +function debounce(callback, delay) { + let timeoutId = null; + return (...args) => { + window.clearTimeout(timeoutId); + timeoutId = window.setTimeout(() => { + callback(...args); + }, delay); + }; +} + +class KeyComboListener { + /** @type {string[]} */ + #keyCodes = []; + + /** @type {() => Promise } */ + action; + + /** @type {Element} */ + element; + + /** @type {string[]} */ + #combo = []; + + /** + * @param {string[]} keyCodes + * @param {() => Promise } action + * @param {Element} element + */ + constructor(keyCodes, action, element) { + this.#keyCodes = keyCodes; + this.action = action; + this.element = element; + + document.addEventListener("keydown", (e) => { + const code = e.code; + const keyCodes = this.#keyCodes; + const combo = this.#combo; + if (keyCodes.includes(code) && !combo.includes(code)) { + combo.push(code); + } + if (combo.length === 0 || keyCodes.length !== combo.length) { + return; + } + for (let i = 0; i < combo.length; i++) { + if (keyCodes[i] !== combo[i]) { + return; + } + } + if (document.activeElement !== this.element) { + return; + } + e.preventDefault(); + e.stopPropagation(); + this.action(); + this.#combo.length = 0; + }); + document.addEventListener("keyup", (e) => { + // Mac keyup doesn't fire when meta key is held: https://stackoverflow.com/a/73419500 + const code = e.code; + if (code === "MetaLeft" || code === "MetaRight") { + this.#combo.length = 0; + } + else { + this.#combo = this.#combo.filter(x => x !== code); + } + }); + } +} + +/** + * Handles Firefox's drag event, which returns different coordinates and then fails when calling `elementFromPoint`. + * @param {DragEvent} event + * @returns {[Number, Number, HTMLElement]} [clientX, clientY, targetElement] + */ +function elementFromDragEvent(event) { + let clientX = null; + let clientY = null; + let target; + const userAgentString = navigator.userAgent; + if (userAgentString.indexOf("Firefox") > -1) { + clientX = event.clientX; + clientY = event.clientY; + const screenOffsetX = window.screenLeft; + if (clientX >= screenOffsetX) { + clientX = clientX - screenOffsetX; + } + const screenOffsetY = window.screenTop; + if (clientY >= screenOffsetY) { + clientY = clientY - screenOffsetY; + } + target = document.elementFromPoint(clientX, clientY); + } + else { + clientX = event.clientX; + clientY = event.clientY; + target = document.elementFromPoint(event.clientX, event.clientY); + } + return [clientX, clientY, target]; +} + +/** + * @param {string} url + */ +async function loadWorkflow(url) { + const uri = (new URL(url)).searchParams.get("uri"); + const fileNameIndex = Math.max(uri.lastIndexOf("/"), uri.lastIndexOf("\\")) + 1; + const fileName = uri.substring(fileNameIndex); + const response = await fetch(url); + const data = await response.blob(); + const file = new File([data], fileName, { type: data.type }); + app.handleFile(file); +} + const modelNodeType = { "checkpoints": "CheckpointLoaderSimple", "clip": "CLIPLoader", @@ -34,12 +157,13 @@ const modelNodeType = { "vae_approx": undefined, }; -const MODEL_EXTENSIONS = [".bin", ".ckpt", ".onnx", ".pt", ".pth", ".safetensors"]; // TODO: ask server for? +const MODEL_EXTENSIONS = [".bin", ".ckpt", "gguf", ".onnx", ".pt", ".pth", ".safetensors"]; // TODO: ask server for? const IMAGE_EXTENSIONS = [ ".png", ".webp", ".jpeg", ".jpg", + ".jfif", ".gif", ".apng", @@ -47,6 +171,7 @@ const IMAGE_EXTENSIONS = [ ".preview.webp", ".preview.jpeg", ".preview.jpg", + ".preview.jfif", ".preview.gif", ".preview.apng", ]; // TODO: /model-manager/image/extensions @@ -114,9 +239,10 @@ class SearchPath { * @param {string | undefined} [dateImageModified=undefined] * @param {string | undefined} [width=undefined] * @param {string | undefined} [height=undefined] + * @param {string | undefined} [imageFormat=undefined] * @returns {string} */ -function imageUri(imageSearchPath = undefined, dateImageModified = undefined, width = undefined, height = undefined) { +function imageUri(imageSearchPath = undefined, dateImageModified = undefined, width = undefined, height = undefined, imageFormat = undefined) { const path = imageSearchPath ?? "no-preview"; const date = dateImageModified; let uri = `/model-manager/preview/get?uri=${path}`; @@ -129,6 +255,9 @@ function imageUri(imageSearchPath = undefined, dateImageModified = undefined, wi if (date !== undefined && date !== null) { uri += `&v=${date}`; } + if (imageFormat !== undefined && imageFormat !== null) { + uri += `&image-format=${imageFormat}`; + } return uri; } const PREVIEW_NONE_URI = imageUri(); @@ -136,43 +265,77 @@ const PREVIEW_THUMBNAIL_WIDTH = 320; const PREVIEW_THUMBNAIL_HEIGHT = 480; /** - * @param {(...args) => void} callback - * @param {number | undefined} delay - * @returns {(...args) => void} + * + * @param {HTMLButtonElement} element + * @returns {[HTMLButtonElement | undefined, HTMLElement | undefined, HTMLSpanElement | undefined]} [button, icon, span] */ -function debounce(callback, delay) { - let timeoutId = null; - return (...args) => { - window.clearTimeout(timeoutId); - timeoutId = window.setTimeout(() => { - callback(...args); - }, delay); - }; +function comfyButtonDisambiguate(element) { + // TODO: This likely can be removed by using a css rule that disables clicking on the inner elements of the button. + let button = undefined; + let icon = undefined; + let span = undefined; + const nodeName = element.nodeName.toLowerCase(); + if (nodeName === "button") { + button = element; + icon = button.getElementsByTagName("i")[0]; + span = button.getElementsByTagName("span")[0]; + } + else if (nodeName === "i") { + icon = element; + button = element.parentElement; + span = button.getElementsByTagName("span")[0]; + } + else if (nodeName === "span") { + button = element.parentElement; + icon = button.getElementsByTagName("i")[0]; + span = element; + } + return [button, icon, span] } /** * @param {HTMLButtonElement} element * @param {boolean} success - * @param {string} [successText=""] - * @param {string} [failureText=""] - * @param {string} [resetText=""] + * @param {string?} successClassName + * @param {string?} failureClassName + * @param {boolean?} [disableCallback=false] */ -function buttonAlert(element, success, successText = "", failureText = "", resetText = "") { - if (element === undefined || element === null) { +function comfyButtonAlert(element, success, successClassName = undefined, failureClassName = undefined, disableCallback = false) { + if (element === undefined || element === null) { return; } + + const [button, icon, span] = comfyButtonDisambiguate(element); + if (button === undefined) { + console.warn("Unable to find button element!"); + console.warn(element); return; } - const name = success ? "button-success" : "button-failure"; - element.classList.add(name); - if (successText != "" && failureText != "") { - element.innerHTML = success ? successText : failureText; - } - // TODO: debounce would be nice to get working... - window.setTimeout((element, name, innerHTML) => { - element.classList.remove(name); - if (innerHTML != "") { - element.innerHTML = innerHTML; + + // TODO: debounce would be nice, but needs some sort of "global" to avoid creating/destroying many objects + + const colorClassName = success ? "comfy-button-success" : "comfy-button-failure"; + + if (icon) { + const iconClassName = (success ? successClassName : failureClassName) ?? ""; + if (iconClassName !== "") { + icon.classList.add(iconClassName); } - }, 1000, element, name, resetText); + icon.classList.add(colorClassName); + if (!disableCallback) { + window.setTimeout((element, iconClassName, colorClassName) => { + if (iconClassName !== "") { + element.classList.remove(iconClassName); + } + element.classList.remove(colorClassName); + }, 1000, icon, iconClassName, colorClassName); + } + } + + button.classList.add(colorClassName); + if (!disableCallback) { + window.setTimeout((element, colorClassName) => { + element.classList.remove(colorClassName); + }, 1000, button, colorClassName); + } } /** @@ -182,7 +345,12 @@ function buttonAlert(element, success, successText = "", failureText = "", reset * @returns {Promise } */ async function saveNotes(modelPath, newValue) { - return await request( + const timestamp = await comfyRequest("/model-manager/timestamp") + .catch((err) => { + console.warn(err); + return false; + }); + return await comfyRequest( "/model-manager/notes/save", { method: "POST", @@ -190,6 +358,7 @@ async function saveNotes(modelPath, newValue) { "path": modelPath, "notes": newValue, }), + timestamp: timestamp, } ).then((result) => { const saved = result["success"]; @@ -212,7 +381,7 @@ function $checkbox(x = { $: (el) => {}, textContent: "", checked: false }) { const text = x.textContent; const input = $el("input", { type: "checkbox", - name: text ?? "checkbox", + name: text ?? "checkbox", checked: x.checked ?? false, }); const label = $el("label", [ @@ -225,6 +394,28 @@ function $checkbox(x = { $: (el) => {}, textContent: "", checked: false }) { return label; } +/** + * @returns {HTMLLabelElement} + */ +function $select(x = { $: (el) => {}, textContent: "", options: [""] }) { + const text = x.textContent; + const select = $el("select", { + name: text ?? "select", + }, x.options.map((option) => { + return $el("option", { + value: option, + }, option); + })); + const label = $el("label", [ + text === "" || text === undefined || text === null ? "" : " " + text, + select, + ]); + if (x.$ !== undefined){ + x.$(select); + } + return label; +} + /** * @param {Any} attr * @returns {HTMLDivElement} @@ -247,7 +438,7 @@ function $radioGroup(attr) { checked: index === 0, $: (el) => (inputRef.value = el), }), - $el("label", [item.label ?? item.value]), + $el("label.no-highlight", item.label ?? item.value), ] ); }); @@ -269,6 +460,117 @@ function $radioGroup(attr) { return $el("div.comfy-radio-group", radioGroup); } +/** + * @param {{name: string, icon: string, tabContent: HTMLDivElement}[]} tabData + * @returns {[HTMLDivElement[], HTMLDivElement[]]} + */ +function GenerateTabGroup(tabData) { + const ACTIVE_TAB_CLASS = "active"; + + /** @type {HTMLDivElement[]} */ + const tabButtons = []; + + /** @type {HTMLDivElement[]} */ + const tabContents = []; + + tabData.forEach((data) => { + const name = data.name; + const icon = data.icon; + /** @type {HTMLDivElement} */ + const tab = new ComfyButton({ + icon: icon, + tooltip: "Open " + name.toLowerCase() + " tab", + classList: "comfyui-button tab-button", + content: name, + action: () => { + tabButtons.forEach((tabButton) => { + if (name === tabButton.getAttribute("data-name")) { + tabButton.classList.add(ACTIVE_TAB_CLASS); + } + else { + tabButton.classList.remove(ACTIVE_TAB_CLASS); + } + }); + tabContents.forEach((tabContent) => { + if (name === tabContent.getAttribute("data-name")) { + tabContent.scrollTop = tabContent.dataset["scrollTop"] ?? 0; + tabContent.style.display = ""; + } + else { + tabContent.dataset["scrollTop"] = tabContent.scrollTop; + tabContent.style.display = "none"; + } + }); + }, + }).element; + tab.dataset.name = name; + const content = $el("div.tab-content", { + dataset: { + name: data.name, + } + }, [ + data.tabContent + ]); + tabButtons.push(tab); + tabContents.push(content); + }); + + return [tabButtons, tabContents]; +} + +/** + * @param {HTMLDivElement} element + * @param {Record []} tabButtons + */ +function GenerateDynamicTabTextCallback(element, tabButtons, minWidth) { + return () => { + if (element.style.display === "none") { + return; + } + const managerRect = element.getBoundingClientRect(); + const isIcon = managerRect.width < minWidth; // TODO: `minWidth` is a magic value + const iconDisplay = isIcon ? "" : "none"; + const spanDisplay = isIcon ? "none" : ""; + tabButtons.forEach((tabButton) => { + tabButton.getElementsByTagName("i")[0].style.display = iconDisplay; + tabButton.getElementsByTagName("span")[0].style.display = spanDisplay; + }); + }; +} + +/** + * @param {[String, int][]} map + * @returns {String} + */ +function TagCountMapToParagraph(map) { + let text = " "; + for (let i = 0; i < map.length; i++) { + const v = map[i]; + const tag = v[0]; + const count = v[1]; + text += tag + " (" + count + ")"; + if (i !== map.length - 1) { + text += ", "; + } + } + text += "
"; + return text; +} + +/** + * @param {String} p + * @returns {[String, int][]} + */ +function ParseTagParagraph(p) { + return p.split(",").map(x => { + const text = x.endsWith(", ") ? x.substring(0, x.length - 2) : x; + const i = text.lastIndexOf("("); + const tag = text.substring(0, i).trim(); + const frequency = parseInt(text.substring(i + 1, text.length - 1)); + return [tag, frequency]; + }); +} + class ImageSelect { /** @constant {string} */ #PREVIEW_DEFAULT = "Default"; /** @constant {string} */ #PREVIEW_UPLOAD = "Upload"; @@ -558,15 +860,20 @@ class ImageSelect { style: { display: "none" }, }, [ el_customUrl, - $el("button.icon-button", { - textContent: "ποΈ", - onclick: async (e) => { + new ComfyButton({ + icon: "magnify", + tooltip: "Search models", + classList: "comfyui-button icon-button", + action: async (e) => { + const [button, icon, span] = comfyButtonDisambiguate(e.target); + button.disabled = true; const value = el_customUrl.value; el_customUrlPreview.src = await getCustomPreviewUrl(value); e.stopPropagation(); el_customUrl.blur(); + button.disabled = false; }, - }), + }).element, ]); const el_previewButtons = $el("div.model-preview-overlay", { @@ -574,14 +881,18 @@ class ImageSelect { display: el_defaultPreviews.children.length > 1 ? "block" : "none", }, }, [ - $el("button.icon-button.model-preview-button-left", { - textContent: "β", - onclick: () => this.stepDefaultPreviews(-1), - }), - $el("button.icon-button.model-preview-button-right", { - textContent: "β", - onclick: () => this.stepDefaultPreviews(1), - }), + new ComfyButton({ + icon: "arrow-left", + tooltip: "Previous image", + classList: "comfyui-button icon-button model-preview-button-left", + action: () => this.stepDefaultPreviews(-1), + }).element, + new ComfyButton({ + icon: "arrow-right", + tooltip: "Next image", + classList: "comfyui-button icon-button model-preview-button-right", + action: () => this.stepDefaultPreviews(1), + }).element, ]); const el_previews = $el("div.item", { $: (el) => (this.elements.previews = el), @@ -847,8 +1158,8 @@ class ModelDirectories { } } -const DROPDOWN_DIRECTORY_SELECTION_KEY_CLASS = "search-dropdown-key-selected"; -const DROPDOWN_DIRECTORY_SELECTION_MOUSE_CLASS = "search-dropdown-mouse-selected"; +const DROPDOWN_DIRECTORY_SELECTION_KEY_CLASS = "search-directory-dropdown-key-selected"; +const DROPDOWN_DIRECTORY_SELECTION_MOUSE_CLASS = "search-directory-dropdown-mouse-selected"; class ModelData { @@ -897,6 +1208,9 @@ class DirectoryDropdown { /** @type {Any} */ #touchSelectionStart = null; + /** @type {() => Boolean} */ + #isDynamicSearch = () => { return false; }; + /** * @param {ModelData} modelData * @param {HTMLInputElement} input @@ -904,10 +1218,11 @@ class DirectoryDropdown { * @param {() => string} [getModelType= () => { return ""; }] * @param {() => void} [updateCallback= () => {}] * @param {() => Promise} [submitCallback= () => {}] + * @param {() => Boolean} [isDynamicSearch= () => { return false; }] */ - constructor(modelData, input, showDirectoriesOnly = false, getModelType = () => { return ""; }, updateCallback = () => {}, submitCallback = () => {}) { + constructor(modelData, input, showDirectoriesOnly = false, getModelType = () => { return ""; }, updateCallback = () => {}, submitCallback = () => {}, isDynamicSearch = () => { return false; }) { /** @type {HTMLDivElement} */ - const dropdown = $el("div.search-dropdown", { // TODO: change to `search-directory-dropdown` + const dropdown = $el("div.search-directory-dropdown", { style: { display: "none", }, @@ -919,14 +1234,18 @@ class DirectoryDropdown { this.#updateCallback = updateCallback; this.#submitCallback = submitCallback; this.showDirectoriesOnly = showDirectoriesOnly; + this.#isDynamicSearch = isDynamicSearch; - input.addEventListener("input", () => { + input.addEventListener("input", async(e) => { const path = this.#updateOptions(); if (path !== undefined) { this.#restoreSelectedOption(path); this.#updateDeepestPath(path); } updateCallback(); + if (isDynamicSearch()) { + await submitCallback(); + } }); input.addEventListener("focus", () => { const path = this.#updateOptions(); @@ -975,7 +1294,9 @@ class DirectoryDropdown { this.#updateDeepestPath(path); } updateCallback(); - //await submitCallback(); + if (isDynamicSearch()) { + await submitCallback(); + } } } else if (e.key === "ArrowLeft" && dropdown.style.display !== "none") { @@ -1007,7 +1328,9 @@ class DirectoryDropdown { this.#updateDeepestPath(path); } updateCallback(); - //await submitCallback(); + if (isDynamicSearch()) { + await submitCallback(); + } } } } @@ -1015,6 +1338,8 @@ class DirectoryDropdown { e.stopPropagation(); const input = e.target; if (dropdown.style.display !== "none") { + /* + // This is WAY too confusing. const selection = options[iSelection]; if (selection !== undefined && selection !== null) { DirectoryDropdown.selectionToInput( @@ -1029,6 +1354,7 @@ class DirectoryDropdown { } updateCallback(); } + */ } await submitCallback(); input.blur(); @@ -1216,6 +1542,9 @@ class DirectoryDropdown { } } this.#updateCallback(); + if (this.#isDynamicSearch()) { + await this.#submitCallback(); + } }; const touch_selection_select = async(e) => { const [startX, startY] = this.#touchSelectionStart; @@ -1419,7 +1748,7 @@ class ModelGrid { } event.stopPropagation(); } - buttonAlert(event.target, success, "β", "β", "β"); + comfyButtonAlert(event.target, success, "mdi-check-bold", "mdi-close-thick"); } static #getWidgetComboIndices(node, value) { @@ -1433,16 +1762,18 @@ class ModelGrid { } /** - * @param {Event} event + * @param {DragEvent} event * @param {string} modelType * @param {string} path * @param {boolean} removeEmbeddingExtension * @param {boolean} strictlyOnWidget */ static #dragAddModel(event, modelType, path, removeEmbeddingExtension, strictlyOnWidget) { - const target = document.elementFromPoint(event.x, event.y); + const [clientX, clientY, target] = elementFromDragEvent(event); if (modelType !== "embeddings" && target.id === "graph-canvas") { - const pos = app.canvas.convertEventToCanvasOffset(event); + //const pos = app.canvas.convertEventToCanvasOffset(event); + const pos = app.canvas.convertEventToCanvasOffset({ clientX: clientX, clientY: clientY }); + const node = app.graph.getNodeOnPos(pos[0], pos[1], app.canvas.visible_nodes); let widgetIndex = -1; @@ -1535,7 +1866,7 @@ class ModelGrid { else { console.warn(`Unable to copy unknown model type '${modelType}.`); } - buttonAlert(event.target, success, "β", "β", "β§οΈ"); + comfyButtonAlert(event.target, success, "mdi-check-bold", "mdi-close-thick"); } /** @@ -1553,47 +1884,87 @@ class ModelGrid { const canShowButtons = modelNodeType[modelType] !== undefined; const showAddButton = canShowButtons && settingsElements["model-show-add-button"].checked; const showCopyButton = canShowButtons && settingsElements["model-show-copy-button"].checked; + const showLoadWorkflowButton = canShowButtons && settingsElements["model-show-load-workflow-button"].checked; const strictDragToAdd = settingsElements["model-add-drag-strict-on-field"].checked; const addOffset = parseInt(settingsElements["model-add-offset"].value); const showModelExtension = settingsElements["model-show-label-extensions"].checked; + const modelInfoButtonOnLeft = !settingsElements["model-info-button-on-left"].checked; const removeEmbeddingExtension = !settingsElements["model-add-embedding-extension"].checked; + const previewThumbnailFormat = settingsElements["model-preview-thumbnail-type"].value; if (models.length > 0) { return models.map((item) => { const previewInfo = item.preview; + const previewThumbnail = $el("img.model-preview", { + 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, + previewThumbnailFormat, + ), + draggable: false, + }); const searchPath = item.path; const path = SearchPath.systemPath(searchPath, searchSeparator, systemSeparator); - let buttons = []; + let actionButtons = []; if (showAddButton && !(modelType === "embeddings" && !navigator.clipboard)) { - buttons.push( - $el("button.icon-button.model-button", { - type: "button", - textContent: "β§οΈ", - onclick: (e) => ModelGrid.#copyModelToClipboard( + actionButtons.push( + new ComfyButton({ + icon: "content-copy", + tooltip: "Copy model to clipboard", + classList: "comfyui-button icon-button model-button", + action: (e) => ModelGrid.#copyModelToClipboard( e, modelType, path, - removeEmbeddingExtension + removeEmbeddingExtension, ), - draggable: false, - }) + }).element, ); } if (showCopyButton) { - buttons.push( - $el("button.icon-button.model-button", { - type: "button", - textContent: "β", - onclick: (e) => ModelGrid.#addModel( + actionButtons.push( + new ComfyButton({ + icon: "plus-box-outline", + tooltip: "Add model to node grid", + classList: "comfyui-button icon-button model-button", + action: (e) => ModelGrid.#addModel( e, modelType, path, removeEmbeddingExtension, - addOffset + addOffset, ), - draggable: false, - }) + }).element, ); } + if (showLoadWorkflowButton) { + actionButtons.push( + new ComfyButton({ + icon: "arrow-bottom-left-bold-box-outline", + tooltip: "Load preview workflow", + classList: "comfyui-button icon-button model-button", + action: async (e) => { + const urlString = previewThumbnail.src; + const url = new URL(urlString); + const urlSearchParams = url.searchParams; + const uri = urlSearchParams.get("uri"); + const v = urlSearchParams.get("v"); + const urlFull = urlString.substring(0, urlString.indexOf("?")) + "?uri=" + uri + "&v=" + v; + await loadWorkflow(urlFull); + }, + }).element, + ); + } + const infoButtons = [ + new ComfyButton({ + icon: "information-outline", + tooltip: "View model information", + classList: "comfyui-button icon-button model-button", + action: async() => { await showModelInfo(searchPath) }, + }).element, + ]; const dragAdd = (e) => ModelGrid.#dragAddModel( e, modelType, @@ -1602,16 +1973,7 @@ class ModelGrid { strictDragToAdd ); return $el("div.item", {}, [ - $el("img.model-preview", { - 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, - }), + previewThumbnail, $el("div.model-preview-overlay", { ondragend: (e) => dragAdd(e), draggable: true, @@ -1619,20 +1981,14 @@ class ModelGrid { $el("div.model-preview-top-right", { draggable: false, }, - buttons + modelInfoButtonOnLeft ? infoButtons : actionButtons, ), $el("div.model-preview-top-left", { draggable: false, - }, [ - $el("button.icon-button.model-button", { - type: "button", - textContent: "β", - onclick: async() => { await showModelInfo(searchPath) }, - draggable: false, - }), - ]), + }, + modelInfoButtonOnLeft ? actionButtons : infoButtons, + ), $el("div.model-label", { - ondragend: (e) => dragAdd(e), draggable: false, }, [ $el("p", [showModelExtension ? item.name : SearchPath.splitExtension(item.name)[0]]) @@ -1660,7 +2016,10 @@ class ModelGrid { const models = modelData.models; let modelType = modelSelect.value; if (models[modelType] === undefined) { - modelType = "checkpoints"; // TODO: magic value + modelType = settings["model-default-browser-model-type"].value; + } + if (models[modelType] === undefined) { + modelType = "checkpoints"; // panic fallback } if (modelType !== previousModelType.value) { @@ -1703,11 +2062,13 @@ class ModelGrid { } } -class ModelInfoView { +class ModelInfo { /** @type {HTMLDivElement} */ element = null; elements = { + /** @type {HTMLDivElement[]} */ tabButtons: null, + /** @type {HTMLDivElement[]} */ tabContents: null, /** @type {HTMLDivElement} */ info: null, /** @type {HTMLTextAreaElement} */ notes: null, /** @type {HTMLButtonElement} */ setPreviewButton: null, @@ -1720,11 +2081,16 @@ class ModelInfoView { /** @type {string} */ #savedNotesValue = null; + /** @type {[HTMLElement][]} */ + #settingsElements = null; + /** * @param {ModelData} modelData * @param {() => Promise } updateModels + * @param {any} settingsElements */ - constructor(modelData, updateModels) { + constructor(modelData, updateModels, settingsElements) { + this.#settingsElements = settingsElements; const moveDestinationInput = $el("input.search-text-area", { name: "move directory", autocomplete: "off", @@ -1743,20 +2109,21 @@ class ModelInfoView { this.previewSelect = previewSelect; previewSelect.elements.previews.style.display = "flex"; - const setPreviewButton = $el("button", { - $: (el) => (this.elements.setPreviewButton = el), - textContent: "Set as Preview", - onclick: async(e) => { + const setPreviewButton = new ComfyButton({ + tooltip: "Overwrite currrent preview with selected image", + content: "Set as Preview", + action: async(e) => { + const [button, icon, span] = comfyButtonDisambiguate(e.target); + button.disabled = true; 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 = await previewSelect.getImage(); if (imageUrl === PREVIEW_NONE_URI) { const encodedPath = encodeURIComponent(path); - updatedPreview = await request( + updatedPreview = await comfyRequest( `/model-manager/preview/delete?path=${encodedPath}`, { method: "POST", @@ -1779,7 +2146,7 @@ class ModelInfoView { formData.append("path", path); const image = imageUrl[0] == "/" ? "" : imageUrl; formData.append("image", image); - updatedPreview = await request( + updatedPreview = await comfyRequest( `/model-manager/preview/set`, { method: "POST", @@ -1804,33 +2171,37 @@ class ModelInfoView { previewSelect.resetModelInfoPreview(); this.element.style.display = "none"; } - - e.target.disabled = false; } - buttonAlert(e.target, updatedPreview); + comfyButtonAlert(e.target, updatedPreview); + button.disabled = false; }, - }); + }).element; + this.elements.setPreviewButton = setPreviewButton; previewSelect.elements.radioButtons.addEventListener("change", (e) => { setPreviewButton.style.display = previewSelect.defaultIsChecked() ? "none" : "block"; }); - this.element = $el("div.model-info-view", { + this.element = $el("div", { style: { display: "none" }, }, [ $el("div.row.tab-header", { display: "block", }, [ $el("div.row.tab-header-flex-block", [ - $el("button.icon-button", { - textContent: "ποΈ", - onclick: async(e) => { + new ComfyButton({ + icon: "trash-can-outline", + tooltip: "Delete model FOREVER", + classList: "comfyui-button icon-button", + action: async(e) => { + const [button, icon, span] = comfyButtonDisambiguate(e.target); + button.disabled = true; const affirmation = "delete"; const confirmation = window.prompt("Type \"" + affirmation + "\" to delete the model PERMANENTLY.\n\nThis includes all image or text files."); let deleted = false; if (confirmation === affirmation) { const container = this.elements.info; const path = encodeURIComponent(container.dataset.path); - deleted = await request( + deleted = await comfyRequest( `/model-manager/model/delete?path=${path}`, { method: "POST", @@ -1855,17 +2226,21 @@ class ModelInfoView { }); } if (!deleted) { - buttonAlert(e.target, false); + comfyButtonAlert(e.target, false); } + button.disabled = false; }, - }), - $el("div.search-models", [ + }).element, + $el("div.search-models.input-dropdown-container", [ // TODO: magic class moveDestinationInput, searchDropdown.element, ]), - $el("button", { - textContent: "Move", - onclick: async(e) => { + new ComfyButton({ + icon: "file-move-outline", + tooltip: "Move file", + action: async(e) => { + const [button, icon, span] = comfyButtonDisambiguate(e.target); + button.disabled = true; const confirmation = window.confirm("Move this file?"); let moved = false; if (confirmation) { @@ -1877,7 +2252,7 @@ class ModelInfoView { modelData.searchSeparator + oldFileName ); - moved = await request( + moved = await comfyRequest( `/model-manager/model/move`, { method: "POST", @@ -1906,9 +2281,10 @@ class ModelInfoView { return false; }); } - buttonAlert(e.target, moved); + comfyButtonAlert(e.target, moved); + button.disabled = false; }, - }), + }).element, ]), ]), $el("div.model-info-container", { @@ -1916,6 +2292,13 @@ class ModelInfoView { "data-path": "", }), ]); + + [this.elements.tabButtons, this.elements.tabContents] = GenerateTabGroup([ + { name: "Overview", icon: "information-box-outline", tabContent: this.element }, + { name: "Metadata", icon: "file-document-outline", tabContent: $el("div", ["Metadata"]) }, + { name: "Tags", icon: "tag-outline", tabContent: $el("div", ["Tags"]) }, + { name: "Notes", icon: "pencil-outline", tabContent: $el("div", ["Notes"]) }, + ]); } /** @returns {void} */ @@ -1925,7 +2308,7 @@ class ModelInfoView { } /** - * @param {boolean} + * @param {boolean} promptUser * @returns {Promise } */ async trySave(promptUser) { @@ -1985,22 +2368,28 @@ 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) { + const [info, metadata, tags, noteText] = await comfyRequest(`/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"], + result["metadata"], + result["tags"], + result["notes"] + ]; + }) + .catch((err) => { + console.log(err); return undefined; } - return result["info"]; - }) - .catch((err) => { - console.log(err); - return undefined; - }); + ); if (info === undefined || info === null) { return; } @@ -2020,9 +2409,13 @@ class ModelInfoView { filename, ]), $el("div", [ - $el("button.icon-button", { - textContent: "β", - onclick: async(e) => { + new ComfyButton({ + icon: "pencil", + tooltip: "Change file name", + classList: "comfyui-button icon-button", + action: async(e) => { + const [button, icon, span] = comfyButtonDisambiguate(e.target); + button.disabled = true; const container = this.elements.info; const oldFile = container.dataset.path; const [oldFilePath, oldFileName] = SearchPath.split(oldFile); @@ -2036,7 +2429,7 @@ class ModelInfoView { newName + SearchPath.splitExtension(oldFile)[1] ); - renamed = await request( + renamed = await comfyRequest( `/model-manager/model/move`, { method: "POST", @@ -2065,9 +2458,10 @@ class ModelInfoView { return false; }); } - buttonAlert(e.target, renamed); + comfyButtonAlert(e.target, renamed); + button.disabled = false; }, - }), + }).element, ]), ]), ); @@ -2100,6 +2494,16 @@ class ModelInfoView { innerHtml.push($el("div", [ previewSelect.elements.previews, $el("div.row.tab-header", [ + $el("div", [ + new ComfyButton({ + content: "Load Workflow", + tooltip: "Attempt to load preview image workflow", + action: async () => { + const urlString = previewSelect.elements.defaultPreviews.children[0].src; + await loadWorkflow(urlString); + }, + }).element, + ]), $el("div.row.tab-header-flex-block", [ previewSelect.elements.radioGroup, ]), @@ -2107,7 +2511,7 @@ class ModelInfoView { setPreviewButton, ]), ]), - $el("h2", ["Details:"]), + $el("h2", ["File Info:"]), $el("div", (() => { const elements = []; @@ -2117,45 +2521,17 @@ class ModelInfoView { } if (Array.isArray(value)) { + // currently only used for "Bucket Resolutions" if (value.length > 0) { elements.push($el("h2", [key + ":"])); - - let text = " "; - for (let i = 0; i < value.length; i++) { - const v = value[i]; - const tag = v[0]; - const count = v[1]; - text += tag + " (" + count + ")"; - if (i !== value.length - 1) { - text += ", "; - } - } - text += "
"; + const text = TagCountMapToParagraph(value); const div = $el("div"); div.innerHTML = text; elements.push(div); } } else { - if (key === "Notes") { - elements.push($el("h2", [key + ":"])); - const notes = $el("textarea.comfy-multiline-input", { - name: "model notes", - value: value, - rows: 12, - }); - this.elements.notes = notes; - this.#savedNotesValue = value; - elements.push($el("button", { - textContent: "Save Notes", - onclick: async (e) => { - const saved = await this.trySave(false); - buttonAlert(e.target, saved); - }, - })); - elements.push(notes); - } - else if (key === "Description") { + if (key === "Description") { if (value !== "") { elements.push($el("h2", [key + ":"])); elements.push($el("p", [value])); @@ -2177,6 +2553,251 @@ class ModelInfoView { ])); infoHtml.append.apply(infoHtml, innerHtml); // TODO: set default value of dropdown and value to model type? + + /** @type {HTMLDivElement} */ + const metadataElement = this.elements.tabContents[1]; // TODO: remove magic value + const isMetadata = typeof metadata === 'object' && metadata !== null && Object.keys(metadata).length > 0; + metadataElement.innerHTML = ""; + metadataElement.append.apply(metadataElement, [ + $el("h1", ["Metadata"]), + $el("div", (() => { + const tableRows = []; + if (isMetadata) { + for (const [key, value] of Object.entries(metadata)) { + if (value === undefined || value === null) { + continue; + } + if (value !== "") { + tableRows.push($el("tr", [ + $el("th.model-metadata-key", [key]), + $el("th.model-metadata-value", [value]), + ])); + } + } + } + return $el("table.model-metadata", tableRows); + })(), + ), + ]); + const metadataButton = this.elements.tabButtons[1]; // TODO: remove magic value + metadataButton.style.display = isMetadata ? "" : "none"; + + /** @type {HTMLDivElement} */ + const tagsElement = this.elements.tabContents[2]; // TODO: remove magic value + const isTags = Array.isArray(tags) && tags.length > 0; + const tagsParagraph = $el("div", (() => { + const elements = []; + if (isTags) { + let text = TagCountMapToParagraph(tags); + const div = $el("div"); + div.innerHTML = text; + elements.push(div); + } + return elements; + })(), + ); + const tagGeneratorRandomizedOutput = $el("textarea.comfy-multiline-input", { + name: "random tag generator output", + rows: 4, + }); + const TAG_GENERATOR_SAMPLER_NAME = "model manager tag generator sampler"; + const tagGenerationCount = $el("input", { + type: "number", + name: "tag generator count", + step: 1, + min: 1, + value: this.#settingsElements["tag-generator-count"].value, + }); + const tagGenerationThreshold = $el("input", { + type: "number", + name: "tag generator threshold", + step: 1, + min: 1, + value: this.#settingsElements["tag-generator-threshold"].value, + }); + const selectedSamplerOption = this.#settingsElements["tag-generator-sampler-method"].value; + const samplerOptions = ["Frequency", "Uniform"]; + const samplerRadioGroup = $radioGroup({ + name: TAG_GENERATOR_SAMPLER_NAME, + onchange: (value) => {}, + options: samplerOptions.map(option => { return { value: option }; }), + }); + const samplerOptionInputs = samplerRadioGroup.getElementsByTagName("input"); + for (let i = 0; i < samplerOptionInputs.length; i++) { + const samplerOptionInput = samplerOptionInputs[i]; + if (samplerOptionInput.value === selectedSamplerOption) { + samplerOptionInput.click(); + break; + } + } + tagsElement.innerHTML = ""; + tagsElement.append.apply(tagsElement, [ + $el("h1", ["Tags"]), + $el("h2", { style: { margin: "0px 0px 16px 0px" } }, ["Random Tag Generator"]), + $el("div", [ + $el("details.tag-generator-settings", { + style: { margin: "10px 0", display: "none" }, + open: false, + }, [ + $el("summary", ["Settings"]), + $el("div", [ + "Sampling Method", + samplerRadioGroup, + ]), + $el("label", [ + "Count", + tagGenerationCount, + ]), + $el("label", [ + "Threshold", + tagGenerationThreshold, + ]), + ]), + tagGeneratorRandomizedOutput, + new ComfyButton({ + content: "Randomize", + tooltip: "Randomly generate subset of tags", + action: () => { + const samplerName = document.querySelector(`input[name="${TAG_GENERATOR_SAMPLER_NAME}"]:checked`).value; + const sampler = samplerName === "Frequency" ? ModelInfo.ProbabilisticTagSampling : ModelInfo.UniformTagSampling; + const sampleCount = tagGenerationCount.value; + const frequencyThreshold = tagGenerationThreshold.value; + const tags = ParseTagParagraph(tagsParagraph.innerText); + const sampledTags = sampler(tags, sampleCount, frequencyThreshold); + tagGeneratorRandomizedOutput.value = sampledTags.join(", "); + }, + }).element, + ]), + $el("h2", {style: { margin: "24px 0px 8px 0px" } }, ["Training Tags"]), + tagsParagraph, + ]); + const tagButton = this.elements.tabButtons[2]; // TODO: remove magic value + tagButton.style.display = isTags ? "" : "none"; + + const saveIcon = "content-save"; + const savingIcon = "cloud-upload-outline"; + + const saveNotesButton = new ComfyButton({ + icon: saveIcon, + tooltip: "Save note", + classList: "comfyui-button icon-button", + action: async (e) => { + const [button, icon, span] = comfyButtonDisambiguate(e.target); + button.disabled = true; + const saved = await this.trySave(false); + comfyButtonAlert(e.target, saved); + button.disabled = false; + }, + }).element; + + const saveDebounce = debounce(async() => { + const saveIconClass = "mdi-" + saveIcon; + const savingIconClass = "mdi-" + savingIcon; + const iconElement = saveNotesButton.getElementsByTagName("i")[0]; + iconElement.classList.remove(saveIconClass); + iconElement.classList.add(savingIconClass); + const saved = await this.trySave(false); + iconElement.classList.remove(savingIconClass); + iconElement.classList.add(saveIconClass); + }, 1000); + + /** @type {HTMLDivElement} */ + const notesElement = this.elements.tabContents[3]; // TODO: remove magic value + notesElement.innerHTML = ""; + notesElement.append.apply(notesElement, + (() => { + const notes = $el("textarea.comfy-multiline-input", { + name: "model notes", + value: noteText, + oninput: (e) => { + if (this.#settingsElements["model-info-autosave-notes"].checked) { + saveDebounce(); + } + }, + }); + + if (navigator.userAgent.includes("Mac")) { + new KeyComboListener( + ["MetaLeft", "KeyS"], + saveDebounce, + notes, + ); + new KeyComboListener( + ["MetaRight", "KeyS"], + saveDebounce, + notes, + ); + } + else { + new KeyComboListener( + ["ControlLeft", "KeyS"], + saveDebounce, + notes, + ); + new KeyComboListener( + ["ControlRight", "KeyS"], + saveDebounce, + notes, + ); + } + + this.elements.notes = notes; + this.#savedNotesValue = noteText; + return [ + $el("div.row", { + style: { "align-items": "center" }, + }, [ + $el("h1", ["Notes"]), + saveNotesButton, + ]), + $el("div", { + style: { "display": "flex", "height": "100%", "min-height": "60px" }, + }, notes), + ]; + })() + ); + } + + static UniformTagSampling(tagsAndCounts, sampleCount, frequencyThreshold = 0) { + const data = tagsAndCounts.filter(x => x[1] >= frequencyThreshold); + let count = data.length; + const samples = []; + for (let i = 0; i < sampleCount; i++) { + if (count === 0) { break; } + const index = Math.floor(Math.random() * count); + const pair = data.splice(index, 1)[0]; + samples.push(pair); + count -= 1; + } + const sortedSamples = samples.sort((x1, x2) => { return parseInt(x2[1]) - parseInt(x1[1]) }); + return sortedSamples.map(x => x[0]); + } + + static ProbabilisticTagSampling(tagsAndCounts, sampleCount, frequencyThreshold = 0) { + const data = tagsAndCounts.filter(x => x[1] >= frequencyThreshold); + let tagFrequenciesSum = data.reduce((accumulator, x) => accumulator + x[1], 0); + let count = data.length; + const samples = []; + for (let i = 0; i < sampleCount; i++) { + if (count === 0) { break; } + const index = (() => { + let frequencyIndex = Math.floor(Math.random() * tagFrequenciesSum); + return data.findIndex(x => { + const frequency = x[1]; + if (frequency > frequencyIndex) { + return true; + } + frequencyIndex = frequencyIndex - frequency; + return false; + }); + })(); + const pair = data.splice(index, 1)[0]; + samples.push(pair); + tagFrequenciesSum -= pair[1]; + count -= 1; + } + const sortedSamples = samples.sort((x1, x2) => { return parseInt(x2[1]) - parseInt(x1[1]) }); + return sortedSamples.map(x => x[0]); } } @@ -2192,7 +2813,9 @@ class Civitai { static async requestInfo(id, apiPath) { const url = "https://civitai.com/api/v1/" + apiPath + "/" + id; try { - return await request(url); + const response = await fetch(url); + const data = await response.json(); + return data; } catch (error) { console.error("Failed to get model info from Civitai!", error); @@ -2250,7 +2873,8 @@ class Civitai { return image["url"]; }), "name": modelVersionInfo["name"], - "description": modelVersionInfo["description"] ?? "", + "description": modelVersionInfo["description"], + "tags": modelVersionInfo["trainedWords"], }; } @@ -2286,7 +2910,8 @@ class Civitai { return { "name": modelVersionInfo["model"]["name"], "type": modelVersionInfo["model"]["type"], - "description": modelVersionInfo["description"] ?? "", + "description": modelVersionInfo["description"], + "tags": modelVersionInfo["trainedWords"], "versions": [filesInfo] } } @@ -2318,7 +2943,7 @@ class Civitai { return { "name": modelInfo["name"], "type": modelInfo["type"], - "description": modelInfo["description"] ?? "", + "description": modelInfo["description"], "versions": modelVersions, } } @@ -2354,7 +2979,9 @@ class Civitai { const id = stringUrl.substring(imagePostUrlPrefix.length).match(/^\d+/)[0]; const url = `https://civitai.com/api/v1/images?imageId=${id}`; try { - return await request(url); + const response = await fetch(url); + const data = await response.json(); + return data; } catch (error) { console.error("Failed to get image info from Civitai!", error); @@ -2380,7 +3007,8 @@ class Civitai { 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 response = await fetch(url); + const imageInfo = await response.json(); const items = imageInfo["items"]; if (items.length === 0) { console.warn("Civitai /api/v1/images returned 0 items."); @@ -2407,7 +3035,9 @@ class HuggingFace { static async requestInfo(id, apiPath = "models") { const url = "https://huggingface.co/api/" + apiPath + "/" + id; try { - return await request(url); + const response = await fetch(url); + const data = await response.json(); + return data; } catch (error) { console.error("Failed to get model info from HuggingFace!", error); @@ -2525,11 +3155,41 @@ async function getModelInfos(urlText) { 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 + const tags = version["tags"]?.map((tag) => tag.trim().replace(/,$/, "")); + const description = [ + tags !== undefined ? "# Trigger Words" : undefined, + tags?.join(tags.some((tag) => { return tag.includes(","); }) ? "\n" : ", "), + version["description"] !== undefined ? "# About this version " : undefined, + version["description"], + civitaiInfo["description"] !== undefined ? "# " + name : undefined, + civitaiInfo["description"], + ].filter(x => x !== undefined).join("\n\n") + .replaceAll("", "\n\n") + .replaceAll("", "**").replaceAll("", "**") + .replaceAll("
", "\n").replaceAll("
", "\n") // wrong + .replaceAll("", "\n").replaceAll("
", "\n") + .replaceAll("", "- ").replaceAll(" ", "\n") + .replaceAll("", "*").replaceAll("", "*") + .replaceAll("", "`").replaceAll("", "`") + .replaceAll("", "\n") + .replaceAll("
", "\n\n---\n\n") + .replaceAll("", "\n") + .replaceAll("
", "\n") + .replaceAll("
", "\n") + .replaceAll("
", "\n") + .replaceAll("
", "\n") + .replaceAll("
", "\n") + .replace(/href="(\S*)">/g, 'href=""> $1 ') + .replace(/src="(\S*)">/g, 'src=""> $1
') + // + // + .replace(/<[^>]+>/g, "") // quick hack + .replaceAll("<", "<").replaceAll(">", ">") + .replaceAll("<e;", "<=").replaceAll(">e;", ">=") + .replaceAll("&", "&"); version["files"].forEach((file) => { infos.push({ "images": images, @@ -2579,13 +3239,22 @@ async function getModelInfos(urlText) { return [name, infos]; } if (urlText.endsWith(".json")) { - const indexInfo = await request(urlText).catch(() => []); + const indexInfo = await (async() => { + try { + const response = await fetch(url); + const data = await response.json(); + return data; + } + catch { + return []; + } + })(); const name = urlText.substring(math.max(urlText.lastIndexOf("/"), 0)); const infos = indexInfo.map((file) => { return { "images": [], "fileName": file["name"], - "modelType": DownloadTab.modelTypeToComfyUiDirectory(file["type"], "") ?? "", + "modelType": DownloadView.modelTypeToComfyUiDirectory(file["type"], "") ?? "", "downloadUrl": file["download"], "downloadFilePath": "", "description": file["description"], @@ -2598,7 +3267,7 @@ async function getModelInfos(urlText) { })(); } -class DownloadTab { +class DownloadView { /** @type {HTMLDivElement} */ element = null; @@ -2606,23 +3275,78 @@ class DownloadTab { /** @type {HTMLInputElement} */ url: null, /** @type {HTMLDivElement} */ infos: null, /** @type {HTMLInputElement} */ overwrite: null, + /** @type {HTMLInputElement} */ downloadNotes: null, + /** @type {HTMLButtonElement} */ searchButton: null, + /** @type {HTMLButtonElement} */ clearSearchButton: null, }; /** @type {DOMParser} */ #domParser = null; + /** @type {Object.
} */ + #settings = null; + /** @type {() => Promise } */ #updateModels = () => {}; /** * @param {ModelData} modelData - * @param {any} settings + * @param {Object. } settings * @param {() => Promise } updateModels */ constructor(modelData, settings, updateModels) { this.#domParser = new DOMParser(); this.#updateModels = updateModels; const update = async() => { await this.#update(modelData, settings); }; + const reset = () => { + this.elements.infos.innerHTML = ""; + this.elements.infos.appendChild( + $el("h1", ["Input a URL to select a model to download."]) + ); + }; + + const searchButton = new ComfyButton({ + icon: "magnify", + tooltip: "Search url", + classList: "comfyui-button icon-button", + action: async(e) => { + const [button, icon, span] = comfyButtonDisambiguate(e.target); + button.disabled = true; + if (this.elements.url.value === "") { + reset(); + } + else { + await update(); + } + button.disabled = false; + }, + }).element; + settings["model-real-time-search"].addEventListener("change", () => { + const hideSearchButton = settings["text-input-always-hide-search-button"].checked; + searchButton.style.display = hideSearchButton ? "none" : ""; + }); + settings["text-input-always-hide-search-button"].addEventListener("change", () => { + const hideSearchButton = settings["text-input-always-hide-search-button"].checked; + searchButton.style.display = hideSearchButton ? "none" : ""; + }); + this.elements.searchButton = searchButton; + + const clearSearchButton = new ComfyButton({ + icon: "close", + tooltip: "Clear search", + classList: "comfyui-button icon-button", + action: async(e) => { + e.stopPropagation(); + this.elements.url.value = ""; + reset(); + }, + }).element; + settings["text-input-always-hide-clear-button"].addEventListener("change", () => { + const hideClearButton = settings["text-input-always-hide-clear-button"].checked; + clearSearchButton.style.display = hideClearButton ? "none" : ""; + }); + this.elements.clearSearchButton = clearSearchButton; + $el("div.tab-header", { $: (el) => (this.element = el), }, [ @@ -2632,19 +3356,22 @@ class DownloadTab { type: "text", name: "model download url", autocomplete: "off", - placeholder: "Search URL...", + placeholder: "Search URL", onkeydown: async (e) => { if (e.key === "Enter") { e.stopPropagation(); - await update(); + if (this.elements.url.value === "") { + reset(); + } + else { + await update(); + } e.target.blur(); } }, }), - $el("button.icon-button", { - onclick: async () => { await update(); }, - textContent: "ποΈ", - }), + clearSearchButton, + searchButton, ]), $el("div.download-model-infos", { $: (el) => (this.elements.infos = el), @@ -2695,6 +3422,28 @@ class DownloadTab { return null; } + /** + * Returns empty string on failure + * @param {float | undefined} fileSizeKB + * @returns {string} + */ + static #fileSizeToFormattedString(fileSizeKB) { + if (fileSizeKB === undefined) { return ""; } + const sizes = ["KB", "MB", "GB", "TB", "PB"]; + let fileSizeString = fileSizeKB.toString(); + const index = fileSizeString.indexOf("."); + const indexMove = index % 3 === 0 ? 3 : index % 3; + const sizeIndex = Math.floor((index - indexMove) / 3); + if (sizeIndex >= sizes.length || sizeIndex < 0) { + fileSizeString = fileSizeString.substring(0, fileSizeString.indexOf(".") + 3); + return `(${fileSizeString} ${sizes[0]})`; + } + const split = fileSizeString.split("."); + fileSizeString = split[0].substring(0, indexMove) + "." + split[0].substring(indexMove) + split[1]; + fileSizeString = fileSizeString.substring(0, fileSizeString.indexOf(".") + 3); + return `(${fileSizeString} ${sizes[sizeIndex]})`; + } + /** * @param {Object} info * @param {ModelData} modelData @@ -2709,8 +3458,8 @@ class DownloadTab { ); const comfyUIModelType = ( - DownloadTab.modelTypeToComfyUiDirectory(info["details"]["fileType"]) ?? - DownloadTab.modelTypeToComfyUiDirectory(info["modelType"]) ?? + DownloadView.modelTypeToComfyUiDirectory(info["details"]["fileType"]) ?? + DownloadView.modelTypeToComfyUiDirectory(info["modelType"]) ?? "" ); const searchSeparator = modelData.searchSeparator; @@ -2749,6 +3498,14 @@ class DownloadTab { }, }); + const infoNotes = $el("textarea.comfy-multiline-input.model-info-notes", { + name: "model info notes", + value: info["description"]??"", + rows: 6, + disabled: true, + style: { display: info["description"] === undefined || info["description"] === "" ? "none" : "" }, + }); + const filepath = info["downloadFilePath"]; const modelInfo = $el("details.download-details", [ $el("summary", [filepath + info["fileName"]]), @@ -2756,9 +3513,12 @@ class DownloadTab { downloadPreviewSelect.elements.previews, $el("div.download-settings-wrapper", [ $el("div.download-settings", [ - $el("button.icon-button", { - textContent: "π₯οΈ", - onclick: async (e) => { + new ComfyButton({ + icon: "arrow-collapse-down", + tooltip: "Download model", + content: "Download " + DownloadView.#fileSizeToFormattedString(info["details"]["fileSizeKB"]), + classList: "comfyui-button download-button", + action: async (e) => { const pathDirectory = el_saveDirectoryPath.value; const modelName = (() => { const filename = info["fileName"]; @@ -2778,8 +3538,9 @@ class DownloadTab { 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( + const [button, icon, span] = comfyButtonDisambiguate(e.target); + button.disabled = true; + const [success, resultText] = await comfyRequest( "/model-manager/model/download", { method: "POST", @@ -2796,21 +3557,21 @@ class DownloadTab { return [false, "π₯οΈ"]; }); if (success) { - const description = info["description"]; - if (settings["download-save-description-as-text-file"].checked && description !== "") { + const description = infoNotes.value; + if (this.elements.downloadNotes.checked && description !== "") { const modelPath = pathDirectory + searchSeparator + modelName; const saved = await saveNotes(modelPath, description); if (!saved) { - console.warn("Description was note saved as notes!"); + console.warn("Model description was not saved!"); } } this.#updateModels(); } - buttonAlert(e.target, success, "β", "β", resultText); - e.target.disabled = success; + comfyButtonAlert(e.target, success, "mdi-check-bold", "mdi-close-thick", success); + button.disabled = success; }, - }), - $el("div.row.tab-header-flex-block", [ + }).element, + $el("div.row.tab-header-flex-block.input-dropdown-container", [ // TODO: magic class el_saveDirectoryPath, searchDropdown.element, ]), @@ -2818,6 +3579,7 @@ class DownloadTab { el_filename, ]), downloadPreviewSelect.elements.radioGroup, + infoNotes, ]), ]), ]), @@ -2855,11 +3617,18 @@ class DownloadTab { const header = $el("div", [ $el("h1", [name]), - $checkbox({ - $: (el) => { this.elements.overwrite = el; }, - textContent: "Overwrite Existing Files.", - checked: false, - }), + $el("div.model-manager-settings", [ + $checkbox({ + $: (el) => { this.elements.overwrite = el; }, + textContent: "Overwrite Existing Files.", + checked: false, + }), + $checkbox({ + $: (el) => { this.elements.downloadNotes = el; }, + textContent: "Save Notes.", + checked: false, + }), + ]) ]); modelInfosHtml.unshift(header); } @@ -2867,10 +3636,29 @@ class DownloadTab { const infosHtml = this.elements.infos; infosHtml.innerHTML = ""; infosHtml.append.apply(infosHtml, modelInfosHtml); + + const downloadNotes = this.elements.downloadNotes; + if (downloadNotes !== undefined && downloadNotes !== null) { + downloadNotes.addEventListener("change", (e) => { + const modelInfoNotes = infosHtml.querySelectorAll(`textarea.model-info-notes`); + const disabled = !e.currentTarget.checked; + for (let i = 0; i < modelInfoNotes.length; i++) { + modelInfoNotes[i].disabled = disabled; + } + }); + downloadNotes.checked = settings["download-save-description-as-text-file"].checked; + downloadNotes.dispatchEvent(new Event('change')); + } + + const hideSearchButtons = settings["text-input-always-hide-search-button"].checked; + this.elements.searchButton.style.display = hideSearchButtons ? "none" : ""; + + const hideClearSearchButtons = settings["text-input-always-hide-clear-button"].checked; + this.elements.clearSearchButton.style.display = hideClearSearchButtons ? "none" : ""; } } -class ModelTab { +class BrowseView { /** @type {HTMLDivElement} */ element = null; @@ -2879,6 +3667,8 @@ class ModelTab { /** @type {HTMLSelectElement} */ modelTypeSelect: null, /** @type {HTMLSelectElement} */ modelSortSelect: null, /** @type {HTMLInputElement} */ modelContentFilter: null, + /** @type {HTMLButtonElement} */ searchButton: null, + /** @type {HTMLButtonElement} */ clearSearchButton: null, }; /** @type {Array} */ @@ -2906,9 +3696,10 @@ class ModelTab { * @param {() => Promise } updateModels * @param {ModelData} modelData * @param {(searchPath: string) => Promise } showModelInfo + * @param {() => void} updateModelGridCallback * @param {any} settingsElements */ - constructor(updateModels, modelData, showModelInfo, settingsElements) { + constructor(updateModels, modelData, showModelInfo, updateModelGridCallback, settingsElements) { /** @type {HTMLDivElement} */ const modelGrid = $el("div.comfy-grid"); this.elements.modelGrid = modelGrid; @@ -2922,7 +3713,7 @@ class ModelTab { type: "text", name: "model search", autocomplete: "off", - placeholder: "/Search...", + placeholder: "/Search", }); const updatePreviousModelFilter = () => { @@ -2947,7 +3738,16 @@ class ModelTab { this.elements.modelContentFilter, showModelInfo, ); - this.element.parentElement.scrollTop = 0; + updateModelGridCallback(); + + const hideSearchButtons = ( + this.#settingsElements["model-real-time-search"].checked | + this.#settingsElements["text-input-always-hide-search-button"].checked + ); + this.elements.searchButton.style.display = hideSearchButtons ? "none" : ""; + + const hideClearSearchButtons = this.#settingsElements["text-input-always-hide-clear-button"].checked; + this.elements.clearSearchButton.style.display = hideClearSearchButtons ? "none" : ""; } this.updateModelGrid = updateModelGrid; @@ -2958,27 +3758,91 @@ class ModelTab { () => { return this.elements.modelTypeSelect.value; }, updatePreviousModelFilter, updateModelGrid, + () => { return this.#settingsElements["model-real-time-search"].checked; }, ); this.directoryDropdown = searchDropdown; + const searchButton = new ComfyButton({ + icon: "magnify", + tooltip: "Search models", + classList: "comfyui-button icon-button", + action: (e) => { + e.stopPropagation(); + const [button, icon, span] = comfyButtonDisambiguate(e.target); + button.disabled = true; + updateModelGrid(); + button.disabled = false; + }, + }).element; + settingsElements["model-real-time-search"].addEventListener("change", () => { + const hideSearchButton = ( + this.#settingsElements["text-input-always-hide-search-button"].checked || + this.#settingsElements["model-real-time-search"].checked + ); + searchButton.style.display = hideSearchButton ? "none" : ""; + }); + settingsElements["text-input-always-hide-search-button"].addEventListener("change", () => { + const hideSearchButton = ( + this.#settingsElements["text-input-always-hide-search-button"].checked || + this.#settingsElements["model-real-time-search"].checked + ); + searchButton.style.display = hideSearchButton ? "none" : ""; + }); + this.elements.searchButton = searchButton; + + const clearSearchButton = new ComfyButton({ + icon: "close", + tooltip: "Clear search", + classList: "comfyui-button icon-button", + action: (e) => { + e.stopPropagation(); + const [button, icon, span] = comfyButtonDisambiguate(e.target); + button.disabled = true; + this.elements.modelContentFilter.value = ""; + updateModelGrid(); + button.disabled = false; + }, + }).element; + settingsElements["text-input-always-hide-clear-button"].addEventListener("change", () => { + const hideClearSearchButton = this.#settingsElements["text-input-always-hide-clear-button"].checked; + clearSearchButton.style.display = hideClearSearchButton ? "none" : ""; + }); + this.elements.clearSearchButton = clearSearchButton; + this.element = $el("div", [ $el("div.row.tab-header", [ $el("div.row.tab-header-flex-block", [ - $el("button.icon-button", { - type: "button", - textContent: "β³", - onclick: () => updateModels(), - }), + new ComfyButton({ + icon: "reload", + tooltip: "Reload model grid", + classList: "comfyui-button icon-button", + action: async(e) => { + const [button, icon, span] = comfyButtonDisambiguate(e.target); + button.disabled = true; + updateModels(); + button.disabled = false; + }, + }).element, $el("select.model-select-dropdown", { $: (el) => (this.elements.modelTypeSelect = el), name: "model-type", - onchange: () => updateModelGrid(), + onchange: (e) => { + const select = e.target; + select.disabled = true; + updateModelGrid(); + select.disabled = false; + }, }), $el("select.model-select-dropdown", { $: (el) => (this.elements.modelSortSelect = el), name: "model select dropdown", - onchange: () => updateModelGrid(), + onchange: (e) => { + const select = e.target; + select.disabled = true; + updateModelGrid(); + select.disabled = false; + }, }, [ $el("option", { value: MODEL_SORT_DATE_CREATED }, ["Created (newest first)"]), @@ -2993,15 +3857,12 @@ class ModelTab { ), ]), $el("div.row.tab-header-flex-block", [ - $el("div.search-models", [ + $el("div.search-models.input-dropdown-container", [ // TODO: magic class searchInput, searchDropdown.element, ]), - $el("button.icon-button", { - type: "button", - textContent: "ποΈ", - onclick: () => updateModelGrid(), - }), + clearSearchButton, + searchButton, ]), ]), modelGrid, @@ -3009,7 +3870,7 @@ class ModelTab { } } -class SettingsTab { +class SettingsView { /** @type {HTMLDivElement} */ element = null; @@ -3018,20 +3879,36 @@ class SettingsTab { /** @type {HTMLButtonElement} */ saveButton: null, /** @type {HTMLDivElement} */ setPreviewButton: null, settings: { - //"sidebar-default-height": null, - //"sidebar-default-width": null, /** @type {HTMLTextAreaElement} */ "model-search-always-append": null, + /** @type {HTMLInputElement} */ "model-default-browser-model-type": null, + /** @type {HTMLInputElement} */ "model-real-time-search": null, /** @type {HTMLInputElement} */ "model-persistent-search": null, - /** @type {HTMLInputElement} */ "model-show-label-extensions": null, - /** @type {HTMLInputElement} */ "model-preview-fallback-search-safetensors-thumbnail": null, + /** @type {HTMLInputElement} */ "model-preview-thumbnail-type": null, + /** @type {HTMLInputElement} */ "model-preview-fallback-search-safetensors-thumbnail": null, + /** @type {HTMLInputElement} */ "model-show-label-extensions": null, /** @type {HTMLInputElement} */ "model-show-add-button": null, /** @type {HTMLInputElement} */ "model-show-copy-button": null, + /** @type {HTMLInputElement} */ "model-show-load-workflow-button": null, + /** @type {HTMLInputElement} */ "model-info-button-on-left": null, + /** @type {HTMLInputElement} */ "model-add-embedding-extension": null, /** @type {HTMLInputElement} */ "model-add-drag-strict-on-field": null, /** @type {HTMLInputElement} */ "model-add-offset": null, + /** @type {HTMLInputElement} */ "model-info-autosave-notes": null, + /** @type {HTMLInputElement} */ "download-save-description-as-text-file": null, + + /** @type {HTMLInputElement} */ "sidebar-default-width": null, + /** @type {HTMLInputElement} */ "sidebar-default-height": null, + /** @type {HTMLInputElement} */ "sidebar-control-always-compact": null, + /** @type {HTMLInputElement} */ "text-input-always-hide-search-button": null, + /** @type {HTMLInputElement} */ "text-input-always-hide-clear-button": null, + + /** @type {HTMLInputElement} */ "tag-generator-sampler-method": null, + /** @type {HTMLInputElement} */ "tag-generator-count": null, + /** @type {HTMLInputElement} */ "tag-generator-threshold": null, }, }; @@ -3042,7 +3919,7 @@ class SettingsTab { * @param {Object} settingsData * @param {boolean} updateModels */ - #setSettings(settingsData, updateModels) { + async #setSettings(settingsData, updateModels) { const settings = this.elements.settings; for (const [key, value] of Object.entries(settingsData)) { const setting = settings[key]; @@ -3055,12 +3932,13 @@ class SettingsTab { case "range": setting.value = parseFloat(value); break; case "textarea": setting.value = value; break; case "number": setting.value = parseInt(value); break; - default: console.warn("Unknown settings input type!"); + case "select-one": setting.value = value; break; + default: console.warn(`Unknown settings input type '${type}'!`); } } if (updateModels) { - this.#updateModels(); // Is this slow? + await this.#updateModels(); // Is this slow? } } @@ -3069,10 +3947,10 @@ class SettingsTab { * @returns {Promise } */ async reload(updateModels) { - const data = await request("/model-manager/settings/load"); + const data = await comfyRequest("/model-manager/settings/load"); const settingsData = data["settings"]; - this.#setSettings(settingsData, updateModels); - buttonAlert(this.elements.reloadButton, true); + await this.#setSettings(settingsData, updateModels); + comfyButtonAlert(this.elements.reloadButton, true); } /** @returns {Promise } */ @@ -3087,12 +3965,13 @@ class SettingsTab { case "range": value = el.value; break; case "textarea": value = el.value; break; case "number": value = el.value; break; + case "select-one": value = el.value; break; default: console.warn("Unknown settings input type!"); } settingsData[setting] = value; } - const data = await request( + const data = await comfyRequest( "/model-manager/settings/save", { method: "POST", @@ -3104,69 +3983,92 @@ class SettingsTab { const success = data["success"]; if (success) { const settingsData = data["settings"]; - this.#setSettings(settingsData, true); + await this.#setSettings(settingsData, true); } - buttonAlert(this.elements.saveButton, success); + comfyButtonAlert(this.elements.saveButton, success); } /** * @param {() => Promise } updateModels + * @param {() => void} updateSidebarButtons */ - constructor(updateModels) { + constructor(updateModels, updateSidebarButtons) { this.#updateModels = updateModels; const settings = this.elements.settings; + + const sidebarControl = $checkbox({ + $: (el) => (settings["sidebar-control-always-compact"] = el), + textContent: "Sidebar controls always compact", + }); + sidebarControl.getElementsByTagName('input')[0].addEventListener("change", () => { + updateSidebarButtons(); + }); + + const reloadButton = new ComfyButton({ + content: "Reload", + tooltip: "Reload settings and model manager files", + action: async(e) => { + const [button, icon, span] = comfyButtonDisambiguate(e.target); + button.disabled = true; + await this.reload(true); + button.disabled = false; + }, + }).element; + this.elements.reloadButton = reloadButton; + + const saveButton = new ComfyButton({ + content: "Save", + tooltip: "Save settings and reload model manager", + action: async(e) => { + const [button, icon, span] = comfyButtonDisambiguate(e.target); + button.disabled = true; + await this.save(); + button.disabled = false; + }, + }).element; + this.elements.saveButton = saveButton; + + const correctPreviewsButton = new ComfyButton({ + content: "Fix Extensions", + tooltip: "Correct image file extensions in all model directories", + action: async(e) => { + const [button, icon, span] = comfyButtonDisambiguate(e.target); + button.disabled = true; + const data = await comfyRequest( + "/model-manager/preview/correct-extensions") + .catch((err) => { + return { "success": false }; + }); + const success = data["success"]; + if (success) { + const detectPlural = data["detected"] === 1 ? "" : "s"; + const correctPlural = data["corrected"] === 1 ? "" : "s"; + const message = `Detected ${data["detected"]} extension${detectPlural}.\nCorrected ${data["corrected"]} extension${correctPlural}.`; + window.alert(message); + } + comfyButtonAlert(e.target, success); + if (data["corrected"] > 0) { + await this.reload(true); + } + button.disabled = false; + }, + }).element; + $el("div.model-manager-settings", { $: (el) => (this.element = el), }, [ $el("h1", ["Settings"]), + $el("div", [ + reloadButton, + saveButton, + ]), $el("a", { + style: { color: "var(--fg-color)" }, 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: async () => { await this.reload(true); }, - }), - $el("button", { - $: (el) => (this.elements.saveButton = el), - type: "button", - textContent: "Save", // πΎοΈ - onclick: async () => { await this.save(); }, - }), - ]), - /* - $el("h2", ["Window"]), - $el("div", [ - $el("p", ["Default sidebar width"]), - $el("input", { - $: (el) => (settings["sidebar-default-width"] = el), - type: "number", - name: "default sidebar width", - value: 0.5, - min: 0.0, - max: 1.0, - step: 0.05, - }), - ]), - $el("div", [ - $el("p", ["Default sidebar height"]), - $el("input", { - $: (el) => (settings["sidebar-default-height"] = el), - type: "number", - name: "default sidebar height", - textContent: "Default sidebar height", - value: 0.5, - min: 0.0, - max: 1.0, - step: 0.05, - }), - ]), - */ $el("h2", ["Model Search"]), $el("div", [ $el("div.search-settings-text", [ @@ -3179,131 +4081,303 @@ class SettingsTab { }), ]), ]), - $checkbox({ - $: (el) => (settings["model-persistent-search"] = el), - textContent: "Persistent search text across model types", + $select({ + $: (el) => (settings["model-default-browser-model-type"] = el), + textContent: "Default model search type (on start up)", + options: ["checkpoints", "clip", "clip_vision", "controlnet", "diffusers", "embeddings", "gligen", "hypernetworks", "loras", "photomaker", "style_models", "unet", "vae", "vae_approx"], }), $checkbox({ - $: (el) => (settings["model-show-label-extensions"] = el), - textContent: "Show model file extension in labels", + $: (el) => (settings["model-real-time-search"] = el), + textContent: "Real-time search", + }), + $checkbox({ + $: (el) => (settings["model-persistent-search"] = el), + textContent: "Persistent search text (across model types)", + }), + $el("h2", ["Model Search Thumbnails"]), + $select({ + $: (el) => (settings["model-preview-thumbnail-type"] = el), + textContent: "Preview thumbnail type", + options: ["AUTO", "JPEG"], // should use AUTO to avoid artifacts from changing between formats; use JPEG for backward compatibility }), $checkbox({ $: (el) => (settings["model-preview-fallback-search-safetensors-thumbnail"] = el), - textContent: "Fallback on embedded thumbnail in safetensors (refresh slow)", + textContent: "Fallback to embedded safetensors image (slow)", }), $checkbox({ - $: (el) => (settings["model-show-add-button"] = el), - textContent: "Show add button", + $: (el) => (settings["model-show-label-extensions"] = el), + textContent: "Show file extension", }), $checkbox({ $: (el) => (settings["model-show-copy-button"] = el), - textContent: "Show copy button", + textContent: "Show \"Copy\" button", }), - $el("h2", ["Model Add"]), + $checkbox({ + $: (el) => (settings["model-show-add-button"] = el), + textContent: "Show \"Add\" button", + }), + $checkbox({ + $: (el) => (settings["model-show-load-workflow-button"] = el), + textContent: "Show \"Load Workflow\" button", + }), + $checkbox({ + $: (el) => (settings["model-info-button-on-left"] = el), + textContent: "\"Model Info\" button on left", + }), + $el("h2", ["Node Graph"]), $checkbox({ $: (el) => (settings["model-add-embedding-extension"] = el), - textContent: "Add extension to embedding", + textContent: "Add embedding with extension", }), $checkbox({ - $: (el) => (settings["model-add-drag-strict-on-field"] = el), - textContent: "Strict dragging model onto a node's model field to add", + $: (el) => (settings["model-add-drag-strict-on-field"] = el), // true -> must drag on field; false -> can drag on node when unambiguous + textContent: "Must always drag thumbnail onto node's input field", }), - $el("div", [ + $el("label", [ + "Add offset", // if a node already was added to the same spot, add the next one with an offset $el("input", { $: (el) => (settings["model-add-offset"] = el), type: "number", name: "model add offset", step: 5, }), - $el("p", ["Add model offset"]), ]), + $el("h2", ["Model Info"]), + $checkbox({ + $: (el) => (settings["model-info-autosave-notes"] = el), // note history deleted on model info close + textContent: "Autosave notes", + }), $el("h2", ["Download"]), $checkbox({ $: (el) => (settings["download-save-description-as-text-file"] = el), - textContent: "Save descriptions as notes (in .txt file).", + textContent: "Save notes by default.", }), + $el("h2", ["Window"]), + sidebarControl, + $el("label", [ + "Sidebar width (on start up)", + $el("input", { + $: (el) => (settings["sidebar-default-width"] = el), + type: "range", + name: "default sidebar width", + value: 0.5, + min: 0.0, + max: 1.0, + step: 0.05, + }), + ]), + $el("label", [ + "Sidebar height (on start up)", + $el("input", { + $: (el) => (settings["sidebar-default-height"] = el), + type: "range", + name: "default sidebar height", + value: 0.5, + min: 0.0, + max: 1.0, + step: 0.05, + }), + ]), + $checkbox({ + $: (el) => (settings["text-input-always-hide-search-button"] = el), + textContent: "Always hide \"Search\" buttons.", + }), + $checkbox({ + $: (el) => (settings["text-input-always-hide-clear-button"] = el), + textContent: "Always hide \"Clear Search\" buttons.", + }), + $el("h2", ["Model Preview Images"]), + $el("div", [ + correctPreviewsButton, + ]), + $el("h2", ["Random Tag Generator"]), + $select({ + $: (el) => (settings["tag-generator-sampler-method"] = el), + textContent: "Default sampling method", + options: ["Frequency", "Uniform"], + }), + $el("label", [ + "Default count", + $el("input", { + $: (el) => (settings["tag-generator-count"] = el), + type: "number", + name: "tag generator count", + step: 1, + min: 1, + }), + ]), + $el("label", [ + "Default minimum threshold", + $el("input", { + $: (el) => (settings["tag-generator-threshold"] = el), + type: "number", + name: "tag generator threshold", + step: 1, + min: 1, + }), + ]), ]); } } -class SidebarButtons { - /** @type {HTMLDivElement} */ - element = null; - - /** @type {ModelManager} */ - #modelManager = null; - - /** - * @param {Event} e - */ - #setSidebar(e) { - // TODO: settings["sidebar-default-width"] - // TODO: settings["sidebar-default-height"] - // TODO: draggable resize? - const button = e.target; - 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++) { - const sidebarButton = sidebarButtons[buttonIndex]; - if (sidebarButton === button) { - break; - } - } - - 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]; - if (modelManager.classList.contains(state)) { - modelManager.classList.remove(state); - break; - } - } - - if (stateIndex != buttonIndex) { - const newSidebarState = sidebarStates[buttonIndex]; - modelManager.classList.add(newSidebarState); - const sidebarButton = sidebarButtons[buttonIndex]; - sidebarButton.classList.add(buttonActiveState); - } +/** + * @param {String[]} labels + * @param {[(event: Event) => Promise ]} callbacks + * @returns {HTMLDivElement} + */ +function GenerateRadioButtonGroup(labels, callbacks = []) { + const RADIO_BUTTON_GROUP_ACTIVE = "radio-button-group-active"; + const radioButtonGroup = $el("div.radio-button-group", []); + const buttons = []; + for (let i = 0; i < labels.length; i++) { + const text = labels[i]; + const callback = callbacks[i] ?? (() => {}); + buttons.push( + $el("button.radio-button", { + textContent: text, + onclick: (event) => { + const targetIsActive = event.target.classList.contains(RADIO_BUTTON_GROUP_ACTIVE); + if (targetIsActive) { + return; + } + const children = radioButtonGroup.children; + for (let i = 0; i < children.length; i++) { + children[i].classList.remove(RADIO_BUTTON_GROUP_ACTIVE); + } + event.target.classList.add(RADIO_BUTTON_GROUP_ACTIVE); + callback(event); + }, + }) + ); } - - /** - * @param {ModelManager} modelManager - */ - constructor(modelManager) { - this.#modelManager = modelManager; - $el("div.sidebar-buttons", - { - $: (el) => (this.element = el), - }, - [ - - $el("button.icon-button", { - textContent: "β¨", - onclick: (event) => this.#setSidebar(event), - }), - $el("button.icon-button", { - textContent: "β¬", - onclick: (event) => this.#setSidebar(event), - }), - $el("button.icon-button", { - textContent: "β¬", - onclick: (event) => this.#setSidebar(event), - }), - $el("button.icon-button", { - textContent: "β§", - onclick: (event) => this.#setSidebar(event), - }), - ]); + radioButtonGroup.append.apply(radioButtonGroup, buttons); + buttons[0]?.classList.add(RADIO_BUTTON_GROUP_ACTIVE); + return radioButtonGroup; +} + +/** + * @param {String[]} labels + * @param {[(event: Event) => Promise ]} activationCallbacks + * @param {(event: Event) => Promise } deactivationCallback + * @returns {HTMLDivElement} + */ +function GenerateToggleRadioButtonGroup(labels, activationCallbacks = [], deactivationCallback = () => {}) { + const RADIO_BUTTON_GROUP_ACTIVE = "radio-button-group-active"; + const radioButtonGroup = $el("div.radio-button-group", []); + const buttons = []; + for (let i = 0; i < labels.length; i++) { + const text = labels[i]; + const activationCallback = activationCallbacks[i] ?? (() => {}); + buttons.push( + $el("button.radio-button", { + textContent: text, + onclick: (event) => { + const targetIsActive = event.target.classList.contains(RADIO_BUTTON_GROUP_ACTIVE); + const children = radioButtonGroup.children; + for (let i = 0; i < children.length; i++) { + children[i].classList.remove(RADIO_BUTTON_GROUP_ACTIVE); + } + if (targetIsActive) { + deactivationCallback(event); + } + else { + event.target.classList.add(RADIO_BUTTON_GROUP_ACTIVE); + activationCallback(event); + } + }, + }) + ); } + radioButtonGroup.append.apply(radioButtonGroup, buttons); + return radioButtonGroup; +} + +/** + * Coupled-state select and radio buttons (hidden first radio button) + * @param {String[]} labels + * @param {[(button: HTMLButtonElement) => Promise ]} activationCallbacks + * @returns {[HTMLDivElement, HTMLSelectElement]} + */ +function GenerateSidebarToggleRadioAndSelect(labels, activationCallbacks = []) { + const RADIO_BUTTON_GROUP_ACTIVE = "radio-button-group-active"; + const radioButtonGroup = $el("div.radio-button-group", []); + const buttons = []; + + const select = $el("select", { + name: "sidebar-select", + onchange: (event) => { + const select = event.target; + const children = select.children; + let value = undefined; + for (let i = 0; i < children.length; i++) { + const child = children[i]; + if (child.selected) { + value = child.value; + } + } + for (let i = 0; i < buttons.length; i++) { + const button = buttons[i]; + if (button.textContent === value) { + for (let i = 0; i < buttons.length; i++) { + buttons[i].classList.remove(RADIO_BUTTON_GROUP_ACTIVE); + } + button.classList.add(RADIO_BUTTON_GROUP_ACTIVE); + activationCallbacks[i](button); + break; + } + } + }, + }, labels.map((option) => { + return $el("option", { + value: option, + }, option); + }) + ); + + for (let i = 0; i < labels.length; i++) { + const text = labels[i]; + const activationCallback = activationCallbacks[i] ?? (() => {}); + buttons.push( + $el("button.radio-button", { + textContent: text, + onclick: (event) => { + const button = event.target; + let textContent = button.textContent; + const targetIsActive = button.classList.contains(RADIO_BUTTON_GROUP_ACTIVE); + if (button === buttons[0] && buttons[0].classList.contains(RADIO_BUTTON_GROUP_ACTIVE)) { + // do not deactivate 0 + return; + } + // update button + const children = radioButtonGroup.children; + for (let i = 0; i < children.length; i++) { + children[i].classList.remove(RADIO_BUTTON_GROUP_ACTIVE); + } + if (targetIsActive) { + // return to 0 + textContent = labels[0]; + buttons[0].classList.add(RADIO_BUTTON_GROUP_ACTIVE); + activationCallbacks[0](buttons[0]); + } + else { + // move to >0 + button.classList.add(RADIO_BUTTON_GROUP_ACTIVE); + activationCallback(button); + } + // update selection + for (let i = 0; i < select.children.length; i++) { + const option = select.children[i]; + option.selected = option.value === textContent; + } + }, + }) + ); + } + radioButtonGroup.append.apply(radioButtonGroup, buttons); + buttons[0].click(); + buttons[0].style.display = "none"; + + return [radioButtonGroup, select]; } class ModelManager extends ComfyDialog { @@ -3313,215 +4387,444 @@ class ModelManager extends ComfyDialog { /** @type {ModelData} */ #modelData = null; - /** @type {ModelInfoView} */ - #modelInfoView = null; + /** @type {ModelInfo} */ + #modelInfo = null; - /** @type {DownloadTab} */ - #downloadTab = null; + /** @type {DownloadView} */ + #downloadView = null; - /** @type {ModelTab} */ - #modelTab = null; + /** @type {BrowseView} */ + #browseView = null; - /** @type {SettingsTab} */ - #settingsTab = null; + /** @type {SettingsView} */ + #settingsView = null; /** @type {HTMLDivElement} */ - #tabs = null; + #topbarRight = null; /** @type {HTMLDivElement} */ - #tabContents = null; + #tabManagerButtons = null; + + /** @type {HTMLDivElement} */ + #tabManagerContents = null; + + /** @type {HTMLDivElement} */ + #tabInfoButtons = null; + + /** @type {HTMLDivElement} */ + #tabInfoContents = null; + + /** @type {HTMLButtonElement} */ + #sidebarButtonGroup = null; + + /** @type {HTMLButtonElement} */ + #sidebarSelect = null; /** @type {HTMLButtonElement} */ #closeModelInfoButton = null; + /** @type {String} */ + #dragSidebarState = ""; + constructor() { super(); this.#modelData = new ModelData(); - const modelInfoView = new ModelInfoView( + this.#settingsView = new SettingsView( + this.#refreshModels, + () => this.#updateSidebarButtons(), + ); + + this.#modelInfo = new ModelInfo( this.#modelData, this.#refreshModels, + this.#settingsView.elements.settings, ); - this.#modelInfoView = modelInfoView; - const settingsTab = new SettingsTab( + this.#browseView = new BrowseView( + this.#refreshModels, + this.#modelData, + this.#showModelInfo, + this.#resetManagerContentsScroll, + this.#settingsView.elements.settings, // TODO: decouple settingsData from elements? + ); + + this.#downloadView = new DownloadView( + this.#modelData, + this.#settingsView.elements.settings, this.#refreshModels, ); - this.#settingsTab = settingsTab; - const ACTIVE_TAB_CLASS = "active"; + const [tabManagerButtons, tabManagerContents] = GenerateTabGroup([ + { name: "Download", icon: "arrow-collapse-down", tabContent: this.#downloadView.element }, + { name: "Models", icon: "folder-search-outline", tabContent: this.#browseView.element }, + { name: "Settings", icon: "cog-outline", tabContent: this.#settingsView.element }, + ]); + tabManagerButtons[0]?.click(); - /** - * @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 tabInfoButtons = this.#modelInfo.elements.tabButtons; + const tabInfoContents = this.#modelInfo.elements.tabContents; + + const [sidebarButtonGroup, sidebarSelect] = GenerateSidebarToggleRadioAndSelect( + ["βΌ", "β¨", "β¬", "β¬", "β§"], + [ + () => { + const element = this.element; + if (element) { // callback on initialization as default state + element.dataset["sidebarState"] = "none"; + } + }, + () => { this.element.dataset["sidebarState"] = "right"; }, + () => { this.element.dataset["sidebarState"] = "top"; }, + () => { this.element.dataset["sidebarState"] = "bottom"; }, + () => { this.element.dataset["sidebarState"] = "left"; }, + ], + ); + this.#sidebarButtonGroup = sidebarButtonGroup; + this.#sidebarSelect = sidebarSelect; + sidebarButtonGroup.classList.add("sidebar-buttons"); + const sidebarButtonGroupChildren = sidebarButtonGroup.children; + for (let i = 0; i < sidebarButtonGroupChildren.length; i++) { + sidebarButtonGroupChildren[i].classList.add("icon-button"); } - 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 closeModelInfoButton = new ComfyButton({ + icon: "arrow-u-left-bottom", + tooltip: "Return to model search", + classList: "comfyui-button icon-button", + action: async() => await this.#tryHideModelInfo(true), + }).element; + this.#closeModelInfoButton = closeModelInfoButton; + closeModelInfoButton.style.display = "none"; const modelManager = $el( "div.comfy-modal.model-manager", { $: (el) => (this.element = el), parent: document.body, + dataset: { + "sidebarState": "none", + "sidebarLeftWidthDecimal": "", + "sidebarRightWidthDecimal": "", + "sidebarTopHeightDecimal": "", + "sidebarBottomHeightDecimal": "", + }, }, [ $el("div.comfy-modal-content", [ // TODO: settings.top_bar_left_to_right or settings.top_bar_right_to_left $el("div.model-manager-panel", [ $el("div.model-manager-head", [ - $el("div.topbar-right", [ - closeManagerButton, + $el("div.topbar-right", { + $: (el) => (this.#topbarRight = el), + }, [ + new ComfyButton({ + icon: "window-close", + tooltip: "Close model manager", + classList: "comfyui-button icon-button", + action: async() => { + const saved = await this.#modelInfo.trySave(true); + if (saved) { + this.close(); + } + }, + }).element, closeModelInfoButton, - sidebarButtons.element, + sidebarSelect, + sidebarButtonGroup, ]), $el("div.topbar-left", [ - $el("div.model-manager-tabs", { - $: (el) => (this.#tabs = el), - }, tabs), + $el("div", [ + $el("div.model-tab-group.no-highlight", { + $: (el) => (this.#tabManagerButtons = el), + }, tabManagerButtons), + $el("div.model-tab-group.no-highlight", { + $: (el) => (this.#tabInfoButtons = el), + style: { display: "none"}, + }, tabInfoButtons), + ]), ]), ]), $el("div.model-manager-body", [ - $el("div.model-manager-tab-contents", { - $: (el) => (this.#tabContents = el), - }, contents), - modelInfoView.element, + $el("div.tab-contents", { + $: (el) => (this.#tabManagerContents = el), + }, tabManagerContents), + $el("div.tab-contents", { + $: (el) => (this.#tabInfoContents = el), + style: { display: "none"}, + }, tabInfoContents), ]), ]), ]), ] ); - new ResizeObserver(() => { - if (modelManager.style.display === "none") { + new ResizeObserver(GenerateDynamicTabTextCallback(modelManager, tabManagerButtons, 704)).observe(modelManager); + new ResizeObserver(GenerateDynamicTabTextCallback(modelManager, tabInfoButtons, 704)).observe(modelManager); + new ResizeObserver(() => this.#updateSidebarButtons()).observe(modelManager); + window.addEventListener('resize', () => { + const width = window.innerWidth; + const height = window.innerHeight; + + const leftDecimal = modelManager.dataset["sidebarLeftWidthDecimal"]; + const rightDecimal = modelManager.dataset["sidebarRightWidthDecimal"]; + const topDecimal = modelManager.dataset["sidebarTopHeightDecimal"]; + const bottomDecimal = modelManager.dataset["sidebarBottomHeightDecimal"]; + + // restore decimal after resize + modelManager.style.setProperty("--model-manager-sidebar-width-left", (leftDecimal * width) + "px"); + modelManager.style.setProperty("--model-manager-sidebar-width-right", (rightDecimal * width) + "px"); + modelManager.style.setProperty("--model-manager-sidebar-height-top", + (topDecimal * height) + "px"); + modelManager.style.setProperty("--model-manager-sidebar-height-bottom", (bottomDecimal * height) + "px"); + }); + + const EDGE_DELTA = 8; + + const endDragSidebar = (e) => { + this.#dragSidebarState = ""; + + modelManager.classList.remove("cursor-drag-left"); + modelManager.classList.remove("cursor-drag-top"); + modelManager.classList.remove("cursor-drag-right"); + modelManager.classList.remove("cursor-drag-bottom"); + + // cache for window resize + modelManager.dataset["sidebarLeftWidthDecimal"] = parseInt(modelManager.style.getPropertyValue("--model-manager-sidebar-width-left")) / window.innerWidth; + modelManager.dataset["sidebarRightWidthDecimal"] = parseInt(modelManager.style.getPropertyValue("--model-manager-sidebar-width-right")) / window.innerWidth; + modelManager.dataset["sidebarTopHeightDecimal"] = parseInt(modelManager.style.getPropertyValue("--model-manager-sidebar-height-top")) / window.innerHeight; + modelManager.dataset["sidebarBottomHeightDecimal"] = parseInt(modelManager.style.getPropertyValue("--model-manager-sidebar-height-bottom")) / window.innerHeight; + }; + document.addEventListener("mouseup", (e) => endDragSidebar(e)); + document.addEventListener("touchend", (e) => endDragSidebar(e)); + + const detectDragSidebar = (e, x, y) => { + const left = modelManager.offsetLeft; + const top = modelManager.offsetTop; + const width = modelManager.offsetWidth; + const height = modelManager.offsetHeight; + const right = left + width; + const bottom = top + height; + + if (!(x >= left && x <= right && y >= top && y <= bottom)) { + // click was not in model manager 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); + + const isOnEdgeLeft = x - left <= EDGE_DELTA; + const isOnEdgeRight = right - x <= EDGE_DELTA; + const isOnEdgeTop = y - top <= EDGE_DELTA; + const isOnEdgeBottom = bottom - y <= EDGE_DELTA; + + const sidebarState = this.element.dataset["sidebarState"]; + if (sidebarState === "left" && isOnEdgeRight) { + this.#dragSidebarState = sidebarState; + } + else if (sidebarState === "right" && isOnEdgeLeft) { + this.#dragSidebarState = sidebarState; + } + else if (sidebarState === "top" && isOnEdgeBottom) { + this.#dragSidebarState = sidebarState; + } + else if (sidebarState === "bottom" && isOnEdgeTop) { + this.#dragSidebarState = sidebarState; + } + + if (this.#dragSidebarState !== "") { + e.preventDefault(); + e.stopPropagation(); + } + }; + modelManager.addEventListener("mousedown", (e) => detectDragSidebar(e, e.clientX, e.clientY)); + modelManager.addEventListener("touchstart", (e) => detectDragSidebar(e, e.touches[0].clientX, e.touches[0].clientY)); + + const updateSidebarCursor = (e, x, y) => { + if (this.#dragSidebarState !== "") { + // do not update cursor style while dragging + return; + } + + const left = modelManager.offsetLeft; + const top = modelManager.offsetTop; + const width = modelManager.offsetWidth; + const height = modelManager.offsetHeight; + const right = left + width; + const bottom = top + height; + + const isOnEdgeLeft = x - left <= EDGE_DELTA; + const isOnEdgeRight = right - x <= EDGE_DELTA; + const isOnEdgeTop = y - top <= EDGE_DELTA; + const isOnEdgeBottom = bottom - y <= EDGE_DELTA; + + const updateClass = (add, className) => { + if (add) { + modelManager.classList.add(className); + } + else { + modelManager.classList.remove(className); + } + }; + + const sidebarState = this.element.dataset["sidebarState"]; + updateClass(sidebarState === "right" && isOnEdgeLeft, "cursor-drag-left"); + updateClass(sidebarState === "bottom" && isOnEdgeTop, "cursor-drag-top"); + updateClass(sidebarState === "left" && isOnEdgeRight, "cursor-drag-right"); + updateClass(sidebarState === "top" && isOnEdgeBottom, "cursor-drag-bottom"); + }; + modelManager.addEventListener("mousemove", (e) => updateSidebarCursor(e, e.clientX, e.clientY)); + modelManager.addEventListener("touchmove", (e) => updateSidebarCursor(e, e.touches[0].clientX, e.touches[0].clientY)); + + const updateDragSidebar = (e, x, y) => { + const sidebarState = this.#dragSidebarState; + if (sidebarState === "") { + return; + } + + e.preventDefault(); + + const width = window.innerWidth; + const height = window.innerHeight; + + if (sidebarState === "left") { + const pixels = clamp(x, 0, width).toString() + "px"; + modelManager.style.setProperty("--model-manager-sidebar-width-left", pixels); + } + else if (sidebarState === "right") { + const pixels = clamp(width - x, 0, width).toString() + "px"; + modelManager.style.setProperty("--model-manager-sidebar-width-right", pixels); + } + else if (sidebarState === "top") { + const pixels = clamp(y, 0, height).toString() + "px"; + modelManager.style.setProperty("--model-manager-sidebar-height-top", pixels); + } + else if (sidebarState === "bottom") { + const pixels = clamp(height - y, 0, height).toString() + "px"; + modelManager.style.setProperty("--model-manager-sidebar-height-bottom", pixels); + } + }; + document.addEventListener("mousemove", (e) => updateDragSidebar(e, e.clientX, e.clientY)); + document.addEventListener("touchmove", (e) => updateDragSidebar(e, e.touches[0].clientX, e.touches[0].clientY)); this.#init(); } - #init() { - this.#settingsTab.reload(false); - this.#refreshModels(); + async #init() { + await this.#settingsView.reload(false); + await this.#refreshModels(); + + const settings = this.#settingsView.elements.settings; + + { + // initialize buttons' visibility state + const hideSearchButtons = settings["text-input-always-hide-search-button"].checked; + const hideClearSearchButtons = settings["text-input-always-hide-clear-button"].checked; + this.#downloadView.elements.searchButton.style.display = hideSearchButtons ? "none" : ""; + this.#downloadView.elements.clearSearchButton.style.display = hideClearSearchButtons ? "none" : ""; + } + + { + // set initial sidebar widths & heights + const width = window.innerWidth; + const height = window.innerHeight; + + const xDecimal = settings["sidebar-default-width"].value; + const yDecimal = settings["sidebar-default-height"].value; + + this.element.dataset["sidebarLeftWidthDecimal"] = xDecimal; + this.element.dataset["sidebarRightWidthDecimal"] = xDecimal; + this.element.dataset["sidebarTopHeightDecimal"] = yDecimal; + this.element.dataset["sidebarBottomHeightDecimal"] = yDecimal; + + const x = Math.floor(width * xDecimal); + const y = Math.floor(height * yDecimal); + + const leftPixels = x.toString() + "px"; + this.element.style.setProperty("--model-manager-sidebar-width-left", leftPixels); + + const rightPixels = x.toString() + "px"; + this.element.style.setProperty("--model-manager-sidebar-width-right", rightPixels); + + const topPixels = y.toString() + "px"; + this.element.style.setProperty("--model-manager-sidebar-height-top", topPixels); + + const bottomPixels = y.toString() + "px"; + this.element.style.setProperty("--model-manager-sidebar-height-bottom", bottomPixels); + } + } + + #resetManagerContentsScroll = () => { + this.#tabManagerContents.scrollTop = 0; } #refreshModels = async() => { const modelData = this.#modelData; - modelData.systemSeparator = await request("/model-manager/system-separator"); - const newModels = await request("/model-manager/models/list"); + modelData.systemSeparator = await comfyRequest("/model-manager/system-separator"); + const newModels = await comfyRequest("/model-manager/models/list"); Object.assign(modelData.models, newModels); // NOTE: do NOT create a new object - const newModelDirectories = await request("/model-manager/models/directory-list"); + const newModelDirectories = await comfyRequest("/model-manager/models/directory-list"); modelData.directories.data.splice(0, Infinity, ...newModelDirectories); // NOTE: do NOT create a new array - this.#modelTab.updateModelGrid(); + this.#browseView.updateModelGrid(); await this.#tryHideModelInfo(false); document.getElementById("comfy-refresh-button")?.click(); } + /** + * @param {searchPath: string} + * @return {Promise } + */ + #showModelInfo = async(searchPath) => { + await this.#modelInfo.update( + searchPath, + this.#refreshModels, + this.#modelData.searchSeparator, + ).then(() => { + this.#tabManagerButtons.style.display = "none"; + this.#tabManagerContents.style.display = "none"; + + this.#closeModelInfoButton.style.display = ""; + this.#tabInfoButtons.style.display = ""; + this.#tabInfoContents.style.display = ""; + + this.#tabInfoButtons.children[0]?.click(); + this.#modelInfo.show(); + this.#tabInfoContents.scrollTop = 0; + }); + } + /** * @param {boolean} promptSave * @returns {Promise } */ #tryHideModelInfo = async(promptSave) => { - if (this.#tabContents.style.display === "none") { - if (!await this.#modelInfoView.tryHide(promptSave)) { + if (this.#tabInfoContents.style.display !== "none") { + if (!await this.#modelInfo.tryHide(promptSave)) { return false; } + this.#closeModelInfoButton.style.display = "none"; - this.#tabs.style.display = ""; - this.#tabContents.style.display = ""; + this.#tabInfoButtons.style.display = "none"; + this.#tabInfoContents.style.display = "none"; + + this.#tabManagerButtons.style.display = ""; + this.#tabManagerContents.style.display = ""; } return true; } + + #updateSidebarButtons = () => { + const managerRect = this.element.getBoundingClientRect(); + const isNarrow = managerRect.width < 768; // TODO: `minWidth` is a magic value + const alwaysShowCompactSidebarControls = this.#settingsView.elements.settings["sidebar-control-always-compact"].checked; + if (isNarrow || alwaysShowCompactSidebarControls) { + this.#sidebarButtonGroup.style.display = "none"; + this.#sidebarSelect.style.display = ""; + } + else { + this.#sidebarButtonGroup.style.display = ""; + this.#sidebarSelect.style.display = "none"; + } + } } /** @type {ModelManager | undefined} */ @@ -3537,6 +4840,17 @@ function getInstance() { return instance; } +const toggleModelManager = () => { + const modelManager = getInstance(); + const style = modelManager.element.style; + if (style.display === "" || style.display === "none") { + modelManager.show(); + } + else { + modelManager.close(); + } +}; + app.registerExtension({ name: "Comfy.ModelManager", init() { @@ -3548,22 +4862,22 @@ app.registerExtension({ href: "./extensions/ComfyUI-Model-Manager/model-manager.css", }); - app.ui.menuContainer.appendChild( + app.ui?.menuContainer?.appendChild( $el("button", { id: "comfyui-model-manager-button", parent: document.querySelector(".comfy-menu"), textContent: "Models", - onclick: () => { - const modelManager = getInstance(); - const style = modelManager.element.style; - if (style.display === "" || style.display === "none") { - modelManager.show(); - } - else { - modelManager.close(); - } - }, + onclick: () => toggleModelManager(), }) ); + + // [Beta] mobile menu + app.menu?.settingsGroup?.append(new ComfyButton({ + icon: "folder-search", + tooltip: "Opens model manager", + action: () => toggleModelManager(), + content: "Model Manager", + popup: getInstance(), + })); }, });