Pick lines on Voice-over (click; Ctrl / Cmd adds, Shift a run; Esc lets go) and click a character in the list on the left to have it read them; with none picked, the click changes who reads by default. On Voice-over the list is the cast: narrower, characters with their colours, and a click never leaves the page. Cards show who reads each line in its colour, with that character's emotions; the bundle carries [name] where the speaker changes, so generating, the cards' listen button, pushing, dragging and copying follow it, and a file voiced again from the audio gallery keeps its speakers. The character cards' Voice-over button is gone. The workflow actions move to ui_script_workflow.js and the cast helpers to ui_script_cast.js, keeping the director under 600 lines. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
212 lines
9.0 KiB
JavaScript
212 lines
9.0 KiB
JavaScript
/**
|
||
* Pure script rules for GPT-SoVITS (Anomalous_TTS) scripts (no DOM, no canvas access).
|
||
*
|
||
* The node takes a character and a script in which `{happy}` switches to that
|
||
* emotion's reference, `{main}` back to the main one, and `[take:N]` asks for
|
||
* another take of one line. `buildTtsPrompt` runs a script without a canvas.
|
||
*/
|
||
|
||
export const MAIN_TAG = '{main}';
|
||
|
||
const normalisePath = value => String(value ?? '').replace(/\\/g, '/');
|
||
|
||
/** Return the widget's own spelling of `relativePath` (slashes may differ on Windows), or null when not offered. */
|
||
export function comboValueForPath(widget, relativePath) {
|
||
const wanted = normalisePath(relativePath);
|
||
if (!wanted) return null;
|
||
const values = Array.isArray(widget?.options?.values) ? widget.options.values : null;
|
||
if (!values) return wanted;
|
||
return values.find(value => normalisePath(value) === wanted) ?? null;
|
||
}
|
||
|
||
const SENTENCE_END = new Set(['。', '!', '?', '!', '?']);
|
||
// Closing quotes/brackets and trailing marks that belong to the sentence they follow.
|
||
const TRAILING = new Set(['。', '!', '?', '!', '?', '」', '』', '”', '’', '"', "'", ')', ')', '】', '》', '〉', '~', '~']);
|
||
const SPEAKABLE = /[\p{L}\p{N}]/u;
|
||
|
||
function splitSentences(line) {
|
||
const chars = [...line];
|
||
const segments = [];
|
||
let current = '';
|
||
for (let i = 0; i < chars.length; i++) {
|
||
const char = chars[i];
|
||
current += char;
|
||
// A single '.' ends an English sentence only before whitespace; '3.5', '...' and '……' do not split.
|
||
const isPeriod = char === '.' && chars[i - 1] !== '.' && chars[i + 1] !== '.' && (i + 1 >= chars.length || /\s/.test(chars[i + 1]));
|
||
if (!SENTENCE_END.has(char) && !isPeriod) continue;
|
||
while (i + 1 < chars.length && TRAILING.has(chars[i + 1])) current += chars[++i];
|
||
segments.push(current);
|
||
current = '';
|
||
}
|
||
segments.push(current);
|
||
return segments;
|
||
}
|
||
|
||
/** Punctuation-only pieces ("……", "!」") cannot be spoken on their own: attach them to a neighbour. */
|
||
function attachUnspeakable(segments) {
|
||
const result = [];
|
||
let pending = '';
|
||
for (const segment of segments) {
|
||
if (SPEAKABLE.test(segment)) {
|
||
result.push(pending + segment);
|
||
pending = '';
|
||
} else if (result.length) {
|
||
result[result.length - 1] += segment;
|
||
} else {
|
||
pending += segment;
|
||
}
|
||
}
|
||
if (pending) result.push(pending);
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Split a script into segments. `sentence`: at 。!?!? (with trailing quotes or
|
||
* brackets) and newlines; ellipses never split. `line`: one segment per line.
|
||
*/
|
||
export function splitScriptLines(text, mode = 'sentence') {
|
||
const lines = String(text || '').split(/\n+/).map(line => line.trim()).filter(Boolean);
|
||
const segments = mode === 'line' ? lines : lines.flatMap(splitSentences);
|
||
return attachUnspeakable(segments.map(segment => segment.trim()).filter(Boolean));
|
||
}
|
||
|
||
/** Split one segment at a caret position; null when either side would be empty. */
|
||
export function splitSegmentAt(text, position) {
|
||
const value = String(text || '');
|
||
const before = value.slice(0, position).trim();
|
||
const after = value.slice(position).trim();
|
||
return before && after ? [before, after] : null;
|
||
}
|
||
|
||
/** Join two segments: a space only between Latin words/digits, none for CJK text. */
|
||
export function joinSegments(first, second) {
|
||
const a = String(first || '').trim();
|
||
const b = String(second || '').trim();
|
||
if (!a || !b) return a || b;
|
||
return /[A-Za-z0-9,.;:!?]$/.test(a) && /^[A-Za-z0-9]/.test(b) ? `${a} ${b}` : `${a}${b}`;
|
||
}
|
||
|
||
/**
|
||
* Once any line is tagged, untagged lines get {main} instead of inheriting the previous voice.
|
||
* A line with `take` > 1 (GPT-SoVITS only) gets `[take:N]` right before its text; a line's
|
||
* `speaker` (`[name]`, where another character takes over) goes first.
|
||
*/
|
||
export function composeScript(lines) {
|
||
const anyTagged = lines.some(line => line.voice?.tag);
|
||
return lines
|
||
.map(line => {
|
||
const tag = line.voice?.tag || (anyTagged ? MAIN_TAG : '');
|
||
const text = line.take > 1 ? `[take:${line.take}]${line.text}` : line.text;
|
||
return `${line.speaker || ''}${tag ? `${tag} ${text}` : text}`;
|
||
})
|
||
.join('\n')
|
||
.trim();
|
||
}
|
||
|
||
/** Emotions of a voice group the node can switch to with `{emotion}` (main first). */
|
||
export function usableEmotions(group) {
|
||
return (group?.slices || []).map(slice => slice.emotion);
|
||
}
|
||
|
||
const speaks = group => Boolean(group?.node_value) && group.has_main !== false && usableEmotions(group).includes('main');
|
||
|
||
/**
|
||
* Bundle script lines for one TTS node. `lines` are `{ text, emotion, speaker? }`; the node's
|
||
* `character` widget gets the group's `node_value`. A line's `speaker` (a voice group key from
|
||
* `groups`) is read by that character instead: `[name]` goes in where the speaker changes
|
||
* (Anomalous_TTS's script syntax). Every emotion must be usable for its line's character.
|
||
* `takes` (runs in the director) writes each line's `take` as `[take:N]`.
|
||
* Returns `{ sample, speech, lineCount, speakers }` (the characters heard, the node's first)
|
||
* or `{ error: localeKey }`.
|
||
*/
|
||
export function buildScriptPackage(lines, group, { takes = false, groups = [] } = {}) {
|
||
if (!group) return { error: 'scriptDirectorPickCharacter' };
|
||
if (!speaks(group)) return { error: 'scriptDirectorMainMissing' };
|
||
const byKey = new Map(groups.map(item => [item.group, item]));
|
||
const voiced = [];
|
||
const speakers = [group.node_value];
|
||
let current = group;
|
||
for (const line of lines || []) {
|
||
const text = String(line?.text || '').trim();
|
||
if (!text) continue;
|
||
const speaker = line.speaker && line.speaker !== group.group ? byKey.get(line.speaker) : group;
|
||
if (!speaks(speaker)) return { error: 'scriptDirectorSpeakerUnusable' };
|
||
const emotion = line.emotion || 'main';
|
||
if (!usableEmotions(speaker).includes(emotion)) return { error: 'scriptDirectorVoiceUnusable' };
|
||
voiced.push({
|
||
text,
|
||
voice: emotion === 'main' ? null : { tag: `{${emotion}}` },
|
||
take: takes ? line.take || 1 : 1,
|
||
speaker: speaker === current ? '' : `[${speaker.node_value}]`,
|
||
});
|
||
current = speaker;
|
||
if (!speakers.includes(speaker.node_value)) speakers.push(speaker.node_value);
|
||
}
|
||
if (!voiced.length) return { error: 'scriptDirectorEmpty' };
|
||
return { sample: group.node_value, speech: composeScript(voiced), lineCount: voiced.length, speakers };
|
||
}
|
||
|
||
/** Save Audio prefix for a character: `output/audio/<character>/…`, keeping file names Windows accepts. */
|
||
export function ttsOutputPrefix(character) {
|
||
const parts = String(character || '').split('/')
|
||
.map(part => part.replace(/[<>:"\\|?*\u0000-\u001f]/g, '_').trim().replace(/^\.+$/, '_') || '_');
|
||
return `audio/${parts.join('/')}/${parts[parts.length - 1]}`;
|
||
}
|
||
|
||
export const TTS_NODE_CLASS = 'AnomalousTTS_CharacterSpeech';
|
||
|
||
/**
|
||
* ComfyUI API prompt for one GPT-SoVITS script: the character node, then Save Audio
|
||
* (`preview: true`: Preview Audio, a temp file that stays out of the audio gallery).
|
||
* `inputs` are more node inputs by name (speed, top_k …); left out, the node's defaults apply.
|
||
*/
|
||
export function buildTtsPrompt({ character, speech, seed, language = 'auto', preview = false, ...inputs }) {
|
||
return {
|
||
1: {
|
||
class_type: TTS_NODE_CLASS,
|
||
inputs: { character, text: speech, seed, language, ...inputs },
|
||
},
|
||
2: preview
|
||
? { class_type: 'PreviewAudio', inputs: { audio: ['1', 0] } }
|
||
: { class_type: 'SaveAudio', inputs: { audio: ['1', 0], filename_prefix: ttsOutputPrefix(character) } },
|
||
};
|
||
}
|
||
|
||
const TAKE_TAG = /^\s*(?:take|版本)\s*[::]\s*\d+\s*$/i;
|
||
const PAUSE_TAG = /^\s*(?:pause|wait|stop|停顿|暂停)\s*[::]/i;
|
||
|
||
/**
|
||
* Read a tagged script back into segments, splitting at `{emotion}` and `[name]` tags
|
||
* (`speaker`: the name, only on segments after one; the emotion goes back to main there).
|
||
* Text before the first tag belongs to the main voice; `[take:N]` is left out, pauses stay.
|
||
*/
|
||
export function parseTaggedSpeech(speech) {
|
||
const segments = [];
|
||
let speaker = null;
|
||
let emotion = 'main';
|
||
let text = '';
|
||
const flush = () => {
|
||
const words = text.trim();
|
||
if (words) segments.push({ emotion, text: words, ...(speaker ? { speaker } : {}) });
|
||
text = '';
|
||
};
|
||
for (const part of String(speech || '').split(/(\{[^{}]+\}|\[[^[\]]+\])/)) {
|
||
const tag = /^\{([^{}]+)\}$/.exec(part);
|
||
const bracket = /^\[([^[\]]+)\]$/.exec(part);
|
||
if (tag) {
|
||
flush();
|
||
emotion = tag[1].trim();
|
||
} else if (bracket && TAKE_TAG.test(bracket[1])) {
|
||
continue; // another take only meant something for that run
|
||
} else if (bracket && !PAUSE_TAG.test(bracket[1])) {
|
||
flush();
|
||
speaker = bracket[1].trim();
|
||
emotion = 'main';
|
||
} else {
|
||
text += part;
|
||
}
|
||
}
|
||
flush();
|
||
return segments;
|
||
}
|