diff --git a/README.md b/README.md index c2dac13..442d69d 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ I made this fork because the original repo was inactive and missing many things - View model metadata, including training tags and bucket resolutions. - Delete or move a model. - Read, edit and save notes in a `.txt` file beside the model. +- Change or remove preview image. ### ComfyUI Node Graph diff --git a/__init__.py b/__init__.py index 4b24851..5c2ef9a 100644 --- a/__init__.py +++ b/__init__.py @@ -1,4 +1,5 @@ import os +import io import pathlib import shutil from datetime import datetime @@ -30,7 +31,7 @@ ui_settings_uri = os.path.join(extension_uri, "ui_settings.yaml") server_settings_uri = os.path.join(extension_uri, "server_settings.yaml") fallback_model_extensions = set([".bin", ".ckpt", ".onnx", ".pt", ".pth", ".safetensors"]) # TODO: magic values -image_extensions = (".apng", ".gif", ".jpeg", ".jpg", ".png", ".webp") +image_extensions = (".apng", ".gif", ".jpeg", ".jpg", ".png", ".webp") # TODO: JavaScript does not know about this (x2 states) #video_extensions = (".avi", ".mp4", ".webm") # TODO: Requires ffmpeg or cv2. Cache preview frame? _folder_names_and_paths = None # dict[str, tuple[list[str], list[str]]] @@ -195,21 +196,11 @@ async def get_model_preview(request): image_path = no_preview_image image_extension = "png" - if uri != "no-post": - rel_image_path = os.path.dirname(uri) - - i = uri.find(os.path.sep) - model_type = uri[0:i] - - j = uri.find(os.path.sep, i + len(os.path.sep)) - if j == -1: - j = len(rel_image_path) - base_index = int(uri[i + len(os.path.sep):j]) - base_path = folder_paths_get_folder_paths(model_type)[base_index] - - abs_image_path = os.path.normpath(base_path + os.path.sep + uri[j:]) # do NOT use os.path.join - if os.path.exists(abs_image_path): - image_path = abs_image_path + if uri != "no-preview": + sep = os.path.sep + uri = uri.replace("/" if sep == "\\" else "/", os.path.sep) + image_path, _ = search_path_to_system_path(uri) + if os.path.exists(image_path): _, image_extension = os.path.splitext(uri) image_extension = image_extension[1:] @@ -219,6 +210,64 @@ async def get_model_preview(request): return web.Response(body=image_data, content_type="image/" + image_extension) +def download_model_preview(formdata): + path = formdata.get("path", None) + if type(path) is not str: + raise ("Invalid path!") + path, _ = search_path_to_system_path(path) + path_without_extension, _ = os.path.splitext(path) + + overwrite = formdata.get("overwrite", "true").lower() + overwrite = True if overwrite == "true" else False + + image = formdata.get("image", None) + if type(image) is str: + image_path = download_image(image, path, overwrite) + _, image_extension = os.path.splitext(image_path) + else: + content_type = image.content_type + if not content_type.startswith("image/"): + raise ("Invalid content type!") + image_extension = "." + content_type[len("image/"):] + if image_extension not in image_extensions: + raise ("Invalid extension!") + + image_path = path_without_extension + image_extension + if not overwrite and os.path.isfile(image_path): + raise ("Image already exists!") + file: io.IOBase = image.file + image_data = file.read() + with open(image_path, "wb") as f: + f.write(image_data) + + delete_same_name_files(path_without_extension, image_extensions, image_extension) + + +@server.PromptServer.instance.routes.post("/model-manager/preview/set") +async def set_model_preview(request): + formdata = await request.post() + try: + download_model_preview(formdata) + return web.json_response({ "success": True }) + except ValueError as e: + print(e, file=sys.stderr, flush=True) + return web.json_response({ "success": False }) + + +@server.PromptServer.instance.routes.post("/model-manager/preview/delete") +async def delete_model_preview(request): + model_path = request.query.get("path", None) + if model_path is None: + return web.json_response({ "success": False }) + model_path = urllib.parse.unquote(model_path) + + file, _ = search_path_to_system_path(model_path) + path_and_name, _ = os.path.splitext(file) + delete_same_name_files(path_and_name, image_extensions) + + return web.json_response({ "success": True }) + + @server.PromptServer.instance.routes.get("/model-manager/models/list") async def load_download_models(request): model_types = os.listdir(comfyui_model_uri) @@ -245,34 +294,40 @@ async def load_download_models(request): for model in dir_models: model_name, _ = os.path.splitext(model) image = None + image_modified = None for iImage in range(len(dir_images)-1, -1, -1): image_name, _ = os.path.splitext(dir_images[iImage]) if model_name == image_name: image = end_swap_and_pop(dir_images, iImage) + img_abs_path = os.path.join(cwd, image) + image_modified = pathlib.Path(img_abs_path).stat().st_mtime_ns break abs_path = os.path.join(cwd, model) stats = pathlib.Path(abs_path).stat() - date_modified = stats.st_mtime_ns - date_created = stats.st_ctime_ns + model_modified = stats.st_mtime_ns + model_created = stats.st_ctime_ns rel_path = "" if cwd == model_base_path else os.path.relpath(cwd, model_base_path) - info = (model, image, base_path_index, rel_path, date_modified, date_created) + info = (model, image, base_path_index, rel_path, model_modified, model_created, image_modified) file_infos.append(info) file_infos.sort(key=lambda tup: tup[4], reverse=True) # TODO: remove sort; sorted on client model_items = [] - for model, image, base_path_index, rel_path, date_modified, date_created in file_infos: + for model, image, base_path_index, rel_path, model_modified, model_created, image_modified in file_infos: item = { "name": model, "path": "/" + os.path.join(model_type, str(base_path_index), rel_path, model).replace(os.path.sep, "/"), # relative logical path #"systemPath": os.path.join(rel_path, model), # relative system path (less information than "search path") - "dateModified": date_modified, - "dateCreated": date_created, + "dateModified": model_modified, + "dateCreated": model_created, #"dateLastUsed": "", # TODO: track server-side, send increment client-side #"countUsed": 0, # TODO: track server-side, send increment client-side } if image is not None: raw_post = os.path.join(model_type, str(base_path_index), rel_path, image) - item["post"] = urllib.parse.quote_plus(raw_post) + item["preview"] = { + "path": urllib.parse.quote_plus(raw_post), + "dateModified": urllib.parse.quote_plus(str(image_modified)), + } model_items.append(item) models[model_type] = model_items @@ -342,13 +397,14 @@ async def directory_list(request): def download_file(url, filename, overwrite): if not overwrite and os.path.isfile(filename): - raise Exception("File already exists!") + raise ValueError("File already exists!") filename_temp = filename + ".download" def_headers = { "User-Agent": "Mozilla/5.0 (iPad; CPU OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148", } + if url.startswith("https://civitai.com/"): api_key = server_settings["civitai_api_key"] if (api_key != ""): @@ -358,10 +414,9 @@ def download_file(url, filename, overwrite): api_key = server_settings["huggingface_api_key"] if api_key != "": def_headers["Authorization"] = f"Bearer {api_key}" - rh = requests.get(url=url, stream=True, verify=False, headers=def_headers, proxies=None, allow_redirects=False) if not rh.ok: - raise Exception("Unable to download") + raise ValueError("Unable to download") downloaded_size = 0 if rh.status_code == 200 and os.path.exists(filename_temp): @@ -369,7 +424,7 @@ def download_file(url, filename, overwrite): headers = {"Range": "bytes=%d-" % downloaded_size} headers["User-Agent"] = def_headers["User-Agent"] - + r = requests.get(url=url, stream=True, verify=False, headers=headers, proxies=None, allow_redirects=False) if rh.status_code == 307 and r.status_code == 307: # Civitai redirect @@ -377,7 +432,7 @@ def download_file(url, filename, overwrite): if not redirect_url.startswith("http"): # Civitai requires login (NSFW or user-required) # TODO: inform user WHY download failed - raise Exception("Unable to download!") + raise ValueError("Unable to download!") download_file(redirect_url, filename, overwrite) return if rh.status_code == 302 and r.status_code == 302: @@ -385,7 +440,7 @@ def download_file(url, filename, overwrite): redirect_url = r.content.decode("utf-8") redirect_url_index = redirect_url.find("http") if redirect_url_index == -1: - raise Exception("Unable to download!") + raise ValueError("Unable to download!") download_file(redirect_url[redirect_url_index:], filename, overwrite) return elif rh.status_code == 200 and r.status_code == 206: @@ -419,18 +474,33 @@ def download_file(url, filename, overwrite): ) ) sys.stdout.flush() - print() + if overwrite and os.path.isfile(filename): os.remove(filename) os.rename(filename_temp, filename) +def download_image(image_uri, model_path, overwrite): + extension = None # TODO: doesn't work for https://civitai.com/images/... + for image_extension in image_extensions: + if image_uri.endswith(image_extension): + extension = image_extension + break + if extension is None: + raise ValueError("Invalid image type!") + + path_without_extension, _ = os.path.splitext(model_path) + file = path_without_extension + extension + download_file(image_uri, file, overwrite) + return file + + @server.PromptServer.instance.routes.get("/model-manager/model/info") async def get_model_info(request): model_path = request.query.get("path", None) if model_path is None: - return web.json_response({}) + return web.json_response({ "success": False }) model_path = urllib.parse.unquote(model_path) file, _ = search_path_to_system_path(model_path) @@ -441,12 +511,25 @@ async def get_model_info(request): path, name = os.path.split(model_path) info["File Name"] = name info["File Directory"] = path - info["File Size"] = os.path.getsize(file) + info["File Size"] = str(os.path.getsize(file)) + " bytes" stats = pathlib.Path(file).stat() date_format = "%Y/%m/%d %H:%M:%S" info["Date Created"] = datetime.fromtimestamp(stats.st_ctime).strftime(date_format) info["Date Modified"] = datetime.fromtimestamp(stats.st_mtime).strftime(date_format) + file_name, _ = os.path.splitext(file) + + for extension in image_extensions: + maybe_image = file_name + extension + if os.path.isfile(maybe_image): + image_path, _ = os.path.splitext(model_path) + image_modified = pathlib.Path(maybe_image).stat().st_mtime_ns + info["Preview"] = { + "path": urllib.parse.quote_plus(image_path + extension), + "dateModified": urllib.parse.quote_plus(str(image_modified)), + } + break + header = get_safetensor_header(file) metadata = header.get("__metadata__", None) if metadata is not None: @@ -455,7 +538,6 @@ async def get_model_info(request): info["Hash"] = metadata.get("sshs_model_hash", "") info["Output Name"] = metadata.get("ss_output_name", "") - file_name, _ = os.path.splitext(file) txt_file = file_name + ".txt" notes = "" if os.path.isfile(txt_file): @@ -500,26 +582,27 @@ async def get_system_separator(request): @server.PromptServer.instance.routes.post("/model-manager/model/download") async def download_model(request): - body = await request.json() + formdata = await request.post() result = { "success": False, "invalid": None, } - overwrite = body.get("overwrite", False) + overwrite = formdata.get("overwrite", "false").lower() + overwrite = True if overwrite == "true" else False - model_path = body.get("path", "/0") + model_path = formdata.get("path", "/0") directory, model_type = search_path_to_system_path(model_path) if directory is None: result["invalid"] = "path" return web.json_response(result) - download_uri = body.get("download") + download_uri = formdata.get("download") if download_uri is None: result["invalid"] = "download" return web.json_response(result) - name = body.get("name") + name = formdata.get("name") model_extension = None for ext in folder_paths_get_supported_pt_extensions(model_type): if name.endswith(ext): @@ -531,27 +614,22 @@ async def download_model(request): file_name = os.path.join(directory, name) try: download_file(download_uri, file_name, overwrite) - except: - result["invalid"] = "download" + except Exception as e: + print(e, file=sys.stderr, flush=True) + result["invalid"] = "model" return web.json_response(result) - image_uri = body.get("image") - if image_uri is not None and image_uri != "": - image_extension = None # TODO: doesn't work for https://civitai.com/images/... - for ext in image_extensions: - if image_uri.endswith(ext): - image_extension = ext - break - if image_extension is not None: - file_path_without_extension = name[:len(name) - len(model_extension)] - image_name = os.path.join( - directory, - file_path_without_extension + image_extension - ) - try: - download_file(image_uri, image_name, overwrite) - except Exception as e: - print(e, file=sys.stderr, flush=True) + image = formdata.get("image") + if image is not None and image != "": + try: + download_model_preview({ + "path": model_path + os.sep + name, + "image": image, + "overwrite": formdata.get("overwrite"), + }) + except Exception as e: + print(e, file=sys.stderr, flush=True) + result["invalid"] = "preview" result["success"] = True return web.json_response(result) @@ -579,7 +657,8 @@ async def move_model(request): new_file = os.path.join(new_path, filename) try: shutil.move(old_file, new_file) - except: + except ValueError as e: + print(e, file=sys.stderr, flush=True) return web.json_response({ "success": False }) old_file_without_extension, _ = os.path.splitext(old_file) @@ -590,12 +669,20 @@ async def move_model(request): if os.path.isfile(old_file): try: shutil.move(old_file, new_file_without_extension + extension) - except Exception as e: + except ValueError as e: print(e, file=sys.stderr, flush=True) return web.json_response({ "success": True }) +def delete_same_name_files(path_without_extension, extensions, keep_extension=None): + for extension in extensions: + if extension == keep_extension: continue + image_file = path_without_extension + extension + if os.path.isfile(image_file): + os.remove(image_file) + + @server.PromptServer.instance.routes.post("/model-manager/model/delete") async def delete_model(request): result = { "success": False } @@ -623,10 +710,7 @@ async def delete_model(request): path_and_name, _ = os.path.splitext(file) - for img_ext in image_extensions: - image_file = path_and_name + img_ext - if os.path.isfile(image_file): - os.remove(image_file) + delete_same_name_files(path_and_name, image_extensions) txt_file = path_and_name + ".txt" if os.path.isfile(txt_file): @@ -656,7 +740,8 @@ async def set_notes(request): try: with open(filename, "w", encoding="utf-8") as f: f.write(text) - except: + except ValueError as e: + print(e, file=sys.stderr, flush=True) web.json_response({ "success": False }) return web.json_response({ "success": True }) diff --git a/web/model-manager.css b/web/model-manager.css index 967d718..d82075c 100644 --- a/web/model-manager.css +++ b/web/model-manager.css @@ -159,6 +159,7 @@ display: flex; gap: 8px; flex-wrap: wrap; + min-width: 0; } .comfy-radio { @@ -358,6 +359,7 @@ .model-manager .tab-header-flex-block { width: 100%; + min-width: 0; } .model-manager .search-models { @@ -377,6 +379,7 @@ flex: 1; min-height: 36px; padding-block: 0; + min-width: 36px; } .model-manager .model-select-dropdown { @@ -450,6 +453,15 @@ resize: vertical; } +.model-preview-select-radio-container { + min-width: 0; + flex: 1; +} + +.model-manager input[type="file"] { + flex: 1; +} + .model-preview-select-radio-container img { position: relative; width: 230px; @@ -485,7 +497,6 @@ background-color: var(--bg-color); border-radius: 16px; color: var(--fg-color); - margin-top: 8px; padding: 16px; width: auto; } diff --git a/web/model-manager.js b/web/model-manager.js index 3ec1fbe..46a1f72 100644 --- a/web/model-manager.js +++ b/web/model-manager.js @@ -19,10 +19,10 @@ function debounce(callback, delay) { /** * @param {string} url - * @param {any} options + * @param {any} [options=undefined] * @returns {Promise} */ -function request(url, options) { +function request(url, options = undefined) { return new Promise((resolve, reject) => { api.fetchApi(url, options) .then((response) => response.json()) @@ -58,6 +58,22 @@ const MODEL_SORT_DATE_NAME = "name"; const MODEL_EXTENSIONS = [".bin", ".ckpt", ".onnx", ".pt", ".pth", ".safetensors"]; // TODO: ask server for? const IMAGE_EXTENSIONS = [".apng", ".gif", ".jpeg", ".jpg", ".png", ".webp"]; // TODO: ask server for? +/** + * @param {string | undefined} [searchPath=undefined] + * @param {string | undefined} [dateImageModified=undefined] + * + * @returns {string} + */ +function imageUri(imageSearchPath = undefined, dateImageModified = undefined) { + const path = imageSearchPath ?? "no-preview"; + const date = dateImageModified; + let uri = `/model-manager/preview/get?uri=${path}`; + if (date !== undefined && date !== null) { + uri += `&v=${date}`; + } + return uri; +} + /** * Tries to return the related ComfyUI model directory if unambigious. * @@ -1112,8 +1128,7 @@ class ModelGrid { const removeEmbeddingExtension = !settingsElements["model-add-embedding-extension"].checked; if (models.length > 0) { return models.map((item) => { - const uri = item.post ?? "no-post"; - const imgUrl = `/model-manager/preview/get?uri=${uri}`; + const previewInfo = item.preview; const searchPath = item.path; const path = searchPathToSystemPath(searchPath, searchSeparator, systemSeparator); let buttons = []; @@ -1157,7 +1172,7 @@ class ModelGrid { ); return $el("div.item", {}, [ $el("img.model-preview", { - src: imgUrl, + src: imageUri(previewInfo?.path, previewInfo?.dateModified), draggable: false, }), $el("div.model-preview-overlay", { @@ -1293,6 +1308,284 @@ function $radioGroup(attr) { return $el("div.comfy-radio-group", radioGroup); } +/** + * @param {HTMLDivElement} previewImageContainer + * @param {Event} e + * @param {1 | -1} step + */ +function updateRadioPreview(previewImageContainer, step) { + const children = previewImageContainer.children; + if (children.length === 0) { + return; + } + let currentIndex = -step; + for (let i = 0; i < children.length; i++) { + const previewImage = children[i]; + const display = previewImage.style.display; + if (display !== "none") { + currentIndex = i; + } + previewImage.style.display = "none"; + } + currentIndex = currentIndex + step; + if (currentIndex >= children.length) { currentIndex = 0; } + else if (currentIndex < 0) { currentIndex = children.length - 1; } + children[currentIndex].style.display = "block"; +} + +/** + * @param {String} uniqueName + * @param {String[]} defaultPreviews + * @returns {[]} + */ +function radioGroupImageSelect(uniqueName, defaultPreviews, defaultChanges=false) { + const defaultImageCount = defaultPreviews.length; + + const el_defaultUri = $el("div", { + style: { display: "none" }, + "data-noimage": imageUri(), + }); + + const el_noImage = $el("img", { + src: imageUri(), + style: { + display: defaultImageCount === 0 ? "block" : "none", + }, + loading: "lazy", + }); + + const el_defaultImages = $el("div", { + style: { + width: "100%", + height: "100%", + }, + }, (() => { + const imgs = defaultPreviews.map((url) => { + return $el("img", { + src: url, + style: { display: "none" }, + loading: "lazy", + onerror: (e) => { + e.target.src = el_defaultUri.dataset.noimage ?? imageUri(); + }, + }); + }); + if (imgs.length > 0) { + imgs[0].style.display = "block"; + } + return imgs; + })()); + + const el_uploadImage = $el("img", { + src: imageUri(), + style: { display : "none" }, + onerror: (e) => { + e.target.src = el_defaultUri.dataset.noimage ?? imageUri(); + }, + }); + const el_uploadFile = $el("input", { + type: "file", + accept: IMAGE_EXTENSIONS.join(", "), + onchange: (e) => { + const file = e.target.files[0]; + if (file) { + el_uploadImage.src = URL.createObjectURL(file); + } + else { + el_uploadImage.src = el_defaultUri.dataset.noimage; + } + }, + }); + const el_upload = $el("div", { + style: { display: "none" }, + }, [ + el_uploadFile, + ]); + + const el_urlImage = $el("img", { + src: imageUri(), + style: { display: "none" }, + onerror: (e) => { + e.target.src = el_defaultUri.dataset.noimage ?? imageUri(); + }, + }); + const el_customUrl = $el("input.search-text-area", { + type: "text", + placeholder: "https://custom-image-preview.png", + }); + const el_custom = $el("div.row.tab-header-flex-block", { + style: { display: "none" }, + }, [ + el_customUrl, + $el("button.icon-button", { + textContent: "🔍︎", + onclick: (e) => { + el_urlImage.src = el_customUrl.value; + }, + }), + ]); + + const el_previewButtons = $el("div.model-preview-overlay", { + style: { + display: el_defaultImages.children.length > 1 ? "block" : "none", + }, + }, [ + $el("button.icon-button.model-preview-button-left", { + textContent: "←", + onclick: () => updateRadioPreview(el_defaultImages, -1), + }), + $el("button.icon-button.model-preview-button-right", { + textContent: "→", + onclick: () => updateRadioPreview(el_defaultImages, 1), + }), + ]); + const previews = [ + el_noImage, + el_defaultImages, + el_urlImage, + el_uploadImage, + ]; + const el_preview = $el("div.item", [ + $el("div", { + style: { + "width": "100%", + "height": "100%", + }, + }, + previews, + ), + el_previewButtons, + ]); + + const PREVIEW_NONE = "No Preview"; + const PREVIEW_DEFAULT = "Default"; + const PREVIEW_URL = "URL"; + const PREVIEW_UPLOAD = "Upload"; + + const el_radioButtons = $radioGroup({ + name: uniqueName, + onchange: (value) => { + el_custom.style.display = "none"; + el_upload.style.display = "none"; + + el_defaultImages.style.display = "none"; + el_previewButtons.style.display = "none"; + + el_noImage.style.display = "none"; + el_uploadImage.style.display = "none"; + el_urlImage.style.display = "none"; + + switch (value) { + case PREVIEW_NONE: + default: + el_noImage.style.display = "block"; + break; + case PREVIEW_DEFAULT: + el_defaultImages.style.display = "block"; + el_previewButtons.style.display = el_defaultImages.children.length > 1 ? "block" : "none"; + break; + case PREVIEW_URL: + el_custom.style.display = "flex"; + el_urlImage.style.display = "block"; + break; + case PREVIEW_UPLOAD: + el_upload.style.display = "flex"; + el_uploadImage.style.display = "block"; + break; + } + }, + options: (() => { + const radios = []; + radios.push({ value: PREVIEW_NONE }); + if (defaultImageCount > 0) { + radios.push({ value: PREVIEW_DEFAULT }); + } + radios.push({ value: PREVIEW_URL }); + radios.push({ value: PREVIEW_UPLOAD }) + return radios; + })(), + }); + + if (defaultImageCount > 0) { + const children = el_radioButtons.children; + for (let i = 0; i < children.length; i++) { + const child = children[i]; + const radioButton = child.children[0]; + if (radioButton.value === PREVIEW_DEFAULT) { + radioButton.checked = true; + break; + } + }; + } + + const resetModelInfoPreview = () => { + let noimage = el_defaultUri.dataset.noimage; + previews.forEach((el) => { + el.style.display = "none"; + if (el_noImage !== el) { + if (el.nodeName === "IMG") { + el.src = noimage; + } + else { + el.children[0].src = noimage; + } + } + else { + el.src = imageUri(); + } + }); + const children = el_radioButtons.children; + for (let i = 0; i < children.length; i++) { + const child = children[i]; + const radioButton = child.children[0]; + if (radioButton.value === PREVIEW_DEFAULT) { + el_defaultImages.style.display = "block"; + radioButton.checked = true; + break; + } + }; + el_uploadFile.value = ""; + el_customUrl.value = ""; + el_upload.style.display = "none"; + el_custom.style.display = "none"; + }; + + const getImage = () => { + const value = document.querySelector(`input[name="${uniqueName}"]:checked`).value; + switch (value) { + case PREVIEW_DEFAULT: + if (defaultImageCount === 0) { + return ""; + } + const children = el_defaultImages.children; + for (let i = 0; i < children.length; i++) { + const child = children[i]; + if (child.style.display !== "none") { + return child.src; + } + } + return ""; + case PREVIEW_URL: + return el_customUrl.value; + case PREVIEW_UPLOAD: + return el_uploadFile.files[0] ?? ""; + case PREVIEW_NONE: + return imageUri(); + } + return ""; + }; + + const el_radioGroup = $el("div.model-preview-select-radio-container", [ + $el("div.row.tab-header-flex-block", [el_radioButtons]), + $el("div", [ + el_custom, + el_upload, + ]), + ]); + + return [el_radioGroup, el_preview, getImage, el_defaultUri, resetModelInfoPreview]; +} + class ModelManager extends ComfyDialog { #el = { /** @type {HTMLDivElement} */ modelInfoView: null, @@ -1300,6 +1593,8 @@ class ModelManager extends ComfyDialog { /** @type {HTMLDivElement} */ modelInfoUrl: null, /** @type {HTMLDivElement} */ modelInfoOverwrite: null, /** @type {HTMLDivElement} */ modelInfos: null, + modelInfoPreview: null, + modelInfoDefaultUri: null, /** @type {HTMLDivElement} */ modelGrid: null, /** @type {HTMLSelectElement} */ modelTypeSelect: null, @@ -1339,6 +1634,8 @@ class ModelManager extends ComfyDialog { /** @type {string} */ #systemSeparator = null; + #resetModelInfoPreview = () => {}; + constructor() { super(); @@ -1359,7 +1656,17 @@ class ModelManager extends ComfyDialog { this.#searchSeparator, true, ); - + + const [el_radioGroup, el_preview, getImage, el_defaultUri, resetModelInfoPreview] = radioGroupImageSelect( + "model-info-preview-model-FYUIKMNVB", + [imageUri()], + ); + el_preview.style.display = "flex"; + this.#el.modelInfoRadioGroup = el_radioGroup; + this.#el.modelInfoPreview = el_preview; + this.#el.modelInfoDefaultUri = el_defaultUri; + this.#resetModelInfoPreview = resetModelInfoPreview; + this.element = $el( "div.comfy-modal.model-manager", { @@ -1402,7 +1709,7 @@ class ModelManager extends ComfyDialog { } return deleted; }) - .catch(err => { + .catch((err) => { return false; }); } @@ -1633,7 +1940,7 @@ class ModelManager extends ComfyDialog { #modelTab_showModelInfo = async(searchPath) => { const path = encodeURIComponent(searchPath); const info = await request(`/model-manager/model/info?path=${path}`) - .catch(err => { + .catch((err) => { console.log(err); return null; }); @@ -1648,66 +1955,152 @@ class ModelManager extends ComfyDialog { if (filename !== undefined && filename !== null && filename !== "") { innerHtml.push($el("h1", [filename])); } - for (const [key, value] of Object.entries(info)) { - if (value === undefined || value === null) { - continue; - } - - if (Array.isArray(value)) { - if (value.length > 0) { - innerHtml.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 += ", "; + + if (info["Preview"]) { + const imagePath = info["Preview"]["path"]; + const imageDateModified = info["Preview"]["dateModified"]; + this.#el.modelInfoDefaultUri.dataset.noimage = imageUri(imagePath, imageDateModified); + this.#resetModelInfoPreview(); + } + + innerHtml.push($el("div", [ + this.#el.modelInfoPreview, + $el("div.row.tab-header", [ + $el("div.row.tab-header-flex-block", [ + $el("button", { + textContent: "Set as Preview", + onclick: async(e) => { + const confirmation = window.confirm("Change preview image PERMANENTLY?"); + let updatedPreview = false; + if (confirmation) { + e.target.disabled = true; + const container = this.#el.modelInfoContainer; + const path = container.dataset.path; + const imageUrl = getImage(); + if (imageUrl === imageUri()) { + const encodedPath = encodeURIComponent(path); + updatedPreview = await request( + `/model-manager/preview/delete?path=${encodedPath}`, + { + method: "POST", + body: JSON.stringify({}), + } + ) + .then((result) => { + return result["success"]; + }) + .catch((err) => { + return false; + }); + } + else { + const formData = new FormData(); + formData.append("path", path); + const image = imageUrl[0] == "/" ? "" : imageUrl; + formData.append("image", image); + updatedPreview = await request( + `/model-manager/preview/set`, + { + method: "POST", + body: formData, + } + ) + .then((result) => { + return result["success"]; + }) + .catch((err) => { + return false; + }); + } + if (updatedPreview) { + this.#modelTab_updateModels(); + this.#el.modelInfoDefaultUri.dataset.noimage = imageUri(); + this.#resetModelInfoPreview(); + this.#el.modelInfoView.style.display = "none"; + } + + e.target.disabled = false; + } + buttonAlert(e.target, updatedPreview); + }, + }), + ]), + $el("div.row.tab-header-flex-block", [ + this.#el.modelInfoRadioGroup, + ]), + ]), + $el("div", + (() => { + const elements = []; + for (const [key, value] of Object.entries(info)) { + if (value === undefined || value === null) { + continue; + } + + if (Array.isArray(value)) { + 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 div = $el("div"); + div.innerHTML = text; + elements.push(div); + } + } + else { + if (key === "Notes") { + elements.push($el("h2", [key + ":"])); + const noteArea = $el("textarea.comfy-multiline-input", { + value: value, + rows: 5, + }); + elements.push(noteArea); + elements.push($el("button", { + textContent: "Save Notes", + onclick: (e) => { + const saved = request( + "/model-manager/notes/save", + { + method: "POST", + body: JSON.stringify({ + "path": this.#el.modelInfoContainer.dataset.path, + "notes": noteArea.value, + }), + } + ).then((result) => { + return result["success"]; + }) + .catch((err) => { + return false; + }); + buttonAlert(e.target, saved); + }, + })); + } + else if (key === "Preview") { + // + } + else { + if (value !== "") { + elements.push($el("p", [key + ": " + value])); + } + } } } - text += ""; - const div = $el("div"); - div.innerHTML = text; - innerHtml.push(div); - } - } - else { - if (key === "Notes") { - innerHtml.push($el("h2", [key + ":"])); - const noteArea = $el("textarea.comfy-multiline-input", { - value: value, - rows: 5, - }); - innerHtml.push(noteArea); - innerHtml.push($el("button", { - textContent: "Save Notes", - onclick: (e) => { - const saved = request( - "/model-manager/notes/save", - { - method: "POST", - body: JSON.stringify({ - "path": this.#el.modelInfoContainer.dataset.path, - "notes": noteArea.value, - }), - } - ).then((result) => { - return result["success"]; - }) - .catch((err) => { - return false; - }); - buttonAlert(e.target, saved); - }, - })); - } - else { - innerHtml.push($el("p", [key + ": " + value])); - } - } - } + return elements; + })(), + ), + ])); infoHtml.append.apply(infoHtml, innerHtml); this.#el.modelInfoView.removeAttribute("style"); // remove "display: none" @@ -1912,31 +2305,6 @@ class ModelManager extends ComfyDialog { } } - /** - * @param {HTMLDivElement} previewImageContainer - * @param {Event} e - * @param {1 | -1} step - */ - static #downloadTab_updatePreview(previewImageContainer, step) { - const children = previewImageContainer.children; - if (children.length === 0) { - return; - } - let currentIndex = -step; - for (let i = 0; i < children.length; i++) { - const previewImage = children[i]; - const display = previewImage.style.display; - if (display !== "none") { - currentIndex = i; - } - previewImage.style.display = "none"; - } - currentIndex = currentIndex + step; - if (currentIndex >= children.length) { currentIndex = 0; } - else if (currentIndex < 0) { currentIndex = children.length - 1; } - children[currentIndex].style.display = "block"; - } - /** * @param {Object} info * @param {String[]} modelTypes @@ -1946,35 +2314,12 @@ class ModelManager extends ComfyDialog { * @returns {HTMLDivElement} */ #downloadTab_modelInfo(info, modelTypes, modelDirectories, searchSeparator, id) { - // TODO: use passed in info - const RADIO_MODEL_PREVIEW_NONE = "No Preview"; - const RADIO_MODEL_PREVIEW_DEFAULT = "Default Preview"; - const RADIO_MODEL_PREVIEW_CUSTOM = "Custom Preview"; + const [el_radioGroup, el_preview, getImage, el_defaultUri, resetModelInfoPreview] = radioGroupImageSelect( + "model-download-info-preview-model" + "-" + id, + info["images"], + ); - const els = { - modelPreviewContainer: null, - previewImgs: null, - buttonLeft: null, - buttonRight: null, - - customPreviewContainer: null, - customPreviewUrl: null, - - modelTypeSelect: null, - saveDirectoryPath: null, - filename: null, - }; - - $el("input.search-text-area", { - $: (el) => (els.saveDirectoryPath = el), - type: "text", - placeholder: this.#searchSeparator + "0", - value: this.#searchSeparator + "0", - }); - - $el("select.model-select-dropdown", { - $: (el) => (els.modelTypeSelect = el), - }, (() => { + const el_modelTypeSelect = $el("select.model-select-dropdown", (() => { const options = [$el("option", { value: "" }, ["-- Model Type --"])]; modelTypes.forEach((modelType) => { options.push($el("option", { value: modelType }, [modelType])); @@ -1982,11 +2327,16 @@ class ModelManager extends ComfyDialog { return options; })()); + const el_saveDirectoryPath = $el("input.search-text-area", { + type: "text", + placeholder: this.#searchSeparator + "0", + value: this.#searchSeparator + "0", + }); let searchDropdown = null; searchDropdown = new DirectoryDropdown( - els.saveDirectoryPath, + el_saveDirectoryPath, () => { - const modelType = els.modelTypeSelect.value; + const modelType = el_modelTypeSelect.value; if (modelType === "") { return; } searchDropdown.update( modelDirectories, @@ -2000,36 +2350,13 @@ class ModelManager extends ComfyDialog { true, ); - const radioGroupName = "model-download-info-preview-model" + "-" + id; - const radioGroup = $radioGroup({ - name: radioGroupName, - onchange: (value) => { - switch (value) { - case RADIO_MODEL_PREVIEW_DEFAULT: - const bottonStyleDisplay = els.previewImgs.children.length > 1 ? "block" : "none"; - els.buttonLeft.style.display = bottonStyleDisplay; - els.buttonRight.style.display = bottonStyleDisplay; - els.modelPreviewContainer.style.display = "block"; - els.customPreviewContainer.style.display = "none"; - break; - case RADIO_MODEL_PREVIEW_CUSTOM: - els.modelPreviewContainer.style.display = "none"; - els.customPreviewContainer.style.display = "flex"; - break; - default: - els.modelPreviewContainer.style.display = "none"; - els.customPreviewContainer.style.display = "none"; - break; - } - }, - options: (() => { - const radios = []; - radios.push({ value: RADIO_MODEL_PREVIEW_NONE }); - if (info["images"].length > 0) { - radios.push({ value: RADIO_MODEL_PREVIEW_DEFAULT }); - } - radios.push({ value: RADIO_MODEL_PREVIEW_CUSTOM }); - return radios; + const el_filename = $el("input.plain-text-area", { + type: "text", + placeholder: (() => { + const filename = info["fileName"]; + // TODO: only remove valid model file extensions + const i = filename.lastIndexOf("."); + return i === - 1 ? filename : filename.substring(0, i); })(), }); @@ -2039,82 +2366,32 @@ class ModelManager extends ComfyDialog { $el("div", { style: { display: "flex", "flex-wrap": "wrap", gap: "16px" }, }, [ - $el("div.item", { - $: (el) => (els.modelPreviewContainer = el), - style: { display: "none" }, - }, [ - $el("div", { - $: (el) => (els.previewImgs = el), - style: { - width: "100%", - height: "100%", - }, - }, (() => { - const imgs = info["images"].map((url) => { - return $el("img", { - src: url, - style: { display: "none" }, - loading: "lazy", - }); - }); - if (imgs.length > 0) { - imgs[0].style.display = "block"; - } - return imgs; - })()), - $el("div.model-preview-overlay", [ - $el("button.icon-button.model-preview-button-left", { - $: (el) => (els.buttonLeft = el), - onclick: () => ModelManager.#downloadTab_updatePreview(els.previewImgs, -1), - textContent: "←", - }), - $el("button.icon-button.model-preview-button-right", { - $: (el) => (els.buttonRight = el), - onclick: () => ModelManager.#downloadTab_updatePreview(els.previewImgs, 1), - textContent: "→", - }), - ]), - ]), + el_preview, $el("div.download-settings", [ $el("div", { style: { "margin-top": "8px" } }, [ - $el("div.model-preview-select-radio-container", [ - $el("div.row.tab-header-flex-block", [radioGroup]), - $el("div", [ - $el("div.row.tab-header-flex-block", { - $: (el) => (els.customPreviewContainer = el), - style: { display: "none" }, - }, [ - $el("input.search-text-area", { - $: (el) => (els.customPreviewUrl = el), - type: "text", - placeholder: "https://custom-image-preview.png" - }), - ]), - ]), + $el("div.row.tab-header-flex-block", [ + el_modelTypeSelect, ]), $el("div.row.tab-header-flex-block", [ - els.modelTypeSelect, - ]), - $el("div.row.tab-header-flex-block", [ - els.saveDirectoryPath, + el_saveDirectoryPath, searchDropdown.element, ]), $el("div.row.tab-header-flex-block", [ $el("button.icon-button", { textContent: "📥︎", onclick: async (e) => { - const record = {}; - record["download"] = info["downloadUrl"]; - record["path"] = ( - els.modelTypeSelect.value + + const formData = new FormData(); + formData.append("download", info["downloadUrl"]); + formData.append("path", + el_modelTypeSelect.value + this.#searchSeparator + // NOTE: this may add multiple separators (server should handle carefully) - els.saveDirectoryPath.value + el_saveDirectoryPath.value ); - record["name"] = (() => { + formData.append("name", (() => { const filename = info["fileName"]; - const name = els.filename.value; + const name = el_filename.value; if (name === "") { return filename; } @@ -2122,36 +2399,23 @@ class ModelManager extends ComfyDialog { return filename.endsWith(ext); }) ?? ""; return name + ext; - })(); - record["image"] = (() => { - const value = document.querySelector(`input[name="${radioGroupName}"]:checked`).value; - switch (value) { - case RADIO_MODEL_PREVIEW_DEFAULT: - const children = els.previewImgs.children; - for (let i = 0; i < children.length; i++) { - const child = children[i]; - if (child.style.display !== "none") { - return child.src; - } - } - return ""; - case RADIO_MODEL_PREVIEW_CUSTOM: - return els.customPreviewUrl.value; - } - return ""; - })(); - record["overwrite"] = this.#el.modelInfoOverwrite.checked; + })()); + formData.append("image", getImage()); + formData.append("overwrite", this.#el.modelInfoOverwrite.checked); e.target.disabled = true; const [success, resultText] = await request( "/model-manager/model/download", { method: "POST", - body: JSON.stringify(record), + body: formData, } - ).then(data => { + ).then((data) => { const success = data["success"]; + if (!success) { + console.warn(data["invalid"]); + } return [success, success ? "✔" : "📥︎"]; - }).catch(err => { + }).catch((err) => { return [false, "📥︎"]; }); if (success) { @@ -2161,59 +2425,26 @@ class ModelManager extends ComfyDialog { e.target.disabled = success; }, }), - $el("input.plain-text-area", { - $: (el) => (els.filename = el), - type: "text", - placeholder: (() => { - const filename = info["fileName"]; - // TODO: only remove valid model file extensions - const i = filename.lastIndexOf("."); - return i === - 1 ? filename : filename.substring(0, i); - })(), - }), + el_filename, ]), + el_radioGroup, ]), - /* - $el("div", (() => { - return Object.entries(info["details"]).filter(([, value]) => { - return value !== undefined && value !== null; - }).map(([key, value]) => { - const el = document.createElement("p"); - el.innerText = key + ": " + value; - return el; - }); - })()), - */ ]), ]), ]); - if (info["images"].length > 0) { - const children = radioGroup.children; - for (let i = 0; i < children.length; i++) { - const child = children[i]; - const radioButton = child.children[0]; - if (radioButton.value === RADIO_MODEL_PREVIEW_DEFAULT) { - els.modelPreviewContainer.style.display = "block"; - radioButton.checked = true; - break; - } - }; - } - - const modelTypeSelect = els.modelTypeSelect; - modelTypeSelect.selectedIndex = 0; // reset + el_modelTypeSelect.selectedIndex = 0; // reset const comfyUIModelType = ( modelTypeToComfyUiDirectory(info["details"]["fileType"]) ?? modelTypeToComfyUiDirectory(info["modelType"]) ?? null ); if (comfyUIModelType !== undefined && comfyUIModelType !== null) { - const modelTypeOptions = modelTypeSelect.children; + const modelTypeOptions = el_modelTypeSelect.children; for (let i = 0; i < modelTypeOptions.length; i++) { const option = modelTypeOptions[i]; if (option.value === comfyUIModelType) { - modelTypeSelect.selectedIndex = i; + el_modelTypeSelect.selectedIndex = i; break; } }