feat(audio): GPT-SoVITS setup card and character import

Uses Anomalous_TTS interface v3 so a new user can get going without
editing extra_model_paths.yaml or sorting model folders by hand.

- Setup card on the GPT-SoVITS studio: character libraries (add/remove),
  pretrained files (per-item or all download with progress, or use an
  existing GPT-SoVITS package) and missing Python packages with the
  install command. Collapses to one line when ready.
- Import form: drop files, pick them in the browser, or pick them on the
  ComfyUI machine (server-side path picker, no upload). Name, SoVITS
  version, clip length, lines, main voice and language are filled in from
  the node's inspect; emotions per clip. Uploads go in 8 MB chunks.
- "Add files" on a card, or a drop onto it, adds clips and emotions to
  that character without replacing its main voice.
- Hidden for nodes without v3; disabled when the page is not opened on
  the ComfyUI machine.
- New modules: tts_setup_api.js (calls + pure form rules),
  ui_tts_setup.js, ui_tts_import.js, ui_tts_path_picker.js and
  styles/12-tts-setup.css. ui_audio_studio.js only wires them in.

Tested: node tests/tts_setup_api.mjs (new, local) plus the audio engine,
node target and script rule tests. Isolated ComfyUI with both plugins in
the browser: setup card for a fresh user, package as pretrained source,
library add, import from local paths (lines found in all.txt), file drop
onto a card (chunked upload, add mode), cancel discards the upload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DemonGatanjieu
2026-09-25 19:37:42 +08:00
co-authored by Claude Opus 5.5
parent ad8596ad48
commit ed86a92aeb
12 changed files with 1638 additions and 13 deletions
+16 -4
View File
@@ -134,10 +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 2
(`/anomalous_tts/characters`, `/audio`, `/settings`; the node repo's
`docs/INTERFACE.md`, mirrored as the project doc `anomalous-tts-interface.md`);
Anomalous never reads or writes its model folders. The character list is a
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`,
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
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
@@ -147,6 +149,16 @@ 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`,
`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
files; 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.
- `audio_node_targets.js` is the single table of canvas nodes the audio studio
writes into and what each takes: F5-TTS (`F5TTSAudio`, `F5TTSAudioAdvanced`,
`F5TTSAudioFromModel`) takes a whole character (main voice in `sample`,
+1
View File
@@ -10,6 +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. Needs Anomalous_TTS with interface version 3; 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.
+27 -2
View File
@@ -110,10 +110,33 @@ Load Audio 正好相反:它只用一条具体的音频,所以要拖某一行
## 6. GPT-SoVITS(Anomalous TTS)
### 角色放在哪
### 准备状态
切到 GPT-SoVITS 后,最上面是 **GPT-SoVITS 准备状态**。全部就绪时它收成一行,点一下展开:
- **角色库**:节点从哪些文件夹里找角色,每个有几个角色。**+ 添加角色库** 选一个你已经整理好的文件夹(里面每个子文件夹是一个角色),马上生效,不用重启,也不用改 `extra_model_paths.yaml`。在这里添加的可以再移除;移除只是不再列出,不会删文件。
- **底模**:每个底模是已就绪、缺少还是下载中。可以单个 **下载**、**全部下载**,或者 **使用已有的 GPT-SoVITS 整合包**(选整合包里的 `GPT_SoVITS` 文件夹),就不用再下一遍。只说日语的角色不需要中文底模;标“可选”的缺了也能用。
- **Python 包**:缺了哪种语言要的包,会给出安装命令,复制到命令行运行后重启 ComfyUI。
这些操作会读写运行 ComfyUI 那台电脑上的文件,所以只能在那台电脑上用;从别的设备打开时按钮是灰的。
### 导入角色(新用户从这里开始)
点 **+ 导入角色**,或者直接把文件拖到音频页上:
1. 放进 GPT 权重(`.ckpt`)、SoVITS 权重(`.pth`)、参考音频,最好再加上 GPT-SoVITS 的标注文件(`.list` 或同格式的 `.txt`)。可以拖进来、用 **选择文件…**,或者用 **从本机路径选择…** 直接选电脑上的文件(比如整合包里的 `GPT_weights_v2`,不用上传,更快)。
2. 表单会自动填好:角色名(取自 GPT 文件名)、SoVITS 的版本(v3 / v4 暂不支持,会提示)、每段音频的长度和台词(从标注文件里找)、主声音(第一段 3~10 秒且有台词的)、语言。都可以改。
3. 其他音频填上情绪名(例如“开心”),就成了 `{开心}`。
4. 选放到哪个角色库,点 **导入**。
节点会建好 `角色名/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` 目录。
- 同一个角色有日配、中配两个版本时,会显示成 `阿罗娜/日配`、`阿罗娜/中配` 两个角色。
### 编辑情绪
@@ -150,3 +173,5 @@ Load Audio 正好相反:它只用一条具体的音频,所以要拖某一行
| 提示“这是 GPT-SoVITS 的声音,F5-TTS 节点用不了” | 两种引擎的声音不能混用。切到对应的引擎,拖到它自己的节点上 |
| GPT-SoVITS 角色卡片上提示“没有找到合适的主声音” | 角色文件夹里没有 3~10 秒、找得到台词的音频。点“编辑情绪”手动选一条 |
| 新放进去的 GPT-SoVITS 角色或音频没出现 | 点 **刷新音色**。节点那边在 ComfyUI 里按 **R** 刷新下拉框 |
| 看不到“准备状态”和“导入角色” | Anomalous_TTS 版本太旧(没有接口版本 3)。更新 Anomalous_TTS 后重启 ComfyUI |
| “导入角色”是灰的 | 页面是从别的设备打开的。导入会写文件,只能在运行 ComfyUI 的电脑上做 |
+20
View File
@@ -174,9 +174,29 @@ async function loadGroups(engineId) {
export function invalidateEngineCache({ rescan = false } = {}) {
loadCache.clear();
installCache = null;
statusCache = null;
if (rescan) rescanNext = true;
}
let statusCache = null; // { at, promise } — GPT-SoVITS setup status
/**
* The GPT-SoVITS node's setup status (libraries, pretrained files, packages; interface v3),
* or null for a node that predates it (no `/anomalous_tts/status`). Cached like loadEngine;
* `force` skips the cache (download progress polling).
*/
export function loadGptSovitsStatus({ force = false } = {}) {
if (!force && fresh(statusCache)) return statusCache.promise;
const promise = getJson('/anomalous_tts/status').then(({ ok, status, data }) => {
if (status === 404) return null;
if (!ok || !data) throw new Error(`HTTP ${status}`);
return data;
});
statusCache = { at: Date.now(), promise };
promise.catch(() => { if (statusCache?.promise === promise) statusCache = null; });
return promise;
}
/**
* { installed, groups, error } for one engine. The F5-TTS library is the plugin's own
* input/F5-TTS folder, so it is listed even when the F5-TTS node is not installed.
+190 -2
View File
@@ -1368,7 +1368,101 @@ export const i18n = {
ttsCharacterError: "这个角色读取失败:{error}",
ttsMainMissing: "没有找到合适的主声音(3~10 秒的参考音频)。点“编辑情绪”选一条。",
ttsEmptyTitle: "还没有 GPT-SoVITS 角色",
ttsEmptyDesc: "把角色文件夹(里面有 .ckpt 和 .pth)放进 ComfyUI/models/gpt_sovits/,或在 extra_model_paths.yaml 里加上 gpt_sovits 目录,然后点“刷新音色”。",
ttsEmptyDesc: "点上面的“导入角色”,或者直接把 GPT(.ckpt)、SoVITS(.pth)和参考音频拖到这里。已经整理好的角色文件夹可以用“添加角色库”。",
ttsSetupTitle: "GPT-SoVITS 准备状态",
ttsSetupCharacters: "{count} 个角色",
ttsSetupPretrainedOk: "底模齐全",
ttsSetupPretrainedMissing: "缺 {count} 个底模",
ttsSetupDownloading: "底模下载中…",
ttsSetupPackagesMissing: "缺 {count} 个 Python 包",
ttsSetupLibraries: "角色库",
ttsSetupPretrained: "底模",
ttsSetupPackages: "Python 包",
ttsSetupRemote: "这些操作只能在运行 ComfyUI 的那台电脑上进行(当前是从别的设备打开的)。",
ttsSetupFailed: "操作失败:{error}",
ttsLibrarySource_default: "默认",
ttsLibrarySource_yaml: "yaml 配置",
ttsLibrarySource_app: "在这里添加",
ttsLibraryMissing: "文件夹不存在",
ttsLibraryAdd: "+ 添加角色库",
ttsLibraryAddHint: "选一个已经整理好的文件夹:里面每个子文件夹是一个角色(有 .ckpt 和 .pth)。不会复制或移动任何文件。",
ttsLibraryHint: "角色库里每个子文件夹是一个角色。添加后马上生效,不用重启,也不用改 extra_model_paths.yaml。",
ttsLibraryRemove: "移除",
ttsLibraryRemoveConfirm: "不再使用这个角色库?\n{path}\n\n文件不会被删除,只是不再列出里面的角色。",
ttsNeededFor_all: "所有模型",
ttsNeededFor_zh: "中文",
ttsNeededFor_en: "英语",
ttsNeededFor_ja: "日语",
ttsNeededFor_v2pro: "v2Pro 模型",
ttsPretrainedOk: "已就绪",
ttsPretrainedMissing: "缺少",
ttsPretrainedOptional: "缺少(可选)",
ttsPretrainedQueued: "等待下载",
ttsPretrainedDownloading: "下载中 {percent}%",
ttsPretrainedError: "下载失败",
ttsPretrainedDownload: "下载(约 {size})",
ttsPretrainedDownloadAll: "全部下载(约 {size})",
ttsPretrainedUsePackage: "使用已有的 GPT-SoVITS 整合包",
ttsPretrainedPackageHint: "选择整合包里的 GPT_SoVITS 文件夹(里面有 pretrained_models 和 text),就不用再下载底模了。",
ttsPretrainedSource: "底模来源",
ttsPretrainedHint: "底模也会在第一次生成时自动下载;只说日语的角色不需要中文底模。标“可选”的缺了也能用,只是效果差一点。",
ttsPackagesFor: "{lang}缺少 {packages}",
ttsPackagesHint: "在命令行运行上面的命令,然后重启 ComfyUI。",
ttsCopyCommand: "复制命令",
ttsCopied: "已复制",
ttsKind_gpt: "GPT",
ttsKind_sovits: "SoVITS",
ttsKind_audio: "音频",
ttsKind_text: "台词",
ttsPickFolderTitle: "选择文件夹",
ttsPickFilesTitle: "选择文件",
ttsPickPathPlaceholder: "输入或粘贴路径,按回车打开",
ttsPickUp: "上一级",
ttsPickGo: "打开",
ttsPickThisFolder: "选择这个文件夹",
ttsPickAddFiles: "添加选中的文件({count})",
ttsPickEmpty: "这里没有可用的文件或文件夹",
ttsPickTruncated: "文件太多,只显示了前一部分",
ttsImportOpen: "+ 导入角色",
ttsImportAddOpen: "添加文件",
ttsImportTitle: "导入 GPT-SoVITS 角色",
ttsImportSubtitle: "放入 GPT 权重(.ckpt)、SoVITS 权重(.pth)、参考音频和台词文件,自动建好角色文件夹。所有文件都是复制,原文件不动。",
ttsImportAddTitle: "添加文件 · {name}",
ttsImportAddSubtitle: "给这个角色加参考音频、情绪或新的权重。文件会复制进角色文件夹,已有的文件不会被覆盖。",
ttsImportName: "角色名",
ttsImportNamePlaceholder: "例如 阿罗娜",
ttsImportLibrary: "放到哪个角色库",
ttsImportLibraryOption: "{path}({count} 个角色)",
ttsImportDropHint: "把文件拖到这里(可以一次拖多个)",
ttsImportDropFormats: ".ckpt · .pth · WAV / FLAC / OGG / MP3 · .txt / .list(GPT-SoVITS 标注文件,用来自动找台词)",
ttsImportPickFiles: "选择文件…",
ttsImportPickLocal: "从本机路径选择…",
ttsImportPickLocalHint: "直接选择电脑上的文件(比如整合包里的 GPT_weights_v2 / SoVITS_weights_v2),不用上传,更快。",
ttsImportSkipped: "跳过了不支持或重复的文件:{files}",
ttsImportRowUploading: "上传中 {percent}%",
ttsImportRowReady: "就绪",
ttsImportRowError: "失败:{error}",
ttsImportSeconds: "{seconds} 秒",
ttsImportUnsupported: "{version}(不支持)",
ttsImportMain: "主声音",
ttsImportMainTitle: "设为主声音(剧本里没标情绪的部分用它)",
ttsImportEmotionPlaceholder: "情绪名(可选)",
ttsImportTextPlaceholder: "参考台词(没找到就手动粘贴)",
ttsImportLanguage: "角色说的语言",
ttsImportLanguageAuto: "自动判断",
ttsImportLanguageAutoFound: "自动(识别为{lang})",
ttsImportNote: "会建立:{path}",
ttsImportAddNote: "文件会加进「{name}」的角色文件夹。",
ttsImportSubmit: "导入",
ttsImportAddSubmit: "添加",
ttsImportFailed: "导入失败:{error}",
ttsImportNoLibrary: "没有可以写入的角色库。先在准备状态里添加一个角色库。",
ttsImportNoFiles: "还没有放入文件",
ttsImportStillUploading: "还有 {count} 个文件在上传,请稍等",
ttsImportNameMissing: "请填写角色名",
ttsImportWeightsMissing: "新角色至少要有一个 GPT 权重(.ckpt)和一个 SoVITS 权重(.pth)",
ttsDropNew: "松开:导入为新角色",
ttsDropAddTo: "松开:添加到「{name}」",
scriptDirectorMainMissingGptSovits: "这个角色还没有主声音,请先在卡片上点“编辑情绪”选一条参考音频",
scriptDirectorNoCharactersGptSovits: "还没有 GPT-SoVITS 角色。",
audioDragCardHint: "拖标题栏 → F5-TTS 节点 · 拖单条 → Load Audio · 点 {标签} 复制",
@@ -2868,7 +2962,101 @@ export const i18n = {
ttsCharacterError: "This character could not be read: {error}",
ttsMainMissing: "No suitable main voice (a 3–10 s reference clip) was found. Use \"Edit emotions\" to pick one.",
ttsEmptyTitle: "No GPT-SoVITS characters yet",
ttsEmptyDesc: "Put character folders (with .ckpt and .pth) into ComfyUI/models/gpt_sovits/, or add a gpt_sovits folder in extra_model_paths.yaml, then press Refresh Voices.",
ttsEmptyDesc: "Press \"Import character\" above, or drop the GPT (.ckpt), SoVITS (.pth) and reference clips here. Folders you already organised can be added with \"Add library\".",
ttsSetupTitle: "GPT-SoVITS setup",
ttsSetupCharacters: "{count} characters",
ttsSetupPretrainedOk: "pretrained files ready",
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}",
ttsLibrarySource_default: "default",
ttsLibrarySource_yaml: "yaml config",
ttsLibrarySource_app: "added here",
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.",
ttsNeededFor_all: "all models",
ttsNeededFor_zh: "Chinese",
ttsNeededFor_en: "English",
ttsNeededFor_ja: "Japanese",
ttsNeededFor_v2pro: "v2Pro models",
ttsPretrainedOk: "ready",
ttsPretrainedMissing: "missing",
ttsPretrainedOptional: "missing (optional)",
ttsPretrainedQueued: "queued",
ttsPretrainedDownloading: "downloading {percent}%",
ttsPretrainedError: "download failed",
ttsPretrainedDownload: "Download (~{size})",
ttsPretrainedDownloadAll: "Download all (~{size})",
ttsPretrainedUsePackage: "Use an existing GPT-SoVITS package",
ttsPretrainedPackageHint: "Pick the package's GPT_SoVITS folder (it has pretrained_models and text); nothing needs downloading then.",
ttsPretrainedSource: "pretrained source",
ttsPretrainedHint: "Pretrained files are also downloaded on first use; Japanese-only characters do not need the Chinese ones. \"Optional\" ones only improve quality.",
ttsPackagesFor: "{lang}: missing {packages}",
ttsPackagesHint: "Run the command above in a terminal, then restart ComfyUI.",
ttsCopyCommand: "Copy command",
ttsCopied: "Copied",
ttsKind_gpt: "GPT",
ttsKind_sovits: "SoVITS",
ttsKind_audio: "audio",
ttsKind_text: "text",
ttsPickFolderTitle: "Choose a folder",
ttsPickFilesTitle: "Choose files",
ttsPickPathPlaceholder: "Type or paste a path, press Enter",
ttsPickUp: "Up",
ttsPickGo: "Open",
ttsPickThisFolder: "Choose this folder",
ttsPickAddFiles: "Add selected files ({count})",
ttsPickEmpty: "No usable files or folders here",
ttsPickTruncated: "Too many files: only the first ones are shown",
ttsImportOpen: "+ Import character",
ttsImportAddOpen: "Add files",
ttsImportTitle: "Import a GPT-SoVITS character",
ttsImportSubtitle: "Add the GPT weight (.ckpt), SoVITS weight (.pth), reference clips and line files; the character folder is built for you. Everything is copied; your files stay where they are.",
ttsImportAddTitle: "Add files · {name}",
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",
ttsImportLibrary: "Library",
ttsImportLibraryOption: "{path} ({count} characters)",
ttsImportDropHint: "Drop files here (several at once is fine)",
ttsImportDropFormats: ".ckpt · .pth · WAV / FLAC / OGG / MP3 · .txt / .list (GPT-SoVITS annotation files, used to find the lines)",
ttsImportPickFiles: "Choose files…",
ttsImportPickLocal: "Choose from this computer…",
ttsImportPickLocalHint: "Pick files on the computer directly (e.g. a package's GPT_weights_v2 / SoVITS_weights_v2): no upload, faster.",
ttsImportSkipped: "Skipped unsupported or duplicate files: {files}",
ttsImportRowUploading: "uploading {percent}%",
ttsImportRowReady: "ready",
ttsImportRowError: "failed: {error}",
ttsImportSeconds: "{seconds} s",
ttsImportUnsupported: "{version} (not supported)",
ttsImportMain: "Main voice",
ttsImportMainTitle: "Use as the main voice (for text without an emotion tag)",
ttsImportEmotionPlaceholder: "Emotion (optional)",
ttsImportTextPlaceholder: "Reference line (paste it if not found)",
ttsImportLanguage: "Character's language",
ttsImportLanguageAuto: "Detect",
ttsImportLanguageAutoFound: "Detect (found {lang})",
ttsImportNote: "Creates: {path}",
ttsImportAddNote: "Files are added to the folder of \"{name}\".",
ttsImportSubmit: "Import",
ttsImportAddSubmit: "Add",
ttsImportFailed: "Import failed: {error}",
ttsImportNoLibrary: "There is no writable character library. Add one in the setup card first.",
ttsImportNoFiles: "No files added yet",
ttsImportStillUploading: "{count} files are still uploading, please wait",
ttsImportNameMissing: "Enter a character name",
ttsImportWeightsMissing: "A new character needs at least one GPT weight (.ckpt) and one SoVITS weight (.pth)",
ttsDropNew: "Drop to import as a new character",
ttsDropAddTo: "Drop to add to \"{name}\"",
scriptDirectorMainMissingGptSovits: "This character has no main voice yet. Pick a reference clip with \"Edit emotions\" on its card first",
scriptDirectorNoCharactersGptSovits: "No GPT-SoVITS characters yet.",
audioDragCardHint: "Drag the header → F5-TTS node · drag a row → Load Audio · click a {tag} to copy",
+164
View File
@@ -0,0 +1,164 @@
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
* characters. This module only calls its API and holds the pure rules the import
* form needs; it has no DOM.
*/
export const UPLOAD_CHUNK = 8 * 1024 * 1024; // under ComfyUI's default 100 MB request limit
const IMPORT_KINDS = {
'.ckpt': 'gpt', '.pth': 'sovits',
'.wav': 'audio', '.flac': 'audio', '.ogg': 'audio', '.mp3': 'audio',
'.txt': 'text', '.list': 'text',
};
const FORBIDDEN_IN_EMOTION = /[{}[\]]/;
/** gpt | sovits | audio | text | null — the node checks again; this only avoids uploading unusable files. */
export function importKind(name) {
const dot = String(name || '').lastIndexOf('.');
return dot < 0 ? null : IMPORT_KINDS[String(name).slice(dot).toLowerCase()] || null;
}
async function request(url, { method = 'POST', body, raw, signal, keepalive } = {}) {
const headers = raw ? { 'Content-Type': 'application/octet-stream' } : body !== undefined ? { 'Content-Type': 'application/json' } : undefined;
const resp = await fetch(url, {
method, signal, keepalive, headers,
body: raw ?? (body !== undefined ? JSON.stringify(body) : undefined),
});
const text = await resp.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch (_) { /* plain-text error message */ }
if (!resp.ok) {
const error = new Error(data?.error || text || `HTTP ${resp.status}`);
error.status = resp.status;
error.data = data;
throw error;
}
return data;
}
// ---------- libraries, 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 } });
invalidateEngineCache();
return status;
}
/** Use (or stop using) a GPT-SoVITS package as a pretrained source. Returns the new status. */
export async function changePretrainedSource(path, remove = false) {
const status = await request('/anomalous_tts/pretrained/source', { body: { path, remove } });
invalidateEngineCache();
return status;
}
/** Start background downloads; progress shows up in the status. */
export async function startPretrainedDownload(ids) {
await request('/anomalous_tts/pretrained/download', { body: { ids } });
invalidateEngineCache();
}
export function browseFolder(path, signal) {
return request(`/anomalous_tts/browse${path ? `?path=${encodeURIComponent(path)}` : ''}`, { method: 'GET', signal });
}
// ---------- import ----------
/**
* Upload one browser File in chunks. `onStart(id)` gets the upload id at once so the
* caller can discard it if the form is closed; a 409 resumes from what the node has.
*/
export async function uploadFile(file, { library, signal, onStart, onProgress } = {}) {
const { upload } = await request('/anomalous_tts/import/upload', { body: { name: file.name, size: file.size, library }, signal });
onStart?.(upload);
let offset = 0;
while (offset < file.size) {
try {
const { received } = await request(`/anomalous_tts/import/upload?upload=${upload}&offset=${offset}`, {
raw: file.slice(offset, offset + UPLOAD_CHUNK), signal,
});
offset = received;
} catch (e) {
if (e.status !== 409 || !Number.isInteger(e.data?.received)) throw e;
offset = e.data.received;
}
onProgress?.(offset / file.size);
}
return upload;
}
export function inspectImport(files, signal) {
return request('/anomalous_tts/import/inspect', { body: { files }, signal });
}
/** Not tied to the form's AbortSignal: closing the form must not pretend to cancel a write. */
export async function commitImport(body) {
const result = await request('/anomalous_tts/import/commit', { body });
invalidateEngineCache();
return result.character;
}
/** Fire and forget; `keepalive` lets it finish while the page is closing. */
export function discardUploads(ids) {
if (!ids.length) return;
request('/anomalous_tts/import/discard', { body: { uploads: ids }, keepalive: true }).catch(() => {});
}
/**
* The commit request for the import form. `rows` are in file order:
* { spec: {upload}|{path}, kind, emotion, text }. `language` '' = let the node decide.
*/
export function buildImportBody({ target = null, library = '', character = '', rows, referenceIndex = null, language = '' }) {
const settings = {};
if (language) settings.language = language;
const main = referenceIndex !== null ? rows[referenceIndex] : null;
if (main?.kind === 'audio') {
settings.reference = { file: referenceIndex };
if (main.text) settings.reference.text = main.text;
if (language) settings.reference.language = language;
}
const emotions = {};
rows.forEach((row, index) => {
if (row.kind !== 'audio' || index === referenceIndex || !row.emotion) return;
emotions[row.emotion] = { file: index };
if (row.text) emotions[row.emotion].text = row.text;
});
if (Object.keys(emotions).length) settings.emotions = emotions;
const body = { files: rows.map(row => row.spec), settings };
if (target) body.target = target;
else Object.assign(body, { library, character });
return body;
}
/** First reason the form cannot be sent, as [locale key, params], or null. */
export function importProblem({ target = null, character = '', rows, uploading = 0 }) {
if (!rows.length) return ['ttsImportNoFiles', {}];
if (uploading) return ['ttsImportStillUploading', { count: uploading }];
if (!target) {
if (!character.trim()) return ['ttsImportNameMissing', {}];
if (!rows.some(row => row.kind === 'gpt') || !rows.some(row => row.kind === 'sovits')) return ['ttsImportWeightsMissing', {}];
}
const seen = new Set();
for (const row of rows) {
if (row.kind !== 'audio' || !row.emotion) continue;
if (row.emotion === 'main' || FORBIDDEN_IN_EMOTION.test(row.emotion)) return ['ttsEditorNameInvalid', { name: row.emotion }];
if (seen.has(row.emotion)) return ['ttsEditorNameDuplicate', { name: row.emotion }];
seen.add(row.emotion);
}
return null;
}
/** Summary for the setup card: what still needs doing. */
export function setupSummary(status) {
const characters = (status?.libraries || []).reduce((sum, lib) => sum + (lib.characters || 0), 0);
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 requiredMissing = missing.filter(item => item.required).length;
return { characters, missing: missing.length, requiredMissing, packages, downloading,
ready: characters > 0 && requiredMissing === 0 && packages === 0 };
}
+22 -5
View File
@@ -5,8 +5,10 @@ import { stopGalleryAudio } from './ui_audio_gallery.js';
import { openAudioUploaderModal } from './ui_audio_uploader.js';
import { getActiveAudioFilter, renderAudioSidebar, setActiveAudioFilter } from './ui_audio_sidebar.js';
import { AUDIO_NODE_TARGETS, alignedVoiceValue, planVoiceDrop } from './audio_node_targets.js';
import { AUDIO_ENGINES, detectEngines, engineById, engineTargetLabels, getStoredEngine, invalidateEngineCache, loadEngine, pickEngine, setStoredEngine } from './audio_engines.js';
import { AUDIO_ENGINES, detectEngines, engineById, engineTargetLabels, getStoredEngine, invalidateEngineCache, loadEngine, loadGptSovitsStatus, pickEngine, setStoredEngine } from './audio_engines.js';
import { openGptSovitsEditor } from './ui_audio_tts_editor.js';
import { renderTtsSetup } from './ui_tts_setup.js';
import { bindTtsFileDrop, openTtsImport } from './ui_tts_import.js';
import { bindMaterialDrag } from './material_drag.js';
import {
openScriptDirector,
@@ -263,10 +265,11 @@ function renderSliceRow(slice, owner) {
return row;
}
function renderCharacterCard(group, owner, { onChanged } = {}) {
function renderCharacterCard(group, owner, { onChanged, canImport = false } = {}) {
const isTts = group.engine === 'gpt_sovits';
const card = document.createElement('div');
card.className = 'anomalous-character-voice-card';
if (isTts && canImport && !group.error) card.dataset.ttsCharacter = group.character; // file drop target
const header = document.createElement('div');
header.className = 'anomalous-character-voice-header';
@@ -314,6 +317,11 @@ function renderCharacterCard(group, owner, { onChanged } = {}) {
const edit = createToolButton(SVG.EDIT, t('ttsEditorOpen'), 'is-compact');
edit.onclick = () => openGptSovitsEditor(group, { onSaved: () => onChanged?.() });
headerRight.append(edit);
if (canImport) {
const add = createToolButton(SVG.PLUS, t('ttsImportAddOpen'), 'is-compact');
add.onclick = () => openTtsImport({ target: group.character, onDone: () => onChanged?.() });
headerRight.append(add);
}
}
headerRight.append(countBadge);
header.append(titleGroup, headerRight);
@@ -614,7 +622,9 @@ export async function renderAudioStudio(container, { filter = null, owner = null
studioWrapper.lastChild.replaceWith(renderInstallCard(engine));
return;
}
const result = await loadEngine(engineId);
// The setup status only exists for GPT-SoVITS nodes with interface v3 (null otherwise).
const statusRequest = engineId === 'gpt_sovits' ? loadGptSovitsStatus().catch(() => null) : null;
const [result, ttsStatus] = await Promise.all([loadEngine(engineId), statusRequest]);
if (renderTokens.get(container) !== token) return;
if (result.error) {
studioWrapper.lastChild.replaceWith(renderStatus('anomalous-audio-status is-error', t('audioLoadFailed', { error: result.error })));
@@ -624,6 +634,14 @@ export async function renderAudioStudio(container, { filter = null, owner = null
updateScriptDirectorVoices(characters, characterFilter?.value || null, engineId);
if (!installed) studioWrapper.insertBefore(renderInstallCard(engine, { compact: true }), studioWrapper.lastChild);
const onChanged = () => { rerender(); if (owner) renderAudioSidebar(owner); };
const canImport = Boolean(ttsStatus) && ttsStatus.local !== false;
if (ttsStatus) {
const onImport = (options = {}) => openTtsImport({ ...options, onDone: onChanged });
studioWrapper.insertBefore(renderTtsSetup(ttsStatus, { onChanged, onImport: () => onImport() }), studioWrapper.lastChild);
if (canImport) bindTtsFileDrop(studioWrapper, (files, target) => onImport({ files, target }));
}
if (characterFilter) {
characters = characters.filter(group => group.group === characterFilter.value);
}
@@ -632,7 +650,6 @@ export async function renderAudioStudio(container, { filter = null, owner = null
studioWrapper.lastChild.replaceWith(renderEmptyGuide(engineId));
return;
}
const onChanged = () => { rerender(); if (owner) renderAudioSidebar(owner); };
characters.forEach(group => grid.appendChild(renderCharacterCard(group, owner, { onChanged })));
characters.forEach(group => grid.appendChild(renderCharacterCard(group, owner, { onChanged, canImport })));
studioWrapper.lastChild.replaceWith(grid);
}
+422
View File
@@ -0,0 +1,422 @@
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 { buildImportBody, commitImport, discardUploads, importKind, importProblem, inspectImport, uploadFile } from './tts_setup_api.js';
import { PICKER_OVERLAY_CLASS, formatSize, pickServerPath } from './ui_tts_path_picker.js';
/**
* Import GPT-SoVITS files as a new character, or add them to one (`target`), through
* the Anomalous_TTS node. Files come from a drop, the browser's file dialog (both are
* uploaded in chunks) or the node's folder picker (sent as paths). The node copies
* everything; the originals are never touched.
*
* Each file is one row object; its DOM is built once and updated in place, so
* upload progress and inspect results never steal focus from a field being typed in.
*/
let activeScope = null;
const LANGUAGES = ['', 'ja', 'zh', 'en'];
const ACCEPT = '.ckpt,.pth,.wav,.flac,.ogg,.mp3,.txt,.list';
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function button(className, label, onClick, title) {
const btn = el('button', className, label);
btn.type = 'button';
if (title) { btn.title = title; btn.setAttribute('aria-label', title); }
btn.onclick = onClick;
return btn;
}
function input(className, placeholder, value = '') {
const node = el('input', className);
node.type = 'text';
node.placeholder = placeholder;
node.value = value;
return node;
}
/**
* Let OS files be dropped on `root` (the studio). `onDrop(files, character)` gets the
* character of the card they were dropped on, or null. Internal drags are ignored.
*/
export function bindTtsFileDrop(root, onDrop) {
const hasFiles = e => [...(e.dataTransfer?.types || [])].includes('Files');
let marked = null;
root.dataset.dropHint = t('ttsDropNew');
const mark = (card) => {
if (marked === card) return;
marked?.classList.remove('is-file-target');
card?.classList.add('is-file-target');
marked = card;
root.dataset.dropHint = card ? t('ttsDropAddTo', { name: card.dataset.ttsCharacter }) : t('ttsDropNew');
};
const cardAt = e => e.target?.closest?.('[data-tts-character]') || null;
root.addEventListener('dragover', (e) => {
if (!hasFiles(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
root.classList.add('is-file-dragover');
mark(cardAt(e));
});
root.addEventListener('dragleave', (e) => {
if (root.contains(e.relatedTarget)) return;
root.classList.remove('is-file-dragover');
mark(null);
});
root.addEventListener('drop', (e) => {
if (!hasFiles(e)) return;
e.preventDefault();
e.stopPropagation(); // ComfyUI would try to open the files as a workflow
const card = cardAt(e);
root.classList.remove('is-file-dragover');
mark(null);
onDrop([...e.dataTransfer.files], card?.dataset.ttsCharacter || null);
});
}
/** Open the import form. `onDone(character)` runs after the node created or updated it. */
export async function openTtsImport({ files = [], target = null, onDone } = {}) {
let status;
try {
status = await loadGptSovitsStatus();
} catch (e) {
await anomalousAlert(t('ttsSetupFailed', { error: e.message || String(e) }));
return;
}
if (!status) return;
if (status.local === false) {
await anomalousAlert(t('ttsSetupRemote'));
return;
}
const libraries = status.libraries.filter(lib => lib.writable);
if (!target && !libraries.length) {
await anomalousAlert(t('ttsImportNoLibrary'));
return;
}
activeScope?.dispose();
const scope = createViewScope();
activeScope = scope;
const rows = [];
let committed = false;
let referenceKey = null;
let referenceChosen = Boolean(target); // adding files never replaces the main voice unless asked
let nameEdited = false;
let inspectTimer = null;
let inspectToken = 0;
let player = null;
let nextKey = 0;
let uploads = Promise.resolve();
const overlay = el('div', 'anomalous-voice-modal-overlay');
const modal = el('div', 'anomalous-voice-modal anomalous-tts-import');
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
const close = () => scope.dispose();
scope.onDispose(() => {
clearTimeout(inspectTimer);
player?.pause();
for (const row of rows) {
row.controller.abort();
if (row.url) URL.revokeObjectURL(row.url);
}
if (!committed) discardUploads(rows.map(row => row.uploadId).filter(Boolean));
overlay.remove();
if (activeScope === scope) activeScope = null;
});
// No close on a backdrop click: a stray click would throw away the files and uploads.
scope.listen(window, 'keydown', (e) => {
if (e.key !== 'Escape' || document.querySelector(`.anomalous-dialog-overlay, .${PICKER_OVERLAY_CLASS}`)) return;
e.stopPropagation();
close();
}, true);
// ---- header, target ----
const header = el('div', 'anomalous-voice-modal-header');
const heading = el('div', 'anomalous-voice-modal-heading');
heading.append(
el('div', 'anomalous-voice-modal-title', target ? t('ttsImportAddTitle', { name: target }) : t('ttsImportTitle')),
el('div', 'anomalous-voice-modal-subtitle', t(target ? 'ttsImportAddSubtitle' : 'ttsImportSubtitle')),
);
header.append(heading, button('anomalous-voice-modal-close', '×', close, t('close')));
const nameInput = input('anomalous-uploader-input', t('ttsImportNamePlaceholder'));
nameInput.oninput = () => { nameEdited = true; syncNote(); };
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.onchange = () => syncNote();
const targetBox = el('div', 'anomalous-tts-import-target');
if (!target) {
const nameField = el('label', 'anomalous-voice-field');
nameField.append(el('span', 'anomalous-voice-field-label', t('ttsImportName')), nameInput);
const libField = el('label', 'anomalous-voice-field');
libField.append(el('span', 'anomalous-voice-field-label', t('ttsImportLibrary')), librarySelect);
targetBox.append(nameField, libField);
}
// ---- sources ----
const fileInput = el('input');
fileInput.type = 'file';
fileInput.multiple = true;
fileInput.accept = ACCEPT;
fileInput.hidden = true;
fileInput.onchange = () => { addBrowserFiles([...fileInput.files]); fileInput.value = ''; };
const dropzone = el('div', 'anomalous-audio-dropzone anomalous-tts-import-drop');
const pickLocal = button('anomalous-tts-add', t('ttsImportPickLocal'), async (e) => {
e.stopPropagation();
const paths = await pickServerPath({ mode: 'files', title: t('ttsImportPickLocal'), hint: t('ttsImportPickLocalHint') });
if (paths && !scope.signal.aborted) addPaths(paths);
});
const pickBrowser = button('anomalous-tts-add', t('ttsImportPickFiles'), (e) => { e.stopPropagation(); fileInput.click(); });
const dropButtons = el('div', 'anomalous-tts-setup-actions');
dropButtons.append(pickBrowser, pickLocal);
dropzone.append(el('div', 'anomalous-audio-dropzone-hint', t('ttsImportDropHint')),
el('div', 'anomalous-audio-dropzone-formats', t('ttsImportDropFormats')), dropButtons, fileInput);
dropzone.ondragover = (e) => { e.preventDefault(); dropzone.classList.add('dragover'); };
dropzone.ondragleave = () => dropzone.classList.remove('dragover');
dropzone.ondrop = (e) => {
e.preventDefault();
e.stopPropagation();
dropzone.classList.remove('dragover');
addBrowserFiles([...(e.dataTransfer?.files || [])]);
};
const list = el('div', 'anomalous-tts-import-list');
const skipped = el('div', 'anomalous-tts-hint');
const problems = el('div', 'anomalous-tts-import-problems');
const languageSelect = el('select', 'anomalous-uploader-input');
const languageField = el('label', 'anomalous-voice-field anomalous-tts-import-language');
languageField.append(el('span', 'anomalous-voice-field-label', t('ttsImportLanguage')), languageSelect);
const fillLanguages = (suggested) => {
const keep = languageSelect.value;
languageSelect.replaceChildren(...LANGUAGES.map(code => {
const option = el('option', '', code ? t(`ttsNeededFor_${code}`)
: suggested ? t('ttsImportLanguageAutoFound', { lang: t(`ttsNeededFor_${suggested}`) }) : t('ttsImportLanguageAuto'));
option.value = code;
return option;
}));
languageSelect.value = keep;
};
fillLanguages(null);
const note = el('div', 'anomalous-tts-hint');
const syncNote = () => {
note.textContent = target ? t('ttsImportAddNote', { name: target })
: t('ttsImportNote', { path: `${librarySelect.value}/${nameInput.value.trim() || '…'}` });
};
syncNote();
// ---- rows ----
const readyRows = () => rows.filter(row => row.spec && !row.error);
function renderState(row) {
const { state } = row.els;
state.classList.toggle('is-error', Boolean(row.error));
state.textContent = row.error ? t('ttsImportRowError', { error: row.error })
: row.spec ? t('ttsImportRowReady') : t('ttsImportRowUploading', { percent: Math.floor(100 * row.progress) });
}
function renderMeta(row) {
const info = row.info || {};
const parts = row.size ? [formatSize(row.size)] : [];
if (info.version) parts.push(info.supported ? info.version : t('ttsImportUnsupported', { version: info.version }));
if (info.seconds !== undefined) parts.push(t('ttsImportSeconds', { seconds: info.seconds }));
row.els.meta.textContent = parts.join(' · ');
}
function syncReference() {
for (const row of rows) {
if (!row.els.main) continue;
const on = row.key === referenceKey;
row.els.main.setAttribute('aria-pressed', String(on));
row.els.main.classList.toggle('active', on);
row.els.emotion.disabled = on;
}
}
function removeRow(row) {
row.controller.abort();
if (row.uploadId) discardUploads([row.uploadId]);
if (row.url) URL.revokeObjectURL(row.url);
rows.splice(rows.indexOf(row), 1);
row.els.root.remove();
if (referenceKey === row.key) referenceKey = null;
scheduleInspect();
}
function buildRow(row) {
const root = el('div', 'anomalous-tts-import-row');
root.dataset.kind = row.kind;
const line = el('div', 'anomalous-tts-import-line');
const name = el('span', 'anomalous-tts-picker-name', row.name);
name.title = row.path || row.name;
row.els = { root, meta: el('span', 'anomalous-tts-picker-meta'), state: el('span', 'anomalous-tts-import-state') };
line.append(el('span', 'anomalous-tts-kind', t(`ttsKind_${row.kind}`)), name, row.els.meta, row.els.state,
button('anomalous-tts-remove', '×', () => removeRow(row), t('ttsEditorRemove')));
root.append(line);
if (row.kind === 'audio') {
const audio = el('div', 'anomalous-tts-import-audio');
row.els.main = button('anomalous-tag-chip', t('ttsImportMain'), () => {
referenceKey = referenceKey === row.key ? null : row.key;
referenceChosen = true;
syncReference();
}, t('ttsImportMainTitle'));
const play = button('anomalous-audio-play-btn', '▶', () => {
player?.pause();
row.url = row.url || URL.createObjectURL(row.file);
player = new Audio(row.url);
player.play().catch(() => {});
}, t('audioPlay'));
play.hidden = !row.file; // local paths cannot be played from the browser
row.els.emotion = input('anomalous-tts-name-input', t('ttsImportEmotionPlaceholder'));
row.els.emotion.oninput = () => { row.emotion = row.els.emotion.value.trim(); };
row.els.text = input('anomalous-tts-text-input', t('ttsImportTextPlaceholder'));
row.els.text.oninput = () => { row.text = row.els.text.value.trim(); row.textEdited = true; };
audio.append(row.els.main, play, row.els.emotion, row.els.text);
root.append(audio);
}
renderMeta(row);
renderState(row);
return root;
}
function addRow(fields) {
const duplicate = rows.some(row => row.kind === fields.kind && row.name.toLowerCase() === fields.name.toLowerCase());
if (duplicate) return null;
const row = { key: nextKey++, progress: 0, emotion: '', text: '', textEdited: false, uploadId: null,
spec: null, error: '', info: null, controller: new AbortController(), ...fields };
rows.push(row);
list.append(buildRow(row));
return row;
}
function reportSkipped(names) {
skipped.textContent = names.length ? t('ttsImportSkipped', { files: names.join(', ') }) : '';
}
function addBrowserFiles(fileList) {
const bad = [];
for (const file of fileList) {
const kind = importKind(file.name);
const row = kind ? addRow({ name: file.name, kind, size: file.size, file }) : null;
if (!row) { bad.push(file.name); continue; }
uploads = uploads.then(() => upload(row));
}
reportSkipped(bad);
}
function addPaths(paths) {
const bad = [];
for (const path of paths) {
const name = path.split('/').pop();
const kind = importKind(name);
const row = kind ? addRow({ name, kind, size: 0, path, spec: { path } }) : null;
if (!row) bad.push(name);
}
reportSkipped(bad);
scheduleInspect();
}
async function upload(row) {
if (row.controller.signal.aborted) return;
try {
await uploadFile(row.file, {
library: target ? undefined : librarySelect.value,
signal: row.controller.signal,
onStart: (id) => { row.uploadId = id; },
onProgress: (fraction) => { row.progress = fraction; renderState(row); },
});
row.spec = { upload: row.uploadId };
} catch (e) {
if (row.controller.signal.aborted) return;
row.error = e.message || String(e);
}
renderState(row);
scheduleInspect();
}
function scheduleInspect() {
clearTimeout(inspectTimer);
inspectTimer = setTimeout(runInspect, 250);
}
async function runInspect() {
const ready = readyRows();
if (!ready.length || rows.some(row => !row.spec && !row.error)) {
if (!ready.length) problems.replaceChildren();
return;
}
const mine = ++inspectToken;
try {
const result = await inspectImport(ready.map(row => row.spec), scope.signal);
if (mine !== inspectToken || scope.signal.aborted) return;
ready.forEach((row, i) => {
row.info = result.files[i];
if (row.info.size) row.size = row.info.size;
renderMeta(row);
if (row.els.text && !row.textEdited && row.info.text) {
row.text = row.info.text;
row.els.text.value = row.info.text;
}
});
const { suggested } = result;
if (!target && !nameEdited && suggested.name) { nameInput.value = suggested.name; syncNote(); }
if (!referenceChosen) referenceKey = suggested.reference !== null ? ready[suggested.reference]?.key ?? null : null;
syncReference();
fillLanguages(suggested.language);
problems.replaceChildren(...result.problems.map(text => el('div', 'anomalous-tts-import-problem', text)));
} catch (e) {
if (mine !== inspectToken || scope.signal.aborted) return;
problems.replaceChildren(el('div', 'anomalous-tts-import-problem is-error', e.message || String(e)));
}
}
// ---- footer ----
const footer = el('div', 'anomalous-voice-modal-footer');
const submit = button('anomalous-voice-modal-submit', t(target ? 'ttsImportAddSubmit' : 'ttsImportSubmit'), async () => {
const ready = readyRows();
const pending = rows.filter(row => !row.spec && !row.error).length;
const problem = importProblem({ target, character: nameInput.value, rows: ready, uploading: pending });
if (problem) {
await anomalousAlert(t(problem[0], problem[1]));
return;
}
const index = ready.findIndex(row => row.key === referenceKey);
const body = buildImportBody({
target, library: librarySelect.value, 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 })),
});
submit.disabled = true;
try {
const character = await commitImport(body);
committed = true;
onDone?.(character);
close();
} catch (e) {
if (!scope.signal.aborted) await anomalousAlert(t('ttsImportFailed', { error: e.message || String(e) }));
} finally {
if (!scope.signal.aborted) submit.disabled = false;
}
});
footer.append(button('anomalous-voice-modal-cancel', t('dialogCancel'), close), submit);
const body = el('div', 'anomalous-tts-editor-body');
body.append(targetBox, dropzone, skipped, list, languageField, problems, note);
modal.append(header, body, footer);
overlay.appendChild(modal);
document.body.appendChild(overlay);
addBrowserFiles(files);
(target ? pickBrowser : nameInput).focus();
}
+159
View File
@@ -0,0 +1,159 @@
import { t } from './interface_settings.js';
import { createViewScope } from './ui_lifecycle.js';
import { browseFolder } from './tts_setup_api.js';
/**
* Choose a folder, or files, on the computer running ComfyUI. The browser cannot
* see local paths, so the Anomalous_TTS node lists folders (`/anomalous_tts/browse`).
* Resolves with the folder path, an array of file paths, or null when cancelled.
*/
const LAST_PATH_KEY = 'anomalous_tts_last_path';
export const PICKER_OVERLAY_CLASS = 'anomalous-tts-picker-overlay';
const FOLDER_SVG = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/></svg>';
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function button(className, label, onClick, title) {
const btn = el('button', className, label);
btn.type = 'button';
if (title) { btn.title = title; btn.setAttribute('aria-label', title); }
btn.onclick = onClick;
return btn;
}
function lastPath() {
try { return localStorage.getItem(LAST_PATH_KEY) || ''; } catch (_) { return ''; }
}
function rememberPath(path) {
try { localStorage.setItem(LAST_PATH_KEY, path); } catch (_) { /* convenience only */ }
}
export function formatSize(bytes) {
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
}
/**
* `mode`: 'folder' (resolve with the open folder) or 'files' (tick files, resolve with their paths).
* `kinds` limits the files shown in 'files' mode (gpt, sovits, audio, text).
*/
export function pickServerPath({ mode = 'folder', title, hint = '', kinds = null } = {}) {
return new Promise(resolve => {
const scope = createViewScope();
let result = null;
let current = '';
let token = 0;
const picked = new Set();
const overlay = el('div', `anomalous-voice-modal-overlay ${PICKER_OVERLAY_CLASS}`);
const modal = el('div', 'anomalous-voice-modal anomalous-tts-picker');
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
const close = () => scope.dispose();
scope.onDispose(() => { overlay.remove(); resolve(result); });
overlay.onclick = (e) => { if (e.target === overlay) close(); };
scope.listen(window, 'keydown', (e) => {
if (e.key !== 'Escape' || document.querySelector('.anomalous-dialog-overlay')) return;
e.stopPropagation();
close();
}, true);
const header = el('div', 'anomalous-voice-modal-header');
const heading = el('div', 'anomalous-voice-modal-heading');
heading.append(el('div', 'anomalous-voice-modal-title', title || t(mode === 'folder' ? 'ttsPickFolderTitle' : 'ttsPickFilesTitle')));
if (hint) heading.append(el('div', 'anomalous-voice-modal-subtitle', hint));
header.append(heading, button('anomalous-voice-modal-close', '×', close, t('close')));
const pathInput = el('input', 'anomalous-tts-picker-path');
pathInput.type = 'text';
pathInput.placeholder = t('ttsPickPathPlaceholder');
pathInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); open(pathInput.value.trim()); } };
const upBtn = button('anomalous-tts-add', t('ttsPickUp'), () => open(upBtn.dataset.parent || ''));
const goBtn = button('anomalous-tts-add', t('ttsPickGo'), () => open(pathInput.value.trim()));
const bar = el('div', 'anomalous-tts-picker-bar');
bar.append(upBtn, pathInput, goBtn);
const list = el('div', 'anomalous-tts-picker-list');
list.setAttribute('role', 'listbox');
const status = el('div', 'anomalous-tts-hint');
const footer = el('div', 'anomalous-voice-modal-footer');
const confirm = button('anomalous-voice-modal-submit', '', () => {
if (mode === 'folder') {
if (!current) return;
result = current;
} else {
if (!picked.size) return;
result = [...picked];
}
rememberPath(current);
close();
});
const syncConfirm = () => {
confirm.textContent = mode === 'folder' ? t('ttsPickThisFolder') : t('ttsPickAddFiles', { count: picked.size });
confirm.disabled = mode === 'folder' ? !current : !picked.size;
};
footer.append(button('anomalous-voice-modal-cancel', t('dialogCancel'), close), confirm);
const fileRow = (folder, file) => {
const path = `${folder.replace(/\/$/, '')}/${file.name}`;
const row = el('label', 'anomalous-tts-picker-item is-file');
const box = el('input');
box.type = 'checkbox';
box.checked = picked.has(path);
box.onchange = () => { if (box.checked) picked.add(path); else picked.delete(path); syncConfirm(); };
row.append(box, el('span', 'anomalous-tts-picker-name', file.name),
el('span', 'anomalous-tts-kind', t(`ttsKind_${file.kind}`)), el('span', 'anomalous-tts-picker-meta', formatSize(file.size)));
return row;
};
async function open(path) {
const mine = ++token;
status.textContent = t('audioLoading');
status.classList.remove('is-error');
try {
const data = await browseFolder(path, scope.signal);
if (mine !== token || scope.signal.aborted) return;
current = data.path;
pathInput.value = data.path;
upBtn.disabled = data.parent === null;
upBtn.dataset.parent = data.parent || '';
const items = data.dirs.map(name => {
const target = data.path ? `${data.path.replace(/\/$/, '')}/${name}` : name;
const row = button('anomalous-tts-picker-item is-dir', '', () => open(target));
const icon = el('span', 'anomalous-tts-picker-icon');
icon.innerHTML = FOLDER_SVG;
row.append(icon, el('span', 'anomalous-tts-picker-name', name));
return row;
});
if (mode === 'files') {
items.push(...data.files.filter(f => !kinds || kinds.includes(f.kind)).map(f => fileRow(data.path, f)));
}
list.replaceChildren(...items);
status.textContent = !items.length ? t('ttsPickEmpty') : data.truncated ? t('ttsPickTruncated') : '';
syncConfirm();
} catch (e) {
if (mine !== token || scope.signal.aborted) return;
if (path && path === initial && !current) { open(''); return; } // remembered folder is gone
status.textContent = e.message || String(e);
status.classList.add('is-error');
}
}
modal.append(header, bar, list, status, footer);
overlay.appendChild(modal);
document.body.appendChild(overlay);
syncConfirm();
const initial = lastPath();
open(initial);
pathInput.focus();
});
}
+218
View File
@@ -0,0 +1,218 @@
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 { formatSize, pickServerPath } from './ui_tts_path_picker.js';
/**
* GPT-SoVITS setup card at the top of the audio studio: character libraries,
* 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.
*/
const OPEN_KEY = 'anomalous_tts_setup_open';
const POLL_MS = 1000;
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function button(className, label, onClick, title) {
const btn = el('button', className, label);
btn.type = 'button';
if (title) btn.title = title;
btn.onclick = onClick;
return btn;
}
function storedOpen() {
try { return localStorage.getItem(OPEN_KEY); } catch (_) { return null; }
}
function storeOpen(open) {
try { localStorage.setItem(OPEN_KEY, open ? '1' : '0'); } catch (_) { /* convenience only */ }
}
/**
* Run a change, report failures, then let the studio redraw. The button stays disabled
* while it runs; `work` returns false when the user cancelled (nothing to redraw).
*/
async function act(btn, work, onChanged) {
btn.disabled = true;
try {
if (await work() === false) btn.disabled = false;
else onChanged();
} catch (e) {
await anomalousAlert(t('ttsSetupFailed', { error: e.message || String(e) }));
btn.disabled = false;
}
}
function summaryText(status, summary) {
const parts = [t('ttsSetupCharacters', { count: summary.characters })];
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) {
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}`)),
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);
}, onChanged), t('ttsLibraryRemove'));
remove.disabled = !local;
row.append(remove);
}
group.append(row);
}
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')));
return group;
}
function pretrainedState(item) {
if (item.state === 'ok') return el('span', 'anomalous-tts-state is-ok', t('ttsPretrainedOk'));
if (item.state === 'downloading') {
const pct = item.size ? Math.min(99, Math.floor(100 * (item.done || 0) / item.size)) : 0;
return el('span', 'anomalous-tts-state is-busy', t('ttsPretrainedDownloading', { percent: pct }));
}
if (item.state === 'queued') return el('span', 'anomalous-tts-state is-busy', t('ttsPretrainedQueued'));
const state = el('span', `anomalous-tts-state ${item.state === 'error' ? 'is-error' : 'is-missing'}`,
item.state === 'error' ? t('ttsPretrainedError') : t(item.required ? 'ttsPretrainedMissing' : 'ttsPretrainedOptional'));
if (item.error) state.title = item.error;
return state;
}
function renderPretrained(status, local, onChanged) {
const group = el('div', 'anomalous-tts-setup-group');
group.append(el('div', 'anomalous-tts-section', t('ttsSetupPretrained')));
const missing = status.pretrained.filter(item => item.state === 'missing' || item.state === 'error');
for (const item of status.pretrained) {
const row = el('div', 'anomalous-tts-setup-row');
const label = el('span', 'anomalous-tts-setup-path', item.label);
label.title = item.path || item.error || '';
row.append(label, el('span', 'anomalous-tts-kind', t(`ttsNeededFor_${item.needed_for}`)), pretrainedState(item));
if (item.state === 'missing' || item.state === 'error') {
const get = button('anomalous-tts-add', t('ttsPretrainedDownload', { size: formatSize(item.size) }),
() => act(get, () => startPretrainedDownload([item.id]), onChanged));
get.disabled = !local;
row.append(get);
}
group.append(row);
}
const actions = el('div', 'anomalous-tts-setup-actions');
if (missing.length > 1) {
const total = missing.reduce((sum, item) => sum + item.size, 0);
const all = button('anomalous-tts-add', t('ttsPretrainedDownloadAll', { size: formatSize(total) }),
() => act(all, () => startPretrainedDownload(missing.map(item => item.id)), onChanged));
all.disabled = !local;
actions.append(all);
}
const useSource = button('anomalous-tts-add', t('ttsPretrainedUsePackage'), () => act(useSource, async () => {
const path = await pickServerPath({ mode: 'folder', title: t('ttsPretrainedUsePackage'), hint: t('ttsPretrainedPackageHint') });
if (!path) return false;
await changePretrainedSource(path);
}, onChanged));
useSource.disabled = !local;
actions.append(useSource);
group.append(actions);
for (const source of status.pretrained_sources || []) {
const row = el('div', 'anomalous-tts-setup-row');
const path = el('span', 'anomalous-tts-setup-path', source);
path.title = source;
const remove = button('anomalous-tts-remove', '×', () => act(remove, () => changePretrainedSource(source, true), onChanged), t('ttsLibraryRemove'));
remove.disabled = !local;
row.append(path, el('span', 'anomalous-tts-kind', t('ttsPretrainedSource')), remove);
group.append(row);
}
group.append(el('div', 'anomalous-tts-hint', t('ttsPretrainedHint')));
return group;
}
function renderPackages(status) {
const missing = Object.entries(status.dependencies || {}).filter(([, dep]) => !dep.ok);
if (!missing.length) return null;
const group = el('div', 'anomalous-tts-setup-group');
group.append(el('div', 'anomalous-tts-section', t('ttsSetupPackages')));
for (const [lang, dep] of missing) {
const row = el('div', 'anomalous-tts-setup-row is-package');
const code = el('code', 'anomalous-tts-setup-command', dep.command);
const copy = button('anomalous-tts-add', t('ttsCopyCommand'), async () => {
try { await navigator.clipboard.writeText(dep.command); copy.textContent = t('ttsCopied'); } catch (_) { /* select by hand */ }
});
row.append(el('span', 'anomalous-tts-setup-meta', t('ttsPackagesFor', { lang: t(`ttsNeededFor_${lang}`), packages: dep.missing.join(', ') })), code, copy);
group.append(row);
}
group.append(el('div', 'anomalous-tts-hint', t('ttsPackagesHint')));
return group;
}
/**
* The card for one status payload. `onChanged()` redraws the studio (libraries and
* sources change the character list); `onImport()` opens the import form.
*/
export function renderTtsSetup(status, { onChanged, onImport }) {
const summary = setupSummary(status);
const local = status.local !== false;
const card = el('section', `anomalous-tts-setup${summary.ready ? ' is-ready' : ''}`);
const stored = storedOpen();
let open = stored === null ? !summary.ready : stored === '1';
const head = el('div', 'anomalous-tts-setup-head');
const toggle = button('anomalous-tts-setup-toggle', '', () => {
open = !open;
storeOpen(open);
card.classList.toggle('is-open', open);
toggle.setAttribute('aria-expanded', String(open));
});
toggle.setAttribute('aria-expanded', String(open));
toggle.append(el('span', 'anomalous-tts-setup-dot'), el('span', 'anomalous-tts-setup-title', t('ttsSetupTitle')),
el('span', 'anomalous-tts-setup-summary', summaryText(status, summary)), el('span', 'anomalous-tts-setup-chevron', '▾'));
const importBtn = button('anomalous-voice-modal-submit anomalous-tts-setup-import', t('ttsImportOpen'), onImport);
importBtn.disabled = !local;
if (!local) importBtn.title = t('ttsSetupRemote');
head.append(toggle, importBtn);
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));
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 });
return card;
}
function pollWhileDownloading(card, options) {
setTimeout(async () => {
if (!card.isConnected) return;
try {
const status = await loadGptSovitsStatus({ force: true });
if (card.isConnected && status) card.replaceWith(renderTtsSetup(status, options));
} catch (_) {
if (card.isConnected) pollWhileDownloading(card, options);
}
}, POLL_MS);
}
+1
View File
@@ -10,5 +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-1");
@import url("./styles/13-script-director.css?v=20260923");
@import url("./styles/14-version-manager.css");
+398
View File
@@ -0,0 +1,398 @@
/* GPT-SoVITS setup card, import form, server path picker and studio file drop.
Modal shell, fields, chips and buttons come from 11-audio-studio.css. */
/* ---------- Setup card ---------- */
.anomalous-tts-setup {
margin: 0 0 12px;
border: 1px solid rgba(245, 158, 11, 0.35);
border-radius: 12px;
background: rgba(245, 158, 11, 0.05);
color: #cbd5e1;
font-size: 12px;
}
.anomalous-tts-setup.is-ready {
border-color: rgba(52, 211, 153, 0.25);
background: rgba(52, 211, 153, 0.04);
}
.anomalous-tts-setup-head {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px 8px 12px;
}
.anomalous-tts-setup-toggle {
display: flex;
flex: 1;
min-width: 0;
align-items: center;
gap: 8px;
padding: 2px 0;
border: none;
background: none;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.anomalous-tts-setup-dot {
flex-shrink: 0;
width: 8px;
height: 8px;
border-radius: 50%;
background: #f59e0b;
}
.anomalous-tts-setup.is-ready .anomalous-tts-setup-dot {
background: #34d399;
}
.anomalous-tts-setup-title {
flex-shrink: 0;
color: #f1f5f9;
font-weight: 700;
}
.anomalous-tts-setup-summary {
min-width: 0;
overflow: hidden;
color: #94a3b8;
text-overflow: ellipsis;
white-space: nowrap;
}
.anomalous-tts-setup-chevron {
margin-left: auto;
color: #64748b;
transition: transform 0.15s ease;
}
.anomalous-tts-setup.is-open .anomalous-tts-setup-chevron {
transform: rotate(180deg);
}
.anomalous-voice-modal-submit.anomalous-tts-setup-import {
flex-shrink: 0;
padding: 5px 14px;
font-size: 12px;
}
.anomalous-tts-setup-body {
display: none;
flex-direction: column;
gap: 14px;
padding: 4px 12px 12px;
}
.anomalous-tts-setup.is-open .anomalous-tts-setup-body {
display: flex;
}
.anomalous-tts-setup-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.anomalous-tts-setup-row {
display: flex;
align-items: center;
gap: 8px;
min-height: 26px;
}
.anomalous-tts-setup-row.is-package {
flex-wrap: wrap;
}
.anomalous-tts-setup-path {
flex: 1;
min-width: 0;
overflow: hidden;
color: #e2e8f0;
text-overflow: ellipsis;
white-space: nowrap;
}
.anomalous-tts-setup-meta {
flex-shrink: 0;
color: #94a3b8;
}
.anomalous-tts-setup-command {
flex: 1 1 320px;
min-width: 0;
padding: 4px 8px;
border-radius: 6px;
background: rgba(0, 0, 0, 0.3);
color: #e2e8f0;
font: 11px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
overflow-wrap: anywhere;
}
.anomalous-tts-setup-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.anomalous-tts-setup-notice {
padding: 8px 10px;
border-radius: 8px;
background: rgba(248, 113, 113, 0.08);
color: #fca5a5;
}
.anomalous-tts-kind {
flex-shrink: 0;
padding: 1px 6px;
border-radius: 4px;
background: rgba(129, 140, 248, 0.12);
color: #a5b4fc;
font-size: 10.5px;
white-space: nowrap;
}
.anomalous-tts-state {
flex-shrink: 0;
font-size: 11px;
white-space: nowrap;
}
.anomalous-tts-state.is-ok { color: #34d399; }
.anomalous-tts-state.is-busy { color: #38bdf8; }
.anomalous-tts-state.is-missing { color: #f59e0b; }
.anomalous-tts-state.is-error { color: #f87171; cursor: help; }
.anomalous-tts-add:disabled,
.anomalous-tts-remove:disabled,
.anomalous-voice-modal-submit.anomalous-tts-setup-import:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ---------- Import form ---------- */
.anomalous-tts-import {
width: 720px;
}
.anomalous-tts-import-target {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.3fr);
gap: 10px;
}
.anomalous-tts-import-target:empty {
display: none;
}
.anomalous-tts-import-drop {
cursor: default;
}
.anomalous-tts-import-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.anomalous-tts-import-row {
display: flex;
flex-direction: column;
gap: 6px;
padding: 7px 8px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
background: rgba(0, 0, 0, 0.2);
}
.anomalous-tts-import-line,
.anomalous-tts-import-audio {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
/* The play button's own display rule would override [hidden] (local paths cannot be played). */
.anomalous-tts-import-audio [hidden] {
display: none;
}
.anomalous-tts-import-audio input {
min-width: 0;
padding: 5px 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
background: rgba(0, 0, 0, 0.25);
color: #e2e8f0;
font-size: 12px;
}
.anomalous-tts-import-audio .anomalous-tts-name-input {
flex: 0 0 120px;
}
.anomalous-tts-import-audio .anomalous-tts-text-input {
flex: 1;
}
.anomalous-tts-import-audio .anomalous-tts-name-input:disabled {
opacity: 0.4;
}
.anomalous-tts-import-state {
flex-shrink: 0;
margin-left: auto;
color: #94a3b8;
font-size: 11px;
}
.anomalous-tts-import-state.is-error {
max-width: 260px;
overflow: hidden;
color: #f87171;
text-overflow: ellipsis;
white-space: nowrap;
}
.anomalous-tts-import-language {
max-width: 260px;
}
.anomalous-tts-import-problems {
display: flex;
flex-direction: column;
gap: 4px;
}
.anomalous-tts-import-problem {
padding: 5px 8px;
border-radius: 6px;
background: rgba(245, 158, 11, 0.08);
color: #fbbf24;
font-size: 11.5px;
}
.anomalous-tts-import-problem.is-error {
background: rgba(248, 113, 113, 0.08);
color: #f87171;
}
@media (max-width: 600px) {
.anomalous-tts-import-target {
grid-template-columns: 1fr;
}
.anomalous-tts-import-audio {
flex-wrap: wrap;
}
}
/* ---------- Server path picker ---------- */
.anomalous-tts-picker {
width: 620px;
}
.anomalous-tts-picker-bar {
display: flex;
gap: 6px;
}
.anomalous-tts-picker-path {
flex: 1;
min-width: 0;
padding: 6px 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
background: rgba(0, 0, 0, 0.25);
color: #e2e8f0;
font-size: 12px;
}
.anomalous-tts-picker-list {
display: flex;
flex-direction: column;
height: 320px;
overflow-y: auto;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
background: rgba(0, 0, 0, 0.2);
}
.anomalous-tts-picker-item {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 8px;
padding: 6px 10px;
border: none;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
background: none;
color: #e2e8f0;
font: inherit;
font-size: 12px;
text-align: left;
cursor: pointer;
}
.anomalous-tts-picker-item:hover,
.anomalous-tts-picker-item:focus-visible {
background: rgba(129, 140, 248, 0.1);
}
.anomalous-tts-picker-icon {
display: inline-flex;
color: #fbbf24;
}
.anomalous-tts-picker-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.anomalous-tts-picker-meta {
flex-shrink: 0;
color: #64748b;
font-size: 11px;
}
.anomalous-tts-hint.is-error {
color: #f87171;
}
/* ---------- Dropping files on the studio ---------- */
.anomalous-audio-studio-wrapper.is-file-dragover {
position: relative;
outline: 2px dashed rgba(129, 140, 248, 0.6);
outline-offset: -2px;
border-radius: 12px;
}
.anomalous-audio-studio-wrapper.is-file-dragover::after {
content: attr(data-drop-hint);
position: absolute;
top: 8px;
left: 50%;
z-index: 2;
padding: 6px 14px;
border-radius: 999px;
background: #4f46e5;
color: #fff;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
transform: translateX(-50%);
pointer-events: none;
}
.anomalous-character-voice-card.is-file-target {
outline: 2px solid #818cf8;
outline-offset: 1px;
}