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:
co-authored by
Claude Opus 5.5
parent
ad8596ad48
commit
ed86a92aeb
+16
-4
@@ -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`,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 的电脑上做 |
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user