Files
DemonGatanjieu-Anomalous_Mo…/web/modules/ui_tts_import_draft.js
T
Claude fe8e08d8cf fix(tts): follow Anomalous_TTS interface 13, no local paths
Anomalous_TTS no longer takes a folder or file path on this computer from
a request (ComfyUI-Manager review of the node): the storage place, other
character folders and pretrained sources are set only in its settings
file, and imported files are always uploaded.

- GPT-SoVITS settings shows the storage place, other places and package
  sources without change, move or remove buttons, and names the node's
  settings file (status.settings_file) with a copy button and how to edit it.
- The import window drops "from this computer": no server path picker
  (ui_tts_path_picker.js is gone), no local folder scan or local clip
  preview; a very large upload is confirmed instead.
- tts_setup_api.js loses the removed routes; formatSize moves there.
- The storage-change activity entry, unused locale keys and picker CSS go.

Verified: tools/check_structure.mjs; tts_setup_api in Node with fetch
stubbed; the settings card, batch hero, add menu, file sources and player
rendered with jsdom and stubbed ComfyUI scripts. Not run in ComfyUI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018P2T2R7TFbJsc3C9maCD8B
2026-10-08 20:21:50 +00:00

420 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { t } from './interface_settings.js';
import { formatSize, importKind, missingForLanguage, rowState, textFromFile, usableAsReference } from './tts_setup_api.js';
/**
* One character card of the import window (and the card of files nobody claims).
* Folded, a card is one line: name, how many steps are left, how many clips.
* Unfolded it leads with a checklist (tts_import_groups.js draftChecklist) that
* lights up the next step and offers its button right there; the chosen weights
* sit in their checklist lines, then come the clips, line files and language.
* The import window owns drafts and rows; this module builds each row's elements
* once (`row.els`), so they keep what was typed when cards are redrawn or a file moves.
*/
export const TRAY = '#unassigned';
const LANGUAGES = ['', 'ja', 'zh', 'en'];
const PICKS = { gpt: ['gpt'], sovits: ['sovits'], audio: ['audio'], files: null };
/** The card's short status for reasons the checklist does not already show. */
const SHORT = {
ttsBatchRowProblem: 'ttsCardFileProblem',
ttsImportExistingDifferent: 'ttsCardFileProblem',
ttsImportFailed: 'ttsCardFailed',
ttsEditorNameInvalid: 'ttsCardEmotionProblem',
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'];
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) {
const node = el('input', className);
node.type = 'text';
node.placeholder = placeholder;
return node;
}
/** Name (a renamed file says what it was), state text, size / version / length, and the row's colour. */
export function updateRow(row) {
if (!row.els) return;
const info = row.info || {};
const from = [row.dir, row.original].filter(Boolean).join('/');
row.els.name.textContent = row.name;
row.els.name.title = row.name !== row.original ? t('ttsImportRenamedFrom', { file: from }) : from;
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(' · ');
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), queued: row.queued, progress: row.progress,
existing: info.existing ?? null, unsupported: info.supported === false });
row.els.root.dataset.tone = view.tone;
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. */
export function showSource(row, source, file = '') {
if (!row.els?.text) return;
row.els.text.title = source ? t(`ttsTextSource_${source}`, { file }) : '';
row.els.text.dataset.source = source || '';
}
/** Mark the main clip of a draft; clips outside 3–10 s cannot be it. */
export function syncReference(draft) {
for (const row of draft.rows) {
if (!row.els?.main) continue;
const usable = usableAsReference(row.info?.seconds);
if (!usable && draft.referenceKey === row.key) draft.referenceKey = null;
const on = row.key === draft.referenceKey;
row.els.main.setAttribute('aria-pressed', String(on));
row.els.main.classList.toggle('active', on);
row.els.main.disabled = !usable;
row.els.main.title = usable ? t(on ? 'ttsCardMainOn' : 'ttsImportMainTitle') : t('ttsImportOutOfRange');
row.els.emotion.disabled = on;
row.els.emotion.placeholder = on ? t('ttsCardMainEmotion') : t('ttsImportEmotionPlaceholder');
}
}
/** A .txt / .lab / .list dropped on a clip's text box fills that clip's line. */
function bindTextDrop(row, ctx) {
const box = row.els.text;
box.addEventListener('dragover', (e) => {
if (![...(e.dataTransfer?.types || [])].includes('Files')) return;
e.preventDefault();
box.classList.add('dragover');
});
box.addEventListener('dragleave', () => box.classList.remove('dragover'));
box.addEventListener('drop', async (e) => {
box.classList.remove('dragover');
const file = [...(e.dataTransfer?.files || [])].find(f => importKind(f.name) === 'text');
if (!file) return;
e.preventDefault();
e.stopPropagation();
const line = textFromFile(await file.text(), row.name);
if (ctx.signal.aborted) return;
if (line === null) { showSource(row, 'missing', file.name); return; }
box.value = line;
row.text = line;
row.textEdited = true;
showSource(row, 'file', file.name);
});
}
/** Build a row's elements the first time it is shown. */
function rowElements(row, ctx) {
if (row.els) return row.els;
const root = el('div', `anomalous-tts-file is-${row.kind}`);
const name = el('span', 'anomalous-tts-file-name', row.name);
const els = { root, name, meta: el('span', 'anomalous-tts-file-meta'), state: el('span', 'anomalous-tts-file-state'),
move: el('select', 'anomalous-tts-file-move') };
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);
if (row.kind === 'audio') {
els.play = button('anomalous-tts-file-play', '▶', () => ctx.play(row), t('audioPlay'));
els.main = button('anomalous-tts-file-main', t('ttsImportMain'), () => ctx.onReference(row));
els.emotion = input('anomalous-tts-file-emotion', t('ttsImportEmotionPlaceholder'));
els.emotion.value = row.emotion;
els.emotion.oninput = () => { row.emotion = els.emotion.value.trim(); ctx.onEdited(row); };
els.text = input('anomalous-tts-file-text', t('ttsImportTextPlaceholder'));
els.text.value = row.text;
els.text.oninput = () => { row.text = els.text.value.trim(); row.textEdited = true; showSource(row, null); };
if (row.info && !row.textEdited) showSource(row, row.info.text_source || 'none');
const top = el('div', 'anomalous-tts-file-top');
top.append(els.play, label, els.state, els.main, els.move, remove);
const bottom = el('div', 'anomalous-tts-file-bottom');
bottom.append(els.emotion, els.text);
root.append(top, bottom);
bindTextDrop(row, ctx);
} else {
root.append(label, els.state, els.move, remove);
}
updateRow(row);
return els;
}
function fillMoveOptions(select, place, places) {
if (select.options.length === places.length) return;
select.replaceChildren();
for (const option of places) {
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) {
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) {
const key = view.tone === 'ready' ? 'ttsCardReady' : view.tone === 'busy' ? 'ttsCardBusy' : view.tone === 'done' ? 'ttsCardDone'
: SHORT[view.problem?.[0]] || null;
const next = view.checklist.find(item => !item.done);
const text = key ? t(key) : t('ttsCardSteps', { count: view.checklist.filter(item => !item.done).length });
return { text, title: view.problem ? t(view.problem[0], view.problem[1]) : next ? t(next.key, next.params) : text };
}
/** What a checklist line offers: file buttons, "fill in the name", "pick in the list". */
function checkActions(item, isNext, draft, view, ctx, parts) {
const side = el('span', 'anomalous-tts-check-actions');
if (item.id in PICKS && !item.done) {
if (isNext) {
side.append(button('anomalous-tts-pick', t('ttsCardPickFile'), () => ctx.pick(draft, PICKS[item.id])));
} else {
side.append(button('anomalous-tts-link', t('ttsCardPickShort'), () => ctx.pick(draft, PICKS[item.id])));
}
} else if (item.id === 'main' && !item.done && isNext) {
side.append(button('anomalous-tts-pick', t('ttsCheckMainGo'), () => parts.clips.scrollIntoView({ block: 'nearest', behavior: 'smooth' })));
} else if (item.id === 'name' && !item.done) {
if (view.conflict?.kind === 'exact') side.append(button('anomalous-tts-link', t('ttsImportSwitchToAdd'), () => ctx.onSwitchToAdd(draft)));
else if (isNext) side.append(button('anomalous-tts-pick', t('ttsCheckNameGo'), () => parts.name?.focus()));
}
return side;
}
function renderChecklist(list, draft, view, ctx, parts) {
list.replaceChildren();
const next = view.checklist.find(item => !item.done);
view.checklist.forEach((item, i) => {
const isNext = item === next;
const line = el('div', `anomalous-tts-check-item ${item.done ? 'is-done' : isNext ? 'is-next' : 'is-todo'}`);
line.dataset.check = item.id;
line.append(el('span', 'anomalous-tts-check-mark', item.done ? '✓' : String(i + 1)), el('span', 'anomalous-tts-check-text', t(item.key, item.params)));
const weight = (item.id === 'gpt' || item.id === 'sovits') && draft.rows.find(row => row.kind === item.id);
if (weight) line.append(showRow(weight, draft, ctx));
else line.append(checkActions(item, isNext, draft, view, ctx, parts));
list.append(line);
});
}
/**
* A character card. `ctx`: `{ status, signal, places(), play(row), pick(draft, kinds, local),
* onToggle(draft), onName(draft, value), onSwitchToAdd(draft), onReference(row), onEdited(row),
* onLanguage(draft, value), onMoveRow(row, id), onRemoveRow(row), onRemoveDraft(draft),
* onDropFiles(draft, dataTransfer) }`. `view`: `{ tone, problem, conflict, problems, checklist }`;
* `solo` = the only card of the window (always open, cannot be removed). The returned
* `refresh(view)` repaints status, checklist and problems without touching the fields.
*/
export function renderDraftCard(draft, view, ctx, { solo = false } = {}) {
const open = solo || draft.expanded;
const card = el('section', `anomalous-tts-card${open ? ' is-open' : ''}${solo ? ' is-solo' : ''}`);
const head = el('div', 'anomalous-tts-card-head');
const avatar = el('span', 'anomalous-tts-card-avatar', (draft.target || draft.name).trim().slice(0, 1).toUpperCase() || '?');
const parts = { name: null, clips: null };
if (!solo) head.append(button('anomalous-tts-card-chevron', '', () => ctx.onToggle(draft), t(open ? 'ttsCardFold' : 'ttsCardUnfold')));
head.append(avatar);
if (draft.target) {
const title = el('span', 'anomalous-tts-card-title');
title.append(el('span', 'anomalous-tts-card-addto', t('ttsCardAddTo')), el('span', 'anomalous-tts-card-name', draft.target));
head.append(title);
} else {
const title = input('anomalous-tts-card-name-input', t('ttsCardNamePlaceholder'));
title.value = draft.name;
title.title = t('ttsCardNameTitle');
title.oninput = () => {
avatar.textContent = title.value.trim().slice(0, 1).toUpperCase() || '?';
ctx.onName(draft, title.value);
};
parts.name = title;
head.append(title);
}
const pill = el('span', 'anomalous-tts-card-pill');
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); });
card.append(head);
// Files dropped on a card are this character's.
card.addEventListener('dragover', (e) => {
if (![...(e.dataTransfer?.types || [])].includes('Files')) return;
e.preventDefault();
card.classList.add('is-drop-target');
});
card.addEventListener('dragleave', (e) => { if (!card.contains(e.relatedTarget)) card.classList.remove('is-drop-target'); });
card.addEventListener('drop', (e) => {
card.classList.remove('is-drop-target');
if (![...(e.dataTransfer?.types || [])].includes('Files')) return;
e.preventDefault();
e.stopPropagation();
ctx.onDropFiles(draft, e.dataTransfer);
});
const checklist = el('div', 'anomalous-tts-check');
const problems = el('div', 'anomalous-tts-card-problems');
const paint = (next) => {
card.dataset.tone = next.tone;
const status = statusOf(next);
pill.textContent = status.text;
pill.title = status.title;
if (!open) return;
renderChecklist(checklist, draft, next, ctx, parts);
problems.replaceChildren();
if (next.problem && !IN_CHECKLIST.includes(next.problem[0])) {
problems.append(el('div', 'anomalous-tts-card-problem is-blocking', t(next.problem[0], next.problem[1])));
}
for (const text of next.problems) problems.append(el('div', 'anomalous-tts-card-problem', text));
};
if (!open) {
paint(view);
return { root: card, refresh: paint };
}
const body = el('div', 'anomalous-tts-card-body');
body.append(checklist);
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) {
const audioHead = el('div', 'anomalous-tts-card-section');
audioHead.append(el('span', 'anomalous-tts-card-section-title', t('ttsCardClipsTitle', { count: audioRows.length })),
el('span', 'anomalous-tts-card-section-hint', t('ttsCardClipsHint')),
button('anomalous-tts-link', t('ttsCardAddClips'), () => ctx.pick(draft, ['audio'])));
const audioList = el('div', 'anomalous-tts-card-files');
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 = 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')));
for (const row of textRows) lines.append(showRow(row, draft, ctx));
lines.append(button('anomalous-tts-link', t('ttsCardAddLineFile'), () => ctx.pick(draft, ['text'])));
extras.append(lines);
if (!draft.target) {
const languageSelect = el('select', 'anomalous-tts-card-language');
for (const code of LANGUAGES) {
const option = el('option', '', code ? t(`ttsNeededFor_${code}`)
: draft.detectedLanguage ? t('ttsImportLanguageAutoFound', { lang: t(`ttsNeededFor_${draft.detectedLanguage}`) }) : t('ttsImportLanguageAuto'));
option.value = code;
languageSelect.append(option);
}
languageSelect.value = draft.language;
languageSelect.onchange = () => ctx.onLanguage(draft, languageSelect.value);
const language = el('label', 'anomalous-tts-card-lang');
language.append(el('span', 'anomalous-tts-card-extra-label', t('ttsImportLanguage')), languageSelect);
extras.append(language);
}
body.append(extras);
const missing = missingForLanguage(ctx.status, draft.language || draft.detectedLanguage || '');
if (missing.length && !draft.target) {
body.append(el('div', 'anomalous-tts-card-note', t('ttsImportPretrainedNote', {
files: missing.map(item => item.label).join('、'),
size: formatSize(missing.reduce((sum, item) => sum + (item.size || 0), 0)),
})));
}
body.append(problems);
card.append(body);
paint(view);
syncReference(draft);
return { root: card, refresh: paint };
}
/** Files that did not match any character: each says where it should go. */
export function renderTrayCard(tray, ctx) {
const card = el('section', 'anomalous-tts-card is-tray is-open');
const head = el('div', 'anomalous-tts-card-head');
head.append(el('span', 'anomalous-tts-card-avatar', '?'),
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 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;
}