diff --git a/README.md b/README.md index 139f1ed..da7c1f4 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,66 @@ # comfyui-model-manager -Manage models: browsing, donwload and delete. + +Browse models in ComfyUI. (Downloading and deleting are WIP.) + +![Model Manager Demo Screenshot](model-manager-demo-screenshot.png) + +## About this branch + +I made this branch because the original repo was inactive and missing things I needed to make the ComfyUI usable. Also, many other custom nodes bundle unrelated features together or search the internet without asking for permission. + +## Branch Improvements + +- Search models in models tab. +- Advanced keyword search using `"multiple words in quotes"` or a minus sign to `-exclude`. +- Search `/`subdirectories of main directory based on your file structure (for example, `/1.5/styles`). +- Include models listed in `extra_model_paths.yaml`. +- Increased supported preview image types. +- Correctly change colors using ComfyUI's theme colors. +- Simplified UI. + +## TODO + +### One-click to add a model/node to workspace + +- ☐ Copy icon `๐Ÿ“‹` or plus icon `+`? +- ☐ Sidebar mode + - ☐ Drag to add? + +### Downloading tab + +- ☐ Replace Install tab with Downloading tab (more practical IMO). +- ☐ Download a model from a url. +- ☐ Choose save path in browser. + +### Search filtering and sort + +- ☐ Add auto-suggest paths in search +- ☐ Filters dropdown + - ☐ Stable Diffusion model version/Clip/Upscale/? + - ☐ Favorites +- ☐ Sort-by dropdown + - ☐ Date modified (ascending/decending) + - ☐ Date created (ascending/decending) + - ☐ Recently used (ascending/decending) + - ☐ Frequently used (ascending/decending) +- ☐ `or` vs `and` search keywords (currently `and`) + +### Settings + +- ☐ Exclude hidden folders with a `.` prefix. +- ☐ Include a optional string to always add to searches. +- ☐ Enable optional checksum to detect if a model is already downloaded. +- ☐ Add `settings.yaml` and add file to `.gitignore`. + +### Model info window/panel (server load/send on demand) + +- ☐ Info icon `โ“˜` +- ☐ Optional (re)download `๐Ÿ“ฅ๏ธŽ`model info from the internet and cache the text file locally. (requires checksum enabled) +- ☐ Delete model with warning popup. + +### Image preview + +- ☐ Support multiple preview images (swipe?). +- ☐ Show preview images for videos. + - ☐ If ffmpeg or cv2 available, extract the first frame of the video and use as image preview. + - ☐ Play preview video? diff --git a/__init__.py b/__init__.py index e430f06..583b824 100644 --- a/__init__.py +++ b/__init__.py @@ -1,106 +1,229 @@ +import os +import sys +import hashlib from aiohttp import web import server -import os +import urllib.parse +import struct +import json +import requests +import folder_paths + +requests.packages.urllib3.disable_warnings() + +def folder_paths_get_supported_pt_extensions(folder_name): # Missing api function. + return folder_paths.folder_names_and_paths[folder_name][1] -model_uri = os.path.join(os.getcwd(), "models") -extension_uri = os.path.join(os.getcwd(), "custom_nodes/ComfyUI-Model-Manager") +comfyui_model_uri = os.path.join(os.getcwd(), "models") +extension_uri = os.path.join(os.getcwd(), "custom_nodes" + os.path.sep + "ComfyUI-Model-Manager") +index_uri = os.path.join(extension_uri, "index.json") +#checksum_cache_uri = os.path.join(extension_uri, "checksum_cache.txt") +no_preview_image = os.path.join(extension_uri, "no-preview.png") -model_type_dir_dict = { - "checkpoint": "checkpoints", - "clip": "clip", - "clip_vision": "clip_vision", - "controlnet": "controlnet", - "diffuser": "diffusers", - "embedding": "embeddings", - "gligen": "gligen", - "hypernetwork": "hypernetworks", - "lora": "loras", - "style_models": "style_models", - "unet": "unet", - "upscale_model": "upscale_models", - "vae": "vae", - "vae_approx": "vae_approx", -} +image_extensions = (".apng", ".gif", ".jpeg", ".jpg", ".png", ".webp") +#video_extensions = (".avi", ".mp4", ".webm") # TODO: Requires ffmpeg or cv2. Cache preview frame? + +#hash_buffer_size = 4096 + +def get_safetensor_header(path): + try: + with open(path, "rb") as f: + length_of_header = struct.unpack(" p { + width: calc(100% - 2rem); + overflow-x: scroll; + white-space: nowrap; + display: inline-block; + vertical-align: middle; + margin: 0; +} + +.comfy-grid .item div { + scrollbar-width: none; + -ms-overflow-style: none; +} + +.comfy-grid .item div ::-webkit-scrollbar { + width: 0; + height: 0; } /* comfy radio group */ @@ -87,12 +109,26 @@ gap: 4px; padding: 4px 8px; color: var(--input-text); - border: 1px solid var(--border-color); + border: 2px solid var(--comfy-input-bg); border-radius: 8px; background-color: var(--comfy-input-bg); font-size: 18px; } +.comfy-radio:has(> input[type="radio"]:checked) { + border-color: var(--border-color); + background-color: var(--comfy-menu-bg); +} + +.comfy-radio input[type="radio"]:checked + label { + color: var(--fg-color); +} + +.radio-input { + opacity: 0; + position: absolute; +} + /* model manager */ .model-manager { box-sizing: border-box; @@ -101,7 +137,7 @@ max-width: unset; max-height: unset; padding: 10px; - color: #fff; + color: var(--bg-color); z-index: 2000; } @@ -116,12 +152,13 @@ .model-manager input { padding: 4px 8px; margin: 0; + border: 2px solid var(--border-color); } .model-manager button:disabled, .model-manager select:disabled, .model-manager input:disabled { - background-color: #6a6a6a; + background-color: var(--comfy-menu-bg); filter: brightness(1.2); cursor: not-allowed; } @@ -136,7 +173,7 @@ } .model-manager ::-webkit-scrollbar { - width: 6px; + width: 16px; } .model-manager ::-webkit-scrollbar-track { @@ -150,6 +187,23 @@ border-radius: 3px; } +.model-manager .search-text-area::-webkit-input-placeholder { + font-style: italic; +} +.model-manager .search-text-area:-moz-placeholder { + font-style: italic; +} +.model-manager .search-text-area::-moz-placeholder { + font-style: italic; +} +.model-manager .search-text-area:-ms-input-placeholder { + font-style: italic; +} + +.icon-button { + aspect-ratio: 1; +} + /* model manager row */ .model-manager .row { display: flex; @@ -173,7 +227,7 @@ position: relative; max-height: 100%; padding: 0 16px; - overflow-x: hidden; + overflow-x: auto; } /* model manager special */ @@ -189,18 +243,33 @@ padding-top: 2px; margin-top: -2px; padding-bottom: 18px; - margin-bottom: -2px; - top: 0px; - background-color: #2e2e2e; + margin-bottom: 1px; + top: -1px; + background-color: var(--comfy-input-bg); z-index: 1; } +.model-manager [data-name="Install"] input { + flex-grow: 1; + overflow-x: clip; +} + .model-manager .table-head { - position: sticky; - top: 52px; + position: sticky; + top: 116px; z-index: 1; } -.model-manager div[data-name="Model List"] .row { - align-items: flex-start; +.model-manager .tab-header { + display: block; +} + +.model-manager .tab-header-flex-block { + width: 100%; +} + +.model-manager .search-text-area, +.model-manager .source-text-area, +.model-manager .model-type-dropdown { + flex: 1; } diff --git a/web/model-manager.js b/web/model-manager.js index 246d8a4..fcc66c1 100644 --- a/web/model-manager.js +++ b/web/model-manager.js @@ -185,10 +185,12 @@ class Grid { this.element, this.#dataSource.map((item) => { const uri = item.post ?? "no-post"; - const imgUrl = `/model-manager/imgPreview?uri=${uri}`; + const imgUrl = `/model-manager/image-preview?uri=${uri}`; return $el("div.item", {}, [ $el("img", { src: imgUrl }), - $el("p", [item.name]), + $el("div", {}, [ + $el("p", [item.name]) + ]), ]); }) ); @@ -247,16 +249,16 @@ class ModelManager extends ComfyDialog { #el = { loadSourceBtn: null, - loadSourceFromSelect: null, loadSourceFromInput: null, sourceInstalledFilter: null, sourceContentFilter: null, sourceFilterBtn: null, modelTypeSelect: null, + modelContentFilter: null, }; #data = { - sourceList: [], + sources: [], models: {}, }; @@ -270,14 +272,14 @@ class ModelManager extends ComfyDialog { { parent: document.body }, [ $el("div.comfy-modal-content", [ - $el("button.close", { - textContent: "X", + $el("button.close.icon-button", { + textContent: "โœ•", onclick: () => this.close(), }), $tabs([ - $tab("Source Install", this.#createSourceInstall()), - $tab("Customer Install", []), - $tab("Model List", this.#createModelList()), + $tab("Install", this.#createSourceInstall()), + $tab("Models", this.#createModelList()), + $tab("Settings", []), ]), ]), ] @@ -295,58 +297,44 @@ class ModelManager extends ComfyDialog { this.#createSourceList(); return [ - $el("div.row", [ - $el("button", { - type: "button", - textContent: "Load From", - $: (el) => (this.#el.loadSourceBtn = el), - onclick: () => this.#refreshSourceList(), - }), - $el( - "select", - { - $: (el) => (this.#el.loadSourceFromSelect = el), - onchange: (e) => { - const val = e.target.val; - this.#el.loadSourceFromInput.disabled = - val === "Local Source"; + $el("div.row.tab-header", [ + $el("div.row.tab-header-flex-block", [ + $el("button.icon-button", { + type: "button", + textContent: "โŸณ", + $: (el) => (this.#el.loadSourceBtn = el), + onclick: () => this.#refreshSourceList(), + }), + $el("input.source-text-area", { + $: (el) => (this.#el.loadSourceFromInput = el), + placeholder: "https://ComfyUI-Model-Manager/index.json", + }), + ]), + $el("div.row.tab-header-flex-block", [ + $el("input.search-text-area", { + $: (el) => (this.#el.sourceContentFilter = el), + placeholder: "example: \"sd_xl\" -vae", + onkeyup: (e) => e.key === "Enter" && this.#filterSourceList(), + }), + $el( + "select", + { + $: (el) => (this.#el.sourceInstalledFilter = el), + style: { width: 0 }, + onchange: () => this.#filterSourceList(), }, - }, - [ - $el("option", ["Local Source"]), - $el("option", ["Web Source"]), - ] - ), - $el("input", { - $: (el) => (this.#el.loadSourceFromInput = el), - value: "https://github.com/hayden-fr/ComfyUI-Model-Manager/blob/main/index.json", - style: { flex: 1 }, - disabled: true, - }), - $el("div", { style: { width: "50px" } }), - $el( - "select", - { - $: (el) => (this.#el.sourceInstalledFilter = el), - onchange: () => this.#filterSourceList(), - }, - [ - $el("option", ["Filter: All"]), - $el("option", ["Installed"]), - $el("option", ["Non-Installed"]), - ] - ), - $el("input", { - $: (el) => (this.#el.sourceContentFilter = el), - placeholder: "Input search keyword", - onkeyup: (e) => - e.code === "Enter" && this.#filterSourceList(), - }), - $el("button", { - type: "button", - textContent: "Search", - onclick: () => this.#filterSourceList(), - }), + [ + $el("option", ["Filter: All"]), + $el("option", ["Downloaded"]), + $el("option", ["Not Downloaded"]), + ] + ), + $el("button.icon-button", { + type: "button", + textContent: "๐Ÿ”๏ธŽ", + onclick: () => this.#filterSourceList(), + }), + ]), ]), this.#sourceList.element, ]; @@ -387,7 +375,7 @@ class ModelManager extends ComfyDialog { return $el("button.block", { type: "button", disabled: installed, - textContent: installed ? "Installed" : "Install", + textContent: installed ? "โœ“๏ธŽ" : "๐Ÿ“ฅ๏ธŽ", onclick: async (e) => { e.disabled = true; const response = await this.#request( @@ -397,7 +385,6 @@ class ModelManager extends ComfyDialog { body: JSON.stringify(record), } ); - console.log(response); e.disabled = false; }, }); @@ -410,40 +397,58 @@ class ModelManager extends ComfyDialog { async #refreshSourceList() { this.#el.loadSourceBtn.disabled = true; - this.#el.loadSourceFromSelect.disabled = true; - const sourceType = this.#el.loadSourceFromSelect.value; - const webSource = this.#el.loadSourceFromInput.value; - const uri = sourceType === "Local Source" ? "local" : webSource; + const source = this.#el.loadSourceFromInput.value; + const uri = (source === "https://ComfyUI-Model-Manager/index.json") || (source === "") ? "local" : source; const dataSource = await this.#request( `/model-manager/source?uri=${uri}` ).catch(() => []); - this.#data.sourceList = dataSource; + this.#data.sources = dataSource; this.#sourceList.setData(dataSource); this.#el.sourceInstalledFilter.value = "Filter: All"; this.#el.sourceContentFilter.value = ""; this.#el.loadSourceBtn.disabled = false; - this.#el.loadSourceFromSelect.disabled = false; } - #filterSourceList() { - const installedType = this.#el.sourceInstalledFilter.value; - /** @type {Array} */ - const content = this.#el.sourceContentFilter.value - .split(" ") - .map((item) => item.toLowerCase()) - .filter(Boolean); +#filterSourceList() { + /** @type {Array} */ + const content = this.#el.sourceContentFilter.value + .replace("*", " ") + .split(/(-?".*?"|[^\s"]+)+/g) + .map((item) => item + .trim() + .replace(/(?:'|")+/g, "") + .toLowerCase() // TODO: Quotes should be exact? + ) + .filter(Boolean); - const newDataSource = this.#data.sourceList.filter((row) => { - const filterField = ["type", "name", "base", "description"]; - const rowContent = filterField - .reduce((memo, field) => memo + " " + row[field], "") - .toLowerCase(); - return content.reduce((memo, target) => { - return memo && rowContent.includes(target); - }, true); - }); + const installedType = this.#el.sourceInstalledFilter.value; + const newDataSource = this.#data.sources.filter((row) => { + if (installedType !== "Filter: All") { + if ((installedType === "Downloaded" && !row["installed"]) || + (installedType === "Not Downloaded" && row["installed"])) { + return false; + } + } + + let filterField = ["type", "name", "base", "description"]; + const rowText = filterField + .reduce((memo, field) => memo + " " + row[field], "") + .toLowerCase(); + return content.reduce((memo, target) => { + const excludeTarget = target[0] === "-"; + if (excludeTarget && target.length === 1) { return memo; } + const filteredTarget = excludeTarget ? target.slice(1) : target; + const regexSHA256 = /^[a-f0-9]{64}$/gi; + if (row["SHA256"] !== undefined && regexSHA256.test(filteredTarget)) { + return memo && excludeTarget !== (filteredTarget === row["SHA256"]); + } + else { + return memo && excludeTarget !== rowText.includes(filteredTarget); + } + }, true); + }); this.#sourceList.setData(newDataSource); } @@ -456,34 +461,51 @@ class ModelManager extends ComfyDialog { this.#modelList = gridInstance; return [ - $el("div.row", [ - $radioGroup({ - $: (el) => (this.#el.modelTypeSelect = el), - name: "model-type", - onchange: () => this.#updateModelList(), - options: [ - { value: "checkpoints" }, - { value: "clip" }, - { value: "clip_vision" }, - { value: "controlnet" }, - { value: "diffusers" }, - { value: "embeddings" }, - { value: "gligen" }, - { value: "hypernetworks" }, - { value: "loras" }, - { value: "style_models" }, - { value: "unet" }, - { value: "upscale_models" }, - { value: "vae" }, - { value: "vae_approx" }, - ], - }), - $el("button", { - type: "button", - textContent: "Refresh", - style: { marginLeft: "auto" }, - onclick: () => this.#refreshModelList(), - }), + $el("div.row.tab-header", [ + $el("div.row.tab-header-flex-block", + [ + $el("button.icon-button", { + type: "button", + textContent: "โŸณ", + onclick: () => this.#refreshModelList(), + }), + $el("select.model-type-dropdown", + { + $: (el) => (this.#el.modelTypeSelect = el), + name: "model-type", + onchange: () => this.#filterModelList(), + }, + [ + $el("option", ["checkpoints"]), + $el("option", ["clip"]), + $el("option", ["clip_vision"]), + $el("option", ["controlnet"]), + $el("option", ["diffusers"]), + $el("option", ["embeddings"]), + $el("option", ["gligen"]), + $el("option", ["hypernetworks"]), + $el("option", ["loras"]), + $el("option", ["style_models"]), + $el("option", ["unet"]), + $el("option", ["upscale_models"]), + $el("option", ["vae"]), + $el("option", ["vae_approx"]), + ] + ), + ] + ), + $el("div.row.tab-header-flex-block", [ + $el("input.search-text-area", { + $: (el) => (this.#el.modelContentFilter = el), + placeholder: "example: 1.5/styles -.pt", + onkeyup: (e) => e.key === "Enter" && this.#filterModelList(), + }), + $el("button.icon-button", { + type: "button", + textContent: "๐Ÿ”๏ธŽ", + onclick: () => this.#filterModelList(), + }), + ]), ]), gridInstance.element, ]; @@ -492,13 +514,42 @@ class ModelManager extends ComfyDialog { async #refreshModelList() { const dataSource = await this.#request("/model-manager/models"); this.#data.models = dataSource; - this.#updateModelList(); + this.#filterModelList(); } - #updateModelList() { - const type = this.#el.modelTypeSelect.value; - const list = this.#data.models[type]; - this.#modelList.setData(list); + #filterModelList() { + /** @type {Array} */ + const content = this.#el.modelContentFilter.value + .replace("*", " ") + .split(/(-?".*?"|[^\s"]+)+/g) + .map((item) => item + .trim() + .replace(/(?:'|")+/g, "") + .toLowerCase() // TODO: Quotes should be exact? + ) + .filter(Boolean); + + const modelType = this.#el.modelTypeSelect.value; + + const newDataSource = this.#data.models[modelType].filter((modelInfo) => { + const filterField = ["name", "path"]; + const modelText = filterField + .reduce((memo, field) => memo + " " + modelInfo[field], "") + .toLowerCase(); + return content.reduce((memo, target) => { + const excludeTarget = target[0] === "-"; + if (excludeTarget && target.length === 1) { return memo; } + const filteredTarget = excludeTarget ? target.slice(1) : target; + const regexSHA256 = /^[a-f0-9]{64}$/gi; + if (modelInfo["SHA256"] !== undefined && regexSHA256.test(filteredTarget)) { + return memo && excludeTarget !== (filteredTarget === modelInfo["SHA256"]); + } + else { + return memo && excludeTarget !== modelText.includes(filteredTarget); + } + }, true); + }); + this.#modelList.setData(newDataSource); } } @@ -516,7 +567,8 @@ function getInstance() { app.registerExtension({ name: "Comfy.ModelManager", - + init() { + }, async setup() { $el("link", { parent: document.head, @@ -524,13 +576,13 @@ app.registerExtension({ href: "./extensions/ComfyUI-Model-Manager/model-manager.css", }); - $el("button", { - parent: document.querySelector(".comfy-menu"), - textContent: "Models", - style: { order: 1 }, - onclick: () => { - getInstance().show(); - }, - }); + app.ui.menuContainer.appendChild( + $el("button", { + id: "comfyui-model-manager-button", + parent: document.querySelector(".comfy-menu"), + textContent: "Models", + onclick: () => { getInstance().show(); }, + }) + ); }, });