feat(tts-import): big batches stay responsive; unusable clips left out

- A folder per character: clips and line files follow the weights in their
  nearest folder, so pinyin weights, Chinese folders and English clips still
  land together (a real 34-character folder: 3065 unassigned files -> 0).
- Clips outside 3-10 s and their same-name line files are left out of the
  import and folded under "left out" instead of one warning per clip.
- GPT-SoVITS program and training folders are skipped in drops (not even
  walked) and picked folders; a very large upload (>1500 files or 2 GB)
  first offers the local folder picker.
- Uploads run three at a time, status repaints are coalesced, a card shows
  its first 60 clips, move menus are filled when opened, and the unassigned
  card folds into one line per folder that can be assigned at once.
- New ui_tts_import_sources.js brings files in (dialogs, drops, local picker).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DemonGatanjieu
2026-09-26 20:17:47 +08:00
co-authored by Claude Opus 5.5
parent 9fc38dc69f
commit 4396ca375c
11 changed files with 455 additions and 109 deletions
+13 -7
View File
@@ -175,16 +175,22 @@ covered by the `styles.css` manifest.
existing character, also opened straight from a card's "Add files"), and runs a
spotlight tour once per screen ("?" replays it). It owns the drafts (characters being built; `target` = files added to an
existing one) and the unassigned tray, every file row (in exactly one draft's
`rows`), the uploads, a debounced inspect per draft, and imports the ready
drafts one after another (each commit on its own; closing discards uncommitted
uploads). `draw()` rebuilds the cards on structural changes, `refresh()` only
`rows`), the uploads (three at a time), a debounced inspect per draft, and imports
the ready drafts one after another (each commit on its own, without the rows
`leftOut` names; closing discards uncommitted uploads). `draw()` rebuilds the cards on structural changes, `refresh()` only
repaints status, so typing never loses focus.
`tts_import_groups.js` holds the pure rules: which draft a file goes to (weight
stems, then folder names or file-name prefixes), where a draft stands, and its
checklist (the next step first). `ui_tts_import_draft.js` draws a draft's card
stems; other files follow the weights in their nearest folder, then folder names
or file-name prefixes), which files are left out (`leftOut`: clips outside 3–10 s
and their line files), which folders are never taken (GPT-SoVITS program and
training folders, the same list as the node's scan), where a draft stands, and its
checklist (the next step first). `ui_tts_import_sources.js` brings files in: the
browser's dialogs and drops (uploaded), the node's picker (paths), leaving out
those folders and offering the picker before a very large upload. `ui_tts_import_draft.js` draws a draft's card
(name in the header, steps left, the checklist with the next step's button and
the chosen weights, one line per clip with its owner picker, line files,
language) and the unassigned card; `ui_tts_import_screens.js` holds the fixed
the chosen weights, one line per clip with its owner picker, the first clips with
"show all", left-out files folded, line files, language) and the unassigned card
(one folded line per folder when large); `ui_tts_import_screens.js` holds the fixed
screens (the first question, the batch drop area) and the tour steps, and builds each row's elements once, so they
survive redraws and moves. `ui_tts_file_drop.js` reads OS
drops (walking dropped folders) for the studio, the sidebar and the workbench. `ui_tts_path_picker.js` picks server-side folders or files
+1 -1
View File
@@ -11,7 +11,7 @@
- **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.
- **Audio sidebar (音频侧栏)**: characters are grouped by language (GPT-SoVITS) or folder (F5-TTS) in foldable groups; a character unfolds into its clips, and a click plays one. Characters drag onto nodes from the sidebar too, a red dot marks characters that need a look, a search box appears from six characters on, and files dropped on a GPT-SoVITS character open **Add files** for it.
- **GPT-SoVITS setup and import (GPT-SoVITS 准备与导入)**: an empty studio is a drop area with **Import character**, which also sits in the toolbar. **GPT-SoVITS settings** at the bottom of the sidebar opens a dialog with where characters are kept, pretrained files and missing Python packages; missing pretrained files only get a small dot on that entry, with "don't remind me", once your characters need them, and the import form notes what the chosen language will download. **Batch import (批量导入)**: the import window takes one or many characters. It first asks what you have (one character, many characters or a package, or files for a character you already have), with three steps at the top and a spotlight tour the first time ("?" replays it). Each character card leads with a checklist that lights up the next step (GPT model, SoVITS model, clips, main voice, name) and offers its button right there; the card says how many steps are left. Drop files or whole folders, or pick a folder on this PC (a GPT-SoVITS package): weights pair up by name (latest epoch kept), clips and line files go to the character their folder or file name points at, and anything unclear waits in a card of its own. Each character is a card: one line with its name, a short status and what it holds, unfolding into its model tiles and clips; files move between characters with the picker on each row. **Import N characters** imports each ready one on its own, so one failure leaves the others alone. Status is colour-coded throughout: green ready, amber still missing something, red a failed or refused file, blue uploading; **Import** stays at the bottom. Adding to a character skips files it already has and merges annotation files (Anomalous_TTS interface 5). There is one storage place (default `models/gpt_sovits`); changing it can move the existing characters there with progress. Download pretrained files with progress or use an existing GPT-SoVITS package, all without editing `extra_model_paths.yaml` or restarting. **Import character** builds a character from dropped or picked GPT / SoVITS weights, reference clips and annotation files: the name, SoVITS version, clip lengths and lines are filled in automatically, and everything is copied (originals stay). Drop files on a card, or use **Add files**, to add clips and emotions to an existing character. Lines are found in same-name `.txt` / `.lab` files, annotation files or, as a marked guess, the file name; a text file dropped on a clip's line box fills it. Needs Anomalous_TTS with interface version 4; file actions only work on the computer running ComfyUI.
- **GPT-SoVITS setup and import (GPT-SoVITS 准备与导入)**: an empty studio is a drop area with **Import character**, which also sits in the toolbar. **GPT-SoVITS settings** at the bottom of the sidebar opens a dialog with where characters are kept, pretrained files and missing Python packages; missing pretrained files only get a small dot on that entry, with "don't remind me", once your characters need them, and the import form notes what the chosen language will download. **Batch import (批量导入)**: the import window takes one or many characters. It first asks what you have (one character, many characters or a package, or files for a character you already have), with three steps at the top and a spotlight tour the first time ("?" replays it). Each character card leads with a checklist that lights up the next step (GPT model, SoVITS model, clips, main voice, name) and offers its button right there; the card says how many steps are left. Drop files or whole folders, or pick a folder on this PC (a GPT-SoVITS package): weights pair up by name (latest epoch kept), clips and line files follow the weights in their folder (a folder per character works even when the names differ) or the character their folder or file name points at, and anything unclear waits in a card of its own, one line per folder that can be assigned at once. Clips outside 3–10 s are left out with their line files, program and training folders (`runtime`, `pretrained_models`, `logs`…) are skipped, long clip lists show the first 60, and a very large upload first suggests picking the folder on this PC instead; uploads run three at a time and a big batch no longer makes the window sluggish. Each character is a card: one line with its name, a short status and what it holds, unfolding into its model tiles and clips; files move between characters with the picker on each row. **Import N characters** imports each ready one on its own, so one failure leaves the others alone. Status is colour-coded throughout: green ready, amber still missing something, red a failed or refused file, blue uploading; **Import** stays at the bottom. Adding to a character skips files it already has and merges annotation files (Anomalous_TTS interface 5). There is one storage place (default `models/gpt_sovits`); changing it can move the existing characters there with progress. Download pretrained files with progress or use an existing GPT-SoVITS package, all without editing `extra_model_paths.yaml` or restarting. **Import character** builds a character from dropped or picked GPT / SoVITS weights, reference clips and annotation files: the name, SoVITS version, clip lengths and lines are filled in automatically, and everything is copied (originals stay). Drop files on a card, or use **Add files**, to add clips and emotions to an existing character. Lines are found in same-name `.txt` / `.lab` files, annotation files or, as a marked guess, the file name; a text file dropped on a clip's line box fills it. Needs Anomalous_TTS with interface version 4; file actions only work on the computer running ComfyUI.
### Fixes
- All-in-one loader recipes and cross-node prompt injection use one verified adapter table; prompt text never crosses between positive and negative.
+6 -3
View File
@@ -158,13 +158,16 @@ Load Audio 正好相反:它只用一条具体的音频,所以要拖某一行
做完一项就打勾;卡片标题旁写着“还差 N 步”,全部完成变成绿色的 **可以导入**。任何时候都可以直接把文件拖进窗口,会自动放到对的地方。
参考音频一段一行:填上情绪名(例如“开心”)就成了 `{开心}`。台词会自动从同名 `.txt` / `.lab` 或标注文件里找;台词框是黄框表示台词取自文件名,请确认;红色虚线框表示没找到,可以粘贴,也可以把 `.txt` / `.lab` / `.list` 拖到框里。卡片底部是台词文件和 **角色说的语言**(一般自动判断就行)。
参考音频一段一行:填上情绪名(例如“开心”)就成了 `{开心}`。**不在 3~10 秒的音频不会导入**(节点用不了它们),连同同名的台词文件一起收进卡片底部的“不导入”,点开能看到是哪些。一个角色音频很多时先显示前 60 段,点 **显示全部** 看其余的。台词会自动从同名 `.txt` / `.lab` 或标注文件里找;台词框是黄框表示台词取自文件名,请确认;红色虚线框表示没找到,可以粘贴,也可以把 `.txt` / `.lab` / `.list` 拖到框里。卡片底部是台词文件和 **角色说的语言**(一般自动判断就行)。
**很多角色 / 整合包**时:
- 模型按名字配成角色:`ALuoNa-e15.ckpt` 和 `ALuoNa_e16_s224.pth` 都算 `ALuoNa`。好几轮(e5、e10、e15)时自动用轮数最大的。
- 音频和台词文件按所在的文件夹名(比如 `参考音频/ALuoNa/`)或文件名开头分给对应的角色。
- 分不出来的放在最上面一张黄色卡片里,在每个文件右边选它属于哪个角色。分错的文件,鼠标移上去,右边也能改。
- 一个角色一个文件夹时(比如 `模型/阿罗娜/` 里有 `成品模型/` 和 `参考音频/`),文件夹里的音频和台词都归这个文件夹里的模型,名字对不上也没关系。同一个文件夹里有 `Guang` 和 `Guang_v4` 两套模型时,归 `Guang`。
- 其他的按所在的文件夹名(比如 `参考音频/ALuoNa/`)或文件名开头分给对应的角色。
- 分不出来的放在最上面一张黄色卡片里。文件多时一个文件夹一行,可以把整个文件夹一次放到某个角色;点开这一行,也能一个一个选。分错的文件,右边也能改。
- GPT-SoVITS 程序和训练用的文件夹(`runtime`、`pretrained_models`、`logs`、`output`、`TEMP`、`tools` 等)会跳过:里面是底模、训练中间存档和数据集切片,不是角色文件。
- 一次要上传很多文件(超过 1500 个或 2 GB)时会先问你:ComfyUI 就在这台电脑上,改用 **从这台电脑选文件夹** 直接复制,不用上传,快得多,界面也不会卡。
- 卡片收起时只有一行,点开看详情,一次只展开一张。右上角 **+ 继续添加** 可以再加文件,**+ 新角色** 手动加一个角色。
**③ 导入**:底部按钮写着“导入 N 个角色”,只导入“可以导入”的那些。每个角色单独导入,一个失败不影响其他的;失败的留在窗口里标红,改好再点一次。全部导入完窗口自动关闭,并滚到新角色的卡片。底部还写着会保存到哪里(想换地方,在设置里改存放位置)。
+28 -4
View File
@@ -1515,7 +1515,6 @@ export const i18n = {
ttsCheckAudio: "放几段参考音频(3~10 秒、说得清楚的)",
ttsCheckAudioDone: "参考音频 {count} 段",
ttsCheckMain: "在下面的音频里点 ☆,选一段当主声音",
ttsCheckMainNone: "没有 3~10 秒的音频,再放一段长度合适的",
ttsCheckMainDone: "主声音:{file}",
ttsCheckMainGo: "去选",
ttsCheckName: "给角色起个名字",
@@ -1542,13 +1541,26 @@ export const i18n = {
ttsTourHeadBody: "名字可以直接在这里改;旁边写着还差几步,全部完成会变成绿色的“可以导入”。",
ttsTourImportTitle: "最后点导入",
ttsTourImportBody: "清单全部完成后这个按钮就能点。文件都是复制过去的,原文件不动。",
ttsCheckAudioNoneUsable: "这些音频都不在 3~10 秒,再放几段长度合适的",
ttsImportLeftOut: "不导入",
ttsCardShowAll: "显示全部 {count} 段",
ttsCardLeftOut: "不导入 · {count} 段不在 3~10 秒(节点用不了),连同它们的台词文件",
ttsCardTrayFolder: "{folder} · {count} 个文件",
ttsCardTrayTop: "(最外层)",
ttsCardTrayMoveAll: "整个文件夹放到…",
ttsUploadLargeTitle: "要上传的文件很多",
ttsUploadLarge: "这次要上传 {count} 个文件,共 {size}。浏览器上传会很慢,也会让界面变卡。ComfyUI 就在这台电脑上,改用“从这台电脑选文件夹”可以直接复制,不用上传。",
ttsUploadUseLocal: "从这台电脑选文件夹",
ttsUploadAnyway: "仍然上传",
ttsSkippedFolders: "跳过了 GPT-SoVITS 程序和训练用的文件夹:{folders}。",
ttsImportKeptBestMany: "每个角色的 GPT 和 SoVITS 都用了轮数最大的那个(另外 {count} 个没用)。",
ttsImportAddTitle: "添加文件 · {name}",
ttsImportNameTaken: "已经有叫“{name}”的角色了。",
ttsImportNameTakenVariant: "“{name}”已经有这些版本:{variants}。请换一个名字。",
ttsImportSwitchToAdd: "改成添加到这个角色",
ttsImportKeptBest: "{kind}:用了轮数最大的 {file}(另外 {count} 个没用)。",
ttsImportUnsupportedBlock: "{file} 是 {version} 模型,暂不支持。请换一个 v1、v2、v2Pro 或 v2ProPlus 的 SoVITS 权重。",
ttsImportOutOfRange: "参考音频需要 3~10 秒,这条不能当主声音",
ttsImportOutOfRange: "不在 3~10 秒,节点用不了,不会导入",
ttsTextSource_txt: "台词来自同名 .txt",
ttsTextSource_lab: "台词来自同名 .lab",
ttsTextSource_list: "台词来自标注文件",
@@ -3237,7 +3249,6 @@ export const i18n = {
ttsCheckAudio: "Add a few reference clips (clear, 3–10 s)",
ttsCheckAudioDone: "{count} reference clips",
ttsCheckMain: "Press ☆ on a clip below to make it the main voice",
ttsCheckMainNone: "No clip is 3–10 s long; add one that is",
ttsCheckMainDone: "Main voice: {file}",
ttsCheckMainGo: "Pick one",
ttsCheckName: "Name the character",
@@ -3264,13 +3275,26 @@ export const i18n = {
ttsTourHeadBody: "Edit the name here; next to it you see how many steps are left, turning into a green \"ready\" when done.",
ttsTourImportTitle: "Import at the end",
ttsTourImportBody: "This button works once the checklist is complete. Files are copied; the originals stay.",
ttsCheckAudioNoneUsable: "None of these clips is 3–10 s long; add a few that are",
ttsImportLeftOut: "left out",
ttsCardShowAll: "Show all {count} clips",
ttsCardLeftOut: "Left out · {count} clips not 3–10 s long (the node cannot use them), with their line files",
ttsCardTrayFolder: "{folder} · {count} files",
ttsCardTrayTop: "(top level)",
ttsCardTrayMoveAll: "Move the whole folder to…",
ttsUploadLargeTitle: "A lot to upload",
ttsUploadLarge: "This would upload {count} files, {size} in all. Uploading through the browser is slow and makes the window sluggish. ComfyUI runs on this computer: choosing the folder from this computer copies it directly, without uploading.",
ttsUploadUseLocal: "Choose the folder on this computer",
ttsUploadAnyway: "Upload anyway",
ttsSkippedFolders: "Skipped GPT-SoVITS program and training folders: {folders}.",
ttsImportKeptBestMany: "Each character uses its GPT and SoVITS weights of the latest epoch ({count} others not used).",
ttsImportAddTitle: "Add files · {name}",
ttsImportNameTaken: "There is already a character named \"{name}\".",
ttsImportNameTakenVariant: "\"{name}\" already has these versions: {variants}. Please pick another name.",
ttsImportSwitchToAdd: "Add to that character instead",
ttsImportKeptBest: "{kind}: using the latest epoch, {file} ({count} others left out).",
ttsImportUnsupportedBlock: "{file} is a {version} model, which is not supported yet. Use a v1, v2, v2Pro or v2ProPlus SoVITS weight.",
ttsImportOutOfRange: "Reference clips must be 3–10 s; this one cannot be the main voice",
ttsImportOutOfRange: "Not 3–10 s long: the node cannot use it, so it is left out",
ttsTextSource_txt: "Line from the .txt of the same name",
ttsTextSource_lab: "Line from the .lab of the same name",
ttsTextSource_list: "Line from the annotation file",
+90 -8
View File
@@ -9,6 +9,51 @@ import { importProblem, rowState, usableAsReference } from './tts_setup_api.js';
*/
const WEIGHT_TAIL = { gpt: /-e\d+.*$/i, sovits: /_e\d+.*$/i };
const SIDECAR = /\.(txt|lab)$/i;
/**
* Folders of the GPT-SoVITS program and its training runs (base models, training
* checkpoints, dataset slices, tool models): never a character's files. The same
* list as the node's folder scan (`SCAN_SKIP` in Anomalous_TTS `core/browse.py`).
*/
const SKIP_FOLDERS = new Set(['runtime', 'gpt_sovits', 'pretrained_models', 'logs', 'output', 'temp', 'tools',
'__pycache__', 'site-packages', 'venv', 'node_modules']);
export function isSkippedFolder(name) {
const lower = String(name || '').toLowerCase();
return SKIP_FOLDERS.has(lower) || lower.startsWith('.');
}
/** Whether a file whose folder is `dir` (`a/b`, relative to what was dropped) sits in a skipped folder. */
export function inSkippedFolder(dir) {
return String(dir || '').split('/').some(isSkippedFolder);
}
/**
* Rows the import leaves out: clips whose measured length is outside 3–10 s (the node
* cannot use them as a reference or an emotion) and their same-name .txt / .lab line
* files. Rows are `{ kind, name, seconds }`; returns a Set of their indexes.
*/
export function leftOut(rows) {
const stem = name => String(name || '').replace(/\.[^.]+$/, '').toLowerCase();
const out = new Set();
const kept = new Set();
rows.forEach((row, i) => {
if (row.kind !== 'audio') return;
if (row.seconds !== undefined && !usableAsReference(row.seconds)) out.add(i);
else kept.add(stem(row.name));
});
const outStems = new Set([...out].map(i => stem(rows[i].name)));
rows.forEach((row, i) => {
if (row.kind === 'text' && SIDECAR.test(row.name) && outStems.has(stem(row.name)) && !kept.has(stem(row.name))) out.add(i);
});
return out;
}
const keptRows = (rows) => {
const out = leftOut(rows);
return rows.filter((_, i) => !out.has(i));
};
/** Grouping key: case, spaces, `_`, `-` and `.` do not count. */
export function groupKey(name) {
@@ -30,11 +75,39 @@ function matchKey(item, keys) {
return pick(byFolder) ?? pick(byName);
}
/**
* The character a file belongs to by where it sits: the nearest folder above it
* (its own folder first) that holds weights decides, when those weights name one
* character, or several where one name starts all the others (`Guang` and
* `Guang_v4` → `Guang`). `weights`: `[{ dir, key }]`. null when no folder decides
* (the top of the drop is left to the name rules).
*/
function ownerByFolder(dir, weights) {
let at = String(dir || '');
while (at) {
const inside = new Set(weights.filter(w => w.dir === at || w.dir.startsWith(`${at}/`)).map(w => w.key));
if (inside.size) {
const list = [...inside];
return list.find(key => list.every(other => other.startsWith(key))) ?? null;
}
at = at.includes('/') ? at.slice(0, at.lastIndexOf('/')) : '';
}
return null;
}
/** The top folder of `dir`; a package's own weight folders count as the top of the drop. */
function topFolder(dir) {
const top = String(dir || '').split('/')[0];
return /^(gpt|sovits)_weights/i.test(top) ? '' : top;
}
/**
* `items`: `{ name, dir, kind }`; `drafts`: the characters already open, `{ key, target }`.
* Returns `{ added: [{ key, name }], assign: [key | null per item] }`: new characters to
* open (one per weight stem not open yet, or one blank one when nothing else is open),
* and where each item goes. Adding to one existing character takes everything into it.
* and where each item goes: weights by their name, other files by the folder their
* weights sit in (a folder per character), else by folder or file name. Adding to one
* existing character takes everything into it.
*/
export function groupFiles(items, drafts) {
const keys = drafts.map(draft => draft.key);
@@ -56,10 +129,17 @@ export function groupFiles(items, drafts) {
added.push({ key: '', name: '' });
}
const only = keys.length === 1 ? keys[0] : null;
const weights = items.filter(item => item.kind === 'gpt' || item.kind === 'sovits')
.map(item => ({ dir: String(item.dir || ''), key: groupKey(weightStem(item.name, item.kind)) }))
.filter(w => w.key);
// The only character takes the rest, unless its weights sit in their own folder and the
// file in another one: then it is someone else's (or nobody's) and waits unassigned.
const weightTops = new Set(weights.map(w => topFolder(w.dir)));
const fallback = item => (weightTops.size && !weightTops.has('') && !weightTops.has(topFolder(item.dir)) ? null : only);
const assign = items.map(item => {
if (soleTarget !== null) return soleTarget;
if (item.kind === 'gpt' || item.kind === 'sovits') return groupKey(weightStem(item.name, item.kind)) || only;
return matchKey(item, keys.filter(Boolean)) ?? only;
return ownerByFolder(item.dir, weights) ?? matchKey(item, keys.filter(Boolean)) ?? fallback(item);
});
return { added, assign };
}
@@ -70,7 +150,9 @@ export function groupFiles(items, drafts) {
* takes 3–10 s references); adding to one needs any file. The first item not
* done is the next step the card offers.
*/
export function draftChecklist({ target = null, name = '', rows, main = null, conflict = null, duplicate = false }) {
export function draftChecklist({ target = null, name = '', rows: all, main = null, conflict = null, duplicate = false }) {
const rows = keptRows(all);
const skipped = all.length - rows.length;
if (target) {
return [{ id: 'files', done: rows.length > 0, key: rows.length ? 'ttsCheckFilesDone' : 'ttsCheckFiles', params: { count: rows.length } }];
}
@@ -82,10 +164,9 @@ export function draftChecklist({ target = null, name = '', rows, main = null, co
{ id: 'gpt', done: Boolean(weight('gpt')), key: weight('gpt') ? 'ttsCheckGptDone' : 'ttsCheckGpt', params: {} },
{ id: 'sovits', done: Boolean(sovits) && sovits.supported !== false,
key: !sovits ? 'ttsCheckSovits' : sovits.supported === false ? 'ttsCheckSovitsUnsupported' : 'ttsCheckSovitsDone', params: { version: sovits?.version || '?' } },
{ id: 'audio', done: clips.length > 0, key: clips.length ? 'ttsCheckAudioDone' : 'ttsCheckAudio', params: { count: clips.length } },
{ id: 'main', done: Boolean(main),
key: main ? 'ttsCheckMainDone' : clips.length && clips.every(row => row.seconds !== undefined && !usableAsReference(row.seconds)) ? 'ttsCheckMainNone' : 'ttsCheckMain',
params: { file: main || '' } },
{ id: 'audio', done: clips.length > 0, key: clips.length ? 'ttsCheckAudioDone' : skipped ? 'ttsCheckAudioNoneUsable' : 'ttsCheckAudio',
params: { count: clips.length } },
{ id: 'main', done: Boolean(main), key: main ? 'ttsCheckMainDone' : 'ttsCheckMain', params: { file: main || '' } },
{ id: 'name', done: Boolean(trimmed) && !conflict && !duplicate,
key: !trimmed ? 'ttsCheckName' : conflict ? 'ttsCheckNameTaken' : duplicate ? 'ttsCheckNameTwice' : 'ttsCheckNameDone', params: { name: trimmed } },
];
@@ -98,8 +179,9 @@ export function draftChecklist({ target = null, name = '', rows, main = null, co
* `{ kind, name, emotion, error, uploaded, progress, existing, supported, version, seconds }`;
* `main` is the main clip's name (a new character needs one).
*/
export function draftState({ target = null, name = '', rows, main = null, conflict = null, duplicate = false, done = false, failed = '', importing = false }) {
export function draftState({ target = null, name = '', rows: all, main = null, conflict = null, duplicate = false, done = false, failed = '', importing = false }) {
if (done) return { tone: 'done', problem: null };
const rows = keptRows(all);
const views = rows.map(row => rowState({ ...row, unsupported: row.supported === false }));
if (importing || views.some(view => view.tone === 'busy')) return { tone: 'busy', problem: null };
if (failed) return { tone: 'error', problem: ['ttsImportFailed', { error: failed }] };
+6 -3
View File
@@ -1,4 +1,5 @@
import { t } from './interface_settings.js';
import { isSkippedFolder } from './tts_import_groups.js';
/**
* Files dropped from the OS on the audio studio, the sidebar or the import
@@ -6,8 +7,11 @@ import { t } from './interface_settings.js';
* from (dropped folders are walked), so the workbench can sort them.
*/
async function walkEntry(entry, dir) {
/** GPT-SoVITS program and training folders inside a drop are not walked: `{ skipped: folder }`. */
async function walkEntry(entry, dir, top = false) {
if (entry.isFile) return [{ file: await new Promise((ok, fail) => entry.file(ok, fail)), dir }];
const sub = dir ? `${dir}/${entry.name}` : entry.name;
if (!top && isSkippedFolder(entry.name)) return [{ skipped: sub }];
const reader = entry.createReader();
const children = [];
for (;;) {
@@ -15,7 +19,6 @@ async function walkEntry(entry, dir) {
if (!batch.length) break;
children.push(...batch);
}
const sub = dir ? `${dir}/${entry.name}` : entry.name;
return (await Promise.all(children.map(child => walkEntry(child, sub)))).flat();
}
@@ -26,7 +29,7 @@ async function walkEntry(entry, dir) {
export function readDroppedFiles(dataTransfer) {
const entries = [...(dataTransfer?.items || [])].map(item => item.webkitGetAsEntry?.()).filter(Boolean);
if (!entries.length) return Promise.resolve([...(dataTransfer?.files || [])].map(file => ({ file, dir: '' })));
return Promise.all(entries.map(entry => walkEntry(entry, ''))).then(lists => lists.flat());
return Promise.all(entries.map(entry => walkEntry(entry, '', true))).then(lists => lists.flat());
}
/**
+82 -63
View File
@@ -3,12 +3,13 @@ import { createViewScope } from './ui_lifecycle.js';
import { anomalousAlert, anomalousConfirm } from './ui_dialog.js';
import { loadEngine, loadGptSovitsStatus } from './audio_engines.js';
import {
buildImportBody, commitImport, discardUploads, importKind, inspectImport, nameConflict, pickWeights, scanFolder, uploadFile,
buildImportBody, commitImport, discardUploads, importKind, inspectImport, nameConflict, pickWeights, uploadFile,
} from './tts_setup_api.js';
import { draftChecklist, draftState, groupFiles, groupKey } from './tts_import_groups.js';
import { draftChecklist, draftState, groupFiles, groupKey, leftOut } from './tts_import_groups.js';
import { TRAY, renderDraftCard, renderTrayCard, showSource, syncReference, updateRow } from './ui_tts_import_draft.js';
import { PICKER_OVERLAY_CLASS, pickServerPath } from './ui_tts_path_picker.js';
import { PICKER_OVERLAY_CLASS } from './ui_tts_path_picker.js';
import { readDroppedFiles } from './ui_tts_file_drop.js';
import { createFileSources } from './ui_tts_import_sources.js';
import { closeSpotlightTour, isSpotlightTourActive, startSpotlightTour } from './ui_spotlight_tour.js';
import { TOURS, markTourSeen, renderBatchHero, renderChooseScreen, seenTours } from './ui_tts_import_screens.js';
@@ -21,16 +22,16 @@ import { TOURS, markTourSeen, renderBatchHero, renderChooseScreen, seenTours } f
* character's "Add files"). A spotlight tour explains each screen the first time.
*
* Files come from a drop (folders too), the browser's file or folder dialog
* (uploaded in chunks) or the node's folder picker (sent as paths). The node copies
* everything; the originals are never touched. Each draft is inspected and
* (uploaded in chunks, a few files at a time) or the node's folder picker (sent as
* paths); see ui_tts_import_sources.js. The node copies what is imported; the
* originals are never touched. Clips outside 3–10 s are left out. Each draft is inspected and
* imported on its own, so one failure leaves the others alone. Every file is one
* row object, owned by exactly one draft's `rows`; its elements are built once
* (ui_tts_import_draft.js) and move with it.
*/
let activeScope = null;
const ACCEPT = { gpt: '.ckpt', sovits: '.pth', audio: '.wav,.flac,.ogg,.mp3', text: '.txt,.lab,.list' };
const ACCEPT_ALL = Object.values(ACCEPT).join(',');
const UPLOADS_AT_ONCE = 3;
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
@@ -86,8 +87,9 @@ export async function openTtsImport({ files = [], target = null, onDone } = {})
let nextRow = 0;
let importing = false;
let player = null;
let uploads = Promise.resolve();
let pickFor = null; // the draft the browser file dialog adds to, or null (sort by name)
const uploadQueue = [];
let uploading = 0;
let refreshTimer = 0;
const overlay = el('div', 'anomalous-voice-modal-overlay');
const modal = el('div', 'anomalous-voice-modal anomalous-tts-import');
@@ -98,6 +100,8 @@ export async function openTtsImport({ files = [], target = null, onDone } = {})
scope.onDispose(() => {
closeSpotlightTour();
for (const draft of drafts) clearTimeout(draft.inspectTimer);
clearTimeout(refreshTimer);
uploadQueue.length = 0;
player?.pause();
const rows = allRows();
for (const row of rows) {
@@ -135,6 +139,11 @@ export async function openTtsImport({ files = [], target = null, onDone } = {})
const rowView = row => ({ kind: row.kind, name: row.name, emotion: row.emotion, error: row.error, uploaded: Boolean(row.spec),
progress: row.progress, existing: row.info?.existing ?? null, supported: row.info?.supported, version: row.info?.version,
seconds: row.info?.seconds });
/** Keys of the draft's rows the import leaves out (clips outside 3–10 s and their line files). */
const skippedKeys = (rows) => {
const out = leftOut(rows.map(rowView));
return new Set(rows.filter((_, i) => out.has(i)).map(row => row.key));
};
function viewOf(draft) {
const name = draft.name.trim();
@@ -146,7 +155,7 @@ export async function openTtsImport({ files = [], target = null, onDone } = {})
const state = draftState({ target: draft.target, name, rows, main, conflict, duplicate,
done: draft.done, failed: draft.failed, importing: draft.importing });
const checklist = draftChecklist({ target: draft.target, name, rows, main, conflict, duplicate });
return { ...state, conflict, problems: draft.problems, checklist };
return { ...state, conflict, problems: draft.problems, checklist, skipped: skippedKeys(draft.rows) };
}
/** One card open at a time; a lone character is always open. */
@@ -203,6 +212,19 @@ export async function openTtsImport({ files = [], target = null, onDone } = {})
draw();
}
/** A whole folder of unassigned files at once. */
function moveRows(rows, id) {
const to = placeOf(id);
if (!to || to === tray) return;
for (const row of rows) {
tray.rows.splice(tray.rows.indexOf(row), 1);
if (!placeRow(to, row)) forgetRow(row);
}
touch(to);
if (!openDrafts().some(draft => draft.expanded)) expand(to);
draw();
}
/**
* Add files: `items` are `{ name, dir, file }` or `{ name, dir, path, size }`. Into
* `into` when given (or the only card outside batch mode), else sorted into drafts.
@@ -221,11 +243,12 @@ export async function openTtsImport({ files = [], target = null, onDone } = {})
assign = grouped.assign.map(key => (key === null ? tray : openDrafts().find(draft => draft.key === key)));
}
const notes = [];
const keptBest = [];
const touched = new Set();
for (const owner of new Set(assign)) {
const mine = usable.filter((_, i) => assign[i] === owner);
const { skip, kept } = pickWeights(mine);
notes.push(...kept.map(k => t('ttsImportKeptBest', { kind: t(`ttsKind_${k.kind}`), file: k.name, count: k.others })));
keptBest.push(...kept);
mine.forEach((item, i) => {
if (skip.has(i)) return;
const row = {
@@ -235,9 +258,14 @@ export async function openTtsImport({ files = [], target = null, onDone } = {})
};
if (!placeRow(owner, row)) { bad.push(item.name); return; }
touched.add(owner);
if (row.file) uploads = uploads.then(() => upload(row));
if (row.file) queueUpload(row);
});
}
if (keptBest.length > 2) {
notes.push(t('ttsImportKeptBestMany', { count: keptBest.reduce((sum, k) => sum + k.others, 0) }));
} else {
notes.push(...keptBest.map(k => t('ttsImportKeptBest', { kind: t(`ttsKind_${k.kind}`), file: k.name, count: k.others })));
}
if (bad.length) notes.push(t('ttsImportSkipped', { files: bad.join(', ') }));
noteLine.textContent = notes.join(' ');
touched.forEach(touch);
@@ -246,6 +274,25 @@ export async function openTtsImport({ files = [], target = null, onDone } = {})
draw();
}
function queueUpload(row) {
uploadQueue.push(row);
pumpUploads();
}
function pumpUploads() {
while (uploading < UPLOADS_AT_ONCE && uploadQueue.length) {
const row = uploadQueue.shift();
if (row.controller.signal.aborted) continue;
uploading++;
upload(row).finally(() => { uploading--; pumpUploads(); });
}
}
/** Many uploads finish close together: repaint once for them. */
function refreshSoon() {
if (!refreshTimer) refreshTimer = setTimeout(() => { refreshTimer = 0; if (!scope.signal.aborted) refresh(); }, 100);
}
async function upload(row) {
if (row.controller.signal.aborted) return;
try {
@@ -261,7 +308,7 @@ export async function openTtsImport({ files = [], target = null, onDone } = {})
}
updateRow(row);
touch(ownerOf(row));
refresh();
refreshSoon();
}
async function inspect(draft) {
@@ -277,6 +324,7 @@ export async function openTtsImport({ files = [], target = null, onDone } = {})
try {
const result = await inspectImport(ready.map(row => row.spec), scope.signal, draft.target);
if (mine !== draft.inspectToken || scope.signal.aborted) return;
const skippedBefore = skippedKeys(draft.rows).size;
ready.forEach((row, i) => {
row.info = result.files[i];
if (row.info.size) row.size = row.info.size;
@@ -296,7 +344,8 @@ export async function openTtsImport({ files = [], target = null, onDone } = {})
draft.detectedLanguage = suggested.language;
draft.problems = result.problems;
syncReference(draft);
if (renamed || (languageChanged && (draft.expanded || solo()))) { draw(); return; }
const regrouped = skippedKeys(draft.rows).size !== skippedBefore && (draft.expanded || solo());
if (renamed || regrouped || (languageChanged && (draft.expanded || solo()))) { draw(); return; }
} catch (e) {
if (mine !== draft.inspectToken || scope.signal.aborted) return;
draft.problems = [e.message || String(e)];
@@ -304,43 +353,13 @@ export async function openTtsImport({ files = [], target = null, onDone } = {})
refresh();
}
// ---- picking files ----
const fileInput = el('input');
fileInput.type = 'file';
fileInput.multiple = true;
fileInput.hidden = true;
fileInput.onchange = () => {
addItems([...fileInput.files].map(file => ({ name: file.name, dir: '', file })), pickFor);
fileInput.value = '';
};
const folderInput = el('input');
folderInput.type = 'file';
folderInput.webkitdirectory = true;
folderInput.hidden = true;
folderInput.onchange = () => {
addItems([...folderInput.files].map(file => ({ name: file.name, dir: file.webkitRelativePath.split('/').slice(0, -1).join('/'), file })));
folderInput.value = '';
};
const chooseFiles = (kinds = null, into = null) => {
pickFor = into;
fileInput.accept = kinds ? kinds.map(kind => ACCEPT[kind]).join(',') : ACCEPT_ALL;
fileInput.click();
};
const chooseLocalFiles = async (kinds = null, into = null) => {
const paths = await pickServerPath({ mode: 'files', title: t('ttsImportPickLocal'), hint: t('ttsImportPickLocalHint'), kinds });
if (paths && !scope.signal.aborted) addItems(paths.map(path => ({ name: path.split('/').pop(), dir: '', path })), into);
};
const chooseLocalFolder = async () => {
const path = await pickServerPath({ mode: 'folder', title: t('ttsBatchPickLocalFolder'), hint: t('ttsBatchPickLocalFolderHint') });
if (!path || scope.signal.aborted) return;
try {
const result = await scanFolder(path, scope.signal);
addItems(result.files.map(file => ({ name: file.name, dir: file.dir, path: file.path, size: file.size })));
if (result.truncated) noteLine.textContent += ` ${t('ttsBatchScanTruncated', { count: result.files.length })}`;
} catch (e) {
if (!scope.signal.aborted) await anomalousAlert(t('ttsSetupFailed', { error: e.message || String(e) }));
}
};
// ---- picking files (ui_tts_import_sources.js) ----
const sources = createFileSources({
signal: scope.signal,
add: (items, into) => addItems(items, into),
note: (text) => { noteLine.textContent = `${noteLine.textContent} ${text}`.trim(); },
});
const { chooseFiles, chooseLocalFiles, chooseLocalFolder } = sources;
// ---- layout: header, steps, content, footer ----
const header = el('div', 'anomalous-voice-modal-header');
@@ -375,7 +394,7 @@ export async function openTtsImport({ files = [], target = null, onDone } = {})
return item;
};
menu.append(menuItem(t('ttsImportPickFiles'), t('ttsMenuUploadHint'), () => chooseFiles()),
menuItem(t('ttsBatchPickFolder'), t('ttsMenuUploadHint'), () => folderInput.click()),
menuItem(t('ttsBatchPickFolder'), t('ttsMenuUploadHint'), sources.chooseFolder),
menuItem(t('ttsMenuLocalFiles'), t('ttsMenuLocalHint'), () => chooseLocalFiles()),
menuItem(t('ttsBatchPickLocalFolder'), t('ttsMenuLocalHint'), chooseLocalFolder));
const addMore = button('anomalous-tts-ghost', t('ttsAddMore'), () => menu.classList.toggle('is-open'));
@@ -402,7 +421,7 @@ export async function openTtsImport({ files = [], target = null, onDone } = {})
// Batch mode before any file: the drop area says what to bring.
const hero = renderBatchHero({
folder: () => folderInput.click(), files: () => chooseFiles(), localFolder: chooseLocalFolder,
folder: sources.chooseFolder, files: () => chooseFiles(), localFolder: chooseLocalFolder,
localFiles: () => chooseLocalFiles(), back: () => { mode = null; draw(); },
});
@@ -441,7 +460,7 @@ export async function openTtsImport({ files = [], target = null, onDone } = {})
draft.nameEdited = true;
draft.key = groupKey(value) || draft.key;
draft.failed = '';
refresh();
refreshSoon();
},
onSwitchToAdd: (draft) => {
const conflict = nameConflict(draft.name, existing);
@@ -469,6 +488,8 @@ export async function openTtsImport({ files = [], target = null, onDone } = {})
},
onLanguage: (draft, value) => { draft.language = value; draw(); },
onMoveRow: moveRow,
onMoveRows: moveRows,
onShowAll: (draft) => { draft.showAll = true; draw(); },
onRemoveRow: (row) => removeRow(row),
onRemoveDraft: async (draft) => {
if (draft.rows.length && !await anomalousConfirm(t('ttsCardRemoveConfirm', { name: draft.target || draft.name.trim() || t('ttsBatchUnnamed'), count: draft.rows.length }))) return;
@@ -481,9 +502,7 @@ export async function openTtsImport({ files = [], target = null, onDone } = {})
},
onDropFiles: (draft, dataTransfer) => {
modal.classList.remove('is-dragover');
readDroppedFiles(dataTransfer).then(dropped => {
if (!scope.signal.aborted) addItems(dropped.map(({ file, dir }) => ({ name: file.name, dir, file })), draft);
});
readDroppedFiles(dataTransfer).then(dropped => { if (!scope.signal.aborted) sources.takeFiles(dropped, draft); });
},
};
@@ -566,7 +585,8 @@ export async function openTtsImport({ files = [], target = null, onDone } = {})
for (const draft of queue) {
draft.importing = true;
refresh();
const ready = draft.rows.filter(row => row.spec && !row.error);
const skipped = skippedKeys(draft.rows);
const ready = draft.rows.filter(row => row.spec && !row.error && !skipped.has(row.key));
const index = ready.findIndex(row => row.key === draft.referenceKey);
const body = buildImportBody({
target: draft.target, library: status.storage, character: draft.name.trim(), language: draft.language,
@@ -614,15 +634,14 @@ export async function openTtsImport({ files = [], target = null, onDone } = {})
readDroppedFiles(e.dataTransfer).then(dropped => {
if (scope.signal.aborted) return;
if (mode === null) mode = 'batch';
addItems(dropped.map(({ file, dir }) => ({ name: file.name, dir, file })));
sources.takeFiles(dropped);
});
});
modal.append(header, steps, content, footer, dropCover, fileInput, folderInput);
modal.append(header, steps, content, footer, dropCover, ...sources.inputs);
overlay.appendChild(modal);
document.body.appendChild(overlay);
if (target) newDraft({ key: groupKey(target), name: target, target });
const initial = files.map(item => (item instanceof File ? { file: item, dir: '' } : item));
if (initial.length) addItems(initial.map(({ file, dir }) => ({ name: file.name, dir, file })));
else draw();
draw();
if (files.length) sources.takeFiles(files.map(item => (item instanceof File ? { file: item, dir: '' } : item)));
}
+83 -19
View File
@@ -25,6 +25,7 @@ const SHORT = {
ttsEditorNameDuplicate: 'ttsCardEmotionProblem',
};
/** Problems the checklist already shows. */
const LIST_LIMIT = 60; // clips shown before "show all": a character can bring hundreds
const IN_CHECKLIST = ['ttsImportWeightsMissing', 'ttsImportNameTaken', 'ttsImportNameTakenVariant', 'ttsBatchNoAudio', 'ttsBatchNoMain',
'ttsImportNameMissing', 'ttsImportNoFiles', 'ttsBatchDuplicateName', 'ttsImportUnsupportedBlock'];
@@ -58,12 +59,14 @@ export function updateRow(row) {
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(' · ');
row.els.root.classList.toggle('is-out-of-range', row.kind === 'audio' && info.seconds !== undefined && !usableAsReference(info.seconds));
const outOfRange = row.kind === 'audio' && info.seconds !== undefined && !usableAsReference(info.seconds);
row.els.root.classList.toggle('is-out-of-range', outOfRange);
const view = rowState({ error: row.error, uploaded: Boolean(row.spec), progress: row.progress,
existing: info.existing ?? null, unsupported: info.supported === false });
row.els.root.dataset.tone = view.tone;
row.els.state.textContent = view.tone === 'ready' ? '' : t(view.key, view.params);
row.els.state.title = t(view.key, view.params);
const leftOut = outOfRange && view.tone === 'ready';
row.els.state.textContent = leftOut ? t('ttsImportLeftOut') : view.tone === 'ready' ? '' : t(view.key, view.params);
row.els.state.title = leftOut ? t('ttsImportOutOfRange') : t(view.key, view.params);
}
/** Where a clip's line came from, shown as the text box's tooltip and colour. */
@@ -125,6 +128,11 @@ function rowElements(row, ctx) {
row.els = els;
els.move.title = t('ttsBatchMoveTo');
els.move.onchange = () => ctx.onMoveRow(row, els.move.value);
// The full list is built when the menu is about to open: thousands of rows each
// holding every character would make each redraw slow.
const fill = () => { if (els.movePlace) fillMoveOptions(els.move, els.movePlace, ctx.places()); };
els.move.addEventListener('pointerdown', fill);
els.move.addEventListener('focus', fill);
const remove = button('anomalous-tts-file-remove', '×', () => ctx.onRemoveRow(row), t('ttsEditorRemove'));
const label = el('span', 'anomalous-tts-file-label');
label.append(name, els.meta);
@@ -152,24 +160,29 @@ function rowElements(row, ctx) {
return els;
}
/** Where this row can go; hidden while there is nowhere else to put it. */
function fillMove(row, place, ctx) {
const select = row.els.move;
const places = ctx.places();
select.hidden = places.length < 2;
function fillMoveOptions(select, place, places) {
if (select.options.length === places.length) return;
select.replaceChildren();
for (const option of places) {
const node = el('option', '', option.id === place.id ? t('ttsCardMoveHere', { name: option.label }) : t('ttsCardMoveTo', { name: option.label }));
const label = option.id === place.id ? t('ttsCardMoveHere', { name: option.label }) : t('ttsCardMoveTo', { name: option.label });
const node = el('option', '', label);
node.value = option.id;
select.append(node);
}
select.value = place.id;
}
/** Where this row can go (the list itself is filled on open); hidden while there is nowhere else. */
function showRow(row, place, ctx) {
rowElements(row, ctx);
fillMove(row, place, ctx);
return row.els.root;
const els = rowElements(row, ctx);
const places = ctx.places();
els.move.hidden = places.length < 2;
els.movePlace = place;
const here = places.find(option => option.id === place.id);
const node = el('option', '', here ? t('ttsCardMoveHere', { name: here.label }) : '');
node.value = place.id;
els.move.replaceChildren(node);
return els.root;
}
function statusOf(view) {
@@ -246,7 +259,7 @@ export function renderDraftCard(draft, view, ctx, { solo = false } = {}) {
head.append(title);
}
const pill = el('span', 'anomalous-tts-card-pill');
const clipCount = draft.rows.filter(row => row.kind === 'audio').length;
const clipCount = draft.rows.filter(row => row.kind === 'audio' && !view.skipped.has(row.key)).length;
head.append(pill, el('span', 'anomalous-tts-card-meta', t('ttsCardClips', { count: clipCount })));
if (!solo && !draft.target) head.append(button('anomalous-tts-card-remove', '×', () => ctx.onRemoveDraft(draft), t('ttsCardRemove')));
if (!solo) head.addEventListener('click', (e) => { if (!e.target.closest('input, button')) ctx.onToggle(draft); });
@@ -290,7 +303,8 @@ export function renderDraftCard(draft, view, ctx, { solo = false } = {}) {
const body = el('div', 'anomalous-tts-card-body');
body.append(checklist);
const audioRows = draft.rows.filter(row => row.kind === 'audio');
const kept = draft.rows.filter(row => !view.skipped.has(row.key));
const audioRows = kept.filter(row => row.kind === 'audio');
const clips = el('div', 'anomalous-tts-card-clips');
parts.clips = clips;
if (audioRows.length) {
@@ -299,12 +313,21 @@ export function renderDraftCard(draft, view, ctx, { solo = false } = {}) {
el('span', 'anomalous-tts-card-section-hint', t('ttsCardClipsHint')),
button('anomalous-tts-link', t('ttsCardAddClips'), () => ctx.pick(draft, ['audio'], false)));
const audioList = el('div', 'anomalous-tts-card-files');
for (const row of audioRows) audioList.append(showRow(row, draft, ctx));
const shown = draft.showAll ? audioRows : audioRows.filter((row, i) => i < LIST_LIMIT || row.key === draft.referenceKey);
for (const row of shown) audioList.append(showRow(row, draft, ctx));
clips.append(audioHead, audioList);
if (shown.length < audioRows.length) {
clips.append(button('anomalous-tts-link anomalous-tts-card-more', t('ttsCardShowAll', { count: audioRows.length }), () => ctx.onShowAll(draft)));
}
body.append(clips);
}
const skippedRows = draft.rows.filter(row => view.skipped.has(row.key));
if (skippedRows.length) {
const count = skippedRows.filter(row => row.kind === 'audio').length;
body.append(foldedRows(skippedRows, draft, ctx, t('ttsCardLeftOut', { count })));
}
const textRows = draft.rows.filter(row => row.kind === 'text');
const textRows = kept.filter(row => row.kind === 'text');
const extras = el('div', 'anomalous-tts-card-extras');
const lines = el('div', 'anomalous-tts-card-lines');
lines.append(el('span', 'anomalous-tts-card-extra-label', t('ttsCardLineFiles')));
@@ -348,9 +371,50 @@ export function renderTrayCard(tray, ctx) {
el('span', 'anomalous-tts-card-title', t('ttsCardTrayTitle', { count: tray.rows.length })));
const body = el('div', 'anomalous-tts-card-body');
body.append(el('div', 'anomalous-tts-card-note', t('ttsCardTrayHint')));
const list = el('div', 'anomalous-tts-card-files');
for (const row of tray.rows) list.append(showRow(row, tray, ctx));
body.append(list);
const folders = new Map();
for (const row of tray.rows) {
if (!folders.has(row.dir)) folders.set(row.dir, []);
folders.get(row.dir).push(row);
}
if (tray.rows.length <= LIST_LIMIT && folders.size === 1) {
const list = el('div', 'anomalous-tts-card-files');
for (const row of tray.rows) list.append(showRow(row, tray, ctx));
body.append(list);
} else {
// One line per folder: assign the whole folder, or open it to sort file by file.
for (const [dir, rows] of folders) {
const fold = foldedRows(rows, tray, ctx, t('ttsCardTrayFolder', { folder: dir || t('ttsCardTrayTop'), count: rows.length }));
const move = el('select', 'anomalous-tts-file-move');
move.title = t('ttsBatchMoveTo');
const first = el('option', '', t('ttsCardTrayMoveAll'));
first.value = '';
move.append(first);
move.addEventListener('pointerdown', () => {
if (move.options.length > 1) return;
for (const place of ctx.places().filter(option => option.id !== TRAY)) {
const node = el('option', '', t('ttsCardMoveTo', { name: place.label }));
node.value = place.id;
move.append(node);
}
});
move.onchange = () => { if (move.value) ctx.onMoveRows(rows, move.value); };
fold.querySelector('summary').append(move);
body.append(fold);
}
}
card.append(head, body);
return card;
}
/** Rows folded under a summary line; built the first time it is opened. */
function foldedRows(rows, place, ctx, label) {
const box = el('details', 'anomalous-tts-card-fold');
const summary = el('summary', 'anomalous-tts-card-fold-summary');
summary.append(el('span', 'anomalous-tts-card-fold-label', label));
const list = el('div', 'anomalous-tts-card-files');
box.append(summary, list);
box.addEventListener('toggle', () => {
if (box.open && !list.childElementCount) for (const row of rows) list.append(showRow(row, place, ctx));
});
return box;
}
+109
View File
@@ -0,0 +1,109 @@
import { t } from './interface_settings.js';
import { anomalousAlert, anomalousConfirm } from './ui_dialog.js';
import { importKind, scanFolder } from './tts_setup_api.js';
import { isSkippedFolder } from './tts_import_groups.js';
import { formatSize, pickServerPath } from './ui_tts_path_picker.js';
/**
* Where the import window's files come from: the browser's file and folder dialogs
* and drops (uploaded to the node), or the node's own picker (local paths, copied
* without uploading). Files inside GPT-SoVITS program or training folders are left
* out, and a very large upload first offers the node's picker instead: ComfyUI runs
* on this computer, so copying from the path skips sending every byte twice.
*/
const ACCEPT = { gpt: '.ckpt', sovits: '.pth', audio: '.wav,.flac,.ogg,.mp3', text: '.txt,.lab,.list' };
const ACCEPT_ALL = Object.values(ACCEPT).join(',');
export const UPLOAD_WARN_BYTES = 2 * 1024 ** 3;
export const UPLOAD_WARN_FILES = 1500;
/** The first skipped folder on `dir` (`a/logs/b` → `a/logs`), or null. */
function skippedPart(dir) {
const parts = String(dir || '').split('/');
const at = parts.findIndex(isSkippedFolder);
return at < 0 ? null : parts.slice(0, at + 1).join('/');
}
/**
* `add(items, into)` puts `{ name, dir, file | path, size }` items into the window;
* `note(text)` adds a line under the toolbar. Returns the hidden inputs to mount and
* the ways to bring files in.
*/
export function createFileSources({ signal, add, note }) {
let pickFor = null; // the draft the browser file dialog adds to, or null (sort by name)
const chooseLocalFiles = async (kinds = null, into = null) => {
const paths = await pickServerPath({ mode: 'files', title: t('ttsImportPickLocal'), hint: t('ttsImportPickLocalHint'), kinds });
if (paths && !signal.aborted) add(paths.map(path => ({ name: path.split('/').pop(), dir: '', path })), into);
};
const chooseLocalFolder = async () => {
const path = await pickServerPath({ mode: 'folder', title: t('ttsBatchPickLocalFolder'), hint: t('ttsBatchPickLocalFolderHint') });
if (!path || signal.aborted) return;
try {
const result = await scanFolder(path, signal);
add(result.files.map(file => ({ name: file.name, dir: file.dir, path: file.path, size: file.size })));
if (result.truncated) note(t('ttsBatchScanTruncated', { count: result.files.length }));
} catch (e) {
if (!signal.aborted) await anomalousAlert(t('ttsSetupFailed', { error: e.message || String(e) }));
}
};
/**
* Browser files, `{ file, dir }` (or `{ skipped: folder }` for a folder a drop did
* not go into). Leaves out program and training folders, and asks before a very
* large upload.
*/
async function takeFiles(list, into = null) {
const skipped = new Set();
const items = [];
for (const item of list) {
const part = item.skipped || skippedPart(item.dir);
if (part) skipped.add(part);
else items.push({ name: item.file.name, dir: item.dir || '', file: item.file });
}
const usable = items.filter(item => importKind(item.name));
const bytes = usable.reduce((sum, item) => sum + item.file.size, 0);
if (bytes > UPLOAD_WARN_BYTES || usable.length > UPLOAD_WARN_FILES) {
const choice = await anomalousConfirm(t('ttsUploadLarge', { count: usable.length, size: formatSize(bytes) }),
t('ttsUploadLargeTitle'), { okLabel: t('ttsUploadUseLocal'), noLabel: t('ttsUploadAnyway') });
if (signal.aborted || choice === null) return;
if (choice) { await chooseLocalFolder(); return; }
}
if (items.length) add(items, into);
if (skipped.size) {
const names = [...skipped];
note(t('ttsSkippedFolders', { folders: names.slice(0, 3).join('、') + (names.length > 3 ? '…' : '') }));
}
}
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.multiple = true;
fileInput.hidden = true;
fileInput.onchange = () => {
takeFiles([...fileInput.files].map(file => ({ file, dir: '' })), pickFor);
fileInput.value = '';
};
const folderInput = document.createElement('input');
folderInput.type = 'file';
folderInput.webkitdirectory = true;
folderInput.hidden = true;
folderInput.onchange = () => {
takeFiles([...folderInput.files].map(file => ({ file, dir: file.webkitRelativePath.split('/').slice(0, -1).join('/') })));
folderInput.value = '';
};
const chooseFiles = (kinds = null, into = null) => {
pickFor = into;
fileInput.accept = kinds ? kinds.map(kind => ACCEPT[kind]).join(',') : ACCEPT_ALL;
fileInput.click();
};
return {
inputs: [fileInput, folderInput],
chooseFiles,
chooseFolder: () => folderInput.click(),
chooseLocalFiles,
chooseLocalFolder,
takeFiles,
};
}
+1 -1
View File
@@ -10,6 +10,6 @@
@import url("./styles/09-prompt-drawers.css?v=20260918-model-sources-1");
@import url("./styles/10-model-sources.css?v=20260918-model-sources-1");
@import url("./styles/11-audio-studio.css?v=20260926-audio-sidebar-1");
@import url("./styles/12-tts-setup.css?v=20260926-tts-import-guide-1");
@import url("./styles/12-tts-setup.css?v=20260926-tts-import-batch-2");
@import url("./styles/13-script-director.css?v=20260923");
@import url("./styles/14-version-manager.css");
+36
View File
@@ -834,6 +834,38 @@
gap: 6px;
}
.anomalous-tts-card-more {
align-self: flex-start;
}
/* ---- folded rows: files left out, and the unassigned files of one folder ---- */
.anomalous-tts-card-fold {
border: 1px dashed rgba(255, 255, 255, 0.1);
border-radius: 10px;
}
.anomalous-tts-card-fold-summary {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 10px;
color: #94a3b8;
font-size: 12px;
cursor: pointer;
}
.anomalous-tts-card-fold-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.anomalous-tts-card-fold[open] > .anomalous-tts-card-files {
padding: 0 8px 8px;
}
/* ---- the checklist: done items ticked and quiet, the next one lit with its button ---- */
.anomalous-tts-check {
display: flex;
@@ -1023,6 +1055,10 @@
font-size: 11px;
}
.anomalous-tts-file.is-out-of-range {
opacity: 0.6;
}
.anomalous-tts-file.is-out-of-range .anomalous-tts-file-meta {
color: #fbbf24;
}