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;