refactor: centralize path utilities in core/path_utils
Extract repeated path normalization and identity logic from __init__.py, core/resolver.py, core/downloader.py, and core/scanner.py into a shared core/path_utils module. Also add a loading state to the local model picker so it waits for models to load before rendering options.
This commit is contained in:
+10
-112
@@ -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()
|
||||
|
||||
+4
-6
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
+9
-10
@@ -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:
|
||||
|
||||
+4
-2
@@ -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:
|
||||
|
||||
@@ -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 = `
|
||||
<div class="mr-local-model-browser-scroll">
|
||||
<div class="mr-folder-browser-empty">${this.escapeHtml(message)}</div>
|
||||
</div>
|
||||
`;
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user