├── requirements.txt ├── .vscode ├── extensions.json └── settings.json ├── no-preview.png ├── demo ├── tab-download.png ├── tab-models.png ├── tab-settings.png ├── tab-model-drag-add.gif ├── tab-models-dropdown.png ├── tab-model-info-overview.png ├── tab-model-preview-thumbnail-buttons-example.png └── beta-menu-model-manager-button-settings-group.png ├── .prettierrc.json ├── web ├── eslint.config.mjs ├── downshow.js └── model-manager.css ├── .editorconfig ├── config_loader.py ├── README.md ├── .gitignore ├── LICENSE └── __init__.py /requirements.txt: -------------------------------------------------------------------------------- 1 | markdownify -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "esbenp.prettier-vscode" 4 | ] 5 | } -------------------------------------------------------------------------------- /no-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cdb-boop/ComfyUI-Model-Manager/HEAD/no-preview.png -------------------------------------------------------------------------------- /demo/tab-download.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cdb-boop/ComfyUI-Model-Manager/HEAD/demo/tab-download.png -------------------------------------------------------------------------------- /demo/tab-models.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cdb-boop/ComfyUI-Model-Manager/HEAD/demo/tab-models.png -------------------------------------------------------------------------------- /demo/tab-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cdb-boop/ComfyUI-Model-Manager/HEAD/demo/tab-settings.png -------------------------------------------------------------------------------- /demo/tab-model-drag-add.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cdb-boop/ComfyUI-Model-Manager/HEAD/demo/tab-model-drag-add.gif -------------------------------------------------------------------------------- /demo/tab-models-dropdown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cdb-boop/ComfyUI-Model-Manager/HEAD/demo/tab-models-dropdown.png -------------------------------------------------------------------------------- /demo/tab-model-info-overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cdb-boop/ComfyUI-Model-Manager/HEAD/demo/tab-model-info-overview.png -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "singleQuote": true, 6 | "trailingComma": "all" 7 | } -------------------------------------------------------------------------------- /demo/tab-model-preview-thumbnail-buttons-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cdb-boop/ComfyUI-Model-Manager/HEAD/demo/tab-model-preview-thumbnail-buttons-example.png -------------------------------------------------------------------------------- /demo/beta-menu-model-manager-button-settings-group.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cdb-boop/ComfyUI-Model-Manager/HEAD/demo/beta-menu-model-manager-button-settings-group.png -------------------------------------------------------------------------------- /web/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import pluginJs from "@eslint/js"; 3 | 4 | 5 | export default [ 6 | {languageOptions: { globals: globals.browser }}, 7 | pluginJs.configs.recommended, 8 | ]; -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | insert_final_newline = true 13 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "apng", 4 | "Civitai", 5 | "ckpt", 6 | "comfyui", 7 | "FYUIKMNVB", 8 | "gguf", 9 | "gligen", 10 | "jfif", 11 | "locon", 12 | "loras", 13 | "noimage", 14 | "onnx", 15 | "rfilename", 16 | "unet", 17 | "upscaler" 18 | ], 19 | "editor.defaultFormatter": "esbenp.prettier-vscode" 20 | } -------------------------------------------------------------------------------- /config_loader.py: -------------------------------------------------------------------------------- 1 | import yaml 2 | from dataclasses import dataclass 3 | 4 | @dataclass 5 | class Rule: 6 | key: any 7 | value_default: any 8 | value_type: type 9 | value_min: any # int | float | None 10 | value_max: any # int | float | None 11 | 12 | def __init__( 13 | self, 14 | key, 15 | value_default, 16 | value_type: type, 17 | value_min: any = None, # int | float | None 18 | value_max: any = None, # int | float | None 19 | ): 20 | self.key = key 21 | self.value_default = value_default 22 | self.value_type = value_type 23 | self.value_min = value_min 24 | self.value_max = value_max 25 | 26 | def _get_valid_value(data: dict, r: Rule): 27 | if r.value_type != type(r.value_default): 28 | raise Exception(f"'value_type' does not match type of 'value_default'!") 29 | value = data.get(r.key) 30 | if value is None: 31 | value = r.value_default 32 | else: 33 | try: 34 | value = r.value_type(value) 35 | except: 36 | value = r.value_default 37 | 38 | value_is_numeric = r.value_type == int or r.value_type == float 39 | if value_is_numeric and r.value_min: 40 | if r.value_type != type(r.value_min): 41 | raise Exception(f"Type of 'value_type' does not match the type of 'value_min'!") 42 | value = max(r.value_min, value) 43 | if value_is_numeric and r.value_max: 44 | if r.value_type != type(r.value_max): 45 | raise Exception(f"Type of 'value_type' does not match the type of 'value_max'!") 46 | value = min(r.value_max, value) 47 | 48 | return value 49 | 50 | def validated(rules: list[Rule], data: dict = {}): 51 | valid = {} 52 | for r in rules: 53 | valid[r.key] = _get_valid_value(data, r) 54 | return valid 55 | 56 | def yaml_load(path, rules: list[Rule]): 57 | data = {} 58 | try: 59 | with open(path, 'r') as file: 60 | data = yaml.safe_load(file) 61 | except: 62 | pass 63 | return validated(rules, data) 64 | 65 | def yaml_save(path, rules: list[Rule], data: dict) -> bool: 66 | data = validated(rules, data) 67 | try: 68 | with open(path, 'w') as file: 69 | yaml.dump(data, file) 70 | return True 71 | except: 72 | return False 73 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # comfyui-model-manager 2 | 3 | Download, browse and delete models in ComfyUI. 4 | 5 | Designed to support desktop, mobile and multi-screen devices. 6 | 7 | Model Manager Demo Screenshot 8 | 9 | Model Manager Demo Screenshot 10 | 11 | ## Features 12 | 13 | ### Node Graph 14 | 15 | Model Manager Demo Screenshot 16 | 17 | - Drag a model thumbnail onto the graph to add a new node. 18 | - Drag a model thumbnail onto an existing node to set the input field. 19 | - If there are multiple valid possible fields, then the drag must be exact. 20 | - Drag an embedding thumbnail onto a text area, or highlight any number of nodes, to append it onto the end of the text. 21 | - Drag the preview image in a model's info view onto the graph to load the embedded workflow (if it exists). 22 | 23 | Model Manager Demo Screenshot 24 | 25 | - Press the "copy" button to copy a model to ComfyUI's clipboard or copy the embedding to the system clipboard. (Copying the embedding to the system clipboard requires a secure http connection.) 26 | - Press the "add" button to add the model to the ComfyUI graph or append the embedding to one or more selected nodes. 27 | - Press the "load workflow" button to try and load a workflow embedded in a model's preview image. 28 | - Press the "open model url" button to try and search the web and open a model's webpage. 29 | 30 | ### Download Tab 31 | 32 | Model Manager Demo Screenshot 33 | 34 | - View multiple models associated with a url. 35 | - Select a save directory and input a filename. 36 | - Optionally set a model's preview image. 37 | - Optionally edit and save descriptions as a .txt note. (Default behavior can be set in the settings tab.) 38 | - Add Civitai and HuggingFace API tokens in `server_settings.yaml`. 39 | 40 | ### Models Tab 41 | 42 | Model Manager Demo Screenshot 43 | 44 | - Search in real-time for models using the search bar. 45 | - Use advance keyword search by typing `"multiple words in quotes"` or a minus sign before to `-exclude` a word or phrase. 46 | - Add `/` at the start of a search to view a dropdown list of subdirectories (for example, `/0/1.5/styles/clothing`). 47 | - Any directory paths in ComfyUI's `extra_model_paths.yaml` or directories added in `ComfyUI/models/` will automatically be detected. 48 | - Sort models by "Date Created", "Date Modified", "Name" and "File Size". 49 | 50 | ### Model Info View 51 | 52 | Model Manager Demo Screenshot 53 | 54 | - View file info and metadata. 55 | - Rename, move or **permanently** remove a model and all of it's related files. 56 | - Read, edit and save notes. (Saved as a `.txt` file beside the model). 57 | - `Ctrl+s` or `⌘+S` to save a note when the textarea is in focus. 58 | - Autosave can be enabled in settings. (Note: Once the model info view is closed, the undo history is lost.) 59 | - Automatically search the web for model info and save as notes with a single button. 60 | - Change or remove a model's preview image. 61 | - View training tags and use the random tag generator to generate prompt ideas. (Inspired by the one in A1111.) 62 | 63 | ### Settings Tab 64 | 65 | Model Manager Demo Screenshot 66 | 67 | - Settings are saved to `ui_settings.yaml`. 68 | - Most settings should update immediately, but a few may require a page reload to take effect. 69 | - Press the "Fix Extensions" button to correct all image file extensions in the model directories. (Note: This may take a minute or so to complete.) 70 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | ui_settings.yaml 162 | server_settings.yaml 163 | 164 | # macOS: 165 | # General 166 | .DS_Store 167 | .AppleDouble 168 | .LSOverride 169 | 170 | # Icon must end with two \r 171 | Icon 172 | 173 | # Thumbnails 174 | ._* 175 | 176 | # Files that might appear in the root of a volume 177 | .DocumentRevisions-V100 178 | .fseventsd 179 | .Spotlight-V100 180 | .TemporaryItems 181 | .Trashes 182 | .VolumeIcon.icns 183 | .com.apple.timemachine.donotpresent 184 | 185 | # Directories potentially created on remote AFP share 186 | .AppleDB 187 | .AppleDesktop 188 | Network Trash Folder 189 | Temporary Items 190 | .apdisk 191 | -------------------------------------------------------------------------------- /web/downshow.js: -------------------------------------------------------------------------------- 1 | /** 2 | * downshow.js -- A javascript library to convert HTML to markdown. 3 | * 4 | * Copyright (c) 2013 Alex Cornejo. 5 | * 6 | * Original Markdown Copyright (c) 2004-2005 John Gruber 7 | * 8 | * 9 | * Redistributable under a BSD-style open source license. 10 | * 11 | * downshow has no external dependencies. It has been tested in chrome and 12 | * firefox, it probably works in internet explorer, but YMMV. 13 | * 14 | * Basic Usage: 15 | * 16 | * downshow(document.getElementById('#yourid').innerHTML); 17 | * 18 | * TODO: 19 | * - Remove extra whitespace between words in headers and other places. 20 | */ 21 | 22 | (function () { 23 | var doc; 24 | 25 | // Use browser DOM with jsdom as a fallback (for node.js) 26 | try { 27 | doc = document; 28 | } catch(e) { 29 | var jsdom = require("jsdom").jsdom; 30 | doc = jsdom(""); 31 | } 32 | 33 | /** 34 | * Returns every element in root in their bfs traversal order. 35 | * 36 | * In the process it transforms any nested lists to conform to the w3c 37 | * standard, see: http://www.w3.org/wiki/HTML_lists#Nesting_lists 38 | */ 39 | function bfsOrder(root) { 40 | var inqueue = [root], outqueue = []; 41 | root._bfs_parent = null; 42 | while (inqueue.length > 0) { 43 | var elem = inqueue.shift(); 44 | outqueue.push(elem); 45 | var children = elem.childNodes; 46 | var liParent = null; 47 | for (var i=0 ; i 0) { 99 | if (prefix && suffix) 100 | node._bfs_text = prefix + content + suffix; 101 | else 102 | node._bfs_text = content; 103 | } else 104 | node._bfs_text = ''; 105 | } 106 | 107 | /** 108 | * Get a node's content. 109 | */ 110 | function getContent(node) { 111 | var text = '', atom; 112 | for (var i = 0; i 0) 157 | setContent(node, '[' + text + '](' + href + (title ? ' "' + title + '"' : '') + ')'); 158 | else 159 | setContent(node, ''); 160 | } else if (node.tagName === 'IMG') { 161 | var src = node.getAttribute('src') ? nltrim(node.getAttribute('src')) : '', alt = node.alt ? nltrim(node.alt) : '', caption = node.title ? nltrim(node.title) : ''; 162 | if (src.length > 0) 163 | setContent(node, '![' + alt + '](' + src + (caption ? ' "' + caption + '"' : '') + ')'); 164 | else 165 | setContent(node, ''); 166 | } else if (node.tagName === 'BLOCKQUOTE') { 167 | var block_content = getContent(node); 168 | if (block_content.length > 0) 169 | setContent(node, prefixBlock('> ', block_content), '\n\n', '\n\n'); 170 | else 171 | setContent(node, ''); 172 | } else if (node.tagName === 'CODE') { 173 | if (node._bfs_parent.tagName === 'PRE' && node._bfs_parent._bfs_parent !== null) 174 | setContent(node, prefixBlock(' ', getContent(node))); 175 | else 176 | setContent(node, nltrim(getContent(node)), '`', '`'); 177 | } else if (node.tagName === 'LI') { 178 | var list_content = getContent(node); 179 | if (list_content.length > 0) 180 | if (node._bfs_parent.tagName === 'OL') 181 | setContent(node, trim(prefixBlock(' ', list_content, true)), '1. ', '\n\n'); 182 | else 183 | setContent(node, trim(prefixBlock(' ', list_content, true)), '- ', '\n\n'); 184 | else 185 | setContent(node, ''); 186 | } else 187 | setContent(node, getContent(node)); 188 | } 189 | 190 | function downshow(html, options) { 191 | var root = doc.createElement('pre'); 192 | root.innerHTML = html; 193 | var nodes = bfsOrder(root).reverse(), i; 194 | 195 | if (options && options.nodeParser) { 196 | for (i = 0; i )+[^\n]*)\n+(\n(?:> )+)/g, "$1\n$2") 212 | // remove empty blockquotes 213 | .replace(/\n((?:> )+[ ]*\n)+/g, '\n\n') 214 | // remove extra newlines 215 | .replace(/\n[ \t]*(?:\n[ \t]*)+\n/g,'\n\n') 216 | // remove trailing whitespace 217 | .replace(/\s\s*$/, '') 218 | // convert lists to inline when not using paragraphs 219 | .replace(/^([ \t]*(?:\d+\.|\+|\-)[^\n]*)\n\n+(?=[ \t]*(?:\d+\.|\+|\-|\*)[^\n]*)/gm, "$1\n") 220 | // remove starting newlines 221 | .replace(/^\n\n*/, ''); 222 | } 223 | 224 | // Export for use in server and client. 225 | if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') 226 | module.exports = downshow; 227 | else if (typeof define === 'function' && define.amd) 228 | define([], function () {return downshow;}); 229 | else 230 | window.downshow = downshow; 231 | })(); -------------------------------------------------------------------------------- /web/model-manager.css: -------------------------------------------------------------------------------- 1 | /* model manager */ 2 | .model-manager { 3 | background-color: var(--comfy-menu-bg); 4 | box-sizing: border-box; 5 | color: var(--bg-color); 6 | font-family: monospace; 7 | font-size: 15px; 8 | height: 100%; 9 | padding: 8px; 10 | position: fixed; 11 | overflow: hidden; 12 | width: 100%; 13 | z-index: 1100; /*needs to be below the dialog modal element*/ 14 | 15 | /*override comfy-modal settings*/ 16 | border-radius: 0; 17 | box-shadow: none; 18 | justify-content: unset; 19 | max-height: 100vh; 20 | max-width: 100vw; 21 | transform: none; 22 | /*disable double-tap zoom on model manager*/ 23 | touch-action: manipulation; 24 | } 25 | 26 | .model-manager .model-manager-dialog { 27 | z-index: 2001; /*needs to be above the model manager element*/ 28 | } 29 | 30 | .model-manager .comfy-modal-content { 31 | width: 100%; 32 | gap: 16px; 33 | } 34 | 35 | .model-manager .no-highlight { 36 | user-select: none; 37 | -moz-user-select: none; 38 | -webkit-text-select: none; 39 | -webkit-user-select: none; 40 | } 41 | 42 | .model-manager label:has(> *){ 43 | pointer-events: none; 44 | } 45 | 46 | .model-manager label > * { 47 | pointer-events: auto; 48 | } 49 | 50 | /* sidebar */ 51 | 52 | .model-manager { 53 | --model-manager-sidebar-width-left: 50vw; 54 | --model-manager-sidebar-width-right: 50vw; 55 | --model-manager-sidebar-height-top: 50vh; 56 | --model-manager-sidebar-height-bottom: 50vh; 57 | 58 | --model-manager-thumbnail-width: 240px; 59 | --model-manager-thumbnail-height: 360px; 60 | 61 | --model-manager-left: 0; 62 | --model-manager-right: 0; 63 | --model-manager-top: 0; 64 | --model-manager-bottom: 0; 65 | 66 | left: var(--model-manager-left); 67 | top: var(--model-manager-right); 68 | right: var(--model-manager-top); 69 | bottom: var(--model-manager-bottom); 70 | } 71 | 72 | .model-manager.cursor-drag-left, 73 | .model-manager.cursor-drag-right { 74 | cursor: ew-resize; 75 | } 76 | 77 | .model-manager.cursor-drag-top, 78 | .model-manager.cursor-drag-bottom { 79 | cursor: ns-resize; 80 | } 81 | 82 | .model-manager.cursor-drag-top.cursor-drag-left, 83 | .model-manager.cursor-drag-bottom.cursor-drag-right { 84 | cursor: nwse-resize; 85 | } 86 | 87 | .model-manager.cursor-drag-top.cursor-drag-right, 88 | .model-manager.cursor-drag-bottom.cursor-drag-left { 89 | cursor: nesw-resize; 90 | } 91 | 92 | /* sidebar buttons */ 93 | .model-manager .sidebar-buttons { 94 | overflow: hidden; 95 | color: var(--input-text); 96 | display: flex; 97 | gap: 2px; 98 | flex-direction: row-reverse; 99 | flex-wrap: wrap; 100 | } 101 | 102 | .model-manager .sidebar-buttons .radio-button-group-active { 103 | border-color: var(--fg-color); 104 | color: var(--fg-color); 105 | overflow: hidden; 106 | } 107 | 108 | .model-manager[data-sidebar-state="left"] { 109 | width: var(--model-manager-sidebar-width-left); 110 | max-width: 95vw; 111 | min-width: 22vw; 112 | right: auto; 113 | border-right: solid var(--border-color) 2px; 114 | } 115 | 116 | .model-manager[data-sidebar-state="top"] { 117 | height: var(--model-manager-sidebar-height-top); 118 | max-height: 95vh; 119 | min-height: 22vh; 120 | bottom: auto; 121 | border-bottom: solid var(--border-color) 2px; 122 | } 123 | 124 | .model-manager[data-sidebar-state="bottom"] { 125 | height: var(--model-manager-sidebar-height-bottom); 126 | max-height: 95vh; 127 | min-height: 22vh; 128 | top: auto; 129 | border-top: solid var(--border-color) 2px; 130 | } 131 | 132 | .model-manager[data-sidebar-state="right"] { 133 | width: var(--model-manager-sidebar-width-right); 134 | max-width: 95vw; 135 | min-width: 22vw; 136 | left: auto; 137 | border-left: solid var(--border-color) 2px; 138 | } 139 | 140 | /* common */ 141 | .model-manager h1 { 142 | min-width: 0; 143 | overflow-wrap: break-word; 144 | } 145 | 146 | .model-manager textarea { 147 | border: solid 2px var(--border-color); 148 | border-radius: 8px; 149 | font-size: 1.2em; 150 | resize: vertical; 151 | width: 100%; 152 | height: 100%; 153 | } 154 | 155 | .model-manager input[type="file"] { 156 | width: 100%; 157 | } 158 | 159 | .model-manager button, .model-manager .model-manager-head .topbar-right select { 160 | margin: 0; 161 | border: 2px solid var(--border-color); 162 | } 163 | 164 | .model-manager button:not(.icon-button), 165 | .model-manager select, 166 | .model-manager input { 167 | padding: 4px 8px; 168 | margin: 0; 169 | } 170 | 171 | .model-manager button:disabled, 172 | .model-manager select:disabled, 173 | .model-manager input:disabled { 174 | background-color: var(--comfy-menu-bg); 175 | filter: brightness(1.2); 176 | cursor: not-allowed; 177 | } 178 | 179 | .model-manager select:hover{ 180 | filter: brightness(1.2); 181 | cursor: pointer; 182 | } 183 | 184 | .model-manager button.block { 185 | width: 100%; 186 | } 187 | 188 | .model-manager ::-webkit-scrollbar { 189 | width: 16px; 190 | } 191 | 192 | .model-manager ::-webkit-scrollbar-track { 193 | background-color: var(--comfy-input-bg); 194 | border-right: 1px solid var(--border-color); 195 | border-bottom: 1px solid var(--border-color); 196 | } 197 | 198 | .model-manager ::-webkit-scrollbar-thumb { 199 | background-color: var(--fg-color); 200 | border-radius: 3px; 201 | } 202 | 203 | .model-manager .search-text-area::-webkit-input-placeholder { 204 | font-style: italic; 205 | } 206 | .model-manager .search-text-area:-moz-placeholder { 207 | font-style: italic; 208 | } 209 | .model-manager .search-text-area::-moz-placeholder { 210 | font-style: italic; 211 | } 212 | .model-manager .search-text-area:-ms-input-placeholder { 213 | font-style: italic; 214 | } 215 | 216 | .model-manager .icon-button { 217 | height: 40px; 218 | width: 40px; 219 | line-height: 1.15; 220 | } 221 | 222 | .model-manager .row { 223 | display: flex; 224 | min-width: 0; 225 | gap: 8px; 226 | } 227 | 228 | .model-manager .tab-header { 229 | display: flex; 230 | padding: 8px 0px; 231 | flex-direction: column; 232 | background-color: var(--bg-color); 233 | } 234 | 235 | .model-manager .tab-header-flex-block { 236 | width: 100%; 237 | min-width: 0; 238 | } 239 | 240 | .model-manager .comfy-button-success { 241 | color: green; 242 | border-color: green; 243 | } 244 | 245 | .model-manager .comfy-button-failure { 246 | color: darkred; 247 | border-color: darkred; 248 | } 249 | 250 | .model-manager .no-select { 251 | -webkit-user-select: none; 252 | -ms-user-select: none; 253 | user-select: none; 254 | } 255 | 256 | .model-manager code { 257 | text-wrap: wrap; 258 | } 259 | 260 | /* main content */ 261 | .model-manager .model-manager-panel { 262 | color: var(--fg-color); 263 | } 264 | 265 | .model-manager .model-tab-group { 266 | display: flex; 267 | gap: 4px; 268 | height: 44px; 269 | } 270 | 271 | .model-manager .model-tab-group .tab-button { 272 | background-color: var(--comfy-menu-bg); 273 | border: 2px solid var(--border-color); 274 | border-bottom: none; 275 | border-radius: 8px 8px 0px 0px; 276 | cursor: pointer; 277 | padding: 8px 12px; 278 | margin-bottom: 0px; 279 | z-index: 1; 280 | } 281 | 282 | .model-manager .model-tab-group .tab-button.active { 283 | background-color: var(--bg-color); 284 | margin-bottom: -2px; 285 | cursor: default; 286 | position: relative; 287 | z-index: 1; 288 | pointer-events: none; 289 | } 290 | 291 | .model-manager .model-manager-body { 292 | background-color: var(--bg-color); 293 | border: 2px solid var(--border-color); 294 | } 295 | 296 | .model-manager .model-manager-panel { 297 | flex: 1; 298 | display: flex; 299 | flex-direction: column; 300 | overflow: hidden; 301 | } 302 | 303 | .model-manager .model-manager-body { 304 | flex: 1; 305 | overflow: hidden; 306 | padding: 8px 0px 8px 16px; 307 | } 308 | 309 | .model-manager .model-manager-body .tab-contents { 310 | position: relative; 311 | display: flex; 312 | flex-direction: column; 313 | height: 100%; 314 | width: auto; 315 | overflow-x: auto; 316 | overflow-y: hidden; 317 | } 318 | 319 | .model-manager .model-manager-body .tab-content { 320 | display: flex; 321 | flex-direction: column; 322 | height: 100%; 323 | overflow-y: auto; 324 | padding-right: 16px; 325 | } 326 | 327 | /* model info view */ 328 | .model-manager .model-info-container { 329 | background-color: var(--bg-color); 330 | border-radius: 16px; 331 | color: var(--fg-color); 332 | width: auto; 333 | } 334 | 335 | .model-manager .model-metadata { 336 | table-layout: fixed; 337 | text-align: left; 338 | width: 100%; 339 | } 340 | 341 | .model-manager .model-metadata-key { 342 | overflow-wrap: break-word; 343 | width: 20%; 344 | } 345 | 346 | .model-manager .model-metadata-value { 347 | overflow-wrap: anywhere; 348 | width: 80%; 349 | } 350 | 351 | .model-manager table { 352 | border-collapse: collapse; 353 | } 354 | 355 | .model-manager th { 356 | border: 1px solid; 357 | padding: 4px 8px; 358 | } 359 | 360 | /* download tab */ 361 | 362 | .model-manager .download-model-infos { 363 | display: flex; 364 | flex-direction: column; 365 | padding: 0; 366 | row-gap: 10px; 367 | } 368 | 369 | .model-manager .download-details summary { 370 | background-color: var(--comfy-menu-bg); 371 | border-radius: 16px; 372 | padding: 16px; 373 | word-wrap: break-word; 374 | } 375 | 376 | .model-manager .download-details[open] summary { 377 | background-color: var(--border-color); 378 | } 379 | 380 | .model-manager .download-details > div { 381 | column-gap: 8px; 382 | display: flex; 383 | flex-direction: row; 384 | flex-wrap: wrap; 385 | padding: 8px; 386 | row-gap: 16px; 387 | } 388 | 389 | .model-manager [data-name="Download"] .download-settings-wrapper { 390 | flex: 1; 391 | } 392 | 393 | .model-manager [data-name="Download"] .download-settings { 394 | display: flex; 395 | flex-direction: column; 396 | row-gap: 16px; 397 | } 398 | 399 | .model-manager .download-button { 400 | max-width: fit-content; 401 | } 402 | 403 | /* models tab */ 404 | .model-manager [data-name="Models"] .row { 405 | position: sticky; 406 | z-index: 1; 407 | top: 0; 408 | } 409 | 410 | /* preview image */ 411 | .model-manager .item { 412 | position: relative; 413 | width: var(--model-manager-thumbnail-width);; 414 | height: var(--model-manager-thumbnail-height);; 415 | text-align: center; 416 | overflow: hidden; 417 | border-radius: 8px; 418 | } 419 | 420 | .model-manager .model-info-container .item { 421 | width: fit-content; 422 | height: 50vh; 423 | } 424 | 425 | .model-manager .item img { 426 | width: 100%; 427 | height: 100%; 428 | object-fit: cover; 429 | border-radius: 8px; 430 | } 431 | 432 | .model-manager .model-info-container .item img, 433 | .model-manager .model-preview-full { 434 | height: auto; 435 | width: auto; 436 | max-width: 100%; 437 | max-height: 50vh; 438 | border-radius: 8px; 439 | } 440 | 441 | .model-manager .model-preview-button-left, 442 | .model-manager .model-preview-button-right { 443 | position: absolute; 444 | top: 0; 445 | bottom: 0; 446 | margin: auto; 447 | border-radius: 20px; 448 | } 449 | 450 | .model-manager .model-preview-button-right { 451 | right: 4px; 452 | } 453 | 454 | .model-manager .model-preview-button-left { 455 | left: 4px; 456 | } 457 | 458 | .model-manager .item .model-preview-overlay { 459 | position: absolute; 460 | top: 0; 461 | left: 0; 462 | height: 100%; 463 | width: 100%; 464 | background-color: rgba(0, 0, 0, 0); 465 | } 466 | 467 | /* grid */ 468 | .model-manager .comfy-grid { 469 | display: flex; 470 | flex-wrap: wrap; 471 | gap: 16px; 472 | } 473 | 474 | .model-manager .comfy-grid .model-label { 475 | background-color: rgb(from var(--content-hover-bg) r g b / 0.6); 476 | width: 100%; 477 | height: 2.2rem; 478 | position: absolute; 479 | bottom: 0; 480 | text-align: center; 481 | line-height: 2.2rem; 482 | } 483 | 484 | .model-manager .comfy-grid .model-label > p { 485 | width: calc(100% - 2rem); 486 | overflow-x: scroll; 487 | white-space: nowrap; 488 | display: inline-block; 489 | vertical-align: middle; 490 | margin: 0; 491 | } 492 | 493 | .model-manager .comfy-grid .model-label { 494 | scrollbar-width: none; 495 | -ms-overflow-style: none; 496 | } 497 | 498 | .model-manager .comfy-grid .model-label ::-webkit-scrollbar { 499 | width: 0; 500 | height: 0; 501 | } 502 | 503 | .model-manager .comfy-grid .model-preview-top-right, 504 | .model-manager .comfy-grid .model-preview-top-left { 505 | position: absolute; 506 | flex-direction: column; 507 | gap: 8px; 508 | top: 8px; 509 | } 510 | 511 | .model-manager .comfy-grid .model-preview-top-right { 512 | right: 8px; 513 | } 514 | 515 | .model-manager .comfy-grid .model-preview-top-left { 516 | left: 8px; 517 | } 518 | 519 | .model-manager .item .model-buttons-hidden { 520 | display: none; 521 | } 522 | 523 | .model-manager .item:hover .model-buttons-hidden, 524 | .model-manager .comfy-grid .model-buttons-visible { 525 | display: flex; 526 | } 527 | 528 | .model-manager .comfy-grid .model-button { 529 | opacity: 0.65; 530 | } 531 | 532 | .model-manager .comfy-grid .model-button:hover { 533 | opacity: 1; 534 | } 535 | 536 | .model-manager .comfy-grid .model-label { 537 | user-select: text; 538 | } 539 | 540 | /* radio */ 541 | .model-manager .comfy-radio-group { 542 | display: flex; 543 | gap: 8px; 544 | flex-wrap: wrap; 545 | min-width: 0; 546 | } 547 | 548 | .model-manager .comfy-radio { 549 | display: flex; 550 | gap: 4px; 551 | padding: 4px 16px; 552 | color: var(--input-text); 553 | border: 2px solid var(--border-color); 554 | border-radius: 16px; 555 | background-color: var(--comfy-input-bg); 556 | font-size: 18px; 557 | } 558 | 559 | .model-manager .comfy-radio:has(> input[type="radio"]:checked) { 560 | border-color: var(--border-color); 561 | background-color: var(--comfy-menu-bg); 562 | } 563 | 564 | .model-manager .comfy-radio input[type="radio"]:checked + label { 565 | color: var(--fg-color); 566 | } 567 | 568 | .model-manager .radio-input { 569 | opacity: 0; 570 | position: absolute; 571 | } 572 | 573 | /* model preview select */ 574 | .model-manager .model-preview-select-radio-container { 575 | min-width: 0; 576 | flex: 1; 577 | } 578 | 579 | .model-manager .model-preview-select-radio-inputs > div { 580 | padding: 16px 0 8px 0; 581 | } 582 | 583 | .model-manager .model-preview-select-radio-container img { 584 | position: relative; 585 | width: 230px; 586 | height: 345px; 587 | text-align: center; 588 | overflow: hidden; 589 | border-radius: 8px; 590 | object-fit: cover; 591 | } 592 | 593 | /* topbar */ 594 | .model-manager .topbar-buttons { 595 | display: flex; 596 | float: right; 597 | } 598 | 599 | .model-manager .topbar-buttons button { 600 | height: 33px; 601 | padding: 1px 6px; 602 | width: 33px; 603 | } 604 | 605 | .model-manager .model-manager-head .topbar-left { 606 | display: flex; 607 | float: left; 608 | } 609 | 610 | .model-manager .model-manager-head .topbar-right { 611 | column-gap: 4px; 612 | display: flex; 613 | flex-direction: row-reverse; 614 | float: right; 615 | } 616 | 617 | .model-manager .model-manager-head .topbar-right select { 618 | position: relative; 619 | top: 0; 620 | bottom: 0; 621 | font-size: 20px; 622 | text-align-last: center; 623 | -o-appearance: none; 624 | -ms-appearance: none; 625 | -webkit-appearance: none; 626 | -moz-appearance: none; 627 | appearance: none; 628 | } 629 | 630 | /* search dropdown */ 631 | .model-manager .input-dropdown-container { 632 | position: relative; 633 | } 634 | 635 | .model-manager .search-models { 636 | display: flex; 637 | flex: 1; 638 | flex-direction: row; 639 | min-width: 0; 640 | } 641 | 642 | .model-manager .model-select-dropdown { 643 | min-width: 0; 644 | overflow: auto; 645 | } 646 | 647 | .model-manager .search-text-area, 648 | .model-manager .plain-text-area, 649 | .model-manager .model-select-dropdown { 650 | flex: 1; 651 | min-height: 36px; 652 | padding-block: 0; 653 | min-width: 36px; 654 | } 655 | 656 | .model-manager .model-select-dropdown { 657 | min-height: 40px; 658 | } 659 | 660 | .model-manager .search-directory-dropdown { 661 | background-color: var(--bg-color); 662 | border: 2px var(--border-color) solid; 663 | border-radius: 10px; 664 | color: var(--fg-color); 665 | max-height: 40vh; 666 | overflow: auto; 667 | position: absolute; 668 | z-index: 1; 669 | } 670 | 671 | @media (pointer:none), (pointer:coarse) { 672 | .model-manager .search-directory-dropdown { 673 | max-height: 17.5vh; 674 | } 675 | } 676 | 677 | .model-manager .search-directory-dropdown:empty { 678 | display: none; 679 | } 680 | 681 | .model-manager .search-directory-dropdown > p { 682 | margin: 0; 683 | padding: 0.85em 20px; 684 | min-width: 0; 685 | } 686 | .model-manager .search-directory-dropdown > p { 687 | -ms-overflow-style: none; /* Internet Explorer 10+ */ 688 | scrollbar-width: none; /* Firefox */ 689 | } 690 | .model-manager .search-directory-dropdown > p::-webkit-scrollbar { 691 | display: none; /* Safari and Chrome */ 692 | } 693 | 694 | .model-manager .search-directory-dropdown > p.search-directory-dropdown-key-selected, 695 | .model-manager .search-directory-dropdown > p.search-directory-dropdown-mouse-selected { 696 | background-color: var(--border-color); 697 | } 698 | 699 | .model-manager .search-directory-dropdown > p.search-directory-dropdown-key-selected { 700 | border-left: 1mm solid var(--input-text); 701 | } 702 | 703 | /* model manager settings */ 704 | .model-manager .model-manager-settings > div, 705 | .model-manager .model-manager-settings > label, 706 | .model-manager .tag-generator-settings > label, 707 | .model-manager .tag-generator-settings > div { 708 | display: flex; 709 | flex-direction: row; 710 | align-items: center; 711 | gap: 8px; 712 | margin: 16px 0; 713 | } 714 | 715 | .model-manager .model-manager-settings button { 716 | height: 40px; 717 | min-width: 120px; 718 | justify-content: center; 719 | } 720 | 721 | .model-manager .model-manager-settings input[type="number"], 722 | .model-manager .tag-generator-settings input[type="number"]{ 723 | width: 60px; 724 | } 725 | 726 | .model-manager .search-settings-text { 727 | width: 100%; 728 | } 729 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import io 3 | import pathlib 4 | import shutil 5 | from datetime import datetime 6 | import sys 7 | import copy 8 | import importlib 9 | import re 10 | import base64 11 | import hashlib 12 | import markdownify 13 | 14 | from aiohttp import web 15 | import server 16 | import urllib.parse 17 | import urllib.request 18 | import struct 19 | import json 20 | import requests 21 | requests.packages.urllib3.disable_warnings() 22 | 23 | import comfy.utils 24 | import folder_paths 25 | 26 | comfyui_model_uri = folder_paths.models_dir 27 | 28 | extension_uri = os.path.dirname(os.path.abspath(__file__)) 29 | 30 | config_loader_path = os.path.join(extension_uri, 'config_loader.py') 31 | config_loader_spec = importlib.util.spec_from_file_location('config_loader', config_loader_path) 32 | config_loader = importlib.util.module_from_spec(config_loader_spec) 33 | config_loader_spec.loader.exec_module(config_loader) 34 | 35 | no_preview_image = os.path.join(extension_uri, "no-preview.png") 36 | ui_settings_uri = os.path.join(extension_uri, "ui_settings.yaml") 37 | server_settings_uri = os.path.join(extension_uri, "server_settings.yaml") 38 | 39 | fallback_model_extensions = set([".bin", ".ckpt", ".gguf", ".onnx", ".pt", ".pth", ".safetensors"]) # TODO: magic values 40 | jpeg_format_names = ["JPG", "JPEG", "JFIF"] 41 | image_extensions = ( 42 | ".png", # order matters 43 | ".webp", 44 | ".jpeg", 45 | ".jpg", 46 | ".jfif", 47 | ".gif", 48 | ".apng", 49 | ) 50 | stable_diffusion_webui_civitai_helper_image_extensions = ( 51 | ".preview.png", # order matters 52 | ".preview.webp", 53 | ".preview.jpeg", 54 | ".preview.jpg", 55 | ".preview.jfif", 56 | ".preview.gif", 57 | ".preview.apng", 58 | ) 59 | preview_extensions = ( # TODO: JavaScript does not know about this (x2 states) 60 | image_extensions + # order matters 61 | stable_diffusion_webui_civitai_helper_image_extensions 62 | ) 63 | model_notes_extension = ".txt" 64 | model_info_extension = ".json" 65 | #video_extensions = (".avi", ".mp4", ".webm") # TODO: Requires ffmpeg or cv2. Cache preview frame? 66 | 67 | def split_valid_ext(s, *arg_exts): 68 | sl = s.lower() 69 | for exts in arg_exts: 70 | for ext in exts: 71 | if sl.endswith(ext.lower()): 72 | return (s[:-len(ext)], ext) 73 | return (s, "") 74 | 75 | _folder_names_and_paths = None # dict[str, tuple[list[str], list[str]]] 76 | def folder_paths_folder_names_and_paths(refresh = False): 77 | # TODO: "diffusers" extension whitelist is ["folder"] 78 | global _folder_names_and_paths 79 | if refresh or _folder_names_and_paths is None: 80 | _folder_names_and_paths = {} 81 | for item_name in os.listdir(comfyui_model_uri): 82 | item_path = os.path.join(comfyui_model_uri, item_name) 83 | if not os.path.isdir(item_path): 84 | continue 85 | if item_name == "configs": 86 | continue 87 | if item_name in folder_paths.folder_names_and_paths: 88 | dir_paths, extensions = copy.deepcopy(folder_paths.folder_names_and_paths[item_name]) 89 | else: 90 | dir_paths = [item_path] 91 | extensions = copy.deepcopy(fallback_model_extensions) 92 | _folder_names_and_paths[item_name] = (dir_paths, extensions) 93 | return _folder_names_and_paths 94 | 95 | def folder_paths_get_folder_paths(folder_name, refresh = False): # API function crashes querying unknown model folder 96 | paths = folder_paths_folder_names_and_paths(refresh) 97 | if folder_name in paths: 98 | return paths[folder_name][0] 99 | 100 | maybe_path = os.path.join(comfyui_model_uri, folder_name) 101 | if os.path.exists(maybe_path): 102 | return [maybe_path] 103 | return [] 104 | 105 | def folder_paths_get_supported_pt_extensions(folder_name, refresh = False): # Missing API function 106 | paths = folder_paths_folder_names_and_paths(refresh) 107 | if folder_name in paths: 108 | return paths[folder_name][1] 109 | model_extensions = copy.deepcopy(fallback_model_extensions) 110 | return model_extensions 111 | 112 | 113 | def search_path_to_system_path(model_path): 114 | sep = os.path.sep 115 | model_path = os.path.normpath(model_path.replace("/", sep)) 116 | model_path = model_path.lstrip(sep) 117 | 118 | isep1 = model_path.find(sep, 0) 119 | if isep1 == -1 or isep1 == len(model_path): 120 | return (None, None) 121 | 122 | isep2 = model_path.find(sep, isep1 + 1) 123 | if isep2 == -1 or isep2 - isep1 == 1: 124 | isep2 = len(model_path) 125 | 126 | model_path_type = model_path[0:isep1] 127 | paths = folder_paths_get_folder_paths(model_path_type) 128 | if len(paths) == 0: 129 | return (None, None) 130 | 131 | model_path_index = model_path[isep1 + 1:isep2] 132 | try: 133 | model_path_index = int(model_path_index) 134 | except: 135 | return (None, None) 136 | if model_path_index < 0 or model_path_index >= len(paths): 137 | return (None, None) 138 | 139 | system_path = os.path.normpath( 140 | paths[model_path_index] + 141 | sep + 142 | model_path[isep2:] 143 | ) 144 | 145 | return (system_path, model_path_type) 146 | 147 | 148 | def get_safetensor_header(path): 149 | try: 150 | header_bytes = comfy.utils.safetensors_header(path) 151 | header_json = json.loads(header_bytes) 152 | return header_json if header_json is not None else {} 153 | except: 154 | return {} 155 | 156 | 157 | def end_swap_and_pop(x, i): 158 | x[i], x[-1] = x[-1], x[i] 159 | return x.pop(-1) 160 | 161 | 162 | def model_type_to_dir_name(model_type): 163 | if model_type == "checkpoint": return "checkpoints" 164 | #elif model_type == "clip": return "clip" 165 | #elif model_type == "clip_vision": return "clip_vision" 166 | #elif model_type == "controlnet": return "controlnet" 167 | elif model_type == "diffuser": return "diffusers" 168 | elif model_type == "embedding": return "embeddings" 169 | #elif model_type== "gligen": return "gligen" 170 | elif model_type == "hypernetwork": return "hypernetworks" 171 | elif model_type == "lora": return "loras" 172 | #elif model_type == "style_models": return "style_models" 173 | #elif model_type == "unet": return "unet" 174 | elif model_type == "upscale_model": return "upscale_models" 175 | #elif model_type == "vae": return "vae" 176 | #elif model_type == "vae_approx": return "vae_approx" 177 | else: return model_type 178 | 179 | 180 | def ui_rules(): 181 | Rule = config_loader.Rule 182 | return [ 183 | Rule("model-search-always-append", "", str), 184 | Rule("model-default-browser-model-type", "checkpoints", str), 185 | Rule("model-real-time-search", True, bool), 186 | Rule("model-persistent-search", True, bool), 187 | 188 | Rule("model-preview-thumbnail-type", "AUTO", str), 189 | Rule("model-preview-fallback-search-safetensors-thumbnail", False, bool), 190 | Rule("model-preview-thumbnail-width", 240, int, 150, 480), 191 | Rule("model-preview-thumbnail-height", 360, int, 185, 480), 192 | Rule("model-show-label-extensions", False, bool), 193 | Rule("model-show-add-button", True, bool), 194 | Rule("model-show-copy-button", True, bool), 195 | Rule("model-show-load-workflow-button", True, bool), 196 | Rule("model-show-open-model-url-button", False, bool), 197 | Rule("model-info-button-on-left", False, bool), 198 | Rule("model-buttons-only-on-hover", True, bool), 199 | 200 | Rule("model-add-embedding-extension", False, bool), 201 | Rule("model-add-drag-strict-on-field", False, bool), 202 | Rule("model-add-offset", 25, int), 203 | 204 | Rule("model-info-autosave-notes", False, bool), 205 | 206 | Rule("download-save-description-as-text-file", True, bool), 207 | 208 | Rule("sidebar-control-always-compact", False, bool), 209 | Rule("sidebar-default-width", 0.5, float, 0.0, 1.0), 210 | Rule("sidebar-default-height", 0.5, float, 0.0, 1.0), 211 | Rule("sidebar-default-state", "None", str), 212 | Rule("text-input-always-hide-search-button", False, bool), 213 | Rule("text-input-always-hide-clear-button", False, bool), 214 | 215 | Rule("tag-generator-sampler-method", "Frequency", str), 216 | Rule("tag-generator-count", 10, int), 217 | Rule("tag-generator-threshold", 2, int), 218 | ] 219 | 220 | 221 | def server_rules(): 222 | Rule = config_loader.Rule 223 | return [ 224 | #Rule("model_extension_download_whitelist", [".safetensors"], list), 225 | Rule("civitai_api_key", "", str), 226 | Rule("huggingface_api_key", "", str), 227 | ] 228 | server_settings = config_loader.yaml_load(server_settings_uri, server_rules()) 229 | config_loader.yaml_save(server_settings_uri, server_rules(), server_settings) 230 | 231 | 232 | def get_def_headers(url=""): 233 | def_headers = { 234 | "User-Agent": "Mozilla/5.0 (iPad; CPU OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148", 235 | } 236 | 237 | if url.startswith("https://civitai.com/"): 238 | api_key = server_settings["civitai_api_key"] 239 | if (api_key != ""): 240 | def_headers["Content-Type"] = f"application/json" 241 | def_headers["Authorization"] = f"Bearer {api_key}" 242 | url += "&" if "?" in url else "?" # not the most robust solution 243 | url += f"token={api_key}" # TODO: Authorization didn't work in the header 244 | elif url.startswith("https://huggingface.co/"): 245 | api_key = server_settings["huggingface_api_key"] 246 | if api_key != "": 247 | def_headers["Authorization"] = f"Bearer {api_key}" 248 | 249 | return def_headers 250 | 251 | 252 | def save_web_url(path, url): 253 | with open(path, "w", encoding="utf-8") as f: 254 | f.write(f"[InternetShortcut]\nURL={url}\n") 255 | 256 | 257 | def try_load_web_url(path): 258 | with open(path, "r", encoding="utf-8") as f: 259 | if f.readline() != "[InternetShortcut]\n": return "" 260 | url = f.readline() 261 | if not url.startswith("URL="): return "" 262 | if not url.endswith("\n"): return "" 263 | return url[4:len(url)-1] 264 | 265 | 266 | def hash_file(path, buffer_size=1024*1024): 267 | sha256 = hashlib.sha256() 268 | with open(path, 'rb') as f: 269 | while True: 270 | data = f.read(buffer_size) 271 | if not data: break 272 | sha256.update(data) 273 | return sha256.hexdigest() 274 | 275 | 276 | class Civitai: 277 | IMAGE_URL_SUBDIRECTORY_PREFIX = "https://civitai.com/images/" 278 | IMAGE_URL_DOMAIN_PREFIX = "https://image.civitai.com/" 279 | 280 | @staticmethod 281 | def image_subdirectory_url_to_image_url(image_url): 282 | url_suffix = image_url[len(Civitai.IMAGE_URL_SUBDIRECTORY_PREFIX):] 283 | image_id = re.search(r"^\d+", url_suffix).group(0) 284 | image_id = str(int(image_id)) 285 | image_info_url = f"https://civitai.com/api/v1/images?imageId={image_id}" 286 | def_headers = get_def_headers(image_info_url) 287 | response = requests.get( 288 | url=image_info_url, 289 | stream=False, 290 | verify=False, 291 | headers=def_headers, 292 | proxies=None, 293 | allow_redirects=False, 294 | ) 295 | if response.ok: 296 | #content_type = response.headers.get("Content-Type") 297 | info = response.json() 298 | items = info["items"] 299 | if len(items) == 0: 300 | raise RuntimeError("Civitai /api/v1/images returned 0 items!") 301 | return items[0]["url"] 302 | else: 303 | raise RuntimeError("Bad response from api/v1/images!") 304 | 305 | @staticmethod 306 | def image_domain_url_full_size(url): 307 | result = re.search("/width=(\d+)", url) 308 | if result is None: 309 | return url 310 | span = result.span() 311 | i0 = span[0] 312 | i1 = span[1] 313 | return url[0:i0] + "/original=true,quality=90,optimized=true" + url[i1:] 314 | 315 | @staticmethod 316 | def search_by_hash(sha256_hash): 317 | url_api_hash = r"https://civitai.com/api/v1/model-versions/by-hash/" + sha256_hash 318 | hash_response = requests.get(url_api_hash) 319 | if hash_response.status_code != 200: 320 | return {} 321 | return hash_response.json() # model version info 322 | 323 | @staticmethod 324 | def search_by_model_id(model_id): 325 | url_api_model = r"https://civitai.com/api/v1/models/" + str(model_id) 326 | model_response = requests.get(url_api_model) 327 | if model_response.status_code != 200: 328 | return {} 329 | return model_response.json() # model group info 330 | 331 | @staticmethod 332 | def get_model_url(model_version_info): 333 | if len(model_version_info) == 0: return "" 334 | model_id = model_version_info.get("modelId") 335 | if model_id is None: 336 | # there can be incomplete model info, so don't throw just in case 337 | return "" 338 | url = f"https://civitai.com/models/{model_id}" 339 | version_id = model_version_info.get("id") 340 | if version_id is not None: 341 | url += f"?modelVersionId={version_id}" 342 | return url 343 | 344 | @staticmethod 345 | def get_preview_urls(model_version_info, full_size=False): 346 | images = model_version_info.get("images", None) 347 | if images is None: 348 | return [] 349 | preview_urls = [] 350 | for image_info in images: 351 | url = image_info["url"] 352 | if full_size: 353 | url = Civitai.image_domain_url_full_size(url) 354 | preview_urls.append(url) 355 | return preview_urls 356 | 357 | @staticmethod 358 | def search_notes(model_version_info): 359 | if len(model_version_info) == 0: 360 | return "" 361 | model_name = None 362 | if "modelId" in model_version_info and "id" in model_version_info: 363 | model_id = model_version_info.get("modelId") 364 | model_version_id = model_version_info.get("id") 365 | 366 | model_version_description = "" 367 | model_trigger_words = [] 368 | model_info = Civitai.search_by_model_id(model_id) 369 | if len(model_info) == 0: # can happen if model download is disabled 370 | print("Model Manager WARNING: Unable to find Civitai 'modelId' " + str(model_id) + ". Try deleting .json file and trying again later!") 371 | return "" 372 | model_name = model_info.get("name") 373 | model_description = model_info.get("description") 374 | for model_version in model_info["modelVersions"]: 375 | if model_version["id"] == model_version_id: 376 | model_version_description = model_version.get("description") 377 | model_trigger_words = model_version.get("trainedWords") 378 | break 379 | elif "description" in model_version_info and "activation text" in model_version_info and "notes" in model_version_info: 380 | # {'description': str, 'sd version': str, 'activation text': str, 'preferred weight': int, 'notes': str} 381 | model_description = model_version_info.get("description") 382 | activation_text = model_version_info.get("activation text") 383 | if activation_text != "": 384 | model_trigger_words = [activation_text] 385 | else: 386 | model_trigger_words = [] 387 | model_version_description = model_version_info.get("notes") 388 | else: 389 | return "" 390 | model_description = model_description if model_description is not None else "" 391 | model_trigger_words = model_trigger_words if model_trigger_words is not None else [] 392 | model_version_description = model_version_description if model_version_description is not None else "" 393 | model_name = model_name if model_name is not None else "Model Description" 394 | 395 | notes = "" 396 | if len(model_trigger_words) > 0: 397 | notes += "# Trigger Words\n\n" 398 | model_trigger_words = [re.sub(",$", "", s.strip()) for s in model_trigger_words] 399 | join_separator = ', ' 400 | for s in model_trigger_words: 401 | if ',' in s: 402 | join_separator = '\n' 403 | break 404 | if join_separator == '\n': 405 | model_trigger_words = ["* " + s for s in model_trigger_words] 406 | notes += join_separator.join(model_trigger_words) 407 | if model_version_description != "": 408 | if len(notes) > 0: notes += "\n\n" 409 | notes += "# About this version\n\n" 410 | notes += markdownify.markdownify(model_version_description) 411 | if model_description != "": 412 | if len(notes) > 0: notes += "\n\n" 413 | notes += "# " + model_name + "\n\n" 414 | notes += markdownify.markdownify(model_description) 415 | return notes.strip() 416 | 417 | 418 | class ModelInfo: 419 | @staticmethod 420 | def search_by_hash(sha256_hash): 421 | model_info = Civitai.search_by_hash(sha256_hash) 422 | if len(model_info) > 0: return model_info 423 | # TODO: search other websites 424 | return {} 425 | 426 | @staticmethod 427 | def try_load_cached(model_path): 428 | model_info_path = os.path.splitext(model_path)[0] + model_info_extension 429 | if os.path.isfile(model_info_path): 430 | with open(model_info_path, "r", encoding="utf-8") as f: 431 | model_info = json.load(f) 432 | return model_info 433 | return {} 434 | 435 | @staticmethod 436 | def get_hash(model_info): 437 | model_info = Civitai.get_hash(model_info) 438 | if len(model_info) > 0: return model_info 439 | # TODO: search other websites 440 | return {} 441 | 442 | @staticmethod 443 | def search_info(model_path, cache=True, use_cached=True): 444 | model_info = ModelInfo.try_load_cached(model_path) 445 | if use_cached and len(model_info) > 0: 446 | return model_info 447 | 448 | sha256_hash = hash_file(model_path) 449 | model_info = ModelInfo.search_by_hash(sha256_hash) 450 | if cache and len(model_info) > 0: 451 | model_info_path = os.path.splitext(model_path)[0] + model_info_extension 452 | with open(model_info_path, "w", encoding="utf-8") as f: 453 | json.dump(model_info, f, indent=4) 454 | print("Saved file: " + model_info_path) 455 | 456 | return model_info 457 | 458 | @staticmethod 459 | def get_url(model_info): 460 | if len(model_info) == 0: 461 | return "" 462 | model_url = Civitai.get_model_url(model_info) 463 | if model_url != "": 464 | return model_url 465 | # TODO: huggingface has / formats 466 | # TODO: support other websites 467 | return "" 468 | 469 | @staticmethod 470 | def search_notes(model_path): 471 | assert(os.path.isfile(model_path)) 472 | model_info = ModelInfo.search_info(model_path, cache=True, use_cached=True) # assume cached is correct; re-download elsewhere 473 | if len(model_info) == 0: 474 | return "" 475 | notes = Civitai.search_notes(model_info) 476 | if len(notes) > 0 and not notes.isspace(): 477 | return notes 478 | # TODO: search other websites 479 | return "" 480 | 481 | @staticmethod 482 | def get_web_preview_urls(model_info, full_size=False): 483 | if len(model_info) == 0: 484 | return [] 485 | preview_urls = Civitai.get_preview_urls(model_info, full_size) 486 | if len(preview_urls) > 0: 487 | return preview_urls 488 | # TODO: support other websites 489 | return [] 490 | 491 | @server.PromptServer.instance.routes.get("/model-manager/timestamp") 492 | async def get_timestamp(request): 493 | return web.json_response({ "timestamp": datetime.now().timestamp() }) 494 | 495 | 496 | @server.PromptServer.instance.routes.get("/model-manager/settings/load") 497 | async def load_ui_settings(request): 498 | rules = ui_rules() 499 | settings = config_loader.yaml_load(ui_settings_uri, rules) 500 | return web.json_response({ "settings": settings }) 501 | 502 | 503 | @server.PromptServer.instance.routes.post("/model-manager/settings/save") 504 | async def save_ui_settings(request): 505 | body = await request.json() 506 | settings = body.get("settings") 507 | rules = ui_rules() 508 | validated_settings = config_loader.validated(rules, settings) 509 | success = config_loader.yaml_save(ui_settings_uri, rules, validated_settings) 510 | print("Saved file: " + ui_settings_uri) 511 | return web.json_response({ 512 | "success": success, 513 | "settings": validated_settings if success else "", 514 | }) 515 | 516 | 517 | from PIL import Image, TiffImagePlugin 518 | from PIL.PngImagePlugin import PngInfo 519 | def PIL_cast_serializable(v): 520 | # source: https://github.com/python-pillow/Pillow/issues/6199#issuecomment-1214854558 521 | if isinstance(v, TiffImagePlugin.IFDRational): 522 | return float(v) 523 | elif isinstance(v, tuple): 524 | return tuple(PIL_cast_serializable(t) for t in v) 525 | elif isinstance(v, bytes): 526 | return v.decode(errors="replace") 527 | elif isinstance(v, dict): 528 | for kk, vv in v.items(): 529 | v[kk] = PIL_cast_serializable(vv) 530 | return v 531 | else: 532 | return v 533 | 534 | 535 | def get_safetensors_image_bytes(path): 536 | if not os.path.isfile(path): 537 | raise RuntimeError("Path was invalid!") 538 | header = get_safetensor_header(path) 539 | metadata = header.get("__metadata__", None) 540 | if metadata is None: 541 | return None 542 | thumbnail = metadata.get("modelspec.thumbnail", None) 543 | if thumbnail is None: 544 | return None 545 | image_data = thumbnail.split(',')[1] 546 | return base64.b64decode(image_data) 547 | 548 | 549 | def get_image_info(image): 550 | metadata = None 551 | if len(image.info) > 0: 552 | metadata = PngInfo() 553 | for (key, value) in image.info.items(): 554 | value_str = str(PIL_cast_serializable(value)) # not sure if this is correct (sometimes includes exif) 555 | metadata.add_text(key, value_str) 556 | return metadata 557 | 558 | 559 | def image_format_is_equal(f1, f2): 560 | if not isinstance(f1, str) or not isinstance(f2, str): 561 | return False 562 | if f1[0] == ".": f1 = f1[1:] 563 | if f2[0] == ".": f2 = f2[1:] 564 | f1 = f1.upper() 565 | f2 = f2.upper() 566 | return f1 == f2 or (f1 in jpeg_format_names and f2 in jpeg_format_names) 567 | 568 | 569 | def get_auto_thumbnail_format(original_format): 570 | if original_format in ["JPEG", "WEBP", "JPG"]: # JFIF? 571 | return original_format 572 | return "JPEG" # default fallback 573 | 574 | 575 | @server.PromptServer.instance.routes.get("/model-manager/preview/get/{uri}") 576 | async def get_model_preview(request): 577 | uri = request.match_info["uri"] 578 | if uri is None: # BUG: this should never happen 579 | print(f"Invalid uri! Request url: {request.url}") 580 | uri = "no-preview" 581 | uri = urllib.parse.unquote(uri) 582 | quality = 75 583 | response_image_format = request.query.get("image-format", None) 584 | if isinstance(response_image_format, str): 585 | response_image_format = response_image_format.upper() 586 | 587 | image_path = no_preview_image 588 | file_name = os.path.split(no_preview_image)[1] 589 | if uri != "no-preview": 590 | sep = os.path.sep 591 | uri = uri.replace("/" if sep == "\\" else "/", sep) 592 | path, _ = search_path_to_system_path(uri) 593 | head, extension = split_valid_ext(path, preview_extensions) 594 | if os.path.exists(path): 595 | image_path = path 596 | file_name = os.path.split(head)[1] + extension 597 | elif os.path.exists(head) and head.endswith(".safetensors"): 598 | image_path = head 599 | file_name = os.path.splitext(os.path.split(head)[1])[0] + extension 600 | 601 | w = request.query.get("width") 602 | h = request.query.get("height") 603 | try: 604 | w = int(w) 605 | if w < 1: 606 | w = None 607 | except: 608 | w = None 609 | try: 610 | h = int(h) 611 | if w < 1: 612 | h = None 613 | except: 614 | h = None 615 | 616 | image_data = None 617 | if w is None and h is None: # full size 618 | if image_path.endswith(".safetensors"): 619 | image_data = get_safetensors_image_bytes(image_path) 620 | else: 621 | with open(image_path, "rb") as image: 622 | image_data = image.read() 623 | fp = io.BytesIO(image_data) 624 | with Image.open(fp) as image: 625 | image_format = image.format 626 | if response_image_format is None: 627 | response_image_format = image_format 628 | elif response_image_format == "AUTO": 629 | response_image_format = get_auto_thumbnail_format(image_format) 630 | 631 | if not image_format_is_equal(response_image_format, image_format): 632 | exif = image.getexif() 633 | metadata = get_image_info(image) 634 | if response_image_format in jpeg_format_names: 635 | image = image.convert('RGB') 636 | image_bytes = io.BytesIO() 637 | image.save(image_bytes, format=response_image_format, exif=exif, pnginfo=metadata, quality=quality) 638 | image_data = image_bytes.getvalue() 639 | else: 640 | if image_path.endswith(".safetensors"): 641 | image_data = get_safetensors_image_bytes(image_path) 642 | fp = io.BytesIO(image_data) 643 | else: 644 | fp = image_path 645 | 646 | with Image.open(fp) as image: 647 | image_format = image.format 648 | if response_image_format is None: 649 | response_image_format = image_format 650 | elif response_image_format == "AUTO": 651 | response_image_format = get_auto_thumbnail_format(image_format) 652 | 653 | w0, h0 = image.size 654 | if w is None: 655 | w = (h * w0) // h0 656 | elif h is None: 657 | h = (w * h0) // w0 658 | 659 | exif = image.getexif() 660 | metadata = get_image_info(image) 661 | 662 | ratio_original = w0 / h0 663 | ratio_thumbnail = w / h 664 | if abs(ratio_original - ratio_thumbnail) < 0.01: 665 | crop_box = (0, 0, w0, h0) 666 | elif ratio_original > ratio_thumbnail: 667 | crop_width_fp = h0 * w / h 668 | x0 = int((w0 - crop_width_fp) / 2) 669 | crop_box = (x0, 0, x0 + int(crop_width_fp), h0) 670 | else: 671 | crop_height_fp = w0 * h / w 672 | y0 = int((h0 - crop_height_fp) / 2) 673 | crop_box = (0, y0, w0, y0 + int(crop_height_fp)) 674 | image = image.crop(crop_box) 675 | 676 | if w < w0 and h < h0: 677 | resampling_method = Image.Resampling.BOX 678 | else: 679 | resampling_method = Image.Resampling.BICUBIC 680 | image.thumbnail((w, h), resample=resampling_method) 681 | 682 | if not image_format_is_equal(image_format, response_image_format) and response_image_format in jpeg_format_names: 683 | image = image.convert('RGB') 684 | image_bytes = io.BytesIO() 685 | image.save(image_bytes, format=response_image_format, exif=exif, pnginfo=metadata, quality=quality) 686 | image_data = image_bytes.getvalue() 687 | 688 | response_file_name = os.path.splitext(file_name)[0] + '.' + response_image_format.lower() 689 | return web.Response( 690 | headers={ 691 | "Content-Disposition": f"inline; filename={response_file_name}", 692 | }, 693 | body=image_data, 694 | content_type="image/" + response_image_format.lower(), 695 | ) 696 | 697 | 698 | @server.PromptServer.instance.routes.get("/model-manager/image/extensions") 699 | async def get_image_extensions(request): 700 | return web.json_response(image_extensions) 701 | 702 | 703 | def download_model_preview(path, image, overwrite): 704 | if not os.path.isfile(path): 705 | raise ValueError("Invalid path!") 706 | path_without_extension = os.path.splitext(path)[0] 707 | 708 | if type(image) is str: 709 | if image.startswith(Civitai.IMAGE_URL_SUBDIRECTORY_PREFIX): 710 | image = Civitai.image_subdirectory_url_to_image_url(image) 711 | if image.startswith(Civitai.IMAGE_URL_DOMAIN_PREFIX): 712 | image = Civitai.image_domain_url_full_size(image) 713 | _, image_extension = split_valid_ext(image, image_extensions) 714 | if image_extension == "": 715 | raise ValueError("Invalid image type!") 716 | image_path = path_without_extension + image_extension 717 | download_file(image, image_path, overwrite) 718 | else: 719 | content_type = image.content_type 720 | if not content_type.startswith("image/"): 721 | raise RuntimeError("Invalid content type!") 722 | image_extension = "." + content_type[len("image/"):] 723 | if image_extension not in image_extensions: 724 | raise RuntimeError("Invalid extension!") 725 | 726 | image_path = path_without_extension + image_extension 727 | if not overwrite and os.path.isfile(image_path): 728 | raise RuntimeError("Image already exists!") 729 | file: io.IOBase = image.file 730 | image_data = file.read() 731 | with open(image_path, "wb") as f: 732 | f.write(image_data) 733 | print("Saved file: " + image_path) 734 | 735 | if overwrite: 736 | delete_same_name_files(path_without_extension, preview_extensions, image_extension) 737 | 738 | # detect (and try to fix) wrong file extension 739 | image_format = None 740 | try: 741 | with Image.open(image_path) as image: 742 | image_format = image.format 743 | image_dir_and_name, image_ext = os.path.splitext(image_path) 744 | if not image_format_is_equal(image_format, image_ext): 745 | corrected_image_path = image_dir_and_name + "." + image_format.lower() 746 | if os.path.exists(corrected_image_path) and not overwrite: 747 | print("WARNING: '" + image_path + "' has wrong extension!") 748 | else: 749 | os.rename(image_path, corrected_image_path) 750 | print("Saved file: " + corrected_image_path) 751 | image_path = corrected_image_path 752 | except Image.UnidentifiedImageError as e: #TODO: handle case where "image" is actually video 753 | print("WARNING: '" + image_path + "' image format was unknown!") 754 | os.remove(image_path) 755 | print("Deleted file: " + image_path) 756 | image_path = "" 757 | return image_path # return in-case need corrected path 758 | 759 | 760 | @server.PromptServer.instance.routes.post("/model-manager/preview/set") 761 | async def set_model_preview(request): 762 | formdata = await request.post() 763 | try: 764 | search_path = formdata.get("path", None) 765 | model_path, model_type = search_path_to_system_path(search_path) 766 | 767 | image = formdata.get("image", None) 768 | 769 | overwrite = formdata.get("overwrite", "true").lower() 770 | overwrite = True if overwrite == "true" else False 771 | 772 | download_model_preview(model_path, image, overwrite) 773 | return web.json_response({ "success": True }) 774 | except ValueError as e: 775 | print(e, file=sys.stderr, flush=True) 776 | return web.json_response({ 777 | "success": False, 778 | "alert": "Failed to set preview!\n\n" + str(e), 779 | }) 780 | 781 | 782 | @server.PromptServer.instance.routes.post("/model-manager/preview/delete") 783 | async def delete_model_preview(request): 784 | result = { "success": False } 785 | 786 | model_path = request.query.get("path", None) 787 | if model_path is None: 788 | result["alert"] = "Missing model path!" 789 | return web.json_response(result) 790 | model_path = urllib.parse.unquote(model_path) 791 | 792 | model_path, model_type = search_path_to_system_path(model_path) 793 | model_extensions = folder_paths_get_supported_pt_extensions(model_type) 794 | path_and_name, _ = split_valid_ext(model_path, model_extensions) 795 | delete_same_name_files(path_and_name, preview_extensions) 796 | 797 | result["success"] = True 798 | return web.json_response(result) 799 | 800 | 801 | def correct_image_extensions(root_dir): 802 | detected_image_count = 0 803 | corrected_image_count = 0 804 | for root, dirs, files in os.walk(root_dir): 805 | for file_name in files: 806 | file_path = root + os.path.sep + file_name 807 | image_format = None 808 | try: 809 | with Image.open(file_path) as image: 810 | image_format = image.format 811 | except: 812 | continue 813 | image_path = file_path 814 | image_dir_and_name, image_ext = os.path.splitext(image_path) 815 | if not image_format_is_equal(image_format, image_ext): 816 | detected_image_count += 1 817 | corrected_image_path = image_dir_and_name + "." + image_format.lower() 818 | if os.path.exists(corrected_image_path): 819 | print("WARNING: '" + image_path + "' has wrong extension!") 820 | else: 821 | try: 822 | os.rename(image_path, corrected_image_path) 823 | except: 824 | print("WARNING: Unable to rename '" + image_path + "'!") 825 | continue 826 | ext0 = os.path.splitext(image_path)[1] 827 | ext1 = os.path.splitext(corrected_image_path)[1] 828 | print(f"({ext0} -> {ext1}): {corrected_image_path}") 829 | corrected_image_count += 1 830 | return (detected_image_count, corrected_image_count) 831 | 832 | 833 | @server.PromptServer.instance.routes.get("/model-manager/preview/correct-extensions") 834 | async def correct_preview_extensions(request): 835 | result = { "success": False } 836 | 837 | detected = 0 838 | corrected = 0 839 | 840 | model_types = os.listdir(comfyui_model_uri) 841 | model_types.remove("configs") 842 | model_types.sort() 843 | 844 | for model_type in model_types: 845 | for base_path_index, model_base_path in enumerate(folder_paths_get_folder_paths(model_type)): 846 | 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") 847 | continue 848 | d, c = correct_image_extensions(model_base_path) 849 | detected += d 850 | corrected += c 851 | 852 | result["success"] = True 853 | result["detected"] = detected 854 | result["corrected"] = corrected 855 | return web.json_response(result) 856 | 857 | 858 | @server.PromptServer.instance.routes.get("/model-manager/models/list") 859 | async def get_model_list(request): 860 | use_safetensor_thumbnail = ( 861 | config_loader.yaml_load(ui_settings_uri, ui_rules()) 862 | .get("model-preview-fallback-search-safetensors-thumbnail", False) 863 | ) 864 | 865 | model_types = os.listdir(comfyui_model_uri) 866 | model_types.remove("configs") 867 | model_types.sort() 868 | 869 | models = {} 870 | for model_type in model_types: 871 | model_extensions = tuple(folder_paths_get_supported_pt_extensions(model_type)) 872 | file_infos = [] 873 | for base_path_index, model_base_path in enumerate(folder_paths_get_folder_paths(model_type)): 874 | 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") 875 | continue 876 | for cwd, subdirs, files in os.walk(model_base_path): 877 | dir_models = [] 878 | dir_images = [] 879 | 880 | for file in files: 881 | if file.lower().endswith(model_extensions): 882 | dir_models.append(file) 883 | elif file.lower().endswith(preview_extensions): 884 | dir_images.append(file) 885 | 886 | for model in dir_models: 887 | model_name, model_ext = split_valid_ext(model, model_extensions) 888 | image = None 889 | image_modified = None 890 | for ext in preview_extensions: # order matters 891 | for iImage in range(len(dir_images)-1, -1, -1): 892 | image_name = dir_images[iImage] 893 | if not image_name.lower().endswith(ext.lower()): 894 | continue 895 | image_name = image_name[:-len(ext)] 896 | if model_name == image_name: 897 | image = end_swap_and_pop(dir_images, iImage) 898 | img_abs_path = os.path.join(cwd, image) 899 | image_modified = pathlib.Path(img_abs_path).stat().st_mtime_ns 900 | break 901 | if image is not None: 902 | break 903 | abs_path = os.path.join(cwd, model) 904 | stats = pathlib.Path(abs_path).stat() 905 | sizeBytes = stats.st_size 906 | model_modified = stats.st_mtime_ns 907 | model_created = stats.st_ctime_ns 908 | if use_safetensor_thumbnail and image is None and model_ext == ".safetensors": 909 | # try to fallback on safetensor embedded thumbnail 910 | header = get_safetensor_header(abs_path) 911 | metadata = header.get("__metadata__", None) 912 | if metadata is not None: 913 | thumbnail = metadata.get("modelspec.thumbnail", None) 914 | if thumbnail is not None: 915 | i0 = thumbnail.find("/") + 1 916 | i1 = thumbnail.find(";") 917 | image_ext = "." + thumbnail[i0:i1] 918 | if image_ext in image_extensions: 919 | image = model + image_ext 920 | image_modified = model_modified 921 | rel_path = "" if cwd == model_base_path else os.path.relpath(cwd, model_base_path) 922 | info = ( 923 | model, 924 | image, 925 | base_path_index, 926 | rel_path, 927 | model_modified, 928 | model_created, 929 | image_modified, 930 | sizeBytes, 931 | ) 932 | file_infos.append(info) 933 | #file_infos.sort(key=lambda tup: tup[4], reverse=True) # TODO: remove sort; sorted on client 934 | 935 | model_items = [] 936 | for model, image, base_path_index, rel_path, model_modified, model_created, image_modified, sizeBytes in file_infos: 937 | item = { 938 | "name": model, 939 | "path": "/" + os.path.join(model_type, str(base_path_index), rel_path, model).replace(os.path.sep, "/"), # relative logical path 940 | #"systemPath": os.path.join(rel_path, model), # relative system path (less information than "search path") 941 | "dateModified": model_modified, 942 | "dateCreated": model_created, 943 | #"dateLastUsed": "", # TODO: track server-side, send increment client-side 944 | #"countUsed": 0, # TODO: track server-side, send increment client-side 945 | "sizeBytes": sizeBytes, 946 | } 947 | if image is not None: 948 | raw_post = os.path.join(model_type, str(base_path_index), rel_path, image) 949 | item["preview"] = { 950 | "path": raw_post, 951 | "dateModified": str(image_modified), 952 | } 953 | model_items.append(item) 954 | 955 | models[model_type] = model_items 956 | 957 | return web.json_response(models) 958 | 959 | 960 | def linear_directory_hierarchy(refresh = False): 961 | model_paths = folder_paths_folder_names_and_paths(refresh) 962 | dir_list = [] 963 | dir_list.append({ "name": "", "childIndex": 1, "childCount": len(model_paths) }) 964 | for model_dir_name, (model_dirs, _) in model_paths.items(): 965 | dir_list.append({ "name": model_dir_name, "childIndex": None, "childCount": len(model_dirs) }) 966 | for model_dir_index, (_, (model_dirs, extension_whitelist)) in enumerate(model_paths.items()): 967 | model_dir_child_index = len(dir_list) 968 | dir_list[model_dir_index + 1]["childIndex"] = model_dir_child_index 969 | for dir_path_index, dir_path in enumerate(model_dirs): 970 | dir_list.append({ "name": str(dir_path_index), "childIndex": None, "childCount": None }) 971 | for dir_path_index, dir_path in enumerate(model_dirs): 972 | if not os.path.exists(dir_path) or os.path.isfile(dir_path): 973 | continue 974 | 975 | #dir_list.append({ "name": str(dir_path_index), "childIndex": None, "childCount": 0 }) 976 | dir_stack = [(dir_path, model_dir_child_index + dir_path_index)] 977 | while len(dir_stack) > 0: # DEPTH-FIRST 978 | dir_path, dir_index = dir_stack.pop() 979 | 980 | dir_items = os.listdir(dir_path) 981 | dir_items = sorted(dir_items, key=str.casefold) 982 | 983 | dir_child_count = 0 984 | 985 | # TODO: sort content of directory: alphabetically 986 | # TODO: sort content of directory: files first 987 | 988 | subdirs = [] 989 | for item_name in dir_items: # BREADTH-FIRST 990 | item_path = os.path.join(dir_path, item_name) 991 | if os.path.isdir(item_path): 992 | # dir 993 | subdir_index = len(dir_list) # this must be done BEFORE `dir_list.append` 994 | subdirs.append((item_path, subdir_index)) 995 | dir_list.append({ "name": item_name, "childIndex": None, "childCount": 0 }) 996 | dir_child_count += 1 997 | else: 998 | # file 999 | if extension_whitelist is None or split_valid_ext(item_name, extension_whitelist)[1] != "": 1000 | dir_list.append({ "name": item_name }) 1001 | dir_child_count += 1 1002 | if dir_child_count > 0: 1003 | dir_list[dir_index]["childIndex"] = len(dir_list) - dir_child_count 1004 | dir_list[dir_index]["childCount"] = dir_child_count 1005 | subdirs.reverse() 1006 | for dir_path, subdir_index in subdirs: 1007 | dir_stack.append((dir_path, subdir_index)) 1008 | return dir_list 1009 | 1010 | 1011 | @server.PromptServer.instance.routes.get("/model-manager/models/directory-list") 1012 | async def get_directory_list(request): 1013 | #body = await request.json() 1014 | dir_list = linear_directory_hierarchy(True) 1015 | #json.dump(dir_list, sys.stdout, indent=4) 1016 | return web.json_response(dir_list) 1017 | 1018 | 1019 | def try_download_and_save_model_info(model_file_path): 1020 | success = [0, 0, 0] #info, notes, url 1021 | head, _ = os.path.splitext(model_file_path) 1022 | model_info_path = head + model_info_extension 1023 | model_notes_path = head + model_notes_extension 1024 | model_url_path = head + ".url" 1025 | if os.path.exists(model_info_path) and os.path.exists(model_notes_path) and os.path.exists(model_url_path): 1026 | return success 1027 | print("Scanning " + model_file_path) 1028 | 1029 | model_info = {} 1030 | model_info = ModelInfo.search_info(model_file_path, cache=True, use_cached=True) 1031 | if len(model_info) == 0: 1032 | return success 1033 | success[0] = 1 1034 | 1035 | if not os.path.exists(model_notes_path): 1036 | notes = ModelInfo.search_notes(model_file_path) 1037 | if not notes.isspace() and notes != "": 1038 | try: 1039 | with open(model_notes_path, "w", encoding="utf-8") as f: 1040 | f.write(notes) 1041 | print("Saved file: " + model_notes_path) 1042 | success[1] = 1 1043 | except Exception as e: 1044 | print(f"Failed to save {model_notes_path}!") 1045 | print(e, file=sys.stderr, flush=True) 1046 | 1047 | if not os.path.exists(model_url_path): 1048 | web_url = ModelInfo.get_url(model_info) 1049 | if web_url is not None and web_url != "": 1050 | try: 1051 | save_web_url(model_url_path, web_url) 1052 | print("Saved file: " + model_url_path) 1053 | success[2] = 1 1054 | except Exception as e: 1055 | print(f"Failed to save {model_url_path}!") 1056 | print(e, file=sys.stderr, flush=True) 1057 | return success 1058 | 1059 | 1060 | @server.PromptServer.instance.routes.post("/model-manager/models/scan") 1061 | async def try_scan_download(request): 1062 | refresh = request.query.get("refresh", None) is not None 1063 | response = { 1064 | "success": False, 1065 | "infoCount": 0, 1066 | "notesCount": 0, 1067 | "urlCount": 0, 1068 | } 1069 | model_paths = folder_paths_folder_names_and_paths(refresh) 1070 | for _, (model_dirs, model_extension_whitelist) in model_paths.items(): 1071 | for root_dir in model_dirs: 1072 | for root, dirs, files in os.walk(root_dir): 1073 | for file in files: 1074 | file_name, file_extension = os.path.splitext(file) 1075 | if file_extension not in model_extension_whitelist: 1076 | continue 1077 | model_file_path = root + os.path.sep + file 1078 | savedInfo, savedNotes, savedUrl = try_download_and_save_model_info(model_file_path) 1079 | response["infoCount"] += savedInfo 1080 | response["notesCount"] += savedNotes 1081 | response["urlCount"] += savedUrl 1082 | 1083 | response["success"] = True 1084 | return web.json_response(response) 1085 | 1086 | @server.PromptServer.instance.routes.post("/model-manager/preview/scan") 1087 | async def try_scan_download_previews(request): 1088 | refresh = request.query.get("refresh", None) is not None 1089 | response = { 1090 | "success": False, 1091 | "count": 0, 1092 | } 1093 | model_paths = folder_paths_folder_names_and_paths(refresh) 1094 | for _, (model_dirs, model_extension_whitelist) in model_paths.items(): 1095 | for root_dir in model_dirs: 1096 | for root, dirs, files in os.walk(root_dir): 1097 | for file in files: 1098 | file_name, file_extension = os.path.splitext(file) 1099 | if file_extension not in model_extension_whitelist: 1100 | continue 1101 | model_file_path = root + os.path.sep + file 1102 | model_file_head = os.path.splitext(model_file_path)[0] 1103 | 1104 | preview_exists = False 1105 | for preview_extension in preview_extensions: 1106 | preview_path = model_file_head + preview_extension 1107 | if os.path.isfile(preview_path): 1108 | preview_exists = True 1109 | break 1110 | if preview_exists: 1111 | continue 1112 | 1113 | model_info = ModelInfo.try_load_cached(model_file_path) # NOTE: model info must already be downloaded 1114 | web_previews = ModelInfo.get_web_preview_urls(model_info, True) 1115 | if len(web_previews) == 0: 1116 | continue 1117 | saved_image_path = download_model_preview( 1118 | model_file_path, 1119 | image=web_previews[0], 1120 | overwrite=False, 1121 | ) 1122 | if os.path.isfile(saved_image_path): 1123 | response["count"] += 1 1124 | 1125 | response["success"] = True 1126 | return web.json_response(response) 1127 | 1128 | 1129 | def download_file(url, filename, overwrite): 1130 | if not overwrite and os.path.isfile(filename): 1131 | raise ValueError("File already exists!") 1132 | 1133 | filename_temp = filename + ".download" 1134 | 1135 | def_headers = get_def_headers(url) 1136 | rh = requests.get( 1137 | url=url, 1138 | stream=True, 1139 | verify=False, 1140 | headers=def_headers, 1141 | proxies=None, 1142 | allow_redirects=False, 1143 | ) 1144 | if not rh.ok: 1145 | raise ValueError( 1146 | "Unable to download! Request header status code: " + 1147 | str(rh.status_code) 1148 | ) 1149 | 1150 | downloaded_size = 0 1151 | if rh.status_code == 200 and os.path.exists(filename_temp): 1152 | downloaded_size = os.path.getsize(filename_temp) 1153 | 1154 | headers = {"Range": "bytes=%d-" % downloaded_size} 1155 | headers["User-Agent"] = def_headers["User-Agent"] 1156 | headers["Authorization"] = def_headers.get("Authorization", None) 1157 | 1158 | r = requests.get( 1159 | url=url, 1160 | stream=True, 1161 | verify=False, 1162 | headers=headers, 1163 | proxies=None, 1164 | allow_redirects=False, 1165 | ) 1166 | if rh.status_code == 307 and r.status_code == 307: 1167 | # Civitai redirect 1168 | redirect_url = r.content.decode("utf-8") 1169 | if not redirect_url.startswith("http"): 1170 | # Civitai requires login (NSFW or user-required) 1171 | # TODO: inform user WHY download failed 1172 | raise ValueError("Unable to download from Civitai! Redirect url: " + str(redirect_url)) 1173 | download_file(redirect_url, filename, overwrite) 1174 | return 1175 | if rh.status_code == 302 and r.status_code == 302: 1176 | # HuggingFace redirect 1177 | redirect_url = r.content.decode("utf-8") 1178 | redirect_url_index = redirect_url.find("http") 1179 | if redirect_url_index == -1: 1180 | raise ValueError("Unable to download from HuggingFace! Redirect url: " + str(redirect_url)) 1181 | download_file(redirect_url[redirect_url_index:], filename, overwrite) 1182 | return 1183 | elif rh.status_code == 200 and r.status_code == 206: 1184 | # Civitai download link 1185 | pass 1186 | 1187 | total_size = int(rh.headers.get("Content-Length", 0)) # TODO: pass in total size earlier 1188 | 1189 | print("Downloading file: " + url) 1190 | if total_size != 0: 1191 | print("Download file size: " + str(total_size)) 1192 | 1193 | mode = "wb" if overwrite else "ab" 1194 | with open(filename_temp, mode) as f: 1195 | for chunk in r.iter_content(chunk_size=1024): 1196 | if chunk is not None: 1197 | downloaded_size += len(chunk) 1198 | f.write(chunk) 1199 | f.flush() 1200 | 1201 | if total_size != 0: 1202 | fraction = 1 if downloaded_size == total_size else downloaded_size / total_size 1203 | progress = int(50 * fraction) 1204 | sys.stdout.reconfigure(encoding="utf-8") 1205 | sys.stdout.write( 1206 | "\r[%s%s] %d%%" 1207 | % ( 1208 | "-" * progress, 1209 | " " * (50 - progress), 1210 | 100 * fraction, 1211 | ) 1212 | ) 1213 | sys.stdout.flush() 1214 | print() 1215 | 1216 | if overwrite and os.path.isfile(filename): 1217 | os.remove(filename) 1218 | os.rename(filename_temp, filename) 1219 | print("Saved file: " + filename) 1220 | 1221 | 1222 | def bytes_to_size(total_bytes): 1223 | units = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"] 1224 | b = total_bytes 1225 | i = 0 1226 | while True: 1227 | b = b >> 10 1228 | if (b == 0): break 1229 | i = i + 1 1230 | if i >= len(units) or i == 0: 1231 | return str(total_bytes) + " " + units[0] 1232 | return "{:.2f}".format(total_bytes / (1 << (i * 10))) + " " + units[i] 1233 | 1234 | 1235 | @server.PromptServer.instance.routes.get("/model-manager/model/info/{path}") 1236 | async def get_model_metadata(request): 1237 | result = { "success": False } 1238 | 1239 | model_path = request.match_info["path"] 1240 | if model_path is None: 1241 | result["alert"] = "Invalid model path!" 1242 | return web.json_response(result) 1243 | model_path = urllib.parse.unquote(model_path) 1244 | 1245 | abs_path, model_type = search_path_to_system_path(model_path) 1246 | if abs_path is None: 1247 | result["alert"] = "Invalid model path!" 1248 | return web.json_response(result) 1249 | 1250 | data = {} 1251 | comfyui_directory, name = os.path.split(model_path) 1252 | data["File Name"] = name 1253 | data["File Directory"] = comfyui_directory 1254 | data["File Size"] = bytes_to_size(os.path.getsize(abs_path)) 1255 | stats = pathlib.Path(abs_path).stat() 1256 | date_format = "%Y-%m-%d %H:%M:%S" 1257 | date_modified = datetime.fromtimestamp(stats.st_mtime).strftime(date_format) 1258 | #data["Date Modified"] = date_modified 1259 | #data["Date Created"] = datetime.fromtimestamp(stats.st_ctime).strftime(date_format) 1260 | 1261 | model_extensions = folder_paths_get_supported_pt_extensions(model_type) 1262 | abs_name , _ = split_valid_ext(abs_path, model_extensions) 1263 | 1264 | for extension in preview_extensions: 1265 | maybe_preview = abs_name + extension 1266 | if os.path.isfile(maybe_preview): 1267 | preview_path, _ = split_valid_ext(model_path, model_extensions) 1268 | preview_modified = pathlib.Path(maybe_preview).stat().st_mtime_ns 1269 | data["Preview"] = { 1270 | "path": preview_path + extension, 1271 | "dateModified": str(preview_modified), 1272 | } 1273 | break 1274 | 1275 | header = get_safetensor_header(abs_path) 1276 | metadata = header.get("__metadata__", None) 1277 | 1278 | if metadata is not None and data.get("Preview", None) is None: 1279 | thumbnail = metadata.get("modelspec.thumbnail") 1280 | if thumbnail is not None: 1281 | i0 = thumbnail.find("/") + 1 1282 | i1 = thumbnail.find(";", i0) 1283 | thumbnail_extension = "." + thumbnail[i0:i1] 1284 | if thumbnail_extension in image_extensions: 1285 | preview_path, _ = split_valid_ext(model_path, model_extensions) 1286 | data["Preview"] = { 1287 | "path": preview_path + thumbnail_extension, 1288 | "dateModified": date_modified, 1289 | } 1290 | 1291 | if metadata is not None: 1292 | data["Base Training Model"] = metadata.get("ss_sd_model_name", "") 1293 | data["Base Model Version"] = metadata.get("ss_base_model_version", "") 1294 | data["Network Dimension"] = metadata.get("ss_network_dim", "") 1295 | data["Network Alpha"] = metadata.get("ss_network_alpha", "") 1296 | 1297 | if metadata is not None: 1298 | training_comment = metadata.get("ss_training_comment", "") 1299 | data["Description"] = ( 1300 | metadata.get("modelspec.description", "") + 1301 | "\n\n" + 1302 | metadata.get("modelspec.usage_hint", "") + 1303 | "\n\n" + 1304 | training_comment if training_comment != "None" else "" 1305 | ).strip() 1306 | 1307 | notes_file = abs_name + model_notes_extension 1308 | notes = "" 1309 | if os.path.isfile(notes_file): 1310 | with open(notes_file, 'r', encoding="utf-8") as f: 1311 | notes = f.read() 1312 | 1313 | web_url_file = abs_name + ".url" 1314 | web_url = "" 1315 | if os.path.isfile(web_url_file): 1316 | web_url = try_load_web_url(web_url_file) 1317 | 1318 | if metadata is not None: 1319 | img_buckets = metadata.get("ss_bucket_info", None) 1320 | datasets = metadata.get("ss_datasets", None) 1321 | 1322 | if type(img_buckets) is str: 1323 | img_buckets = json.loads(img_buckets) 1324 | elif type(datasets) is str: 1325 | datasets = json.loads(datasets) 1326 | if isinstance(datasets, list): 1327 | datasets = datasets[0] 1328 | img_buckets = datasets.get("bucket_info", None) 1329 | resolutions = {} 1330 | if img_buckets is not None: 1331 | buckets = img_buckets.get("buckets", {}) 1332 | for resolution in buckets.values(): 1333 | dim = resolution["resolution"] 1334 | x, y = dim[0], dim[1] 1335 | count = resolution["count"] 1336 | resolutions[str(x) + "x" + str(y)] = count 1337 | resolutions = list(resolutions.items()) 1338 | resolutions.sort(key=lambda x: x[1], reverse=True) 1339 | data["Bucket Resolutions"] = resolutions 1340 | 1341 | tags = None 1342 | if metadata is not None: 1343 | dir_tags = metadata.get("ss_tag_frequency", "{}") 1344 | if type(dir_tags) is str: 1345 | dir_tags = json.loads(dir_tags) 1346 | tags = {} 1347 | for train_tags in dir_tags.values(): 1348 | for tag, count in train_tags.items(): 1349 | tags[tag] = tags.get(tag, 0) + count 1350 | tags = list(tags.items()) 1351 | tags.sort(key=lambda x: x[1], reverse=True) 1352 | 1353 | model_info = ModelInfo.try_load_cached(abs_path) 1354 | web_previews = ModelInfo.get_web_preview_urls(model_info, True) 1355 | 1356 | result["success"] = True 1357 | result["info"] = data 1358 | if metadata is not None: 1359 | result["metadata"] = metadata 1360 | if tags is not None: 1361 | result["tags"] = tags 1362 | result["notes"] = notes 1363 | result["url"] = web_url 1364 | result["webPreviews"] = web_previews 1365 | return web.json_response(result) 1366 | 1367 | 1368 | @server.PromptServer.instance.routes.get("/model-manager/model/web-url") 1369 | async def get_model_web_url(request): 1370 | result = { "success": False } 1371 | 1372 | model_path = request.query.get("path", None) 1373 | if model_path is None: 1374 | result["alert"] = "Invalid model path!" 1375 | return web.json_response(result) 1376 | model_path = urllib.parse.unquote(model_path) 1377 | 1378 | abs_path, model_type = search_path_to_system_path(model_path) 1379 | if abs_path is None: 1380 | result["alert"] = "Invalid model path!" 1381 | return web.json_response(result) 1382 | 1383 | url_path = os.path.splitext(abs_path)[0] + ".url" 1384 | if os.path.isfile(url_path): 1385 | web_url = try_load_web_url(url_path) 1386 | if web_url != "": 1387 | result["success"] = True 1388 | return web.json_response({ "url": web_url }) 1389 | 1390 | model_info = ModelInfo.search_info(abs_path) 1391 | if len(model_info) == 0: 1392 | result["alert"] = "Unable to find model info!" 1393 | return web.json_response(result) 1394 | web_url = ModelInfo.get_url(model_info) 1395 | if web_url != "" and web_url is not None: 1396 | save_web_url(url_path, web_url) 1397 | result["success"] = True 1398 | 1399 | return web.json_response({ "url": web_url }) 1400 | 1401 | 1402 | @server.PromptServer.instance.routes.get("/model-manager/system-separator") 1403 | async def get_system_separator(request): 1404 | return web.json_response(os.path.sep) 1405 | 1406 | 1407 | @server.PromptServer.instance.routes.post("/model-manager/model/download/info") 1408 | async def download_model_info(request): 1409 | result = { "success": False } 1410 | 1411 | model_path = request.query.get("path", None) 1412 | if model_path is None: 1413 | result["alert"] = "Missing model path!" 1414 | return web.json_response(result) 1415 | model_path = urllib.parse.unquote(model_path) 1416 | 1417 | abs_path, model_type = search_path_to_system_path(model_path) 1418 | if abs_path is None: 1419 | result["alert"] = "Invalid model path!" 1420 | return web.json_response(result) 1421 | 1422 | model_info = ModelInfo.search_info(abs_path, cache=True, use_cached=False) 1423 | if len(model_info) > 0: 1424 | result["success"] = True 1425 | 1426 | return web.json_response(result) 1427 | 1428 | 1429 | @server.PromptServer.instance.routes.post("/model-manager/model/download") 1430 | async def download_model(request): 1431 | formdata = await request.post() 1432 | result = { "success": False } 1433 | 1434 | overwrite = formdata.get("overwrite", "false").lower() 1435 | overwrite = True if overwrite == "true" else False 1436 | 1437 | model_path = formdata.get("path", "/0") 1438 | directory, model_type = search_path_to_system_path(model_path) 1439 | if directory is None: 1440 | result["alert"] = "Invalid save path!" 1441 | return web.json_response(result) 1442 | 1443 | # download model 1444 | download_uri = formdata.get("download") 1445 | if download_uri is None: 1446 | result["alert"] = "Invalid download url!" 1447 | return web.json_response(result) 1448 | 1449 | name = formdata.get("name") 1450 | model_extensions = folder_paths_get_supported_pt_extensions(model_type) 1451 | name_head, model_extension = split_valid_ext(name, model_extensions) 1452 | name_without_extension = os.path.split(name_head)[1] 1453 | if name_without_extension == "": 1454 | result["alert"] = "Cannot have empty model name!" 1455 | return web.json_response(result) 1456 | if model_extension == "": 1457 | result["alert"] = "Unrecognized model extension!" 1458 | return web.json_response(result) 1459 | file_name = os.path.join(directory, name) 1460 | try: 1461 | download_file(download_uri, file_name, overwrite) 1462 | except Exception as e: 1463 | print(e, file=sys.stderr, flush=True) 1464 | result["alert"] = "Failed to download model!\n\n" + str(e) 1465 | return web.json_response(result) 1466 | 1467 | # download model info 1468 | model_info = ModelInfo.search_info(file_name, cache=True) # save json 1469 | 1470 | # save url 1471 | url_file_path = os.path.splitext(file_name)[0] + ".url" 1472 | url = ModelInfo.get_url(model_info) 1473 | if url != "" and url is not None: 1474 | save_web_url(url_file_path, url) 1475 | 1476 | # save image as model preview 1477 | image = formdata.get("image") 1478 | if image is not None and image != "": 1479 | try: 1480 | download_model_preview( 1481 | file_name, 1482 | image, 1483 | formdata.get("overwrite"), 1484 | ) 1485 | except Exception as e: 1486 | print(e, file=sys.stderr, flush=True) 1487 | result["alert"] = "Failed to download preview!\n\n" + str(e) 1488 | 1489 | result["success"] = True 1490 | return web.json_response(result) 1491 | 1492 | 1493 | @server.PromptServer.instance.routes.post("/model-manager/model/move") 1494 | async def move_model(request): 1495 | body = await request.json() 1496 | result = { "success": False } 1497 | 1498 | old_file = body.get("oldFile", None) 1499 | if old_file is None: 1500 | result["alert"] = "No model was given!" 1501 | return web.json_response(result) 1502 | old_file, old_model_type = search_path_to_system_path(old_file) 1503 | if not os.path.isfile(old_file): 1504 | result["alert"] = "Model does not exist!" 1505 | return web.json_response(result) 1506 | old_model_extensions = folder_paths_get_supported_pt_extensions(old_model_type) 1507 | old_file_without_extension, model_extension = split_valid_ext(old_file, old_model_extensions) 1508 | if model_extension == "": 1509 | result["alert"] = "Invalid model extension!" 1510 | return web.json_response(result) 1511 | 1512 | new_file = body.get("newFile", None) 1513 | if new_file is None or new_file == "": 1514 | result["alert"] = "New model name was invalid!" 1515 | return web.json_response(result) 1516 | new_file, new_model_type = search_path_to_system_path(new_file) 1517 | if not new_file.endswith(model_extension): 1518 | result["alert"] = "Cannot change model extension!" 1519 | return web.json_response(result) 1520 | if os.path.isfile(new_file): 1521 | result["alert"] = "Cannot overwrite existing model!" 1522 | return web.json_response(result) 1523 | new_model_extensions = folder_paths_get_supported_pt_extensions(new_model_type) 1524 | new_file_without_extension, new_model_extension = split_valid_ext(new_file, new_model_extensions) 1525 | if model_extension != new_model_extension: 1526 | result["alert"] = "Cannot change model extension!" 1527 | return web.json_response(result) 1528 | new_file_dir, new_file_name = os.path.split(new_file) 1529 | if not os.path.isdir(new_file_dir): 1530 | result["alert"] = "Destination directory does not exist!" 1531 | return web.json_response(result) 1532 | new_name_without_extension = os.path.splitext(new_file_name)[0] 1533 | if new_file_name == new_name_without_extension or new_name_without_extension == "": 1534 | result["alert"] = "New model name was empty!" 1535 | return web.json_response(result) 1536 | 1537 | if old_file == new_file: 1538 | # no-op 1539 | result["success"] = True 1540 | return web.json_response(result) 1541 | try: 1542 | shutil.move(old_file, new_file) 1543 | print("Moved file: " + new_file) 1544 | except ValueError as e: 1545 | print(e, file=sys.stderr, flush=True) 1546 | result["alert"] = "Failed to move model!\n\n" + str(e) 1547 | return web.json_response(result) 1548 | 1549 | # TODO: this could overwrite existing files in destination; do a check beforehand? 1550 | for extension in preview_extensions + (model_notes_extension,) + (model_info_extension,): 1551 | old_file = old_file_without_extension + extension 1552 | if os.path.isfile(old_file): 1553 | new_file = new_file_without_extension + extension 1554 | try: 1555 | shutil.move(old_file, new_file) 1556 | print("Moved file: " + new_file) 1557 | except ValueError as e: 1558 | print(e, file=sys.stderr, flush=True) 1559 | msg = result.get("alert","") 1560 | if msg == "": 1561 | result["alert"] = "Failed to move model resource file!\n\n" + str(e) 1562 | else: 1563 | result["alert"] = msg + "\n" + str(e) 1564 | 1565 | result["success"] = True 1566 | return web.json_response(result) 1567 | 1568 | 1569 | def delete_same_name_files(path_without_extension, extensions, keep_extension=None): 1570 | for extension in extensions: 1571 | if extension == keep_extension: continue 1572 | file = path_without_extension + extension 1573 | if os.path.isfile(file): 1574 | os.remove(file) 1575 | print("Deleted file: " + file) 1576 | 1577 | 1578 | @server.PromptServer.instance.routes.post("/model-manager/model/delete") 1579 | async def delete_model(request): 1580 | result = { "success": False } 1581 | 1582 | model_path = request.query.get("path", None) 1583 | if model_path is None: 1584 | result["alert"] = "Missing model path!" 1585 | return web.json_response(result) 1586 | model_path = urllib.parse.unquote(model_path) 1587 | model_path, model_type = search_path_to_system_path(model_path) 1588 | if model_path is None: 1589 | result["alert"] = "Invalid model path!" 1590 | return web.json_response(result) 1591 | 1592 | model_extensions = folder_paths_get_supported_pt_extensions(model_type) 1593 | path_and_name, model_extension = split_valid_ext(model_path, model_extensions) 1594 | if model_extension == "": 1595 | result["alert"] = "Cannot delete file!" 1596 | return web.json_response(result) 1597 | 1598 | if os.path.isfile(model_path): 1599 | os.remove(model_path) 1600 | result["success"] = True 1601 | print("Deleted file: " + model_path) 1602 | 1603 | delete_same_name_files(path_and_name, preview_extensions) 1604 | delete_same_name_files(path_and_name, (model_notes_extension,)) 1605 | delete_same_name_files(path_and_name, (model_info_extension,)) 1606 | 1607 | return web.json_response(result) 1608 | 1609 | 1610 | @server.PromptServer.instance.routes.post("/model-manager/notes/save") 1611 | async def set_notes(request): 1612 | body = await request.json() 1613 | result = { "success": False } 1614 | 1615 | dt_epoch = body.get("timestamp", None) 1616 | 1617 | text = body.get("notes", None) 1618 | if type(text) is not str: 1619 | result["alert"] = "Invalid note!" 1620 | return web.json_response(result) 1621 | 1622 | model_path = body.get("path", None) 1623 | if type(model_path) is not str: 1624 | result["alert"] = "Missing model path!" 1625 | return web.json_response(result) 1626 | model_path, model_type = search_path_to_system_path(model_path) 1627 | model_extensions = folder_paths_get_supported_pt_extensions(model_type) 1628 | file_path_without_extension, _ = split_valid_ext(model_path, model_extensions) 1629 | filename = os.path.normpath(file_path_without_extension + model_notes_extension) 1630 | 1631 | if dt_epoch is not None and os.path.exists(filename) and os.path.getmtime(filename) > dt_epoch: 1632 | # discard late save 1633 | result["success"] = True 1634 | return web.json_response(result) 1635 | 1636 | if text.isspace() or text == "": 1637 | if os.path.exists(filename): 1638 | os.remove(filename) 1639 | #print("Deleted file: " + filename) # autosave -> too verbose 1640 | else: 1641 | try: 1642 | with open(filename, "w", encoding="utf-8") as f: 1643 | f.write(text) 1644 | if dt_epoch is not None: 1645 | os.utime(filename, (dt_epoch, dt_epoch)) 1646 | #print("Saved file: " + filename) # autosave -> too verbose 1647 | except ValueError as e: 1648 | print(e, file=sys.stderr, flush=True) 1649 | result["alert"] = "Failed to save notes!\n\n" + str(e) 1650 | return web.json_response(result) 1651 | 1652 | result["success"] = True 1653 | return web.json_response(result) 1654 | 1655 | 1656 | @server.PromptServer.instance.routes.post("/model-manager/notes/download") 1657 | async def try_download_notes(request): 1658 | result = { "success": False } 1659 | 1660 | model_path = request.query.get("path", None) 1661 | if model_path is None: 1662 | result["alert"] = "Missing model path!" 1663 | return web.json_response(result) 1664 | model_path = urllib.parse.unquote(model_path) 1665 | 1666 | abs_path, model_type = search_path_to_system_path(model_path) 1667 | if abs_path is None: 1668 | result["alert"] = "Invalid model path!" 1669 | return web.json_response(result) 1670 | 1671 | overwrite = request.query.get("overwrite", None) 1672 | overwrite = not (overwrite == "False" or overwrite == "false" or overwrite == None) 1673 | notes_path = os.path.splitext(abs_path)[0] + ".txt" 1674 | if not overwrite and os.path.isfile(notes_path): 1675 | result["alert"] = "Notes already exist!" 1676 | return web.json_response(result) 1677 | 1678 | notes = ModelInfo.search_notes(abs_path) 1679 | if notes.isspace() or notes == "": 1680 | result["alert"] = "No notes found!" 1681 | return web.json_response(result) 1682 | 1683 | try: 1684 | with open(notes_path, "w", encoding="utf-8") as f: 1685 | f.write(notes) 1686 | result["success"] = True 1687 | except ValueError as e: 1688 | print(e, file=sys.stderr, flush=True) 1689 | result["alert"] = "Failed to save notes!\n\n" + str(e) 1690 | return web.json_response(result) 1691 | 1692 | result["notes"] = notes 1693 | return web.json_response(result) 1694 | 1695 | 1696 | WEB_DIRECTORY = "web" 1697 | NODE_CLASS_MAPPINGS = {} 1698 | __all__ = ["NODE_CLASS_MAPPINGS"] 1699 | --------------------------------------------------------------------------------