Files
DemonGatanjieu-Anomalous_Mo…/web/modules/ui_tts_import_draft.js
T
DemonGatanjieuandClaude Opus 5.5 07a989fbe9 Show the GPT-SoVITS model version on character cards; v3/v4 imports (1.59.1)
From 1.60.1 (6996f07): character cards show the version Anomalous_TTS reports
(interface 15); v3/v4 base models and vocoders are listed and reminded of only
when a character or a dropped SoVITS file needs them, with their own labels;
the import tells an unreadable SoVITS file from a version an older node cannot
use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:48:37 +08:00

423 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);
// A new character's language decides the language files; any draft's SoVITS model its own files (v4: base + vocoder).
const needs = draft.rows.flatMap(row => (row.kind === 'sovits' && row.info?.needs) || []);
const missing = missingForLanguage(ctx.status, draft.target ? '' : draft.language || draft.detectedLanguage || '', needs)
.filter(item => !draft.target || needs.includes(item.id));
if (missing.length) {
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;
}