From 2e627039237f937d399155aa183740dfd6efe62e Mon Sep 17 00:00:00 2001 From: DemonGatanjieu <207458273+DemonGatanjieu@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:08:10 +0800 Subject: [PATCH] Update Anomalous_TTS from GPT-SoVITS Settings The version manager takes an optional Checkout, so the companion node pack is handled like the plugin itself: api/tts_version.py finds it among the custom_nodes folders by its node class and accepts only a git clone of the official repository; releases are fetched only on "Check for updates", an update checks out the newest stable tag and can be undone. A checkout without the release tags (cloned before it, then pulled) is matched by ls-remote. Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 8 ++ CHANGELOG.md | 3 + api/__init__.py | 3 +- api/local_guard.py | 1 + api/tts_version.py | 96 ++++++++++++++++++++ api/version_manager.py | 164 ++++++++++++++++++++-------------- tests/test_tts_version.py | 93 +++++++++++++++++++ web/modules/locales.js | 32 ++++++- web/modules/ui_tts_setup.js | 6 +- web/modules/ui_tts_version.js | 139 ++++++++++++++++++++++++++++ web/styles.css | 2 +- web/styles/12-tts-setup.css | 8 ++ 12 files changed, 485 insertions(+), 70 deletions(-) create mode 100644 api/tts_version.py create mode 100644 tests/test_tts_version.py create mode 100644 web/modules/ui_tts_version.js diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4cea9c4..9144b8e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -222,6 +222,14 @@ covered by the `styles.css` manifest. switch refuses to run over modified tracked files. `ui_version_manager.js` is the version line (shown in the update guide opened by the header "!" button) and panel; nothing goes online until "check for updates" is clicked, and restarts go through ComfyUI Manager's reboot route when present. + Every function takes an optional `Checkout` (another folder and its own state + file): `api/tts_version.py` uses it for the companion node pack Anomalous_TTS, + found among the custom_nodes folders by its node class and accepted only as a + git clone whose `origin` is the official repository (`/anomalous/tts_version`, + `/releases` local-only, `/update`, `/undo`; last switch in the ignored + `.anomalous_tts_version.local.json`). `ui_tts_version.js` is its group in the + GPT-SoVITS settings dialog: installed version, "Check for updates", update to + the newest stable release or back, then the restart question. - `web/main.js` coordinates extension registration (with `?v=...` versioned module imports busting aggressive browser ES Module caching and unconditional legacy storage key purging). `browser.js` owns the shared browser class and extracted-method wiring; `browser_entry.js` owns the single diff --git a/CHANGELOG.md b/CHANGELOG.md index 3542eba..001de1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ ### 🧩 Combos (搭配) - **A combo's models are cover cards**, as in the model library: the cover on top, the name below. In a combo saved from picked nodes, the slot (Main model, LoRA…) is written on the cover and a click on the card changes the model; in a default combo, the main models and LoRAs to pick from are cover cards side by side. They used to be rows as wide as the window, with **Change** far from the name (模型卡片不再横向拉满整个窗口). +### 🎙️ Character voices (角色语音) +- **Update Anomalous_TTS from GPT-SoVITS Settings (在 GPT-SoVITS 设置里更新 Anomalous_TTS)**: a new *Anomalous_TTS version* line shows the installed version; **Check for updates** looks up its published stable releases (only then does it go online), **Update to v…** switches to the newest one and offers to restart ComfyUI, and **Back to v…** undoes it. Anomalous_TTS now publishes numbered stable releases (the first is v1.0.0), so an update never lands on unfinished work. Only an Anomalous_TTS installed with git from its GitHub repository is changed (as ComfyUI Manager installs it), only from the computer ComfyUI runs on, and never over files you changed. When an import needs a newer Anomalous_TTS, the message points here. + ### ☁️ ComfyUI on a cloud server (云服务器上使用) - **How far it goes**: fully supported on overseas cloud servers (RunPod…). On servers in mainland China (AutoDL…) only in part: browsing, offline scans and tidying work, but Civitai is mostly out of reach there, so lookups are unreliable and covers rarely download (海外服务器完整支持;国内服务器只能部分支持). Not fully tested yet: one AutoDL server so far, no overseas one. The cloud guide ends with a section for AI assistants (what is going on, the steps, how to check, what to do when it did not work, what to tell the user about safety), so a user can hand the link to their AI. - On a rented cloud server (AutoDL, RunPod…) the page is always opened from another device, so since v1.58.1 the plugin only let it look: scans, downloads, moves and saves were refused. Now there are two ways: open ComfyUI through an **SSH tunnel** (the address starts with 127.0.0.1; it counts as the computer ComfyUI runs on, nothing to change), or make an empty file named **`allow_remote_changes`** in the plugin folder on the server and restart ComfyUI. With the file, every device that can open the page may change things; the page can neither make nor remove it, and the MCP endpoint stays for this computer only. How, and the risk: [docs/guides/cloud-server.md](docs/guides/cloud-server.md). diff --git a/api/__init__.py b/api/__init__.py index 8a9c75a..cacaf36 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -1,7 +1,7 @@ from . import ( activity_log, audio_catalog, folder_types, gallery_routes, materials, media_routes, model_catalog, model_folder, model_media, model_metadata, model_resolution, model_type_listing, kept_images, node_material, recipe_packages, recipes, scan_choice, scan_report, scan_summary, - local_guard, lora_info, mcp_server, model_download, model_import, model_placement, translation_routes, version_manager, backup, + local_guard, lora_info, mcp_server, model_download, model_import, model_placement, translation_routes, version_manager, tts_version, backup, network_check, model_heads, model_usage, usage_ledger, ) from .scanner import * @@ -135,6 +135,7 @@ def setup_routes(app): app.router.add_get('/anomalous/audio_previews', audio_catalog.api_get_audio_previews) app.router.add_post('/anomalous/save_audio_preview', audio_catalog.api_save_audio_preview) version_manager.register_routes(app) + tts_version.register_routes(app) activity_log.register_routes(app) mcp_server.register_routes(app) model_download.register_routes(app) diff --git a/api/local_guard.py b/api/local_guard.py index 6c70e85..d09dd7f 100644 --- a/api/local_guard.py +++ b/api/local_guard.py @@ -33,6 +33,7 @@ OPEN_POSTS = frozenset({ LOCAL_GETS = frozenset({ "/anomalous/backup/file", "/anomalous/version/releases", + "/anomalous/tts_version/releases", "/anomalous/import/folders", "/anomalous/civitai_check", "/anomalous/usage/check", # reads every output and saved workflow diff --git a/api/tts_version.py b/api/tts_version.py new file mode 100644 index 0000000..9d6ce37 --- /dev/null +++ b/api/tts_version.py @@ -0,0 +1,96 @@ +"""Version and updates of the companion node pack Anomalous_TTS (GPT-SoVITS Settings). + +Only a git checkout of the official repository is touched, found among the +custom_nodes folders by its node class and checked by its `origin` remote. The +rules are those of the plugin's own version panel (api/version_manager.py): +published releases are fetched only when asked, a switch checks out a +published release tag, refuses over modified tracked files and can be undone. +""" + +import asyncio +import os + +from aiohttp import web + +import folder_paths + +from . import version_manager as vm + +OFFICIAL = ("demongatanjieu", "anomalous_tts") +NODE_CLASS = "AnomalousTTS_CharacterSpeech" +STATE_FILE = os.path.join(vm.PLUGIN_DIR, ".anomalous_tts_version.local.json") +READ_LIMIT = 200_000 + + +def _node_folders(): + """custom_nodes folders whose nodes.py defines the Anomalous_TTS node (disabled ones left out).""" + for base in folder_paths.get_folder_paths("custom_nodes"): + try: + names = sorted(os.listdir(base)) + except OSError: + continue + for name in names: + folder = os.path.join(base, name) + if name.endswith(".disabled") or not os.path.exists(os.path.join(folder, ".git")): + continue + try: + with open(os.path.join(folder, "nodes.py"), encoding="utf-8", errors="replace") as handle: + if NODE_CLASS in handle.read(READ_LIMIT): + yield folder + except OSError: + continue + + +def find_checkout(): + """The installed Anomalous_TTS as a version_manager.Checkout, or None (not installed, not a git + clone, or cloned from somewhere else).""" + for folder in _node_folders(): + repo = vm.Checkout(folder, STATE_FILE) + owner_repo = vm._github_repo(vm._git_out("remote", "get-url", "origin", repo=repo)) + if owner_repo and tuple(part.lower() for part in owner_repo) == OFFICIAL: + return repo + return None + + +def _checkout(): + repo = find_checkout() + if repo is None: + raise vm.VersionError("tts_missing", "Anomalous_TTS is not installed as a git clone of its GitHub repository") + return repo + + +def state(): + repo = find_checkout() + return {"found": False} if repo is None else {"found": True, "state": vm.current_state(repo)} + + +async def api_get_state(request): + """GET /anomalous/tts_version - Installed Anomalous_TTS version (local, no network).""" + try: + return web.json_response({"success": True, **(await asyncio.to_thread(state))}) + except vm.VersionError as error: + return vm.error_response(error) + + +async def api_get_releases(request): + """GET /anomalous/tts_version/releases?refresh=1 - Its published releases (network, only when asked).""" + refresh = request.query.get("refresh") == "1" + return await vm.run(lambda: vm.list_releases(refresh, _checkout())) + + +async def api_update(request): + """POST /anomalous/tts_version/update {tag} - Check out a published release.""" + tag = str((await vm.json_body(request)).get("tag", "")) + return await vm.run(lambda: vm.switch_to(tag, _checkout())) + + +async def api_undo(request): + """POST /anomalous/tts_version/undo - Back to where the last update came from.""" + return await vm.run(lambda: vm.undo_last_switch(_checkout())) + + +def register_routes(app): + app.router.add_get('/anomalous/tts_version', api_get_state) + app.router.add_get('/anomalous/tts_version/releases', api_get_releases) + app.router.add_post('/anomalous/tts_version/update', api_update) + app.router.add_post('/anomalous/tts_version/undo', api_undo) diff --git a/api/version_manager.py b/api/version_manager.py index 659a912..9a6c441 100644 --- a/api/version_manager.py +++ b/api/version_manager.py @@ -6,6 +6,9 @@ release tag. Nothing runs automatically: every network call starts from a button in the UI. Switching refuses when tracked files have local changes, and only tags that are published releases (or at least exist on the remote) can be checked out. + +Every function acts on the plugin itself unless given a `Checkout`: the +companion node pack Anomalous_TTS is updated the same way (api/tts_version.py). """ import asyncio @@ -39,6 +42,21 @@ _lock = threading.Lock() _releases_cache = {"expires": 0, "data": None} +class Checkout: + """Another git checkout to manage: its folder, where its last switch is noted, and the file + that marks versions with a version panel (None: no such notion).""" + + def __init__(self, root, state_file, marker=None): + self.root = root + self.state_file = state_file + self.marker = marker + self.cache = {"expires": 0, "data": None} + + +def _root(repo): + return repo.root if repo else PLUGIN_DIR + + class VersionError(Exception): """A user-facing failure with a stable code for the UI.""" @@ -57,14 +75,14 @@ def _git_executable(): return found -def _git(*args, timeout=30, check=True): +def _git(*args, timeout=30, check=True, repo=None): kwargs = {} if os.name == "nt": kwargs["creationflags"] = getattr(subprocess, "CREATE_NO_WINDOW", 0) env = dict(os.environ, GIT_TERMINAL_PROMPT="0") try: result = subprocess.run( - [_git_executable(), "-C", PLUGIN_DIR, *args], + [_git_executable(), "-C", _root(repo), *args], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout, env=env, **kwargs, ) @@ -90,17 +108,17 @@ def version_key(tag): return (numbers, 1 if not suffix else 0, suffix) -def current_state(): +def current_state(repo=None): """Local only (no network): what is installed and whether it can be switched.""" - if not os.path.isdir(os.path.join(PLUGIN_DIR, ".git")) and _git_out("rev-parse", "--is-inside-work-tree") != "true": + if not os.path.isdir(os.path.join(_root(repo), ".git")) and _git_out("rev-parse", "--is-inside-work-tree", repo=repo) != "true": return {"is_git": False} - commit = _git_out("rev-parse", "HEAD") - branch = _git_out("symbolic-ref", "--short", "-q", "HEAD") or None - exact_tag = _git_out("describe", "--tags", "--exact-match", "HEAD") or None - described = _git_out("describe", "--tags", "--always") - base_tag = _git_out("describe", "--tags", "--abbrev=0") or None + commit = _git_out("rev-parse", "HEAD", repo=repo) + branch = _git_out("symbolic-ref", "--short", "-q", "HEAD", repo=repo) or None + exact_tag = _git_out("describe", "--tags", "--exact-match", "HEAD", repo=repo) or None + described = _git_out("describe", "--tags", "--always", repo=repo) + base_tag = _git_out("describe", "--tags", "--abbrev=0", repo=repo) or None # Porcelain lines are "XY path"; keep the leading status column (no strip before slicing). - status = _git("status", "--porcelain", "--untracked-files=no", check=False).stdout + status = _git("status", "--porcelain", "--untracked-files=no", check=False, repo=repo).stdout changed = [line[3:] for line in status.splitlines() if line.strip()] return { "is_git": True, @@ -111,8 +129,8 @@ def current_state(): "label": exact_tag or described or commit[:12], "dirty": bool(changed), "changed_files": changed[:20], - "remote": _git_out("remote", "get-url", "origin") or None, - "previous": _read_state().get("previous"), + "remote": _git_out("remote", "get-url", "origin", repo=repo) or None, + "previous": _read_state(repo).get("previous"), } @@ -146,36 +164,50 @@ def _fetch_github_releases(owner, repo): return releases -def _remote_tags(): - output = _git_out("ls-remote", "--tags", "--refs", "origin", timeout=NETWORK_TIMEOUT * 3) +def _remote_tags(repo=None): + output = _git_out("ls-remote", "--tags", "--refs", "origin", timeout=NETWORK_TIMEOUT * 3, repo=repo) tags = [line.split("refs/tags/", 1)[1] for line in output.splitlines() if "refs/tags/" in line] return [{"tag": tag, "name": tag, "published_at": None, "prerelease": False, "notes": "", "url": None} for tag in tags] -def list_releases(force=False): +def _remote_tag_at(commit, repo=None): + """The remote tag on `commit`, for a checkout whose own tags were never fetched (a clone made + before the release, then updated by a pull of its branch).""" + output = _git_out("ls-remote", "--tags", "origin", timeout=NETWORK_TIMEOUT * 3, repo=repo) + for line in output.splitlines(): + sha, _, ref = line.partition("\t") + if commit and sha.startswith(commit) and ref.startswith("refs/tags/"): + return ref[len("refs/tags/"):].removesuffix("^{}") + return None + + +def list_releases(force=False, repo=None): """Published releases, newest first. GitHub API first, `git ls-remote` (uses git's proxy) as fallback.""" - state = current_state() + state = current_state(repo) if not state.get("is_git"): raise VersionError("not_git", "The plugin is not a git checkout") + cache = repo.cache if repo else _releases_cache now = time.monotonic() - if not force and _releases_cache["data"] and now < _releases_cache["expires"]: - releases, source = _releases_cache["data"] + if not force and cache["data"] and now < cache["expires"]: + releases, source = cache["data"] else: releases, source = None, None - repo = _github_repo(state.get("remote")) - if repo: + github = _github_repo(state.get("remote")) + if github: try: - releases, source = _fetch_github_releases(*repo), "github" + releases, source = _fetch_github_releases(*github), "github" except Exception: releases = None if releases is None: - releases, source = _remote_tags(), "git" + releases, source = _remote_tags(repo), "git" if not releases: raise VersionError("offline", "Could not reach GitHub or the git remote") releases.sort(key=lambda item: (item["published_at"] or "", version_key(item["tag"])), reverse=True) if source == "git": releases.sort(key=lambda item: version_key(item["tag"]), reverse=True) - _releases_cache.update(expires=now + RELEASES_CACHE_SECONDS, data=(releases, source)) + cache.update(expires=now + RELEASES_CACHE_SECONDS, data=(releases, source)) + if not state.get("tag") and not state.get("dirty"): + state["tag"] = _remote_tag_at(state.get("commit"), repo) current_key = version_key(state.get("tag") or state.get("base_tag")) stable = [item for item in releases if not item["prerelease"]] @@ -193,24 +225,25 @@ def list_releases(force=False): # ---------- switching ---------- -def _read_state(): +def _read_state(repo=None): try: - with open(STATE_FILE, "r", encoding="utf-8") as handle: + with open(repo.state_file if repo else STATE_FILE, "r", encoding="utf-8") as handle: data = json.load(handle) return data if isinstance(data, dict) else {} except (OSError, ValueError): return {} -def _write_state(data): - temporary = STATE_FILE + ".tmp" +def _write_state(data, repo=None): + path = repo.state_file if repo else STATE_FILE + temporary = path + ".tmp" with open(temporary, "w", encoding="utf-8") as handle: json.dump(data, handle, ensure_ascii=False, indent=2) - os.replace(temporary, STATE_FILE) + os.replace(temporary, path) -def _require_switchable(): - state = current_state() +def _require_switchable(repo=None): + state = current_state(repo) if not state.get("is_git"): raise VersionError("not_git", "The plugin is not a git checkout") if state["dirty"]: @@ -218,25 +251,26 @@ def _require_switchable(): return state -def _known_tag(tag): +def _known_tag(tag, repo=None): if not TAG_NAME.match(str(tag or "")): raise VersionError("bad_tag", "Invalid version name") - tags = {item["tag"] for item in list_releases()["releases"]} + tags = {item["tag"] for item in list_releases(repo=repo)["releases"]} if tag not in tags: raise VersionError("unknown_tag", f"{tag} is not a published version") return tag -def _fetch_tag(tag): - _git("fetch", "--no-tags", "--force", "origin", f"+refs/tags/{tag}:refs/tags/{tag}", timeout=FETCH_TIMEOUT) +def _fetch_tag(tag, repo=None): + _git("fetch", "--no-tags", "--force", "origin", f"+refs/tags/{tag}:refs/tags/{tag}", timeout=FETCH_TIMEOUT, repo=repo) -def preview_switch(tag): +def preview_switch(tag, repo=None): """Fetch the target and report what switching would mean, without changing anything.""" - state = _require_switchable() - _known_tag(tag) - _fetch_tag(tag) - has_switcher = _git("cat-file", "-e", f"refs/tags/{tag}:{SWITCHER_MARKER}", check=False).returncode == 0 + state = _require_switchable(repo) + _known_tag(tag, repo) + _fetch_tag(tag, repo) + marker = repo.marker if repo else SWITCHER_MARKER + has_switcher = not marker or _git("cat-file", "-e", f"refs/tags/{tag}:{marker}", check=False, repo=repo).returncode == 0 target_key = version_key(tag) current_key = version_key(state.get("tag") or state.get("base_tag")) return { @@ -248,15 +282,15 @@ def preview_switch(tag): } -def switch_to(tag): +def switch_to(tag, repo=None): with _lock: - state = _require_switchable() - _known_tag(tag) - _fetch_tag(tag) - previous = {"commit": _git_out("rev-parse", "HEAD"), "branch": state["branch"], "label": state["label"]} - _git("-c", "advice.detachedHead=false", "checkout", "--quiet", "--detach", f"refs/tags/{tag}") - _write_state({"previous": previous, "switched_at": time.time()}) - return {"state": current_state(), "restart_required": True} + state = _require_switchable(repo) + _known_tag(tag, repo) + _fetch_tag(tag, repo) + previous = {"commit": _git_out("rev-parse", "HEAD", repo=repo), "branch": state["branch"], "label": state["label"]} + _git("-c", "advice.detachedHead=false", "checkout", "--quiet", "--detach", f"refs/tags/{tag}", repo=repo) + _write_state({"previous": previous, "switched_at": time.time()}, repo) + return {"state": current_state(repo), "restart_required": True} def _default_branch(): @@ -287,39 +321,39 @@ def return_to_latest(): return {"state": current_state(), "restart_required": True} -def undo_last_switch(): +def undo_last_switch(repo=None): with _lock: - previous = _read_state().get("previous") + previous = _read_state(repo).get("previous") if not previous or not previous.get("commit"): raise VersionError("no_previous", "There is no switch to undo") - state = _require_switchable() - here = {"commit": _git_out("rev-parse", "HEAD"), "branch": state["branch"], "label": state["label"]} + state = _require_switchable(repo) + here = {"commit": _git_out("rev-parse", "HEAD", repo=repo), "branch": state["branch"], "label": state["label"]} if previous.get("branch"): # Going back to a branch means its current tip, even if it moved since (e.g. a Manager update). - _git("checkout", "--quiet", previous["branch"]) + _git("checkout", "--quiet", previous["branch"], repo=repo) else: - _git("-c", "advice.detachedHead=false", "checkout", "--quiet", "--detach", previous["commit"]) - _write_state({"previous": here, "switched_at": time.time()}) - return {"state": current_state(), "restart_required": True} + _git("-c", "advice.detachedHead=false", "checkout", "--quiet", "--detach", previous["commit"], repo=repo) + _write_state({"previous": here, "switched_at": time.time()}, repo) + return {"state": current_state(repo), "restart_required": True} # ---------- routes ---------- -def _error_response(error): +def error_response(error): status = 409 if error.code in {"dirty", "diverged"} else 400 if error.code in {"offline", "git_timeout", "git_failed"}: status = 502 return web.json_response({"success": False, "code": error.code, "error": str(error), **error.details}, status=status) -async def _run(handler, *args): +async def run(handler, *args): try: return web.json_response({"success": True, **(await asyncio.to_thread(handler, *args))}) except VersionError as error: - return _error_response(error) + return error_response(error) -async def _json_body(request): +async def json_body(request): try: data = await request.json() except Exception: @@ -332,32 +366,32 @@ async def api_get_version(request): try: return web.json_response({"success": True, "state": await asyncio.to_thread(current_state)}) except VersionError as error: - return _error_response(error) + return error_response(error) async def api_get_releases(request): """GET /anomalous/version/releases?refresh=1 - Published releases (network, only when asked).""" - return await _run(list_releases, request.query.get("refresh") == "1") + return await run(list_releases, request.query.get("refresh") == "1") async def api_preview_switch(request): """POST /anomalous/version/preview {tag}""" - return await _run(preview_switch, str((await _json_body(request)).get("tag", ""))) + return await run(preview_switch, str((await json_body(request)).get("tag", ""))) async def api_switch_version(request): """POST /anomalous/version/switch {tag}""" - return await _run(switch_to, str((await _json_body(request)).get("tag", ""))) + return await run(switch_to, str((await json_body(request)).get("tag", ""))) async def api_return_to_latest(request): """POST /anomalous/version/latest""" - return await _run(return_to_latest) + return await run(return_to_latest) async def api_undo_switch(request): """POST /anomalous/version/undo""" - return await _run(undo_last_switch) + return await run(undo_last_switch) def register_routes(app): diff --git a/tests/test_tts_version.py b/tests/test_tts_version.py new file mode 100644 index 0000000..1e8c597 --- /dev/null +++ b/tests/test_tts_version.py @@ -0,0 +1,93 @@ +"""Anomalous_TTS updates: only a git clone of the official repository is found, and switching it +uses its own folder and state file, never the plugin's.""" +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest import mock + +PLUGIN = Path(__file__).resolve().parents[1] +sys.path[:0] = [str(PLUGIN), str(PLUGIN.parents[1])] +from api import tts_version, version_manager as vm + +NODES = 'NODE_CLASS_MAPPINGS = {"AnomalousTTS_CharacterSpeech": object}\n' + + +def git(cwd, *args): + return subprocess.run(['git', '-C', str(cwd), *args], check=True, capture_output=True, text=True).stdout.strip() + + +def commit(repo, name, content, message): + Path(repo, name).write_text(content, encoding='utf-8') + git(repo, 'add', name) + git(repo, '-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '-m', message) + + +class TtsVersionTests(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.root = Path(self._tmp.name) + self.nodes = self.root / 'custom_nodes' + self.nodes.mkdir() + paths = mock.patch.object(tts_version.folder_paths, 'get_folder_paths', lambda name: [str(self.nodes)]) + paths.start() + self.addCleanup(paths.stop) + state = mock.patch.object(tts_version, 'STATE_FILE', str(self.root / 'tts_state.json')) + state.start() + self.addCleanup(state.stop) + + def tearDown(self): + self._tmp.cleanup() + + def clone(self, name, remote, nodes=NODES): + folder = self.nodes / name + folder.mkdir() + git(folder, 'init', '-q', '-b', 'main') + commit(folder, 'nodes.py', nodes, 'one') + git(folder, 'remote', 'add', 'origin', remote) + return folder + + def test_only_the_official_clone_is_found(self): + self.assertIsNone(tts_version.find_checkout()) + self.clone('A_fork', 'https://github.com/someone/Anomalous_TTS.git') + self.clone('Other_pack', 'https://github.com/DemonGatanjieu/Anomalous_TTS', nodes='NODE_CLASS_MAPPINGS = {}\n') + (self.nodes / 'Plain_copy').mkdir() + Path(self.nodes / 'Plain_copy' / 'nodes.py').write_text(NODES, encoding='utf-8') # no git: Manager's zip, a copy + self.assertIsNone(tts_version.find_checkout()) + self.assertEqual(tts_version.state(), {'found': False}) + + folder = self.clone('Anomalous_TTS', 'https://github.com/DemonGatanjieu/Anomalous_TTS.git') + repo = tts_version.find_checkout() + self.assertEqual(Path(repo.root), folder) + found = tts_version.state() + self.assertTrue(found['found']) + self.assertEqual(found['state']['branch'], 'main') + + def test_switching_acts_on_the_node_pack_only(self): + upstream = self.root / 'upstream' + upstream.mkdir() + git(upstream, 'init', '-q', '-b', 'main') + commit(upstream, 'nodes.py', NODES, 'one') + git(upstream, 'tag', 'v1.0.0') + commit(upstream, 'nodes.py', NODES + '# two\n', 'two') + git(upstream, 'tag', 'v1.1.0') + folder = self.nodes / 'Anomalous_TTS' + git(self.root, 'clone', '-q', str(upstream), str(folder)) + repo = vm.Checkout(str(folder), tts_version.STATE_FILE) + plugin_head = git(PLUGIN, 'rev-parse', 'HEAD') + git(folder, 'tag', '-d', 'v1.1.0') # cloned before the release, updated by a pull: no local tag + with mock.patch.object(vm, '_fetch_github_releases', side_effect=OSError('offline')): + listing = vm.list_releases(True, repo) + self.assertEqual((listing['latest'], listing['state']['tag']), ('v1.1.0', 'v1.1.0')) + vm.switch_to('v1.0.0', repo) + self.assertEqual(vm.current_state(repo)['tag'], 'v1.0.0') + self.assertEqual(vm.current_state(repo)['previous']['branch'], 'main') + self.assertTrue(Path(tts_version.STATE_FILE).is_file()) + vm.undo_last_switch(repo) + self.assertEqual(vm.current_state(repo)['branch'], 'main') + self.assertEqual(git(PLUGIN, 'rev-parse', 'HEAD'), plugin_head) + + +if __name__ == '__main__': + unittest.main() diff --git a/web/modules/locales.js b/web/modules/locales.js index 2e04096..7f36efa 100644 --- a/web/modules/locales.js +++ b/web/modules/locales.js @@ -2262,6 +2262,20 @@ export const i18n = { ttsNeededFor_en: "英语", ttsNeededFor_ja: "日语", ttsNeededFor_v2pro: "v2Pro 模型", + ttsVersionTitle: "Anomalous_TTS 版本", + ttsVersionHint: "Anomalous_TTS 的稳定版本。点“检查更新”才会联网;更新后要重启 ComfyUI 才生效,不合适可以点“回到”退回去。", + ttsVersionCheck: "检查更新", + ttsVersionChecking: "正在查看已发布的版本…", + ttsVersionUpToDate: "已是最新的稳定版", + ttsVersionNew: "有新的稳定版 {tag}", + ttsVersionUpdate: "更新到 {tag}", + ttsVersionNotes: "看更新内容", + ttsVersionUndo: "回到 {tag}", + ttsVersionConfirm: "把 Anomalous_TTS 从 {from} 更新到 {tag}?\n\n更新后要重启 ComfyUI 才生效。", + ttsVersionUpdated: "Anomalous_TTS 已换成 {tag}。", + ttsVersionUnknown: "读不到版本", + ttsVersionNoGit: "这里只能更新从 GitHub 用 git 安装的 Anomalous_TTS(ComfyUI Manager 就是这样装的)。现在这份不是,请在 ComfyUI Manager 里更新。", + versionError_tts_missing: "没找到用 git 安装的 Anomalous_TTS,请在 ComfyUI Manager 里更新。", ttsNeededFor_v3: "v3 模型", ttsNeededFor_v4: "v4 模型", ttsNeededFor_v5dev: "v5dev 模型", @@ -2422,7 +2436,7 @@ export const i18n = { ttsImportNameTakenVariant: "“{name}”已经有这些版本:{variants}。请换一个名字。", ttsImportSwitchToAdd: "改成添加到这个角色", ttsImportKeptBest: "{kind}:用了轮数最大的 {file}(另外 {count} 个没用)。", - ttsImportUnsupportedBlock: "{file} 是 {version} 模型,现在装的 Anomalous_TTS 还不支持。在 ComfyUI Manager 里更新 Anomalous_TTS 后再导入。", + ttsImportUnsupportedBlock: "{file} 是 {version} 模型,现在装的 Anomalous_TTS 还不支持。在“GPT-SoVITS 设置”里更新 Anomalous_TTS(或用 ComfyUI Manager)后再导入。", ttsImportUnknownBlock: "认不出 {file} 是哪个版本的 SoVITS 权重。请换一个 GPT-SoVITS 训练出的 .pth。", ttsImportOutOfRange: "不在 3~10 秒,节点用不了,不会导入", ttsTextSource_txt: "台词来自同名 .txt", @@ -4859,6 +4873,20 @@ export const i18n = { ttsNeededFor_en: "English", ttsNeededFor_ja: "Japanese", ttsNeededFor_v2pro: "v2Pro models", + ttsVersionTitle: "Anomalous_TTS version", + ttsVersionHint: "Stable releases of Anomalous_TTS. Nothing goes online until \"Check for updates\"; an update takes effect after restarting ComfyUI, and \"Back to\" undoes it.", + ttsVersionCheck: "Check for updates", + ttsVersionChecking: "Looking at the published releases…", + ttsVersionUpToDate: "This is the newest stable release", + ttsVersionNew: "New stable release {tag}", + ttsVersionUpdate: "Update to {tag}", + ttsVersionNotes: "What's new", + ttsVersionUndo: "Back to {tag}", + ttsVersionConfirm: "Update Anomalous_TTS from {from} to {tag}?\n\nIt takes effect after restarting ComfyUI.", + ttsVersionUpdated: "Anomalous_TTS is now {tag}.", + ttsVersionUnknown: "version unknown", + ttsVersionNoGit: "Only an Anomalous_TTS installed from GitHub with git (as ComfyUI Manager does) can be updated here. This one is not; update it in ComfyUI Manager.", + versionError_tts_missing: "No Anomalous_TTS installed with git was found. Update it in ComfyUI Manager.", ttsNeededFor_v3: "v3 models", ttsNeededFor_v4: "v4 models", ttsNeededFor_v5dev: "v5dev models", @@ -5019,7 +5047,7 @@ export const i18n = { ttsImportNameTakenVariant: "\"{name}\" already has these versions: {variants}. Please pick another name.", ttsImportSwitchToAdd: "Add to that character instead", ttsImportKeptBest: "{kind}: using the latest epoch, {file} ({count} {count|other|others} left out).", - ttsImportUnsupportedBlock: "{file} is a {version} model, which the installed Anomalous_TTS cannot use yet. Update Anomalous_TTS in ComfyUI Manager, then import it.", + ttsImportUnsupportedBlock: "{file} is a {version} model, which the installed Anomalous_TTS cannot use yet. Update Anomalous_TTS in GPT-SoVITS Settings (or ComfyUI Manager), then import it.", ttsImportUnknownBlock: "The version of the SoVITS weight {file} is unknown. Use a .pth trained with GPT-SoVITS.", ttsImportOutOfRange: "Not 3–10 s long: the node cannot use it, so it is left out", ttsTextSource_txt: "Line from the .txt of the same name", diff --git a/web/modules/ui_tts_setup.js b/web/modules/ui_tts_setup.js index 8bd03c0..54543c3 100644 --- a/web/modules/ui_tts_setup.js +++ b/web/modules/ui_tts_setup.js @@ -2,6 +2,7 @@ import { t } from './interface_settings.js'; import { createViewScope } from './ui_lifecycle.js'; import { anomalousAlert } from './ui_dialog.js'; import { loadGptSovitsStatus } from './audio_engines.js'; +import { renderTtsVersion } from './ui_tts_version.js'; import { formatSize, pretrainedReminder, setupSummary, shownPretrained, startPretrainedDownload } from './tts_setup_api.js'; /** @@ -283,9 +284,12 @@ export async function openTtsSetup({ onChanged, languages = [], needs = [] }) { if (!scope.signal.aborted && status) draw(); }, }; + // Kept across redraws: the download poll replaces only the card, and a check for updates stays shown. + const version = el('section', 'anomalous-tts-setup'); + version.append(renderTtsVersion(status.local !== false)); const draw = () => { subtitle.textContent = summaryText(status, setupSummary(status, needs)); - body.replaceChildren(renderTtsSetup(status, options)); + body.replaceChildren(renderTtsSetup(status, options), version); }; draw(); modal.append(header, body); diff --git a/web/modules/ui_tts_version.js b/web/modules/ui_tts_version.js new file mode 100644 index 0000000..3ce5ebc --- /dev/null +++ b/web/modules/ui_tts_version.js @@ -0,0 +1,139 @@ +import { t } from './interface_settings.js'; +import { anomalousAlert, anomalousConfirm } from './ui_dialog.js'; +import { button, el } from './usage_view.js'; + +/** + * "Anomalous_TTS version" in GPT-SoVITS Settings: the installed version (local), its + * published releases only after "Check for updates" (network), and an update to the + * newest stable one, or back to where the last update came from; then the restart + * question (ComfyUI Manager's reboot when there is one). The backend only acts on a git + * clone of the official repository (api/tts_version.py). + */ + +async function request(url, options = {}) { + try { + const resp = await fetch(url, options); + return await resp.json().catch(() => ({ success: false, error: `HTTP ${resp.status}` })); + } catch (e) { + return { success: false, code: 'offline', error: e.message }; + } +} + +const post = (url, body = {}) => request(url, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), +}); + +function errorText(result) { + const key = `versionError_${result.code}`; + const text = t(key, { error: result.error || '' }); + const message = text === key ? t('versionErrorGeneric', { error: result.error || result.code || '' }) : text; + return result.files?.length ? `${message}\n\n${result.files.join('\n')}` : message; +} + +function versionOf(state) { + return state.tag || state.base_tag || state.label; +} + +async function askRestart(message) { + const restart = await anomalousConfirm(`${message}\n\n${t('versionRestartQuestion')}`, t('ttsVersionTitle'), { + okLabel: t('versionRestartNow'), cancelLabel: t('versionRestartLater'), + }); + if (!restart) return ''; + // Manager's reboot exits the server, so a dropped connection means it is restarting. + const reboot = await post('/api/manager/reboot'); + if (reboot.code === 'offline' || reboot.success !== false) return t('versionRestarting'); + await anomalousAlert(t('versionRestartManual')); + return ''; +} + +/** The settings group; `local` false (another device) leaves the buttons out. */ +export function renderTtsVersion(local) { + const group = el('div', 'anomalous-tts-setup-group'); + const row = el('div', 'anomalous-tts-setup-row'); + const value = el('span', 'anomalous-tts-setup-path', '…'); + const note = el('span', 'anomalous-tts-setup-meta', ''); + const actions = el('span', 'anomalous-tts-version-actions'); + row.append(value, note, actions); + const hint = el('div', 'anomalous-tts-hint', t('ttsVersionHint')); + group.append(el('div', 'anomalous-tts-section', t('ttsVersionTitle')), row, hint); + + let busy = false; + const run = async (label, work) => { + if (busy) return; + busy = true; + actions.querySelectorAll('button').forEach(btn => { btn.disabled = true; }); + note.textContent = label; + try { await work(); } finally { busy = false; } + }; + + const show = (state, listing = null) => { + value.textContent = versionOf(state); + note.textContent = ''; + actions.replaceChildren(); + if (!local) return; + const latest = listing?.releases.find(item => item.latest); + if (!listing) { + actions.append(button('anomalous-tts-add', t('ttsVersionCheck'), () => run(t('ttsVersionChecking'), check))); + } else if (!latest || latest.current || latest.direction !== 'newer') { + note.textContent = t('ttsVersionUpToDate'); + } else { + note.textContent = t('ttsVersionNew', { tag: latest.tag }); + actions.append(button('anomalous-tts-add', t('ttsVersionUpdate', { tag: latest.tag }), + () => run(t('versionSwitching', { tag: latest.tag }), () => update(state, latest)))); + if (latest.url) { + const notes = el('a', 'anomalous-tts-link', t('ttsVersionNotes')); + notes.href = latest.url; + notes.target = '_blank'; + notes.rel = 'noopener noreferrer'; + actions.append(notes); + } + } + if (state.previous?.label) { + actions.append(button('anomalous-tts-link', t('ttsVersionUndo', { tag: state.previous.label }), + () => run(t('versionSwitching', { tag: state.previous.label }), () => undo(state)))); + } + }; + + const check = async () => { + const listing = await request('/anomalous/tts_version/releases?refresh=1'); + if (!listing.success) { + await anomalousAlert(errorText(listing)); + return load(); + } + show(listing.state, listing); + }; + + const finish = async (result, tag) => { + if (!result.success) { + await anomalousAlert(errorText(result)); + return load(); + } + show(result.state); + note.textContent = await askRestart(t('ttsVersionUpdated', { tag })) || t('versionPendingRestart'); + }; + + const update = async (state, release) => { + if (!await anomalousConfirm(t('ttsVersionConfirm', { tag: release.tag, from: versionOf(state) }), t('ttsVersionTitle'))) return show(state); + await finish(await post('/anomalous/tts_version/update', { tag: release.tag }), release.tag); + }; + + const undo = async (state) => { + const tag = state.previous.label; + if (!await anomalousConfirm(t('versionConfirmUndo', { label: tag }), t('ttsVersionTitle'))) return show(state); + await finish(await post('/anomalous/tts_version/undo'), tag); + }; + + const load = async () => { + const data = await request('/anomalous/tts_version'); + if (!data.success || !data.found) { + value.textContent = t('ttsVersionUnknown'); + note.textContent = ''; + actions.replaceChildren(); + hint.textContent = t('ttsVersionNoGit'); + return; + } + show(data.state); + }; + load(); + return group; +} diff --git a/web/styles.css b/web/styles.css index cb67586..2db0bbb 100644 --- a/web/styles.css +++ b/web/styles.css @@ -10,7 +10,7 @@ @import url("./styles/09-prompt-drawers.css?v=20261007-theme-2"); @import url("./styles/10-model-sources.css?v=20261007-theme-2"); @import url("./styles/11-audio-studio.css?v=20261017-tts-versions"); -@import url("./styles/12-tts-setup.css?v=20261007-theme-2"); +@import url("./styles/12-tts-setup.css?v=20261017-tts-update"); @import url("./styles/13-script-director.css?v=20261007-theme-2"); @import url("./styles/14-version-manager.css?v=20261007-theme-2"); @import url("./styles/15-shell-rail.css?v=20261016-remote-3"); diff --git a/web/styles/12-tts-setup.css b/web/styles/12-tts-setup.css index 41b342c..52a355f 100644 --- a/web/styles/12-tts-setup.css +++ b/web/styles/12-tts-setup.css @@ -76,6 +76,14 @@ color: var(--amb-text-muted); } +.anomalous-tts-version-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + margin-left: auto; +} + .anomalous-tts-setup-command { flex: 1 1 320px; min-width: 0;