From 5dc570d880ff28345109ded56fdfffa5ab3dee57 Mon Sep 17 00:00:00 2001 From: DemonGatanjieu <1027066412@qq.com> Date: Fri, 25 Sep 2026 20:28:20 +0800 Subject: [PATCH] feat(audio): one storage place for GPT-SoVITS characters Uses Anomalous_TTS interface v4. - Setup card: "where characters are kept" replaces the library list. "Change..." picks a folder on the ComfyUI machine and asks whether the current characters move along (move / don't move / cancel). A move shows its progress (characters, and bytes when copying across disks) and the studio redraws when it ends; an error says where it stopped. - "Other places" lists earlier storage places that still hold characters and yaml folders; x on an earlier place stops listing it (files kept). - Import form: no location picker; new characters go to the storage place and the form shows the exact path. - The "add library" action is gone (no longer offered by the node). Tested: node tests/tts_setup_api.mjs (storage calls, moving summary). Isolated ComfyUI in the browser: change storage with move (both characters moved, a non-character folder left, config updated), change back without moving (old place listed under other places, stale move message not shown), remove the earlier place (characters unlisted, files kept), import form shows the storage path; a .list dropped on a clip's text box fills it without being added as a file. Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 25 +++++----- CHANGELOG.md | 2 +- docs/guides/audio-studio.md | 9 ++-- web/modules/locales.js | 58 ++++++++++++++-------- web/modules/tts_setup_api.js | 24 ++++++--- web/modules/ui_tts_import.js | 35 ++++---------- web/modules/ui_tts_setup.js | 94 ++++++++++++++++++++++++++---------- web/styles.css | 2 +- web/styles/12-tts-setup.css | 11 ++--- 9 files changed, 156 insertions(+), 104 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 509035c..df9c96e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -134,12 +134,12 @@ DOM or live LiteGraph state. voice-group shape (`node_value` = what the node's voice widget takes), so cards, sidebar and Script Director share one implementation. A missing engine stays in the switch marked "not installed" with install steps; nothing else depends on it. - GPT-SoVITS data comes only from the node's HTTP contract, version 3 - (`/anomalous_tts/characters`, `/audio`, `/settings`, `/status`, the library, - pretrained, browse and import routes; the node repo's `docs/INTERFACE.md`, + GPT-SoVITS data comes only from the node's HTTP contract, version 4 + (`/anomalous_tts/characters`, `/audio`, `/settings`, `/status`, the storage, + library, pretrained, browse and import routes; the node repo's `docs/INTERFACE.md`, mirrored as the project doc `anomalous-tts-interface.md`); Anomalous never reads or writes its model folders. `loadGptSovitsStatus` caches the setup status - with the engine data (null for a node without v3, which hides setup and import). The character list is a + with the engine data (null for a node without `/status`, which hides setup and import). The character list is a summary without file lists; `fetchGptSovitsCharacter` gets one character's files. Engine presence and loaded voice groups are cached (`MAX_AGE_MS`) so re-renders from sidebar clicks, domain switches and the studio + sidebar pair cost no @@ -149,15 +149,18 @@ DOM or live LiteGraph state. `ui_audio_tts_editor.js` edits a character's emotion references through that API: it opens at once, loads the character's audio list in the background (save waits for it), and keeps settings fields it does not know. - `tts_setup_api.js` holds the other v3 calls (libraries, pretrained sources and - downloads, folder browse, chunked upload, inspect, commit, discard) and the pure - import-form rules (`importKind`, `buildImportBody`, `importProblem`, + `tts_setup_api.js` holds the other calls (storage place and moving, forgetting + an earlier place, pretrained sources and downloads, folder browse, chunked + upload, inspect, commit, discard) and the pure import-form rules (`importKind`, + `pickWeights`, `nameConflict`, `textFromFile`, `buildImportBody`, `importProblem`, `setupSummary`); no DOM. `ui_tts_setup.js` is the setup card at the top of the - GPT-SoVITS studio (polls the status only while a download runs and it is in the - page). `ui_tts_import.js` is the import form (new character or `target` = add + GPT-SoVITS studio: one storage place (changing it asks whether the characters + move along), other places still read, pretrained files, packages. It polls the + status only while a download or move runs and it is in the page, and redraws the + studio when a move ends. `ui_tts_import.js` is the import form (new character or `target` = add files, switchable when the name already exists; one GPT and one SoVITS slot, - the latest epoch kept from a batch; the save location stays folded unless asked - for; rows are built once and updated in place; closing discards unfinished + the latest epoch kept from a batch; new characters always go to the storage + place; a line file dropped on a clip's text box fills it; rows are built once and updated in place; closing discards unfinished uploads) and `bindTtsFileDrop`, which turns OS file drops on the studio or a card into that form. `ui_tts_path_picker.js` picks server-side folders or files through the node's browse route, since the browser cannot see local paths. diff --git a/CHANGELOG.md b/CHANGELOG.md index fc3ab9e..3895516 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ - **Clear drag rules (拖动规则明确)**: drag a character card's header onto an F5-TTS node (sets the main voice), or one row onto a Load Audio node (uses that exact clip). The hint says what releasing will do; unsupported nodes are refused with the reason instead of being guessed. A single emotion clip is never written into F5-TTS's `sample`, which would break `{emotion}` tags. - **Usage guide (使用说明)**: in the audio page, the header **!** opens a five-step how-to; the full guide is `docs/guides/audio-studio.md`. - **Engine switch: F5-TTS / GPT-SoVITS (引擎切换)**: the audio page now manages voices per engine. Installed engines are detected at runtime; a missing one stays visible as "not installed" with install steps instead of disappearing, and nothing else depends on it. GPT-SoVITS characters come from the separate Anomalous_TTS node pack: preview reference clips, pick a reference clip per emotion ("Edit emotions", saved through the node's API), drag a character onto the node, and push scripts with the Script Director. -- **GPT-SoVITS setup and import (GPT-SoVITS 准备与导入)**: a setup card shows the character libraries, pretrained files and missing Python packages; add a library, 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 3; file actions only work on the computer running ComfyUI. +- **GPT-SoVITS setup and import (GPT-SoVITS 准备与导入)**: a setup card shows where characters are kept, pretrained files and missing Python packages. 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 4; file actions only work on the computer running ComfyUI. ### Fixes - All-in-one loader recipes and cross-node prompt injection use one verified adapter table; prompt text never crosses between positive and negative. diff --git a/docs/guides/audio-studio.md b/docs/guides/audio-studio.md index e40f061..450457c 100644 --- a/docs/guides/audio-studio.md +++ b/docs/guides/audio-studio.md @@ -114,7 +114,8 @@ Load Audio 正好相反:它只用一条具体的音频,所以要拖某一行 切到 GPT-SoVITS 后,最上面是 **GPT-SoVITS 准备状态**。全部就绪时它收成一行,点一下展开: -- **角色库**:节点从哪些文件夹里找角色,每个有几个角色。**+ 添加角色库** 选一个你已经整理好的文件夹(里面每个子文件夹是一个角色),马上生效,不用重启,也不用改 `extra_model_paths.yaml`。在这里添加的可以再移除;移除只是不再列出,不会删文件。 +- **角色存放位置**:导入的角色都放在这里,默认 `ComfyUI/models/gpt_sovits/`。点 **更改…** 选一个新文件夹,会问要不要把现有角色 **移过去**:选“移过去”会逐个移动并显示进度(同一个盘上瞬间完成,跨盘先复制再删原来的;遇到同名文件夹或出错会停下,没移的角色照样能用);选“不移动”,它们留在原处照样能用,只有以后导入的放到新位置。都不用重启,也不用改 `extra_model_paths.yaml`。 +- **其他位置**:以前的存放位置里还没移走的角色,以及 `extra_model_paths.yaml` 里的文件夹。以前的存放位置点 **×** 就不再列出,文件不会删除。 - **底模**:每个底模是已就绪、缺少还是下载中。可以单个 **下载**、**全部下载**,或者 **使用已有的 GPT-SoVITS 整合包**(选整合包里的 `GPT_SoVITS` 文件夹),就不用再下一遍。只说日语的角色不需要中文底模;标“可选”的缺了也能用。 - **Python 包**:缺了哪种语言要的包,会给出安装命令,复制到命令行运行后重启 ComfyUI。 @@ -129,18 +130,18 @@ Load Audio 正好相反:它只用一条具体的音频,所以要拖某一行 3. 表单会自动填好:角色名(取自 GPT 文件名)、SoVITS 的版本、每段音频的长度和台词(从标注文件里找)、主声音(第一段 3~10 秒且有台词的)、语言。都可以改。v3 / v4 的 SoVITS 暂不支持,会标红、不能导入;不到 3 秒或超过 10 秒的音频不能当主声音。 4. 其他音频填上情绪名(例如“开心”),就成了 `{开心}`。 5. 每条音频的台词框下面写着台词从哪来:同名 `.txt` / `.lab`、标注文件,或者**文件名**(很多参考音频包直接用台词当文件名,这种会标黄,请确认)。没找到时可以粘贴,也可以把 `.txt`、`.lab` 或 `.list` 文件拖到这条音频的台词框里:普通文本文件整段当台词,标注文件会查出这一条。 -6. 点 **导入**。默认保存到 `ComfyUI/models/gpt_sovits/<角色名>/`;要放别处,点“会保存到”旁边的 **更改位置**。名字和已有角色重复时会马上提示,可以换个名字,或者点 **改成添加到这个角色**。 +6. 点 **导入**。角色会保存到存放位置下的 `<角色名>/`(表单上写着具体路径;想换地方,在准备状态里改存放位置)。名字和已有角色重复时会马上提示,可以换个名字,或者点 **改成添加到这个角色**。 导入后会自动滚到新角色的卡片并高亮一下,接着把它拖到节点上就能用。 -节点会建好 `角色名/GPT_weights/`、`SoVITS_weights/`、`参考音频/` 和设置文件。**一律复制,原文件不动**;中途出错时角色库保持原样,不会留下半个角色。 +节点会建好 `角色名/GPT_weights/`、`SoVITS_weights/`、`参考音频/` 和设置文件。**一律复制,原文件不动**;中途出错时存放位置保持原样,不会留下半个角色。 给已有角色加音频或情绪:点卡片上的 **添加文件**,或者把文件拖到那张卡片上。已有的文件不会被覆盖,主声音也不会被换掉(除非你在表单里点了“主声音”)。 ### 角色文件夹的规则 - 一个角色就是一个文件夹,里面至少有一个 `.ckpt`(GPT 权重)和一个 `.pth`(SoVITS 权重)。 -- 文件夹放在任何一个角色库里:默认的 `ComfyUI/models/gpt_sovits/`、在上面添加的角色库,或者 `extra_model_paths.yaml` 里的 `gpt_sovits` 目录。 +- 角色文件夹放在存放位置里(默认 `ComfyUI/models/gpt_sovits/`),也可以放在 `extra_model_paths.yaml` 里写的 `gpt_sovits` 目录。自己往存放位置里放整理好的角色文件夹也可以,点 **刷新音色** 就能看到。 - 同一个角色有日配、中配两个版本时,会显示成 `阿罗娜/日配`、`阿罗娜/中配` 两个角色。 ### 编辑情绪 diff --git a/web/modules/locales.js b/web/modules/locales.js index f92bcd6..fef33bc 100644 --- a/web/modules/locales.js +++ b/web/modules/locales.js @@ -1375,20 +1375,31 @@ export const i18n = { ttsSetupPretrainedMissing: "缺 {count} 个底模", ttsSetupDownloading: "底模下载中…", ttsSetupPackagesMissing: "缺 {count} 个 Python 包", - ttsSetupLibraries: "角色库", ttsSetupPretrained: "底模", ttsSetupPackages: "Python 包", ttsSetupRemote: "这些操作只能在运行 ComfyUI 的那台电脑上进行(当前是从别的设备打开的)。", ttsSetupFailed: "操作失败:{error}", + ttsSetupStorage: "角色存放位置", + ttsSetupOtherPlaces: "其他位置", + ttsSetupMoving: "正在移动角色 {done}/{total}", + ttsStorageChange: "更改…", + ttsStorageChangeTitle: "选择新的存放位置", + ttsStorageChangeHint: "以后导入的角色都放在这个文件夹里。选好后可以决定要不要把现有的角色一起移过去。", + ttsStorageHint: "导入的角色放在这里。换位置时可以把现有角色一起移过去;同一个盘上是瞬间完成,跨盘会复制完再删掉原来的。", + ttsStorageMoveAsk: "现在的存放位置里有 {count} 个角色。要把它们一起移到\n{path}\n吗?\n\n不移的话,它们留在原处照样能用,只是以后导入的放到新位置。", + ttsStorageMoveYes: "移过去", + ttsStorageMoveNo: "不移动", + ttsStorageMoving: "正在移动 {done}/{total}:{current}", + ttsStorageMovingBytes: "(已复制 {percent}%)", + ttsStorageMoveDone: "已把 {count} 个角色移到这里", + ttsStorageMoveError: "移动停下了:{error}(已移 {done}/{total},其余的留在原处,照样能用)", + ttsOtherPlacesHint: "这些位置里的角色照样能用。以前的存放位置点 × 就不再列出,文件不会删除。", ttsLibrarySource_default: "默认", ttsLibrarySource_yaml: "yaml 配置", - ttsLibrarySource_app: "在这里添加", + ttsLibrarySource_app: "以前的存放位置", ttsLibraryMissing: "文件夹不存在", - ttsLibraryAdd: "+ 添加角色库", - ttsLibraryAddHint: "选一个已经整理好的文件夹:里面每个子文件夹是一个角色(有 .ckpt 和 .pth)。不会复制或移动任何文件。", - ttsLibraryHint: "角色库里每个子文件夹是一个角色。添加后马上生效,不用重启,也不用改 extra_model_paths.yaml。", ttsLibraryRemove: "移除", - ttsLibraryRemoveConfirm: "不再使用这个角色库?\n{path}\n\n文件不会被删除,只是不再列出里面的角色。", + ttsLibraryRemoveConfirm: "不再读取这个以前的存放位置?\n{path}\n\n文件不会删除,只是不再列出里面的角色。", ttsNeededFor_all: "所有模型", ttsNeededFor_zh: "中文", ttsNeededFor_en: "英语", @@ -1431,9 +1442,7 @@ export const i18n = { ttsImportAddSubtitle: "给这个角色加参考音频、情绪或新的权重。文件会复制进角色文件夹,已有的文件不会被覆盖。", ttsImportName: "角色名", ttsImportNamePlaceholder: "例如 阿罗娜", - ttsImportLibraryOption: "{path}({count} 个角色)", - ttsImportSaveTo: "会保存到:{path}", - ttsImportChangeLocation: "更改位置", + ttsImportSaveTo: "会保存到:{path}(存放位置可以在准备状态里改)", ttsImportNameTaken: "已经有叫“{name}”的角色了。", ttsImportNameTakenVariant: "“{name}”已经有这些版本:{variants}。请换一个名字。", ttsImportSwitchToAdd: "改成添加到这个角色", @@ -1476,7 +1485,7 @@ export const i18n = { ttsImportSubmit: "导入", ttsImportAddSubmit: "添加", ttsImportFailed: "导入失败:{error}", - ttsImportNoLibrary: "没有可以写入的角色库。先在准备状态里添加一个角色库。", + ttsImportNoLibrary: "存放位置不能写入:{path}。请在准备状态里换一个存放位置。", ttsImportNoFiles: "还没有放入文件", ttsImportStillUploading: "还有 {count} 个文件在上传,请稍等", ttsImportNameMissing: "请填写角色名", @@ -2989,20 +2998,31 @@ export const i18n = { ttsSetupPretrainedMissing: "{count} pretrained files missing", ttsSetupDownloading: "downloading pretrained files…", ttsSetupPackagesMissing: "{count} Python packages missing", - ttsSetupLibraries: "Character libraries", ttsSetupPretrained: "Pretrained files", ttsSetupPackages: "Python packages", ttsSetupRemote: "These actions only work on the computer running ComfyUI (this page was opened from another device).", ttsSetupFailed: "Could not do that: {error}", + ttsSetupStorage: "Where characters are kept", + ttsSetupOtherPlaces: "Other places", + ttsSetupMoving: "moving characters {done}/{total}", + ttsStorageChange: "Change…", + ttsStorageChangeTitle: "Choose a new place for characters", + ttsStorageChangeHint: "Imported characters go to this folder from now on. You can then choose whether the current characters move there too.", + ttsStorageHint: "Imported characters are kept here. When you change the place, the current characters can move along: instant on the same disk; across disks they are copied first and the originals deleted after.", + ttsStorageMoveAsk: "The current place holds {count} characters. Move them to\n{path}\ntoo?\n\nIf not, they stay where they are and keep working; only new imports go to the new place.", + ttsStorageMoveYes: "Move them", + ttsStorageMoveNo: "Don't move", + ttsStorageMoving: "Moving {done}/{total}: {current}", + ttsStorageMovingBytes: " ({percent}% copied)", + ttsStorageMoveDone: "Moved {count} characters here", + ttsStorageMoveError: "The move stopped: {error} ({done}/{total} moved; the rest stay where they were and keep working)", + ttsOtherPlacesHint: "Characters in these places keep working. Press × on an earlier place to stop listing it; no files are deleted.", ttsLibrarySource_default: "default", ttsLibrarySource_yaml: "yaml config", - ttsLibrarySource_app: "added here", + ttsLibrarySource_app: "earlier place", ttsLibraryMissing: "folder not found", - ttsLibraryAdd: "+ Add library", - ttsLibraryAddHint: "Pick a folder you already organised: each subfolder is one character (with .ckpt and .pth). Nothing is copied or moved.", - ttsLibraryHint: "Each subfolder of a library is one character. Added libraries work at once: no restart, no extra_model_paths.yaml.", ttsLibraryRemove: "Remove", - ttsLibraryRemoveConfirm: "Stop using this library?\n{path}\n\nNo files are deleted; its characters are just no longer listed.", + ttsLibraryRemoveConfirm: "Stop reading this earlier place?\n{path}\n\nNo files are deleted; its characters are just no longer listed.", ttsNeededFor_all: "all models", ttsNeededFor_zh: "Chinese", ttsNeededFor_en: "English", @@ -3045,9 +3065,7 @@ export const i18n = { ttsImportAddSubtitle: "Add reference clips, emotions or new weights to this character. Files are copied into its folder; existing files are never overwritten.", ttsImportName: "Character name", ttsImportNamePlaceholder: "e.g. Arona", - ttsImportLibraryOption: "{path} ({count} characters)", - ttsImportSaveTo: "Saved to: {path}", - ttsImportChangeLocation: "Change location", + ttsImportSaveTo: "Saved to: {path} (the storage place can be changed on the setup card)", ttsImportNameTaken: "There is already a character named \"{name}\".", ttsImportNameTakenVariant: "\"{name}\" already has these versions: {variants}. Please pick another name.", ttsImportSwitchToAdd: "Add to that character instead", @@ -3090,7 +3108,7 @@ export const i18n = { ttsImportSubmit: "Import", ttsImportAddSubmit: "Add", ttsImportFailed: "Import failed: {error}", - ttsImportNoLibrary: "There is no writable character library. Add one in the setup card first.", + ttsImportNoLibrary: "The storage place cannot be written: {path}. Choose another one on the setup card.", ttsImportNoFiles: "No files added yet", ttsImportStillUploading: "{count} files are still uploading, please wait", ttsImportNameMissing: "Enter a character name", diff --git a/web/modules/tts_setup_api.js b/web/modules/tts_setup_api.js index b7abcc7..83414dd 100644 --- a/web/modules/tts_setup_api.js +++ b/web/modules/tts_setup_api.js @@ -1,8 +1,8 @@ import { invalidateEngineCache } from './audio_engines.js'; /** - * GPT-SoVITS setup and import through the Anomalous_TTS node (interface v3, §5.2–5.3). - * The node owns every file: character libraries, pretrained files and imported + * GPT-SoVITS setup and import through the Anomalous_TTS node (interface v4, §5.2–5.3). + * The node owns every file: the storage place, pretrained files and imported * characters. This module only calls its API and holds the pure rules the import * form needs; it has no DOM. */ @@ -46,11 +46,18 @@ async function request(url, { method = 'POST', body, raw, signal, keepalive } = return data; } -// ---------- libraries, pretrained files ---------- +// ---------- storage place, pretrained files ---------- -/** Add (or remove) a character library. Returns the new status. */ -export async function changeLibrary(path, remove = false) { - const status = await request('/anomalous_tts/libraries', { body: { path, remove } }); +/** Make `path` the storage place; `move` moves the current characters there (in the background). */ +export async function changeStorage(path, move) { + const status = await request('/anomalous_tts/storage', { body: { path, move } }); + invalidateEngineCache(); + return status; +} + +/** Stop reading an earlier storage place; its files stay. */ +export async function forgetLibrary(path) { + const status = await request('/anomalous_tts/libraries', { body: { path, remove: true } }); invalidateEngineCache(); return status; } @@ -228,7 +235,8 @@ export function setupSummary(status) { const missing = (status?.pretrained || []).filter(item => item.state !== 'ok'); const packages = Object.values(status?.dependencies || {}).reduce((sum, dep) => sum + (dep.missing?.length || 0), 0); const downloading = missing.some(item => item.state === 'queued' || item.state === 'downloading'); + const moving = status?.move?.state === 'moving'; const requiredMissing = missing.filter(item => item.required).length; - return { characters, missing: missing.length, requiredMissing, packages, downloading, - ready: characters > 0 && requiredMissing === 0 && packages === 0 }; + return { characters, missing: missing.length, requiredMissing, packages, downloading, moving, + ready: characters > 0 && requiredMissing === 0 && packages === 0 && !moving }; } diff --git a/web/modules/ui_tts_import.js b/web/modules/ui_tts_import.js index c84a5f0..4c29823 100644 --- a/web/modules/ui_tts_import.js +++ b/web/modules/ui_tts_import.js @@ -102,12 +102,12 @@ export async function openTtsImport({ files = [], target: initialTarget = null, await anomalousAlert(t('ttsSetupRemote')); return; } - const libraries = status.libraries.filter(lib => lib.writable); - if (!initialTarget && !libraries.length) { - await anomalousAlert(t('ttsImportNoLibrary')); + // New characters always go to the storage place (changed on the setup card). + const home = status.libraries.find(lib => lib.storage); + if (!initialTarget && !home?.writable) { + await anomalousAlert(t('ttsImportNoLibrary', { path: status.storage })); return; } - const defaultLibrary = (libraries.find(lib => lib.source === 'default') || libraries[0])?.path || ''; activeScope?.dispose(); const scope = createViewScope(); @@ -161,28 +161,12 @@ export async function openTtsImport({ files = [], target: initialTarget = null, const nameField = el('label', 'anomalous-voice-field'); nameField.append(el('span', 'anomalous-voice-field-label', t('ttsImportName')), nameInput); const conflictLine = el('div', 'anomalous-tts-import-conflict'); - const librarySelect = el('select', 'anomalous-uploader-input'); - for (const lib of libraries) { - const option = el('option', '', t('ttsImportLibraryOption', { path: lib.path, count: lib.characters })); - option.value = lib.path; - librarySelect.append(option); - } - librarySelect.value = defaultLibrary; - librarySelect.hidden = true; // one standard place; shown only when asked for - const saveTo = el('span', 'anomalous-tts-hint'); - const changeLocation = button('anomalous-tts-link', t('ttsImportChangeLocation'), () => { - librarySelect.hidden = false; - changeLocation.hidden = true; - librarySelect.focus(); - }); - changeLocation.hidden = libraries.length < 2; - const saveLine = el('div', 'anomalous-tts-import-saveto'); - saveLine.append(saveTo, changeLocation); + const saveTo = el('div', 'anomalous-tts-hint'); const nameBlock = el('div', 'anomalous-tts-import-target'); - nameBlock.append(nameField, conflictLine, saveLine, librarySelect); + nameBlock.append(nameField, conflictLine, saveTo); const syncSaveTo = () => { - saveTo.textContent = t('ttsImportSaveTo', { path: `${librarySelect.value}/${nameInput.value.trim() || '…'}` }); + saveTo.textContent = t('ttsImportSaveTo', { path: `${status.storage}/${nameInput.value.trim() || '…'}` }); }; const syncConflict = () => { conflict = target ? null : nameConflict(nameInput.value, existing); @@ -196,7 +180,6 @@ export async function openTtsImport({ files = [], target: initialTarget = null, } }; nameInput.oninput = () => { nameEdited = true; syncSaveTo(); syncConflict(); }; - librarySelect.onchange = syncSaveTo; // ---- adding files ---- const fileInput = el('input'); @@ -415,7 +398,7 @@ export async function openTtsImport({ files = [], target: initialTarget = null, if (row.controller.signal.aborted) return; try { await uploadFile(row.file, { - library: target ? undefined : librarySelect.value, + library: target ? undefined : status.storage, signal: row.controller.signal, onStart: (id) => { row.uploadId = id; }, onProgress: (fraction) => { row.progress = fraction; renderState(row); }, @@ -480,7 +463,7 @@ export async function openTtsImport({ files = [], target: initialTarget = null, } const index = ready.findIndex(row => row.key === referenceKey); const body = buildImportBody({ - target, library: librarySelect.value, character: nameInput.value.trim(), language: languageSelect.value, + target, library: status.storage, character: nameInput.value.trim(), language: languageSelect.value, referenceIndex: index >= 0 ? index : null, rows: ready.map(row => ({ spec: row.spec, kind: row.kind, emotion: row.emotion, text: row.text })), }); diff --git a/web/modules/ui_tts_setup.js b/web/modules/ui_tts_setup.js index 86650bd..67def80 100644 --- a/web/modules/ui_tts_setup.js +++ b/web/modules/ui_tts_setup.js @@ -1,15 +1,16 @@ import { t } from './interface_settings.js'; import { anomalousAlert, anomalousConfirm } from './ui_dialog.js'; import { loadGptSovitsStatus } from './audio_engines.js'; -import { changeLibrary, changePretrainedSource, setupSummary, startPretrainedDownload } from './tts_setup_api.js'; +import { changePretrainedSource, changeStorage, forgetLibrary, setupSummary, startPretrainedDownload } from './tts_setup_api.js'; import { formatSize, pickServerPath } from './ui_tts_path_picker.js'; /** - * GPT-SoVITS setup card at the top of the audio studio: character libraries, + * GPT-SoVITS setup card at the top of the audio studio: the storage place (change it, + * optionally moving the characters there), other places that still hold characters, * pretrained files (download or use an existing GPT-SoVITS package) and missing * Python packages, from the node's `/anomalous_tts/status`. Collapsed to one line - * once everything is ready. While a download runs the card polls the status and - * redraws itself; it stops as soon as it is no longer in the page. + * once everything is ready. While a download or a move runs the card polls the + * status and redraws itself; it stops as soon as it is no longer in the page. */ const OPEN_KEY = 'anomalous_tts_setup_open'; @@ -55,38 +56,76 @@ async function act(btn, work, onChanged) { function summaryText(status, summary) { const parts = [t('ttsSetupCharacters', { count: summary.characters })]; + if (summary.moving) parts.push(t('ttsSetupMoving', { done: status.move.done, total: status.move.total })); parts.push(summary.downloading ? t('ttsSetupDownloading') : summary.missing ? t('ttsSetupPretrainedMissing', { count: summary.missing }) : t('ttsSetupPretrainedOk')); if (summary.packages) parts.push(t('ttsSetupPackagesMissing', { count: summary.packages })); return parts.join(' · '); } -function renderLibraries(status, local, onChanged) { +/** Progress or result of the last move, shown only while it concerns the current storage place. */ +function moveLine(move, storage) { + if (!move || move.to.toLowerCase() !== storage.toLowerCase()) return null; + if (move.state === 'moving') { + const copied = move.bytes_total ? t('ttsStorageMovingBytes', { percent: Math.floor(100 * move.bytes_done / move.bytes_total) }) : ''; + return el('div', 'anomalous-tts-state is-busy', t('ttsStorageMoving', { done: move.done, total: move.total, current: move.current || '…' }) + copied); + } + if (move.state === 'error') { + return el('div', 'anomalous-tts-state is-error', t('ttsStorageMoveError', { error: move.error, done: move.done, total: move.total })); + } + return el('div', 'anomalous-tts-state is-ok', t('ttsStorageMoveDone', { count: move.done })); +} + +/** Ask where to, and whether the characters already there come along. */ +async function pickStorage(status) { + const path = await pickServerPath({ mode: 'folder', title: t('ttsStorageChangeTitle'), hint: t('ttsStorageChangeHint') }); + if (!path) return false; + const here = status.libraries.find(lib => lib.storage)?.characters || 0; + let move = false; + if (here) { + move = await anomalousConfirm(t('ttsStorageMoveAsk', { count: here, path }), 'Anomalous', + { okLabel: t('ttsStorageMoveYes'), noLabel: t('ttsStorageMoveNo') }); + if (move === null) return false; + } + await changeStorage(path, move); +} + +function renderStorage(status, local, onChanged) { const group = el('div', 'anomalous-tts-setup-group'); - group.append(el('div', 'anomalous-tts-section', t('ttsSetupLibraries'))); - for (const lib of status.libraries) { - const row = el('div', 'anomalous-tts-setup-row'); - const path = el('span', 'anomalous-tts-setup-path', lib.path); - path.title = lib.path; - row.append(path, el('span', 'anomalous-tts-kind', t(`ttsLibrarySource_${lib.source}`)), + group.append(el('div', 'anomalous-tts-section', t('ttsSetupStorage'))); + const home = status.libraries.find(lib => lib.storage); + const row = el('div', 'anomalous-tts-setup-row'); + const path = el('span', 'anomalous-tts-setup-path', status.storage); + path.title = status.storage; + const change = button('anomalous-tts-add', t('ttsStorageChange'), () => act(change, () => pickStorage(status), onChanged)); + change.disabled = !local || status.move?.state === 'moving'; + row.append(path, el('span', 'anomalous-tts-setup-meta', t('ttsSetupCharacters', { count: home?.characters || 0 })), change); + group.append(row); + const move = moveLine(status.move, status.storage); + if (move) group.append(move); + group.append(el('div', 'anomalous-tts-hint', t('ttsStorageHint'))); + + // Other places that still hold characters: earlier storage places, yaml folders. + const others = status.libraries.filter(lib => !lib.storage && (lib.source !== 'default' || lib.characters > 0)); + if (!others.length) return group; + group.append(el('div', 'anomalous-tts-section', t('ttsSetupOtherPlaces'))); + for (const lib of others) { + const line = el('div', 'anomalous-tts-setup-row'); + const where = el('span', 'anomalous-tts-setup-path', lib.path); + where.title = lib.path; + line.append(where, el('span', 'anomalous-tts-kind', t(`ttsLibrarySource_${lib.source}`)), el('span', 'anomalous-tts-setup-meta', lib.exists ? t('ttsSetupCharacters', { count: lib.characters }) : t('ttsLibraryMissing'))); if (lib.source === 'app') { const remove = button('anomalous-tts-remove', '×', () => act(remove, async () => { if (!await anomalousConfirm(t('ttsLibraryRemoveConfirm', { path: lib.path }))) return false; - await changeLibrary(lib.path, true); + await forgetLibrary(lib.path); }, onChanged), t('ttsLibraryRemove')); remove.disabled = !local; - row.append(remove); + line.append(remove); } - group.append(row); + group.append(line); } - const add = button('anomalous-tts-add', t('ttsLibraryAdd'), () => act(add, async () => { - const path = await pickServerPath({ mode: 'folder', title: t('ttsLibraryAdd'), hint: t('ttsLibraryAddHint') }); - if (!path) return false; - await changeLibrary(path); - }, onChanged)); - add.disabled = !local; - group.append(add, el('div', 'anomalous-tts-hint', t('ttsLibraryHint'))); + group.append(el('div', 'anomalous-tts-hint', t('ttsOtherPlacesHint'))); return group; } @@ -195,24 +234,27 @@ export function renderTtsSetup(status, { onChanged, onImport }) { const body = el('div', 'anomalous-tts-setup-body'); if (!local) body.append(el('div', 'anomalous-tts-setup-notice', t('ttsSetupRemote'))); - body.append(renderLibraries(status, local, onChanged), renderPretrained(status, local, onChanged)); + body.append(renderStorage(status, local, onChanged), renderPretrained(status, local, onChanged)); const packages = renderPackages(status); if (packages) body.append(packages); card.classList.toggle('is-open', open); card.append(head, body); - if (summary.downloading) pollWhileDownloading(card, { onChanged, onImport }); + if (summary.downloading || summary.moving) pollWhileBusy(card, { onChanged, onImport }, summary.moving); return card; } -function pollWhileDownloading(card, options) { +/** Redraw the card with fresh status; when a move ends, redraw the whole studio (characters moved). */ +function pollWhileBusy(card, options, moving) { setTimeout(async () => { if (!card.isConnected) return; try { const status = await loadGptSovitsStatus({ force: true }); - if (card.isConnected && status) card.replaceWith(renderTtsSetup(status, options)); + if (!card.isConnected || !status) return; + if (moving && status.move?.state !== 'moving') options.onChanged(); + else card.replaceWith(renderTtsSetup(status, options)); } catch (_) { - if (card.isConnected) pollWhileDownloading(card, options); + if (card.isConnected) pollWhileBusy(card, options, moving); } }, POLL_MS); } diff --git a/web/styles.css b/web/styles.css index 0b6d20c..6fdede4 100644 --- a/web/styles.css +++ b/web/styles.css @@ -10,6 +10,6 @@ @import url("./styles/09-prompt-drawers.css?v=20260918-model-sources-1"); @import url("./styles/10-model-sources.css?v=20260918-model-sources-1"); @import url("./styles/11-audio-studio.css?v=20260925-tts-cards-1"); -@import url("./styles/12-tts-setup.css?v=20260925-tts-setup-3"); +@import url("./styles/12-tts-setup.css?v=20260925-tts-setup-4"); @import url("./styles/13-script-director.css?v=20260923"); @import url("./styles/14-version-manager.css"); diff --git a/web/styles/12-tts-setup.css b/web/styles/12-tts-setup.css index 21bf168..dd4f252 100644 --- a/web/styles/12-tts-setup.css +++ b/web/styles/12-tts-setup.css @@ -191,13 +191,14 @@ } .anomalous-tts-import-target[hidden], -.anomalous-tts-import-target select[hidden], -.anomalous-tts-link[hidden], .anomalous-tts-import-empty[hidden] { display: none; } -.anomalous-tts-import-saveto, +.anomalous-tts-import-target > .anomalous-tts-hint { + overflow-wrap: anywhere; +} + .anomalous-tts-import-conflict { display: flex; flex-wrap: wrap; @@ -206,10 +207,6 @@ min-width: 0; } -.anomalous-tts-import-saveto .anomalous-tts-hint { - overflow-wrap: anywhere; -} - .anomalous-tts-import-conflict { color: #fbbf24; font-size: 12px;