Files
DemonGatanjieu-Anomalous_Mo…/web/modules/tts_setup_api.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

266 lines
13 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 { invalidateEngineCache } from './audio_engines.js';
/**
* GPT-SoVITS setup and import through the Anomalous_TTS node (interface v13, §5.2–5.3).
* The node owns every file: the storage place, pretrained files and imported
* characters. Where they are is set only in the node's settings file, never through
* the API, and imported files are always uploaded: no call names a path on the
* computer. This module only calls the API and holds the pure rules the import form
* needs; it has no DOM.
*/
export const UPLOAD_CHUNK = 8 * 1024 * 1024; // under ComfyUI's default 100 MB request limit
const IMPORT_KINDS = {
'.ckpt': 'gpt', '.pth': 'sovits',
'.wav': 'audio', '.flac': 'audio', '.ogg': 'audio', '.mp3': 'audio',
'.txt': 'text', '.lab': 'text', '.list': 'text',
};
// GPT-SoVITS annotation line: path|speaker|LANG|text (same rule as the node's characters.read_list).
const LIST_LINE = /^[^|]+\|[^|]*\|([A-Za-z_]+)\|(.+)$/;
const FORBIDDEN_IN_EMOTION = /[{}[\]]/;
// GPT-SoVITS reference clips must be 3–10 s (the node refuses others as a main voice).
export const REF_MIN_SEC = 3;
export const REF_MAX_SEC = 10;
const EPOCH = { gpt: /-e(\d+)/i, sovits: /_e(\d+)(?:_s(\d+))?/i };
/** gpt | sovits | audio | text | null — the node checks again; this only avoids uploading unusable files. */
export function importKind(name) {
const dot = String(name || '').lastIndexOf('.');
return dot < 0 ? null : IMPORT_KINDS[String(name).slice(dot).toLowerCase()] || null;
}
async function request(url, { method = 'POST', body, raw, signal, keepalive } = {}) {
const headers = raw ? { 'Content-Type': 'application/octet-stream' } : body !== undefined ? { 'Content-Type': 'application/json' } : undefined;
const resp = await fetch(url, {
method, signal, keepalive, headers,
body: raw ?? (body !== undefined ? JSON.stringify(body) : undefined),
});
const text = await resp.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch (_) { /* plain-text error message */ }
if (!resp.ok) {
const error = new Error(data?.error || text || `HTTP ${resp.status}`);
error.status = resp.status;
error.data = data;
throw error;
}
return data;
}
export function formatSize(bytes) {
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
}
// ---------- pretrained files ----------
/** Start background downloads; progress shows up in the status. */
export async function startPretrainedDownload(ids) {
await request('/anomalous_tts/pretrained/download', { body: { ids } });
invalidateEngineCache();
}
// ---------- import ----------
/**
* Upload one browser File in chunks. `onStart(id)` gets the upload id at once so the
* caller can discard it if the form is closed; a 409 resumes from what the node has.
*/
export async function uploadFile(file, { library, signal, onStart, onProgress } = {}) {
const { upload } = await request('/anomalous_tts/import/upload', { body: { name: file.name, size: file.size, library }, signal });
onStart?.(upload);
let offset = 0;
while (offset < file.size) {
try {
const { received } = await request(`/anomalous_tts/import/upload?upload=${upload}&offset=${offset}`, {
raw: file.slice(offset, offset + UPLOAD_CHUNK), signal,
});
offset = received;
} catch (e) {
if (e.status !== 409 || !Number.isInteger(e.data?.received)) throw e;
offset = e.data.received;
}
onProgress?.(offset / file.size);
}
return upload;
}
/** `target`: the character files are added to; each file then says what it meets there (`existing`). */
export function inspectImport(files, signal, target = null) {
return request('/anomalous_tts/import/inspect', { body: target ? { files, target } : { files }, signal });
}
/** Not tied to the form's AbortSignal: closing the form must not pretend to cancel a write. */
export async function commitImport(body) {
const result = await request('/anomalous_tts/import/commit', { body });
invalidateEngineCache();
return result.character;
}
/** Fire and forget; `keepalive` lets it finish while the page is closing. */
export function discardUploads(ids) {
if (!ids.length) return;
request('/anomalous_tts/import/discard', { body: { uploads: ids }, keepalive: true }).catch(() => {});
}
/**
* The commit request for the import form. `rows` are in file order:
* { spec: {upload} (plus `name` when renamed), kind, emotion, text }. `language` '' = let the node decide.
*/
export function buildImportBody({ target = null, library = '', character = '', rows, referenceIndex = null, language = '' }) {
const settings = {};
if (language) settings.language = language;
const main = referenceIndex !== null ? rows[referenceIndex] : null;
if (main?.kind === 'audio') {
settings.reference = { file: referenceIndex };
if (main.text) settings.reference.text = main.text;
if (language) settings.reference.language = language;
}
const emotions = {};
rows.forEach((row, index) => {
if (row.kind !== 'audio' || index === referenceIndex || !row.emotion) return;
emotions[row.emotion] = { file: index };
if (row.text) emotions[row.emotion].text = row.text;
});
if (Object.keys(emotions).length) settings.emotions = emotions;
const body = { files: rows.map(row => row.spec), settings };
if (target) body.target = target;
else Object.assign(body, { library, character });
return body;
}
/** Training epoch in a weight file name (`X-e15.ckpt`, `X_e16_s224.pth`), or -1. */
export function weightEpoch(name, kind) {
const m = EPOCH[kind]?.exec(String(name || ''));
return m ? Number(m[1]) * 1e6 + Number(m[2] || 0) : -1;
}
/**
* A form holds one GPT and one SoVITS weight. From files added together, keep the
* latest epoch of each kind. Returns { skip: Set of indexes, kept: [{ kind, name, others }] }.
*/
export function pickWeights(items) {
const skip = new Set();
const kept = [];
for (const kind of ['gpt', 'sovits']) {
const candidates = items.map((item, index) => ({ ...item, index })).filter(item => item.kind === kind);
if (candidates.length < 2) continue;
const best = candidates.reduce((a, b) => (weightEpoch(b.name, kind) > weightEpoch(a.name, kind) ? b : a));
candidates.forEach(item => { if (item !== best) skip.add(item.index); });
kept.push({ kind, name: best.name, others: candidates.length - 1 });
}
return { skip, kept };
}
/** Does a new character name clash with existing ones? exact | variant (`name/…` versions) | null. */
export function nameConflict(name, existing) {
const wanted = String(name || '').trim();
if (!wanted) return null;
if (existing.includes(wanted)) return { kind: 'exact', name: wanted };
const variants = existing.filter(other => other.startsWith(`${wanted}/`));
return variants.length ? { kind: 'variant', name: wanted, variants } : null;
}
export function usableAsReference(seconds) {
return seconds === undefined || (seconds >= REF_MIN_SEC && seconds <= REF_MAX_SEC);
}
/**
* First reason the form cannot be sent, as [locale key, params], or null. Rows may
* carry the node's inspect result (`supported`, `version`) for SoVITS weights.
*/
export function importProblem({ target = null, character = '', rows, conflict = null }) {
if (!rows.length) return ['ttsImportNoFiles', {}];
if (!target) {
if (!character.trim()) return ['ttsImportNameMissing', {}];
if (conflict?.kind === 'exact') return ['ttsImportNameTaken', { name: conflict.name }];
if (conflict?.kind === 'variant') return ['ttsImportNameTakenVariant', { name: conflict.name, variants: conflict.variants.join('、') }];
if (!rows.some(row => row.kind === 'gpt') || !rows.some(row => row.kind === 'sovits')) return ['ttsImportWeightsMissing', {}];
}
const clash = rows.find(row => row.existing === 'different');
if (clash) return ['ttsImportExistingDifferent', { file: clash.name }];
const unsupported = rows.find(row => row.kind === 'sovits' && row.supported === false);
if (unsupported) return ['ttsImportUnsupportedBlock', { file: unsupported.name, version: unsupported.version || '?' }];
const seen = new Set();
for (const row of rows) {
if (row.kind !== 'audio' || !row.emotion) continue;
if (row.emotion === 'main' || FORBIDDEN_IN_EMOTION.test(row.emotion)) return ['ttsEditorNameInvalid', { name: row.emotion }];
if (seen.has(row.emotion)) return ['ttsEditorNameDuplicate', { name: row.emotion }];
seen.add(row.emotion);
}
return null;
}
/**
* The line for `clipName` from a text file dropped on that clip's text box: a GPT-SoVITS
* annotation file is searched by file name (then without the emotion part, like the node);
* any other text file is the line itself. null = an annotation file without this clip.
*/
export function textFromFile(content, clipName) {
const text = String(content || '').trim(); // trim() also drops a BOM
const lines = text.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
if (!lines.length || !LIST_LINE.test(lines[0])) return text;
const table = new Map();
for (const line of lines) {
const m = LIST_LINE.exec(line);
const file = line.split('|', 1)[0].replace(/\\/g, '/').split('/').pop().toLowerCase();
if (m && !table.has(file)) table.set(file, m[2].trim());
}
const name = String(clipName || '').toLowerCase();
const dot = name.lastIndexOf('.');
const [stem, ext] = dot > 0 ? [name.slice(0, dot), name.slice(dot)] : [name, ''];
return table.get(name) ?? table.get(`${stem.split('.')[0]}${ext}`) ?? null;
}
/**
* Pretrained files worth a reminder dot: missing ones that the characters in the
* studio will actually load (the shared ones, English for mixed-in words, v2Pro's
* speaker model, plus their languages; a character with no known language counts
* as every language). No characters, no reminder: an empty studio is only about
* importing. `due` is false once every such file was dismissed with "don't remind
* me"; a newly needed one (say, the first Chinese character) brings the dot back.
*/
export function pretrainedReminder(status, languages, dismissed = []) {
const langs = new Set(languages || []);
if (!langs.size) return { items: [], size: 0, due: false };
const needed = (item) => ['all', 'en', 'v2pro'].includes(item.needed_for) || langs.has(item.needed_for) || langs.has('');
const items = (status?.pretrained || []).filter(item => (item.state === 'missing' || item.state === 'error') && needed(item));
const seen = new Set(dismissed);
return { items, size: items.reduce((sum, item) => sum + (item.size || 0), 0), due: items.some(item => !seen.has(item.id)) };
}
/** Required pretrained files still missing for a character in `language` ('' = not chosen yet). */
export function missingForLanguage(status, language) {
return (status?.pretrained || []).filter(item => item.required && item.state !== 'ok'
&& (item.needed_for === 'all' || (language && item.needed_for === language)));
}
/**
* One import row at a glance: `tone` colours it (busy | ready | skip | merge | error | idle)
* and `key` / `params` are its state text. `existing` comes from inspect with a target.
* `queued` false: a file nobody claims yet, uploaded once it is put into a character.
*/
export function rowState({ error = '', uploaded = false, queued = true, progress = 0, existing = null, unsupported = false }) {
if (error) return { tone: 'error', key: 'ttsImportRowError', params: { error } };
if (!uploaded && !queued) return { tone: 'idle', key: 'ttsImportRowNotSent', params: {} };
if (!uploaded) return { tone: 'busy', key: 'ttsImportRowUploading', params: { percent: Math.floor(100 * progress) } };
if (existing === 'different') return { tone: 'error', key: 'ttsImportRowConflict', params: {} };
if (unsupported) return { tone: 'error', key: 'ttsImportRowUnsupported', params: {} };
if (existing === 'same') return { tone: 'skip', key: 'ttsImportRowSkip', params: {} };
if (existing === 'merge') return { tone: 'merge', key: 'ttsImportRowMerge', params: {} };
return { tone: 'ready', key: 'ttsImportRowReady', params: {} };
}
/** Summary for the setup card: what still needs doing. */
export function setupSummary(status) {
const characters = (status?.libraries || []).reduce((sum, lib) => sum + (lib.characters || 0), 0);
const missing = (status?.pretrained || []).filter(item => item.state !== 'ok');
const packages = Object.values(status?.dependencies || {}).reduce((sum, dep) => sum + (dep.missing?.length || 0), 0);
const downloading = missing.some(item => item.state === 'queued' || item.state === 'downloading');
const requiredMissing = missing.filter(item => item.required).length;
return { characters, missing: missing.length, requiredMissing, packages, downloading,
ready: characters > 0 && requiredMissing === 0 && packages === 0 };
}