From 85f79283b66cb91669ae3749a0ca8c717ce57f37 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Mon, 12 Oct 2026 11:35:33 +0800 Subject: [PATCH] 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 ("/") 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. --- py/services/model_file_service.py | 21 +++++++++++++-------- py/services/model_scanner.py | 26 +++++++++++++++++++++----- py/utils/sidecar_paths.py | 17 +++++++++++++++-- py/utils/utils.py | 6 ++++-- 4 files changed, 53 insertions(+), 17 deletions(-) diff --git a/py/services/model_file_service.py b/py/services/model_file_service.py index 8768dfeb..a0bf98aa 100644 --- a/py/services/model_file_service.py +++ b/py/services/model_file_service.py @@ -1,7 +1,8 @@ import asyncio import fnmatch -import os import logging +import os +import posixpath import shutil from typing import Any, Dict, List, Optional, Sequence, Set 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] == ":"): raise ValueError("Folder path must be relative to a library root") - normalized = os.path.normpath(normalized) - if ( - normalized == ".." - or normalized.startswith("../") - or normalized.startswith(".." + os.sep) - ): + # posixpath, not os.path: ntpath.normpath would fold the business form back + # to backslashes, and every stored folder, cache entry and comparison in the + # app is forward-slashed. + normalized = posixpath.normpath(normalized) + if normalized == ".." or normalized.startswith("../"): raise ValueError("Folder path must stay inside the library root") return normalized @@ -1062,7 +1062,12 @@ class ModelMoveService: new_relative = self._calculate_relative_folder(target) 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 { diff --git a/py/services/model_scanner.py b/py/services/model_scanner.py index 14a3e7ef..00d4f533 100644 --- a/py/services/model_scanner.py +++ b/py/services/model_scanner.py @@ -3094,12 +3094,28 @@ class ModelScanner: return await MetadataManager.create_default_metadata(file_path, self.model_class) 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(): - if file_path.startswith(root): - rel_path = os.path.relpath(file_path, root) - return os.path.dirname(rel_path).replace(os.path.sep, '/') - return '' + identity = _root_identity(root) + if not identity: + continue + 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): """Hook for subclasses: adjust metadata during scanning""" diff --git a/py/utils/sidecar_paths.py b/py/utils/sidecar_paths.py index 46c46ea3..c2310e1a 100644 --- a/py/utils/sidecar_paths.py +++ b/py/utils/sidecar_paths.py @@ -480,6 +480,19 @@ def _legacy_component(root_path: str) -> str: 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 + ``/`` -- 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]: """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: 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: return 0 root_names = _subdir_names(root) @@ -958,7 +971,7 @@ def resolve_centralized_dir_for_dir( if not component: return None - parts = [root, component] + parts = [_join_component(root, component)] if rel_dir and rel_dir != os.curdir: parts.extend( sanitize_path_component(part) diff --git a/py/utils/utils.py b/py/utils/utils.py index 60c0fd66..f144b245 100644 --- a/py/utils/utils.py +++ b/py/utils/utils.py @@ -303,8 +303,10 @@ def _format_model_name_for_comfyui(file_path: str, model_roots: list[str]) -> st norm_root += os.sep if norm_file.startswith(norm_root): - # Use os.path.relpath to get relative path with OS-native separator - return os.path.relpath(file_path, root) + # ComfyUI names a model with forward slashes on every platform + # ("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): continue