diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index adc11c8..b2f9c9d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -70,7 +70,8 @@ covered by the `styles.css` manifest. ### Backend - `api/config.py` owns configured paths and active model-folder types. -- `api/path_utils.py` owns containment, filename validation, and atomic JSON writes; +- `api/path_utils.py` owns containment, filename validation, atomic JSON writes, and resolving + a saved model value inside the model folders; `api/utils.py` is a compatibility export surface. - `api/metadata.py` owns sidecar and safetensors metadata extraction. - `api/model_catalog.py`, `api/model_resolution.py`, `api/model_metadata.py`, and diff --git a/CHANGELOG.md b/CHANGELOG.md index f261633..cfb9c03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,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 9; file actions only work on the computer running ComfyUI. ### Fixes +- **Saving a workflow works again (保存工作流失败)**: in v1.57 to v1.57.2, **Save Current Workflow** failed with "Could not save this Workflow Recipe" for any workflow with a model loader, because splitting the backend into smaller files left the save path calling a function that was no longer imported. The same split also made model cards load full-size cover images instead of thumbnails (slow on big folders), and made editing details of a model that had moved fail. - All-in-one loader recipes and cross-node prompt injection use one verified adapter table; prompt text never crosses between positive and negative. - The Workflows, Prompt Notes and Material Library workspace no longer covers the header: the Models / Gallery / Workflows tabs stay clickable while it is open, and **Esc** closes it (back to what was open before) unless a dialog is above it or a text field has focus. Closing the Prompt Translator brings back the browser it folded away. diff --git a/api/media_routes.py b/api/media_routes.py index c6896dd..77f7944 100644 --- a/api/media_routes.py +++ b/api/media_routes.py @@ -5,7 +5,9 @@ import hashlib import json import os import struct +import tempfile import threading +import time import urllib.parse from aiohttp import web diff --git a/api/model_metadata.py b/api/model_metadata.py index edcb4a0..0cf3ff9 100644 --- a/api/model_metadata.py +++ b/api/model_metadata.py @@ -14,6 +14,7 @@ from .model_constants import ( CIVITAI_BACKUP_SUFFIXES, MEDIA_EXTENSIONS, MODEL_EXTENSIONS, PREVIEW_SUFFIXES, SIDECAR_SUFFIXES, ) +from .model_catalog import _resolve_paths_to_model_info_sync from .utils import require_filename, resolve_folder_subdir, resolve_within def _first_existing_sidecar(base_path, suffixes): diff --git a/api/path_utils.py b/api/path_utils.py index d6bc0f2..b2f931b 100644 --- a/api/path_utils.py +++ b/api/path_utils.py @@ -6,6 +6,8 @@ import tempfile import folder_paths +from .recipe_constants import MODEL_FILE_SUFFIXES + def resolve_within(base_dir, *parts): """Resolve a path and require it to stay inside base_dir, including through symlinks.""" @@ -19,6 +21,39 @@ def resolve_within(base_dir, *parts): return candidate +def _model_roots(): + for folder_type in getattr(folder_paths, "folder_names_and_paths", {}): + try: + paths = folder_paths.get_folder_paths(folder_type) + except Exception: + continue + for path_index, base_dir in enumerate(paths or []): + if os.path.isdir(base_dir): + yield folder_type, path_index, os.path.realpath(base_dir) + + +def _resolve_exact_model_reference(saved_value): + """Resolve one saved model value without recursive scanning or hashing.""" + if not isinstance(saved_value, str) or not saved_value.strip(): + return None + relative_value = saved_value.replace("/", os.sep) + for folder_type, path_index, base_dir in _model_roots(): + candidate = os.path.realpath(os.path.join(base_dir, relative_value)) + try: + if os.path.commonpath((base_dir, candidate)) != base_dir: + continue + except ValueError: + continue + if not os.path.isfile(candidate) or not candidate.lower().endswith(MODEL_FILE_SUFFIXES): + continue + return { + "path": candidate, + "folder_type": folder_type, + "path_index": path_index, + } + return None + + def get_folder_root(folder_type, path_idx=0): paths = folder_paths.get_folder_paths(folder_type) if not paths or not isinstance(path_idx, int) or path_idx < 0 or path_idx >= len(paths): diff --git a/api/recipe_images.py b/api/recipe_images.py index 82920e8..a58d90d 100644 --- a/api/recipe_images.py +++ b/api/recipe_images.py @@ -1,6 +1,7 @@ """Contained Recipe image assets and bounded output-gallery inspection.""" from io import BytesIO +import hashlib import heapq import json import os @@ -8,6 +9,7 @@ import os import folder_paths from PIL import Image +from .path_utils import _resolve_exact_model_reference from .recipe_constants import * from .utils import require_filename, resolve_within from .workflow_schema import _node_type, _parameter_signature, _workflow_node_signature diff --git a/api/recipe_schema.py b/api/recipe_schema.py index e2db190..51ec55a 100644 --- a/api/recipe_schema.py +++ b/api/recipe_schema.py @@ -10,6 +10,7 @@ import folder_paths from .metadata import get_metadata from .recipe_constants import * +from .path_utils import _model_roots, _resolve_exact_model_reference from .recipe_images import _attach_preview_snapshots from .utils import require_filename, resolve_within from .workflow_schema import ( @@ -59,39 +60,6 @@ def _model_reference_specs(node): return specs -def _model_roots(): - for folder_type in getattr(folder_paths, "folder_names_and_paths", {}): - try: - paths = folder_paths.get_folder_paths(folder_type) - except Exception: - continue - for path_index, base_dir in enumerate(paths or []): - if os.path.isdir(base_dir): - yield folder_type, path_index, os.path.realpath(base_dir) - - -def _resolve_exact_model_reference(saved_value): - """Resolve one saved model value without recursive scanning or hashing.""" - if not isinstance(saved_value, str) or not saved_value.strip(): - return None - relative_value = saved_value.replace("/", os.sep) - for folder_type, path_index, base_dir in _model_roots(): - candidate = os.path.realpath(os.path.join(base_dir, relative_value)) - try: - if os.path.commonpath((base_dir, candidate)) != base_dir: - continue - except ValueError: - continue - if not os.path.isfile(candidate) or not candidate.lower().endswith(MODEL_FILE_SUFFIXES): - continue - return { - "path": candidate, - "folder_type": folder_type, - "path_index": path_index, - } - return None - - def _identity_for_reference(saved_value): resolved = _resolve_exact_model_reference(saved_value) if not resolved: