fix(paths): deduplicate LoRA path overlap (#871)

This commit is contained in:
Will Miao
2026-03-27 17:27:11 +08:00
parent a5191414cc
commit 76ee59cdb9
6 changed files with 542 additions and 5 deletions

View File

@@ -705,6 +705,120 @@ class Config:
return unique_paths
@staticmethod
def _normalize_path_for_comparison(path: str, *, resolve_realpath: bool = False) -> str:
"""Normalize a path for equality checks across platforms."""
candidate = os.path.realpath(path) if resolve_realpath else path
return os.path.normcase(os.path.normpath(candidate)).replace(os.sep, "/")
def _filter_overlapping_extra_lora_paths(
self,
primary_paths: Iterable[str],
extra_paths: Iterable[str],
) -> List[str]:
"""Drop extra LoRA paths that resolve to the same physical location as primary roots."""
primary_map = {
self._normalize_path_for_comparison(path, resolve_realpath=True): path
for path in primary_paths
if isinstance(path, str) and path.strip() and os.path.exists(path)
}
primary_symlink_map = self._collect_first_level_symlink_targets(primary_paths)
filtered: List[str] = []
for original_path in extra_paths:
if not isinstance(original_path, str):
continue
stripped = original_path.strip()
if not stripped:
continue
if not os.path.exists(stripped):
continue
real_path = self._normalize_path_for_comparison(
stripped,
resolve_realpath=True,
)
normalized_path = os.path.normpath(stripped).replace(os.sep, "/")
primary_path = primary_map.get(real_path)
if primary_path:
# Config loading should stay tolerant of existing invalid state and warn.
logger.warning(
"Detected the same LoRA folder in both ComfyUI model paths and "
"LoRA Manager Extra Folder Paths. This can cause duplicate items or "
"other unexpected behavior, and it usually means the path setup is "
"not doing what you intended. LoRA Manager will keep the ComfyUI "
"path and ignore this Extra Folder Paths entry: '%s'. Please review "
"your path settings and remove the duplicate entry.",
normalized_path,
)
continue
symlink_path = primary_symlink_map.get(real_path)
if symlink_path:
# Config loading should stay tolerant of existing invalid state and warn.
logger.warning(
"Detected the same LoRA folder in both ComfyUI model paths and "
"LoRA Manager Extra Folder Paths. This can cause duplicate items or "
"other unexpected behavior, and it usually means the path setup is "
"not doing what you intended. LoRA Manager will keep the ComfyUI "
"path and ignore this Extra Folder Paths entry: '%s'. Please review "
"your path settings and remove the duplicate entry.",
normalized_path,
)
continue
filtered.append(stripped)
return filtered
def _collect_first_level_symlink_targets(
self, roots: Iterable[str]
) -> Dict[str, str]:
"""Return real-path -> link-path mappings for first-level symlinks under the given roots."""
targets: Dict[str, str] = {}
for root in roots:
if not isinstance(root, str):
continue
stripped_root = root.strip()
if not stripped_root or not os.path.isdir(stripped_root):
continue
try:
with os.scandir(stripped_root) as iterator:
for entry in iterator:
try:
if not self._entry_is_symlink(entry):
continue
target_path = os.path.realpath(entry.path)
if not os.path.isdir(target_path):
continue
normalized_target = self._normalize_path_for_comparison(
target_path,
resolve_realpath=True,
)
normalized_link = os.path.normpath(entry.path).replace(
os.sep, "/"
)
targets.setdefault(normalized_target, normalized_link)
except Exception as inner_exc:
logger.debug(
"Error collecting LoRA symlink target for %s: %s",
entry.path,
inner_exc,
)
except Exception as exc:
logger.debug(
"Error scanning first-level LoRA symlinks in %s: %s",
stripped_root,
exc,
)
return targets
def _prepare_checkpoint_paths(
self, checkpoint_paths: Iterable[str], unet_paths: Iterable[str]
) -> Tuple[List[str], List[str], List[str]]:
@@ -796,7 +910,11 @@ class Config:
extra_unet_paths = extra_paths.get("unet", []) or []
extra_embedding_paths = extra_paths.get("embeddings", []) or []
self.extra_loras_roots = self._prepare_lora_paths(extra_lora_paths)
filtered_extra_lora_paths = self._filter_overlapping_extra_lora_paths(
self.loras_roots,
extra_lora_paths,
)
self.extra_loras_roots = self._prepare_lora_paths(filtered_extra_lora_paths)
(
_,
self.extra_checkpoints_roots,

View File

@@ -732,18 +732,23 @@ class ModelScanner:
# Get current cached file paths
cached_paths = {item['file_path'] for item in self._cache.raw_data}
path_to_item = {item['file_path']: item for item in self._cache.raw_data}
cached_real_paths = {}
for cached_path in cached_paths:
try:
cached_real_paths.setdefault(os.path.realpath(cached_path), cached_path)
except Exception:
continue
# Track found files and new files
found_paths = set()
new_files = []
visited_real_paths = set()
discovered_real_files = set()
# Scan all model roots
for root_path in self.get_model_roots():
if not os.path.exists(root_path):
continue
# Track visited real paths to avoid symlink loops
visited_real_paths = set()
# Recursively scan directory
for root, _, files in os.walk(root_path, followlinks=True):
@@ -757,12 +762,18 @@ class ModelScanner:
if ext in self.file_extensions:
# Construct paths exactly as they would be in cache
file_path = os.path.join(root, file).replace(os.sep, '/')
real_file_path = os.path.realpath(os.path.join(root, file))
# Check if this file is already in cache
if file_path in cached_paths:
found_paths.add(file_path)
continue
cached_real_match = cached_real_paths.get(real_file_path)
if cached_real_match:
found_paths.add(cached_real_match)
continue
if file_path in self._excluded_models:
continue
@@ -778,6 +789,10 @@ class ModelScanner:
if matched:
continue
if real_file_path in discovered_real_files:
continue
discovered_real_files.add(real_file_path)
# This is a new file to process
new_files.append(file_path)
@@ -1099,6 +1114,8 @@ class ModelScanner:
tags_count: Dict[str, int] = {}
excluded_models: List[str] = []
processed_files = 0
processed_real_files: Set[str] = set()
visited_real_dirs: Set[str] = set()
async def handle_progress() -> None:
if progress_callback is None:
@@ -1115,9 +1132,10 @@ class ModelScanner:
try:
real_path = os.path.realpath(current_path)
if real_path in visited_paths:
if real_path in visited_paths or real_path in visited_real_dirs:
return
visited_paths.add(real_path)
visited_real_dirs.add(real_path)
with os.scandir(current_path) as iterator:
entries = list(iterator)
@@ -1130,6 +1148,11 @@ class ModelScanner:
continue
file_path = entry.path.replace(os.sep, "/")
real_file_path = os.path.realpath(entry.path)
if real_file_path in processed_real_files:
continue
processed_real_files.add(real_file_path)
result = await self._process_model_file(
file_path,
root_path,

View File

@@ -1046,6 +1046,67 @@ class SettingsManager:
active_name = self.get_active_library_name()
self._validate_folder_paths(active_name, extra_folder_paths)
active_library = self.get_active_library()
active_folder_paths = active_library.get("folder_paths", {})
active_lora_paths = active_folder_paths.get("loras", []) or []
requested_extra_lora_paths = extra_folder_paths.get("loras", []) or []
primary_real_paths = set()
for path in active_lora_paths:
if not isinstance(path, str):
continue
stripped = path.strip()
if not stripped:
continue
normalized = os.path.normcase(os.path.normpath(stripped))
if os.path.exists(stripped):
normalized = os.path.normcase(os.path.normpath(os.path.realpath(stripped)))
primary_real_paths.add(normalized)
primary_symlink_targets = set()
for path in active_lora_paths:
if not isinstance(path, str):
continue
stripped = path.strip()
if not stripped or not os.path.isdir(stripped):
continue
try:
with os.scandir(stripped) as iterator:
for entry in iterator:
try:
if not entry.is_symlink():
continue
target_path = os.path.realpath(entry.path)
if not os.path.isdir(target_path):
continue
primary_symlink_targets.add(
os.path.normcase(os.path.normpath(target_path))
)
except Exception:
continue
except Exception:
continue
overlapping_paths = []
for path in requested_extra_lora_paths:
if not isinstance(path, str):
continue
stripped = path.strip()
if not stripped:
continue
normalized = os.path.normcase(os.path.normpath(stripped))
if os.path.exists(stripped):
normalized = os.path.normcase(os.path.normpath(os.path.realpath(stripped)))
if normalized in primary_real_paths or normalized in primary_symlink_targets:
overlapping_paths.append(stripped)
if overlapping_paths:
collisions = ", ".join(sorted(set(overlapping_paths)))
# Settings writes should reject new conflicting configuration instead of tolerating it.
raise ValueError(
f"Extra LoRA path(s) {collisions} overlap with the active library's primary LoRA roots"
)
normalized_paths = self._normalize_folder_paths(extra_folder_paths)
self.settings["extra_folder_paths"] = normalized_paths
self._update_active_library_entry(extra_folder_paths=normalized_paths)