diff --git a/CHANGELOG.md b/CHANGELOG.md index 82cd891..1d6362e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,7 @@ - **GPT-SoVITS setup and import (GPT-SoVITS 准备与导入)**: an empty studio is a drop area with **Import character**, which also sits in the toolbar. **GPT-SoVITS settings** at the bottom of the sidebar opens a dialog with where characters are kept, pretrained files and missing Python packages; missing pretrained files only get a small dot on that entry, with "don't remind me", once your characters need them, and the import form notes what the chosen language will download. **Batch import (批量导入)**: the import window takes one or many characters. It first asks what you have (one character, many characters or a package, or files for a character you already have), with three steps at the top and a spotlight tour the first time ("?" replays it). Each character card leads with a checklist that lights up the next step (GPT model, SoVITS model, clips, main voice, name) and offers its button right there; the card says how many steps are left. Drop files or whole folders, or pick a folder on this PC (a GPT-SoVITS package): a folder holding one set of GPT + SoVITS weights and its clips is one character named after the folder (two characters whose weights are both called `model` stay apart; `派蒙/v2` becomes "派蒙 v2"); weights in a package's weight folders pair up by name (latest epoch kept); clips and line files follow their character folder, the weights in their folder or the character their folder or file name points at; an annotation file (`.list`) no character claims goes to every character; two clips with the same name (`开心/01.wav`, `难过/01.wav`) both go in, the second as `难过_01.wav`; anything unclear waits in a card of its own, one line per folder that can be assigned at once, and is uploaded only once it is put into a character. Clips outside 3–10 s are left out with their line files. Python environments and base models (`runtime`, `pretrained_models`, `venv`…) are skipped, and a package's program and training folders (`GPT_SoVITS`, `logs`, `output`, `TEMP`, `tools`) only inside a package: your own folders called `output` or `GPT_SoVITS`, and the folder you chose or dropped, are always read; the window says which folders were skipped or too deep (6 levels) instead of files going missing quietly. A big add opens 20 characters (about 800 files) at a time; **Next batch** opens the rest, and it opens by itself once a batch is imported, so a huge folder does not freeze the window. Every clip has **▶** to listen while importing — uploading, unassigned or picked on this PC — and clicking another clip switches to it for quick comparing. Long clip lists show the first 60, and a very large upload first suggests picking the folder on this PC instead; uploads run three at a time. Each character is a card: one line with its name, a short status and what it holds, unfolding into its model tiles and clips; files move between characters with the picker on each row. **Import N characters** imports each ready one on its own, so one failure leaves the others alone. Status is colour-coded throughout: green ready, amber still missing something, red a failed or refused file, blue uploading; **Import** stays at the bottom. Adding to a character skips files it already has and merges annotation files (Anomalous_TTS interface 5). There is one storage place (default `models/gpt_sovits`); changing it can move the existing characters there with progress. Download pretrained files with progress or use an existing GPT-SoVITS package, all without editing `extra_model_paths.yaml` or restarting. **Import character** builds a character from dropped or picked GPT / SoVITS weights, reference clips and annotation files: the name, SoVITS version, clip lengths and lines are filled in automatically, and everything is copied (originals stay). Drop files on a card, or use **Add files**, to add clips and emotions to an existing character. Lines are found in same-name `.txt` / `.lab` files, annotation files or, as a marked guess, the file name; a text file dropped on a clip's line box fills it. Needs Anomalous_TTS with interface version 11; file actions only work on the computer running ComfyUI. ### Fixes +- **GGUF and .pth models show up (GGUF、.pth 模型能看到了)**: the Models page, the folder list's counts, search and model lookups only knew `.safetensors`, `.ckpt` and `.pt`, in lower case, so quantized GGUF models (Wan, Flux, Qwen…), most upscalers (`.pth`), `.sft` and `.bin` files and names like `MODEL.SAFETENSORS` were missing. They are all listed now. Scans still read only `.safetensors`; the others stay under **Not scanned yet**. - **Language follows ComfyUI again (语言跟随 ComfyUI)**: with the language left on "Follow ComfyUI" (the default), changing ComfyUI's language now switches the browser the next time it opens; before, it kept the old language until the page was reloaded. - **Empty list on the audio pages (音频页左边列表是空的)**: after reloading ComfyUI, a browser that reopened straight onto Voices or Audio showed an empty list column until you switched to the image side and back. - The Material Library's top bar no longer cuts off **Prompt Studio**, **Prompt Notes** and refresh when the window is narrow (docked to the side, or a scaled display): the buttons move to a second row. diff --git a/api/model_catalog.py b/api/model_catalog.py index aeb815a..1fb5364 100644 --- a/api/model_catalog.py +++ b/api/model_catalog.py @@ -9,7 +9,7 @@ from aiohttp import web import folder_paths from .metadata import get_metadata -from .model_constants import MEDIA_EXTENSIONS, MODEL_EXTENSIONS, PREVIEW_SUFFIXES +from .model_constants import MEDIA_EXTENSIONS, PREVIEW_SUFFIXES, is_model_file from .model_media import _cache_token, _preview_url_for_model from .folder_types import ( get_active_folder_types, get_active_model_roots, @@ -32,7 +32,7 @@ def _collect_folder_models(target_dir, folder_type, path_idx, rel_subfolder, pag return {"models": [], "total": 0, "page": page, "limit": limit} valid_files = sorted( - (name for name in file_entries if name.endswith(('.safetensors', '.ckpt', '.pt'))), + (name for name in file_entries if is_model_file(name)), key=str.lower, ) total = len(valid_files) @@ -123,7 +123,7 @@ def _collect_folders(): base_dir = item["base_dir"] tree = {} for root, dirs, files in os.walk(base_dir): - has_models = any(f.endswith(('.safetensors', '.ckpt', '.pt')) for f in files) + has_models = any(is_model_file(f) for f in files) rel = os.path.relpath(root, base_dir) if rel == '.': rel = '/' @@ -133,7 +133,7 @@ def _collect_folders(): "path": rel, "name": os.path.basename(root) if rel != '/' else '[Root]', "has_models": has_models, - "model_count": sum(1 for f in files if f.endswith(('.safetensors', '.ckpt', '.pt'))) + "model_count": sum(1 for f in files if is_model_file(f)) } label = item["bn"] @@ -166,7 +166,7 @@ def _collect_folders(): tree = {} for root, dirs, files in os.walk(base_dir): - has_models = any(f.endswith(('.safetensors', '.ckpt', '.pt')) for f in files) + has_models = any(is_model_file(f) for f in files) rel = os.path.relpath(root, base_dir) if rel == '.': rel = '/' @@ -176,7 +176,7 @@ def _collect_folders(): "path": rel, "name": os.path.basename(root) if rel != '/' else '[Root]', "has_models": has_models, - "model_count": sum(1 for f in files if f.endswith(('.safetensors', '.ckpt', '.pt'))) + "model_count": sum(1 for f in files if is_model_file(f)) } try: @@ -253,7 +253,7 @@ def _iter_search_models(): continue for root, _, files in os.walk(base_dir): for filename in files: - if filename.lower().endswith(MODEL_EXTENSIONS + ('.sft',)): + if is_model_file(filename): yield folder_type, path_idx, base_dir, os.path.join(root, filename) @@ -310,7 +310,7 @@ def _walk_model_files(target_types): continue for root, _, files in os.walk(base_dir): for filename in files: - if not filename.lower().endswith(MODEL_EXTENSIONS): + if not is_model_file(filename): continue file_path = os.path.join(root, filename) real_path = os.path.realpath(file_path) @@ -455,7 +455,7 @@ def _resolve_paths_to_model_info_sync(paths, folder_types=None, exact_only=False continue except ValueError: continue - if os.path.isfile(candidate) and candidate.lower().endswith(('.safetensors', '.ckpt', '.pt', '.bin', '.sft')): + if os.path.isfile(candidate) and is_model_file(candidate): exact_results[original] = _model_info_for_path(folder_type, path_idx, base_dir, candidate) unresolved = [(original, normalized) for original, normalized, _ in requested if original not in exact_results] @@ -469,7 +469,7 @@ def _resolve_paths_to_model_info_sync(paths, folder_types=None, exact_only=False for folder_type, path_idx, base_dir in roots: for root, _, files in os.walk(base_dir): for filename in files: - if not filename.lower().endswith(('.safetensors', '.ckpt', '.pt', '.bin', '.sft')): + if not is_model_file(filename): continue rel_path = os.path.relpath(os.path.join(root, filename), base_dir).replace(os.sep, '/').lower() basename = filename.lower() @@ -542,7 +542,6 @@ async def api_resolve_paths_to_previews(request): def _collect_all_scan_models(page, limit): - source_library_extensions = {'.safetensors', '.ckpt', '.pt', '.pth', '.bin', '.sft', '.gguf'} all_tuples = [] for model_root in get_active_model_roots(): t = model_root["type"] @@ -550,7 +549,7 @@ def _collect_all_scan_models(page, limit): base_dir = model_root["base_dir"] for root, dirs, files in os.walk(base_dir): for f in files: - if os.path.splitext(f)[1].lower() in source_library_extensions: + if is_model_file(f): all_tuples.append((t, path_idx, root, base_dir, f)) all_tuples.sort(key=lambda x: (x[0], x[4].lower())) @@ -651,7 +650,7 @@ async def api_batch_select(request): if not os.path.exists(base_dir): continue for root, dirs, files in os.walk(base_dir): for f in files: - if f.endswith(('.safetensors', '.ckpt', '.pt', '.bin', '.sft')): + if is_model_file(f): file_path = os.path.join(root, f) base_name = os.path.splitext(f)[0] if matches_condition(file_path, root, base_name): @@ -681,7 +680,7 @@ async def api_batch_select(request): try: entries = os.listdir(target_dir) except: entries = [] for f in entries: - if f.endswith(('.safetensors', '.ckpt', '.pt', '.bin', '.sft')): + if is_model_file(f): file_path = os.path.join(target_dir, f) if os.path.isfile(file_path): base_name = os.path.splitext(f)[0] diff --git a/api/model_constants.py b/api/model_constants.py index 6a910ef..6816d1f 100644 --- a/api/model_constants.py +++ b/api/model_constants.py @@ -1,7 +1,9 @@ """Shared model catalog, media, and recovery constants.""" MEDIA_EXTENSIONS = ('.png', '.jpg', '.jpeg', '.webp', '.gif', '.avif', '.mp4', '.webm', '.mov', '.avi') -MODEL_EXTENSIONS = ('.safetensors', '.ckpt', '.pt', '.bin') +# Model files the browser lists, counts and looks up: GGUF and .pth too (quantized +# diffusion models, upscalers). Compared without case: see is_model_file(). +MODEL_EXTENSIONS = ('.safetensors', '.ckpt', '.pt', '.pth', '.bin', '.sft', '.gguf') PREVIEW_SUFFIXES = tuple(f'.preview{ext}' for ext in MEDIA_EXTENSIONS) CIVITAI_BACKUP_SUFFIXES = tuple(f'.civitai_bak{ext}' for ext in MEDIA_EXTENSIONS) SIDECAR_SUFFIXES = ( @@ -14,3 +16,8 @@ RESOLVABLE_MODEL_TYPES = ( 'checkpoints', 'loras', 'unet', 'diffusion_models', 'controlnet', 'vae', 'vae_approx', 'clip', 'text_encoders', 'clip_vision', ) + + +def is_model_file(name): + """Whether a file name is a model file (MODEL_EXTENSIONS, any case).""" + return name.lower().endswith(MODEL_EXTENSIONS) diff --git a/docs/architecture/backend.md b/docs/architecture/backend.md index 6dbea6b..4147bc2 100644 --- a/docs/architecture/backend.md +++ b/docs/architecture/backend.md @@ -199,7 +199,9 @@ media. - Foundation components—`vae`, `vae_approx`, `clip`, `text_encoders`, and `clip_vision`—are never physical-rename targets. UI and all backend entry points enforce the same denial. -- Main model extensions (`.safetensors`, `.ckpt`, `.pt`, `.bin`) are never +- Model files are `MODEL_EXTENSIONS` in `api/model_constants.py` (`.safetensors`, + `.ckpt`, `.pt`, `.pth`, `.bin`, `.sft`, `.gguf`); listing, counting, search and + lookups test names with `is_model_file()`, without case. They are never sidecar suffixes. Cleanup must not delete a same-stem model with another extension. If such a sibling remains, ambiguous stem-keyed sidecars remain. - Rename/delete/reset uses centralized immutable suffix tuples and a constant diff --git a/docs/plans/model-source-links.md b/docs/plans/model-source-links.md index 0bd495a..e2c110a 100644 --- a/docs/plans/model-source-links.md +++ b/docs/plans/model-source-links.md @@ -13,7 +13,7 @@ - 中控 `fetchAllLibraryModels()` 调用 `/anomalous/all_scan_models`,后端 `_collect_all_scan_models()` 只读分类模式的 `get_active_folder_types()`,没有遵循当前物理目录模式。 - 本次本地配置中,物理目录模式的 `clip` 已开启,但分类配置的 `text_encoders` 关闭。这能解释本地库遗漏 CLIP;不能因此认定所有缺项都只有这一种原因。 -- 此接口只枚举 `.safetensors/.ckpt/.pt/.bin/.sft`,遗漏 `.gguf/.pth`,且扩展名比较区分大小写。 +- 此接口只枚举 `.safetensors/.ckpt/.pt/.bin/.sft`,遗漏 `.gguf/.pth`,且扩展名比较区分大小写。(已修:统一用 `is_model_file()`。) - 上轮提交只补了工作流收集、基础组件标签与对应类别的元数据读取,没有修复本地目录范围。 ### 一、目录范围与文件格式