Files
DemonGatanjieu-Anomalous_Mo…/web/modules/ui_script_run.js
T
DemonGatanjieuandClaude Opus 5.5 2a7cfe5075 Voice-over: three layers, whole script / character / line, and a draft
The side panel changes one layer at a time under a path (Whole script ›
character › line); each layer has the same rows (speed, tone, language),
each saying where its value comes from, with ↺ to follow the layer above.
The more particular wins: line, character, whole script, the character's
usual settings. The whole script's values go into the node's inputs and
only lines that differ get tags. Lasting settings are links at the foot.
The script is kept as a draft in this browser and comes back after a
reload; editing the text keeps unchanged lines' own settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 04:21:00 +08:00

497 lines
24 KiB
JavaScript

import { t } from './interface_settings.js';
import { anomalousAlert } from './ui_dialog.js';
import { buildTtsPrompt, TTS_NODE_CLASS } from './audio_script.js';
import { startPromptJob } from './audio_tts_run.js';
import { fetchGptSovitsCharacter, loadGptSovitsStatus, saveGptSovitsSettings } from './audio_engines.js';
import {
PRESETS_FORMAT, SAMPLE_LINES, lineTuning, loadPresets, presetLabel, presetsFile, savePresets, stateFromView, tuningRows,
} from './tts_presets_api.js';
import {
AUTO_MODE, LINE_TAGS_FORMAT, TUNE_FIELDS, characterLayer, clearLineTune, lineLayer, lineValues, scriptLayer, setLineTune, setOwn, tuneTags,
} from './script_tune.js';
import { keepParamFocus } from './ui_tts_preset_switch.js';
import { createModePreview } from './ui_tts_mode_preview.js';
import { openTtsSetup } from './ui_tts_setup.js';
import { openTuningEditor } from './ui_tts_tuning.js';
import { LANGUAGES, renderTunePanel } from './ui_script_tune.js';
/**
* The Script Director's "Generate" section for GPT-SoVITS characters: runs the
* script through ComfyUI's queue (audio_tts_run.js) without touching the canvas,
* plays the result, and offers another take of the whole script (new seed) or of
* one line (`[take:N]`, the other lines come from the node's cache).
*
* How each line is read: its language, speed and tone in three layers changed for this script
* (script_tune.js): the line, its character (kept here), the whole script (kept here); then the
* character's usual `defaults` (language and speed, which can be saved back) and the usual tone
* (the presets and the character's own settings decide line by line). The side panel showing
* them follows what is picked on the left (ui_script_tune.js). Anomalous_TTS interface 17 gets
* the whole script's values through the node's inputs and the rest as tags on the lines that
* need them; an older node reads the whole script with the first line's character's values
* through its inputs. The tones can be heard on a line (ui_tts_mode_preview.js); a preset's
* numbers changed here are saved into that preset.
*
* The director owns the lines; this section owns the job, the characters' values and the
* result, which live for the page session like the director's panel. It has two parts that
* the director places together: `bar` (generate / retake / cancel and the result, with a way to
* the audio gallery) and `settings` (the side panel).
*/
const MIN_FORMAT = 11; // Anomalous_TTS interface with [take:N] and `defaults`
const NUMBERS = ['speed'];
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.onclick = onClick;
return btn;
}
function newSeed() {
return Math.floor(Math.random() * 2 ** 31);
}
function audioUrl(audio) {
const query = new URLSearchParams({ filename: audio.filename, subfolder: audio.subfolder || '', type: audio.type || 'output' });
return `/view?${query}`;
}
/** `{ speed: { default, min, max, step } }` from ComfyUI's description of the node. */
async function loadNodeSpec() {
const resp = await fetch(`/object_info/${TTS_NODE_CLASS}`);
const info = (await resp.json())?.[TTS_NODE_CLASS]?.input || {};
const inputs = { ...(info.required || {}), ...(info.optional || {}) };
const spec = {};
for (const name of NUMBERS) {
const options = inputs[name]?.[1];
if (options && typeof options.default === 'number') spec[name] = options;
}
return spec;
}
/**
* `getScript()` -> `{ group, lines, listen, speakerOf, pkg, groups, picked, focus, assign, focusCharacter,
* focusScript }` (the node's character = the first line's, the director's lines, the one to listen
* with, who reads a line, pkg from buildScriptPackage with takes, the characters, the picked line
* indexes, the character shown, and the director's calls); `getNodeGroup()` -> the node's character
* alone (the tags need it, and getScript builds the tagged script); `getGroups()` -> every voice group (for
* the presets' "used by"); `onPlay()` when the result starts playing; `resetTakes()` when a new take
* of the whole script starts; `onBusyChange()` when a job starts or ends (the director re-renders its
* line cards, whose retake buttons depend on it); `onLinesChange()` after a line's values changed (the
* cards' tags); `openGallery()` for the result's "saved to the audio gallery".
*/
export function createRunSection({ getScript, getNodeGroup, getGroups, onPlay, resetTakes, onBusyChange, onLinesChange, openGallery }) {
const state = {
seed: newSeed(),
spec: null, // node input ranges/defaults; null until loaded
presets: null, // loadPresets(); null without interface 16
format: 0, // the node's interface
local: false, // this computer (presets can be changed)
script: {}, // the whole script's values changed here (only the fields set)
cast: new Map(), // character -> its values changed here for this script (only the fields set)
settings: new Map(), // character -> its settings as saved in this session
saved: new Map(), // character -> options saved in this session
savedNow: null, // the character just saved ("Saved" on its button for a moment)
saving: false,
presetNote: '',
job: null,
status: null, // { state: 'queued' | 'running' | 'done' | 'cancelled', value?, max?, seconds? }
result: null, // { audio, url, warnings, character }
supported: null, // TTS interface >= MIN_FORMAT; null while unknown
};
const bar = el('div', 'anomalous-sd-run');
const hint = el('div', 'anomalous-sd-run-hint is-warning', t('scriptRunNeedsUpdate'));
// How it is read: what is picked on the left (ui_script_tune.js).
const settings = el('div', 'anomalous-sd-run-settings');
const actions = el('div', 'anomalous-sd-run-actions');
const generateBtn = button('anomalous-sd-btn accent', t('scriptRunGenerate'), () => generate());
const newTakeBtn = button('anomalous-sd-btn', t('scriptRunNewTake'), () => {
state.seed = newSeed();
resetTakes?.();
generate();
}, t('scriptRunNewTakeHint'));
const cancelBtn = button('anomalous-sd-btn', t('scriptRunCancel'), () => state.job?.cancel());
const status = el('span', 'anomalous-sd-run-status');
actions.append(generateBtn, newTakeBtn, cancelBtn, status);
const player = el('audio', 'anomalous-sd-run-player');
player.controls = true;
player.preload = 'auto';
player.addEventListener('play', () => onPlay?.());
const savedTo = button('anomalous-sd-link-btn anomalous-sd-run-saved', t('scriptRunSavedLink'), () => openGallery?.());
const warnings = el('div', 'anomalous-sd-run-warnings');
bar.append(hint, actions, player, savedTo, warnings);
const characterSettings = group => state.settings.get(group.character) ?? group?.raw?.settings ?? {};
const preview = createModePreview(() => render());
const canTag = () => state.format >= LINE_TAGS_FORMAT;
/** The character's saved language and speed over the node's own defaults; the mode starts on Auto. */
function storedOptions(group) {
if (state.saved.has(group.character)) return state.saved.get(group.character);
const defaults = characterSettings(group).defaults || {};
return {
language: LANGUAGES.includes(defaults.language) ? defaults.language : 'auto',
speed: typeof defaults.speed === 'number' ? defaults.speed : state.spec?.speed?.default ?? null,
preset: AUTO_MODE,
};
}
const base = () => ({ language: 'auto', speed: state.spec?.speed?.default ?? 1, preset: AUTO_MODE });
function castOf(group) {
if (!state.cast.has(group.character)) state.cast.set(group.character, {});
return state.cast.get(group.character);
}
/** A character's values in this script with where each comes from; a preset deleted meanwhile reads as the usual tone. */
function layerOf(group) {
const layer = characterLayer(castOf(group), state.script, storedOptions(group), base());
if (layer.values.preset !== AUTO_MODE && !state.presets?.presets?.[layer.values.preset]) layer.values.preset = AUTO_MODE;
return layer;
}
const currentOptions = group => (group ? layerOf(group).values : scriptLayer(state.script, base()).values);
const sameOptions = (a, b) => a.language === b.language && a.speed === b.speed;
const valuesOfLine = line => lineValues(line, currentOptions(getScript().speakerOf(line)));
/**
* The node's whole-script speed: its input counts only when it differs from the node's default
* and from the first character's own speed (Anomalous_TTS reads it that way), else undefined.
*/
function nodeSpeed() {
const speed = state.script.speed;
const first = getNodeGroup();
return speed === undefined || speed === base().speed || (first && speed === storedOptions(first).speed) ? undefined : speed;
}
/** What the node gives a line of `speaker` by itself: the whole script's values as inputs, else the character's own. */
const nodeValues = speaker => ({
language: state.script.language ?? 'auto',
speed: nodeSpeed() ?? storedOptions(speaker).speed,
preset: state.script.preset ?? AUTO_MODE,
});
/** The tags `line` needs (script_tune.js), for the director's packages; none for an older node. */
function tagsOf(line, speaker) {
if (!canTag() || !speaker) return '';
return tuneTags(lineValues(line, currentOptions(speaker)), nodeValues(speaker));
}
/** The node's inputs for the script: the whole script's values (interface 17), else the first line's character's. */
function scriptInputs(group) {
if (!canTag()) {
const { language, speed, preset } = currentOptions(group);
return { language, speed, ...(state.presets && preset !== AUTO_MODE ? { preset } : {}) };
}
const speed = nodeSpeed();
return {
language: state.script.language ?? 'auto',
...(speed !== undefined ? { speed } : {}),
...(state.presets && state.script.preset ? { preset: state.script.preset } : {}),
};
}
/** The language Auto's numbers are shown for: the one chosen, else the character's own. */
function autoKey(group, values) {
const refLang = group.raw?.reference?.language || group.language || '';
return values.language !== 'auto' ? values.language : (refLang || 'all');
}
/** A mode's four numbers for `group` reading with `values`: Auto's from the presets and its settings. */
function modeNumbers(group, values, id) {
if (id !== AUTO_MODE) return state.presets?.presets?.[id];
const refLang = group.raw?.reference?.language || group.language || '';
return lineTuning(state.presets, tuningRows(characterSettings(group)), refLang, autoKey(group, values)).values;
}
/**
* One line read by its character (`speaker`) in tone `mode`, for the listen row and a card's ▶.
* With interface 17 the values go as the line's tags (exactly as they will be read), else as inputs.
*/
function takeFor(line, mode, speaker = null, values = null) {
const who = speaker || getScript().speakerOf(line);
const own = values || (line ? valuesOfLine(line) : currentOptions(who));
const id = mode ?? own.preset;
const words = String(line?.text || '').trim();
const refLang = who.raw?.reference?.language || who.language;
const emotion = line?.emotion && line.emotion !== 'main' ? `{${line.emotion}}` : '';
const numbers = modeNumbers(who, own, id);
if (canTag()) {
const tags = tuneTags({ ...own, preset: id }, { speed: storedOptions(who).speed });
return { character: who.character, text: `${emotion}${tags}${words || SAMPLE_LINES[refLang] || SAMPLE_LINES.zh}`, language: 'auto', mode: id, preset: '', numbers };
}
const text = words ? `${emotion}${words}` : SAMPLE_LINES[refLang] || SAMPLE_LINES.zh;
return { character: who.character, text, language: own.language, speed: own.speed, mode: id, preset: id, numbers };
}
async function reloadPresets() {
try {
state.presets = await loadPresets();
} catch (_) { /* keep the last ones */ }
render();
}
async function checkSupport() {
try {
const [status, spec] = await Promise.all([loadGptSovitsStatus(), loadNodeSpec()]);
state.spec = spec;
state.format = Number(status?.format) || 0;
state.supported = state.format >= MIN_FORMAT && NUMBERS.every(name => spec[name]);
state.local = status?.local !== false;
if (state.format >= PRESETS_FORMAT) state.presets = await loadPresets();
} catch (_) {
state.supported = false;
}
render();
onBusyChange?.(); // the director draws its cards again, now with their ▶
}
/** A preset's number changed here goes into the preset itself, for every character. */
async function editPreset(name, key, value) {
const next = stateFromView(state.presets);
next.numbers[name][key] = value;
try {
state.presets = await savePresets(presetsFile(state.presets, next));
state.presetNote = t('scriptRunModeSaved', { name: presetLabel(state.presets.labels, name, t) });
} catch (error) {
state.presetNote = t('ttsPresetsSaveFailed', { error: error.message });
}
keepParamFocus(settings, render);
}
async function generate() {
const script = getScript();
if (!script?.group || script.pkg.error || state.job || !state.supported) return;
const character = script.group.character;
const prompt = buildTtsPrompt({ character, speech: script.pkg.speech, seed: state.seed, ...scriptInputs(script.group) });
state.status = { state: 'queued' };
const started = Date.now();
state.job = startPromptJob(prompt, {
onStatus: next => {
state.status = next;
renderStatus();
},
});
onBusyChange?.();
try {
const { outputs } = await state.job.result;
const audio = outputs['2']?.audio?.[0];
if (!audio) throw new Error(t('scriptRunNoAudio'));
// The first line is the node's own count (its sentences, batches, cache), not the reader's lines.
const [, ...rest] = String(outputs['1']?.text?.[0] || '').split('\n');
state.result = { audio, url: `${audioUrl(audio)}&t=${Date.now()}`, warnings: rest.filter(Boolean), character };
state.status = { state: 'done', seconds: Math.max(1, Math.round((Date.now() - started) / 1000)) };
state.job = null;
onBusyChange?.();
player.play().catch(() => {});
} catch (error) {
state.job = null;
state.status = error.cancelled ? { state: 'cancelled' } : null;
onBusyChange?.();
if (!error.cancelled) await anomalousAlert(t('scriptRunFailed', { error: error.message }));
}
}
async function storeDefaults(group) {
const { character } = group;
const opts = { ...currentOptions(group) };
state.saving = true;
render();
try {
// Start from the file as it is now, so edits made elsewhere (the generation settings) are kept.
const fresh = await fetchGptSovitsCharacter(character);
if (fresh.settings_error) throw new Error(t('scriptRunSettingsBroken', { error: fresh.settings_error }));
const saved = { ...(fresh.settings || {}), format: 1 };
// Only what differs from the node's own defaults, so the file stays short.
const defaults = { ...(saved.defaults || {}) };
if (opts.language !== 'auto') defaults.language = opts.language; else delete defaults.language;
if (opts.speed !== state.spec.speed.default) defaults.speed = opts.speed; else delete defaults.speed;
if (Object.keys(defaults).length) saved.defaults = defaults; else delete saved.defaults;
await saveGptSovitsSettings(character, saved);
state.settings.set(character, saved);
state.saved.set(character, { ...opts, preset: AUTO_MODE });
// What was changed for it here is its usual now, unless it still has to go over the whole script's.
for (const field of ['language', 'speed']) if (state.script[field] === undefined) delete castOf(group)[field];
state.savedNow = character;
setTimeout(() => { state.savedNow = null; render(); }, 1600);
} catch (error) {
await anomalousAlert(t('scriptRunDefaultsFailed', { error: error.message }));
} finally {
state.saving = false;
render();
}
}
function saveButton(group) {
const label = state.savedNow === group.character ? t('scriptRunDefaultsSaved') : t('scriptRunSaveDefaults');
const btn = button('anomalous-sd-link-btn', label, () => storeDefaults(group), t('scriptRunSaveDefaultsHint'));
btn.disabled = state.saving;
return btn;
}
/** The side panel's view of the moment (ui_script_tune.js). */
function renderSettings(script, busy) {
const changed = () => {
onLinesChange?.();
render();
};
renderTunePanel(settings, {
script,
view: state.presets,
spec: state.spec,
busy,
local: state.local,
canTag: canTag(),
presetNote: state.presetNote,
preview,
redraw: render,
options: currentOptions,
lineValues: valuesOfLine,
layers: {
script: () => scriptLayer(state.script, base()),
character: layerOf,
line: line => lineLayer(line, layerOf(script.speakerOf(line))),
},
changedFor: {
script: () => TUNE_FIELDS.some(field => state.script[field] !== undefined),
character: group => TUNE_FIELDS.some(field => castOf(group)[field] !== undefined),
},
setScript: (field, value) => {
setOwn(state.script, field, value, base()[field]);
state.presetNote = '';
changed();
},
setCharacter: (group, field, value) => {
setOwn(castOf(group), field, value, state.script[field] ?? storedOptions(group)[field]);
state.presetNote = '';
changed();
},
setLines: (indexes, field, value) => {
setLineTune(indexes.map(index => script.lines[index]), field, value, line => currentOptions(script.speakerOf(line))[field]);
state.presetNote = '';
changed();
},
clearScript: () => {
state.script = {};
changed();
},
clearCharacter: (group) => {
state.cast.delete(group.character);
changed();
},
clearLines: (indexes) => {
clearLineTune(indexes.map(index => script.lines[index]));
changed();
},
modeNumbers,
autoLanguage: (group, values) => {
const key = autoKey(group, values);
return key === 'all' ? t('ttsTuningAll') : t(`scriptRunLang_${key}`);
},
takeFor,
editPreset,
canSave: group => state.savedNow === group.character || !sameOptions(currentOptions(group), storedOptions(group)),
saveButton,
openPresets: () => openTtsSetup({ groups: getGroups?.() || [], showPresets: true, onChanged: reloadPresets }),
openTuning: group => openTuningEditor(group, {
onSaved: (saved) => {
if (saved?.raw?.settings) state.settings.set(group.character, saved.raw.settings);
render();
},
}),
});
}
function renderStatus() {
const s = state.status;
let text = '';
if (s?.state === 'queued') text = t('scriptRunQueued');
else if (s?.state === 'running') text = s.max ? t('scriptRunProgress', { value: s.value, max: s.max }) : t('scriptRunRunning');
else if (s?.state === 'done') text = t('scriptRunDone', { seconds: s.seconds });
else if (s?.state === 'cancelled') text = t('scriptRunCancelled');
status.textContent = text;
status.hidden = !text;
}
function render() {
const script = getScript();
if (state.supported === null) {
state.supported = false;
checkSupport();
}
const { group, pkg } = script;
const busy = Boolean(state.job);
const ready = Boolean(state.supported && group);
hint.hidden = Boolean(state.supported);
generateBtn.title = t('scriptRunHint', { character: group?.character || '' });
settings.hidden = actions.hidden = !ready;
if (ready) renderSettings(script, busy);
generateBtn.disabled = !ready || Boolean(pkg.error) || busy;
const hasResult = Boolean(state.result && group && state.result.character === group.character);
newTakeBtn.hidden = !hasResult;
newTakeBtn.disabled = generateBtn.disabled;
cancelBtn.hidden = !busy;
renderStatus();
player.hidden = !hasResult;
if (hasResult && player.getAttribute('src') !== state.result.url) player.src = state.result.url;
savedTo.hidden = !hasResult;
if (hasResult) {
const folder = String(state.result.audio.subfolder || '').replace(/\\/g, '/');
savedTo.title = `output/${folder}/${state.result.audio.filename}`;
}
warnings.hidden = !hasResult || !state.result.warnings.length;
if (hasResult) warnings.textContent = state.result.warnings.join('\n');
}
return {
settings,
bar,
render,
generate,
tagsOf,
/** A card's ▶: its line (the director set it as the one to listen with) as it will be read. */
listen() {
const script = getScript();
const line = script?.lines?.[script.listen];
if (!line || !this.canListen()) return;
preview.play(takeFor(line));
},
/** Whether the cards show ▶ (the node has modes, a character is chosen, nothing runs). */
canListen() {
return Boolean(state.presets && state.supported && !state.job && getScript()?.group);
},
/** A line can be retaken once this character has a result and nothing is running. */
canRetake(group) {
return Boolean(state.supported && !state.job && state.result && group && state.result.character === group.character);
},
/** The presets, for the tags on the cards (ui_script_tune.js ownTuneText). */
presetsView: () => state.presets,
/** The whole script's and the characters' values changed here, for the director's draft. */
draft: () => ({ script: { ...state.script }, cast: Object.fromEntries([...state.cast].filter(([, own]) => Object.keys(own).length)) }),
restoreDraft({ script = {}, cast = {} } = {}) {
state.script = { ...script };
state.cast = new Map(Object.entries(cast).map(([character, own]) => [character, { ...own }]));
},
stopPlayback() {
player.pause();
preview.stop();
},
};
}