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