fix(paths): keep business paths forward-slashed on Windows

Four places produced the host's separator (or a mixed path) where the app's
business form is forward-slashed, so on Windows the value could not be compared
with the one the rest of the app stores:

* `ModelScanner._calculate_folder` matched `file_path.startswith(root)` as raw
  strings, so every forward-slashed path resolved to the library root and a
  moved model was filed under the root instead of its folder. It now compares
  root identities and slices the relative part off.
* `normalize_relative_folder` ran `os.path.normpath`, and ntpath folds forward
  slashes back to backslashes: every folder parameter (scoped scan, create,
  rename, delete) came back host-spelled and mismatched the stored folders.
* `_format_model_name_for_comfyui` returned `os.path.relpath` unchanged, so the
  checkpoint/unet nodes and the metadata node emitted `unet\base.safetensors`
  on Windows -- a name no ComfyUI widget list matches.
* `resolve_centralized_dir_for_dir` passed a component that spans two
  directories ("<library>/<component>") whole to `os.path.join`, leaving a mixed
  path ("...\comfyui/loras-1a2b3c4d\sub") that no native path equals.

Also hands the rename re-key callback business paths, like every other absolute
path the move service returns.
This commit is contained in:
Will Miao
2026-10-12 11:35:33 +08:00
parent fd40cd952b
commit 85f79283b6
4 changed files with 53 additions and 17 deletions
+13 -8
View File
@@ -1,7 +1,8 @@
import asyncio import asyncio
import fnmatch import fnmatch
import os
import logging import logging
import os
import posixpath
import shutil import shutil
from typing import Any, Dict, List, Optional, Sequence, Set from typing import Any, Dict, List, Optional, Sequence, Set
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
@@ -36,12 +37,11 @@ def normalize_relative_folder(folder: str) -> str:
if normalized.startswith("/") or (len(normalized) > 1 and normalized[1] == ":"): if normalized.startswith("/") or (len(normalized) > 1 and normalized[1] == ":"):
raise ValueError("Folder path must be relative to a library root") raise ValueError("Folder path must be relative to a library root")
normalized = os.path.normpath(normalized) # posixpath, not os.path: ntpath.normpath would fold the business form back
if ( # to backslashes, and every stored folder, cache entry and comparison in the
normalized == ".." # app is forward-slashed.
or normalized.startswith("../") normalized = posixpath.normpath(normalized)
or normalized.startswith(".." + os.sep) if normalized == ".." or normalized.startswith("../"):
):
raise ValueError("Folder path must stay inside the library root") raise ValueError("Folder path must stay inside the library root")
return normalized return normalized
@@ -1062,7 +1062,12 @@ class ModelMoveService:
new_relative = self._calculate_relative_folder(target) new_relative = self._calculate_relative_folder(target)
await self._rename_folder_records( await self._rename_folder_records(
previous_relative, new_relative, absolute_path, target previous_relative,
new_relative,
# Business paths, like every other absolute path this service
# hands out: the scanner re-keys cache entries by prefix.
absolute_path.replace(os.sep, "/"),
target.replace(os.sep, "/"),
) )
return { return {
+21 -5
View File
@@ -3094,12 +3094,28 @@ class ModelScanner:
return await MetadataManager.create_default_metadata(file_path, self.model_class) return await MetadataManager.create_default_metadata(file_path, self.model_class)
def _calculate_folder(self, file_path: str) -> str: def _calculate_folder(self, file_path: str) -> str:
"""Calculate the folder path for a model file""" """Return the library-relative folder holding *file_path*.
The path arrives in whichever spelling its caller holds: ComfyUI's own
(backslashes on Windows) or the forward-slashed business form used for
cache entries. A raw ``startswith(root)`` matched neither the mixed nor
the opposite spelling, so every forward-slashed path on Windows resolved
to the root folder and a moved model was filed under the library root.
Compare identities instead, then slice the relative part off.
"""
candidate = _root_identity(file_path)
if not candidate:
return ""
for root in self.get_model_roots(): for root in self.get_model_roots():
if file_path.startswith(root): identity = _root_identity(root)
rel_path = os.path.relpath(file_path, root) if not identity:
return os.path.dirname(rel_path).replace(os.path.sep, '/') continue
return '' if candidate == identity:
return ""
if candidate.startswith(f"{identity}/"):
relative = candidate[len(identity) + 1:]
return relative.rsplit("/", 1)[0] if "/" in relative else ""
return ""
def adjust_metadata(self, metadata, file_path, root_path): def adjust_metadata(self, metadata, file_path, root_path):
"""Hook for subclasses: adjust metadata during scanning""" """Hook for subclasses: adjust metadata during scanning"""
+15 -2
View File
@@ -480,6 +480,19 @@ def _legacy_component(root_path: str) -> str:
return f"{sanitize_path_component(os.path.basename(normalized))}-{digest}" return f"{sanitize_path_component(os.path.basename(normalized))}-{digest}"
def _join_component(root: str, component: str) -> str:
"""Join a mirror component name onto *root* as a native path.
A component adopted from the pre-identity-map layout is reported as
``<library>/<component>`` -- one *name* spanning two directories. Handing it
whole to ``os.path.join`` left a mixed path on Windows
(``...\\comfyui/loras-1a2b3c4d\\sub``), which no natively built path
compares equal to, so it has to be split first.
"""
return os.path.join(root, *(part for part in component.split("/") if part))
def _list_component_dirs(sidecar_root: str) -> Set[str]: def _list_component_dirs(sidecar_root: str) -> Set[str]:
"""Return the mirror component directories present under ``sidecar_root``. """Return the mirror component directories present under ``sidecar_root``.
@@ -556,7 +569,7 @@ def _mirror_dir_overlap(sidecar_root: str, component: str, root: str) -> int:
if not component: if not component:
return 0 return 0
mirror_names = _subdir_names(os.path.join(sidecar_root, component)) mirror_names = _subdir_names(_join_component(sidecar_root, component))
if not mirror_names: if not mirror_names:
return 0 return 0
root_names = _subdir_names(root) root_names = _subdir_names(root)
@@ -958,7 +971,7 @@ def resolve_centralized_dir_for_dir(
if not component: if not component:
return None return None
parts = [root, component] parts = [_join_component(root, component)]
if rel_dir and rel_dir != os.curdir: if rel_dir and rel_dir != os.curdir:
parts.extend( parts.extend(
sanitize_path_component(part) sanitize_path_component(part)
+4 -2
View File
@@ -303,8 +303,10 @@ def _format_model_name_for_comfyui(file_path: str, model_roots: list[str]) -> st
norm_root += os.sep norm_root += os.sep
if norm_file.startswith(norm_root): if norm_file.startswith(norm_root):
# Use os.path.relpath to get relative path with OS-native separator # ComfyUI names a model with forward slashes on every platform
return os.path.relpath(file_path, root) # ("unet/base.safetensors"); os.path.relpath alone returns the
# host's separator, which no ComfyUI widget list matches on Windows.
return os.path.relpath(file_path, root).replace(os.sep, "/")
except (ValueError, TypeError): except (ValueError, TypeError):
continue continue