diff --git a/__init__.py b/__init__.py index 1c0843b..f285a07 100644 --- a/__init__.py +++ b/__init__.py @@ -1021,35 +1021,11 @@ class ModelResolverExtension: if download_available: - def get_local_path_abs(path_value): - import os - - try: - return os.path.abspath(str(path_value or "")) - except (OSError, ValueError): - return str(path_value or "") - - def get_local_path_key(path_value): - import os - - if not path_value: - return "" - try: - return os.path.normcase(os.path.abspath(path_value)) - except (OSError, ValueError): - return os.path.normcase(str(path_value or "")) - - def get_local_path_identity(path_value): - import os - - if not path_value: - return "" - try: - return os.path.normcase( - os.path.realpath(os.path.abspath(path_value)) - ) - except (OSError, ValueError): - return get_local_path_key(path_value) + from .core.path_utils import ( + get_path_abs as get_local_path_abs, + get_path_key as get_local_path_key, + get_path_identity as get_local_path_identity, + ) def get_comfy_root_path(folder_paths_module): import os @@ -1060,89 +1036,11 @@ class ModelResolverExtension: except Exception: return "" - def is_local_path_within(path_value, root_value): - import os - - if not path_value or not root_value: - return False - try: - path_key = get_local_path_key(path_value) - root_key = get_local_path_key(root_value) - return os.path.commonpath([path_key, root_key]) == root_key - except Exception: - return False - - def prefer_local_base_directory( - candidate, - current, - preferred_directory="", - comfy_root="", - ): - if not current: - return True - if not candidate: - return False - - candidate_key = get_local_path_key(candidate) - current_key = get_local_path_key(current) - preferred_key = get_local_path_key(preferred_directory) - if preferred_key: - if candidate_key == preferred_key and current_key != preferred_key: - return True - if current_key == preferred_key and candidate_key != preferred_key: - return False - - if comfy_root: - candidate_is_external = not is_local_path_within( - candidate, comfy_root - ) - current_is_external = not is_local_path_within( - current, comfy_root - ) - if candidate_is_external != current_is_external: - return candidate_is_external - - candidate_is_canonical = ( - candidate_key == get_local_path_identity(candidate) - ) - current_is_canonical = current_key == get_local_path_identity( - current - ) - if candidate_is_canonical != current_is_canonical: - return candidate_is_canonical - - return False - - def dedupe_local_base_directories( - paths, - preferred_directory="", - comfy_root="", - ): - import os - - by_identity = {} - ordered_identities = [] - for path in paths or []: - if not path or not os.path.isdir(path): - continue - path_abs = get_local_path_abs(path) - path_identity = get_local_path_identity(path_abs) - if not path_identity: - continue - - current = by_identity.get(path_identity) - if not current: - by_identity[path_identity] = path_abs - ordered_identities.append(path_identity) - elif prefer_local_base_directory( - path_abs, - current, - preferred_directory, - comfy_root, - ): - by_identity[path_identity] = path_abs - - return [by_identity[key] for key in ordered_identities] + from .core.path_utils import ( + is_path_within as is_local_path_within, + prefer_local_base_directory, + dedupe_local_base_directories, + ) def cleanup_search_progress(max_age_seconds=300): now = time.time() diff --git a/core/downloader.py b/core/downloader.py index 5c87ad5..16986fa 100644 --- a/core/downloader.py +++ b/core/downloader.py @@ -1,4 +1,4 @@ -""" +""" Model Downloader Module Handles downloading models from various sources with progress tracking. @@ -23,6 +23,7 @@ from .log_system.log_funcs import ( log_exception, ) from .resolver import normalize_sha256 +from .path_utils import is_path_within, get_path_identity try: import folder_paths @@ -656,13 +657,10 @@ def get_download_directory(category: str, preferred_base_directory: str = "") -> folder_keys.append("clip") def _normalize(path_value: str) -> str: - return os.path.normcase(os.path.abspath(path_value)) + return get_path_identity(path_value) def _is_within(path_value: str, root_value: str) -> bool: - try: - return os.path.commonpath([_normalize(path_value), _normalize(root_value)]) == _normalize(root_value) - except Exception: - return False + return is_path_within(path_value, root_value) def _choose_preferred_path(paths: List[str], preferred_key: str = "") -> Optional[str]: if not paths: diff --git a/core/path_utils.py b/core/path_utils.py new file mode 100644 index 0000000..8add723 --- /dev/null +++ b/core/path_utils.py @@ -0,0 +1,116 @@ +""" +Path Utilities Module + +Provides helper functions for path normalization, symlink resolution, +and directory matching to be shared across all modules. +""" + +import os +from typing import Any + + +def get_path_identity(path: str) -> str: + """Return a stable path identity across symlinks/junctions.""" + if not path: + return "" + + try: + return os.path.normcase(os.path.realpath(os.path.abspath(path))) + except (OSError, ValueError): + return os.path.normcase(os.path.abspath(path)) + + +def get_path_abs(path_value: Any) -> str: + """Return absolute path safely, handling OSError/ValueError.""" + try: + return os.path.abspath(str(path_value or "")) + except (OSError, ValueError): + return str(path_value or "") + + +def get_path_key(path_value: Any) -> str: + """Return a normalized absolute path key for comparison.""" + if not path_value: + return "" + try: + return os.path.normcase(os.path.abspath(str(path_value))) + except (OSError, ValueError): + return os.path.normcase(str(path_value or "")) + + +def is_path_within(path_value: Any, root_value: Any) -> bool: + """Check if a path is located within a root directory.""" + if not path_value or not root_value: + return False + try: + path_key = get_path_key(path_value) + root_key = get_path_key(root_value) + return os.path.commonpath([path_key, root_key]) == root_key + except Exception: + return False + + +def prefer_local_base_directory( + candidate: str, + current: str, + preferred_directory: str = "", + comfy_root: str = "", +) -> bool: + """Determine if candidate directory should be preferred over current directory.""" + if not current: + return True + if not candidate: + return False + + candidate_key = get_path_key(candidate) + current_key = get_path_key(current) + preferred_key = get_path_key(preferred_directory) + if preferred_key: + if candidate_key == preferred_key and current_key != preferred_key: + return True + if current_key == preferred_key and candidate_key != preferred_key: + return False + + if comfy_root: + candidate_is_external = not is_path_within(candidate, comfy_root) + current_is_external = not is_path_within(current, comfy_root) + if candidate_is_external != current_is_external: + return candidate_is_external + + candidate_is_canonical = candidate_key == get_path_identity(candidate) + current_is_canonical = current_key == get_path_identity(current) + if candidate_is_canonical != current_is_canonical: + return candidate_is_canonical + + return False + + +def dedupe_local_base_directories( + paths: list, + preferred_directory: str = "", + comfy_root: str = "", +) -> list: + """Deduplicate a list of directory paths, resolving symlinks/junctions.""" + by_identity = {} + ordered_identities = [] + for path in paths or []: + if not path or not os.path.isdir(path): + continue + path_abs = get_path_abs(path) + path_identity = get_path_identity(path_abs) + if not path_identity: + continue + + current = by_identity.get(path_identity) + if not current: + by_identity[path_identity] = path_abs + ordered_identities.append(path_identity) + elif prefer_local_base_directory( + path_abs, + current, + preferred_directory, + comfy_root, + ): + by_identity[path_identity] = path_abs + + return [by_identity[key] for key in ordered_identities] diff --git a/core/resolver.py b/core/resolver.py index 3c33d8d..f4609b6 100644 --- a/core/resolver.py +++ b/core/resolver.py @@ -1,4 +1,4 @@ -""" +""" Core Resolver Module Integrates all components to provide high-level API for model linking. @@ -38,15 +38,14 @@ URL_PATTERN = re.compile(r'(https?://(?:huggingface\.co|civitai\.com)[^\s"\'<>\) MODEL_EXTENSIONS = (".safetensors", ".ckpt", ".pt", ".pth", ".bin", ".onnx", ".gguf") -def get_path_identity(path: str) -> str: - """Return a stable path identity across symlinks/junctions.""" - if not path: - return "" - - try: - return os.path.normcase(os.path.realpath(os.path.abspath(path))) - except (OSError, ValueError): - return os.path.normcase(os.path.abspath(path)) +from .path_utils import ( + get_path_identity, + get_path_abs, + get_path_key, + is_path_within, + prefer_local_base_directory, + dedupe_local_base_directories, +) def strip_known_model_extension(filename: str) -> str: diff --git a/core/scanner.py b/core/scanner.py index dbd6418..0e262a4 100644 --- a/core/scanner.py +++ b/core/scanner.py @@ -1,4 +1,4 @@ -""" +""" Directory Scanner Module Scans configured model directories and finds available model files. @@ -16,6 +16,8 @@ from .log_system.log_funcs import ( log_exception, ) +from .path_utils import get_path_identity + # Import folder_paths lazily - it may not be available until ComfyUI is initialized try: import folder_paths @@ -45,7 +47,7 @@ _MODEL_FILES_CACHE_TTL_SECONDS = 2.0 def _path_identity(path: str) -> str: """Return a stable identity for dedupe across symlinks/junctions.""" - return os.path.normcase(os.path.realpath(os.path.abspath(path))) + return get_path_identity(path) def _directory_identity(path: str) -> str: diff --git a/web/resolver/views/missing_browser_methods.js b/web/resolver/views/missing_browser_methods.js index 06f91ef..0cf8d23 100644 --- a/web/resolver/views/missing_browser_methods.js +++ b/web/resolver/views/missing_browser_methods.js @@ -733,6 +733,7 @@ export const missingBrowserMethods = { const comboRefresh = container.querySelector(`#combo-refresh-${comboId}`); const getAllModels = () => Array.isArray(this.allModels) ? this.allModels : []; + let localModelLoadToken = 0; const normalizeModelPath = (value = '') => String(value || '') .replace(/\//g, '\\') .split('\\') @@ -1003,6 +1004,15 @@ export const missingBrowserMethods = { .join(''); return `${renderModelRows(node.models || [], selectedIdentity)}${folderHtml}`; }; + const renderLocalModelLoadingState = (message = 'Loading local models...') => { + if (!comboList) return; + comboList.innerHTML = ` +
+
${this.escapeHtml(message)}
+
+ `; + positionComboList(); + }; const populateComboOptions = (filterText, options = {}) => { if (!comboList) return; const previousScrollEl = comboList.querySelector('.mr-local-model-browser-scroll'); @@ -1126,19 +1136,35 @@ export const missingBrowserMethods = { } positionComboList(); }; + const ensureLocalModelsThenPopulate = async (filterText = '', options = {}) => { + if (Array.isArray(this.allModels) && this.allModels.length > 0) { + populateComboOptions(filterText, options); + return; + } + + const token = ++localModelLoadToken; + renderLocalModelLoadingState(); + try { + await this.ensureAllModelsLoaded?.(); + } catch (error) { + console.warn('Model Resolver: could not load local models for picker', error); + } + if (token !== localModelLoadToken) return; + populateComboOptions(filterText, options); + }; if (comboList) { - populateComboOptions(''); + ensureLocalModelsThenPopulate(''); } if (comboInput) { const debouncedFilter = this.debounce(() => { - populateComboOptions(comboInput.value); + ensureLocalModelsThenPopulate(comboInput.value); }, 200); comboInput.addEventListener('input', debouncedFilter); comboInput.addEventListener('focus', () => { showComboList(); - populateComboOptions(comboInput.value); + ensureLocalModelsThenPopulate(comboInput.value); }); } @@ -1152,7 +1178,7 @@ export const missingBrowserMethods = { return; } lastPriorityCategory = nextPriorityCategory; - populateComboOptions(comboInput?.value || '', { preserveScroll: true }); + ensureLocalModelsThenPopulate(comboInput?.value || '', { preserveScroll: true }); }; categoryPriorityEl.addEventListener('input', refreshLocalModelPriority); categoryPriorityEl.addEventListener('change', refreshLocalModelPriority);