Files
DemonGatanjieuandClaude Opus 5.5 d778aec946 Fix what a walk through every page found before 1.63
Every page in both languages, all themes and at 480:
- Give the window its own text colour: text with none took ComfyUI's, white on the light theme (Tidy headings and file names, the site chip).
- Keep a task's link chip in its row, its x in reach, and colour the next-task chip.
- Let choices in Settings wrap, the Models search shrink, and a workflow's preset row go to two lines.
- Add --amb-on-link for text on the accent: pale in the dark themes, so white was hard to read.
- Lists and colons follow the language (listSeparator, wordColon); tests/words_in_language.mjs keeps it so.
- Right plurals in English, "AI" on the rail, one line left in Chinese translated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 03:18:07 +08:00

423 lines
21 KiB
JavaScript
Raw Permalink 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(t('listSeparator')),
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;
}