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 <noreply@anthropic.com>
This commit is contained in:
DemonGatanjieu
2026-10-17 13:08:10 +08:00
co-authored by Claude Opus 5.5
parent d857322c96
commit 2e62703923
12 changed files with 485 additions and 70 deletions
+8
View File
@@ -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
+3
View File
@@ -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).
+2 -1
View File
@@ -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)
+1
View File
@@ -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
+96
View File
@@ -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)
+99 -65
View File
@@ -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):
+93
View File
@@ -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()
+30 -2
View File
@@ -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",
+5 -1
View File
@@ -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);
+139
View File
@@ -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;
}
+1 -1
View File
@@ -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");
+8
View File
@@ -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;