Files
DemonGatanjieu-Anomalous_Mo…/web/modules/ui_script_director.js
T
DemonGatanjieuandClaude Opus 5.5 e341b8a26d feat(audio): sampling parameters in the Script Director, roomier drawer, no audio workflow tab
- Generate section: top_k / top_p / temperature / repetition_penalty folded
  under "Advanced", ranges and defaults read from the node's /object_info;
  "save as this character's default" keeps language, speed and these (only
  values that differ from the node's defaults). The seed is not saved.
- Drawer 560px wide, line cards keep 180px, footer scrolls; for GPT-SoVITS
  the bundle preview is hidden and the canvas buttons are compact.
- The audio domain no longer shows the "Audio Workflows" header tab (the
  recipe library filtered to "audio"); a `hidden` header button now really
  hides despite the header's display !important rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 02:16:07 +08:00

520 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 { app } from '../../../scripts/app.js';
import { t } from './interface_settings.js';
import { anomalousAlert, anomalousConfirm } from './ui_dialog.js';
import { buildScriptPackage, comboValueForPath, joinSegments, splitScriptLines, splitSegmentAt, usableEmotions } from './audio_script.js';
import { isScriptTarget, targetForNode } from './audio_node_targets.js';
import { engineById } from './audio_engines.js';
import { bindMaterialDrag } from './material_drag.js';
import { createRunSection } from './ui_script_run.js';
/**
* Script Director: paste a script, pick one character, choose an emotion per
* line on the line cards, then push the bundle to an F5-TTS node or drag it
* onto one (supported nodes: audio_node_targets.js). GPT-SoVITS characters can
* also be generated right here, without the canvas (ui_script_run.js). ComfyUI-F5-TTS resolves `{happy}` to `<sample>.happy.wav`, so the
* node's `sample` becomes the character's main voice and `speech` the tagged text.
* The panel and its lines live for the page session; the studio re-attaches it.
*/
const ICONS = {
PLAY: `<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>`,
STOP: `<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><rect x="5" y="5" width="14" height="14" rx="2"/></svg>`,
GRIP: `<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><circle cx="9" cy="6" r="1.6"/><circle cx="15" cy="6" r="1.6"/><circle cx="9" cy="12" r="1.6"/><circle cx="15" cy="12" r="1.6"/><circle cx="9" cy="18" r="1.6"/><circle cx="15" cy="18" r="1.6"/></svg>`,
};
const state = {
engine: 'f5', // audio_engines.js id; decides which nodes the bundle may go to
groups: [],
groupKey: null,
lines: [], // { text, emotion, take } — take > 1: that line was retaken (GPT-SoVITS)
editing: true,
splitMode: 'sentence',
focusIndex: null, // card whose text box should receive focus after the next render
};
let panel = null;
let refs = null;
let hooks = {};
let previewAudio = null;
let previewButton = null;
let runSection = null;
// bindMaterialDrag hides the browser modal while dragging; the owner is only known once the studio sets hooks.
const dragOwner = { get modal() { return hooks.owner?.modal; } };
// ---------- public API ----------
/** `{ owner, onStateChange(open), onPreviewStart() }` from the studio. */
export function setScriptDirectorHooks(next) {
hooks = next || {};
}
export function openScriptDirector(parentContainer) {
if (!panel) createPanel();
if (panel.parentNode !== parentContainer) parentContainer.appendChild(panel);
panel.hidden = false;
renderAll();
hooks.onStateChange?.(true);
}
export function closeScriptDirector() {
stopScriptDirectorPreview();
if (panel) panel.hidden = true;
hooks.onStateChange?.(false);
}
export function isScriptDirectorActive() {
return Boolean(panel && !panel.hidden && panel.isConnected);
}
/** Called by the studio after each voice fetch; keeps the chosen character when it still exists. */
export function updateScriptDirectorVoices(groups, preferredGroup = null, engine = null) {
if (engine && engine !== state.engine) {
state.engine = engine;
state.groupKey = null;
}
state.groups = Array.isArray(groups) ? groups : [];
const exists = key => state.groups.some(group => group.group === key);
if (!exists(state.groupKey)) {
state.groupKey = (preferredGroup && exists(preferredGroup) ? preferredGroup : null)
|| state.groups.find(group => usableEmotions(group).includes('main'))?.group
|| state.groups[0]?.group
|| null;
}
if (panel) renderAll();
}
export function stopScriptDirectorPreview() {
stopLinePreview();
runSection?.stopPlayback();
}
function stopLinePreview() {
if (previewAudio) {
previewAudio.pause();
previewAudio = null;
}
if (previewButton) {
previewButton.innerHTML = ICONS.PLAY;
previewButton.classList.remove('is-playing');
previewButton = null;
}
}
// ---------- state helpers ----------
function selectedGroup() {
return state.groups.find(group => group.group === state.groupKey) || null;
}
/** Node name shown in every hint, e.g. "F5-TTS" or "GPT-SoVITS". */
/** The script with retakes, for a direct GPT-SoVITS run; null for other engines. */
function runScript() {
if (state.engine !== 'gpt_sovits') return null;
const group = selectedGroup();
return { group, pkg: buildScriptPackage(state.lines, group, { takes: true }) };
}
function nodeLabel() {
return engineById(state.engine)?.label || state.engine;
}
/** Engine-specific wording for the few messages whose advice differs per engine. */
function engineKey(key) {
return state.engine === 'gpt_sovits' && ['scriptDirectorMainMissing', 'scriptDirectorNoCharacters'].includes(key)
? `${key}GptSovits`
: key;
}
function emotionLabel(emotion) {
return emotion === 'main' ? t('scriptDirectorMainEmotion') : emotion;
}
function currentPackage() {
return buildScriptPackage(state.lines, selectedGroup());
}
// ---------- DOM ----------
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 createPanel() {
panel = el('div', 'anomalous-script-director-panel');
panel.hidden = true;
const header = el('div', 'anomalous-sd-header');
const titleGroup = el('div');
const subtitle = el('div', 'anomalous-sd-subtitle');
titleGroup.append(el('div', 'anomalous-sd-title', t('scriptDirectorTitle')), subtitle);
header.append(titleGroup, button('anomalous-sd-close-btn', '×', closeScriptDirector, t('scriptDirectorClose')));
const characterBar = el('label', 'anomalous-sd-character-bar');
const characterSelect = el('select', 'anomalous-sd-character-select');
characterSelect.onchange = () => {
state.groupKey = characterSelect.value;
stopScriptDirectorPreview();
renderAll();
};
const characterHint = el('div', 'anomalous-sd-character-hint');
characterBar.append(el('span', 'anomalous-sd-step', t('scriptDirectorCharacter')), characterSelect);
const inputArea = el('div', 'anomalous-sd-input-area');
const textarea = el('textarea', 'anomalous-sd-textarea');
textarea.placeholder = t('scriptDirectorInputPlaceholder');
const inputActions = el('div', 'anomalous-sd-input-actions');
const splitMode = el('select', 'anomalous-sd-split-mode');
for (const [value, key] of [['sentence', 'scriptDirectorSplitSentence'], ['line', 'scriptDirectorSplitLine']]) {
const option = el('option', '', t(key));
option.value = value;
splitMode.appendChild(option);
}
splitMode.value = state.splitMode;
splitMode.onchange = () => { state.splitMode = splitMode.value; };
const cancelEdit = button('anomalous-sd-btn', t('dialogCancel'), () => { state.editing = false; renderAll(); });
inputActions.append(
splitMode,
button('anomalous-sd-btn', t('scriptDirectorClear'), () => { textarea.value = ''; textarea.focus(); }),
cancelEdit,
button('anomalous-sd-btn primary', t('scriptDirectorParse'), () => parseScript(textarea.value)),
);
inputArea.append(textarea, inputActions);
const linesBar = el('div', 'anomalous-sd-lines-bar');
const linesCount = el('span', 'anomalous-sd-lines-count');
linesBar.append(linesCount, button('anomalous-sd-link-btn', t('scriptDirectorEdit'), () => {
textarea.value = state.lines.map(line => line.text).join('\n');
state.editing = true;
renderAll();
textarea.focus();
}));
const linesContainer = el('div', 'anomalous-sd-lines-container');
const footer = el('div', 'anomalous-sd-footer');
const summary = el('div', 'anomalous-sd-package-summary');
const preview = el('pre', 'anomalous-sd-package-preview');
const actions = el('div', 'anomalous-sd-package-actions');
const dragHandle = el('div', 'anomalous-sd-drag-handle');
dragHandle.innerHTML = ICONS.GRIP;
dragHandle.append(el('span', '', t('scriptDirectorDragHandle')));
bindPackageDrag(dragHandle);
const copyBtn = button('anomalous-sd-btn', t('scriptDirectorCopy'), copyScript);
const pushBtn = button('anomalous-sd-btn accent', t('scriptDirectorInject'), pushToNode);
actions.append(dragHandle, copyBtn, pushBtn);
runSection = createRunSection({
getScript: runScript,
onPlay: () => {
stopLinePreview();
hooks.onPreviewStart?.();
},
resetTakes: () => state.lines.forEach(line => { line.take = 1; }),
onBusyChange: () => renderAll(),
});
footer.append(summary, preview, runSection.element, actions);
panel.append(header, characterBar, characterHint, inputArea, linesBar, linesContainer, footer);
refs = { subtitle, characterSelect, characterHint, inputArea, textarea, cancelEdit, linesBar, linesCount, linesContainer, footer, summary, preview, dragHandle, copyBtn, pushBtn };
}
function renderAll() {
if (!panel) return;
const node = nodeLabel();
refs.subtitle.textContent = t(state.engine === 'gpt_sovits' ? 'scriptDirectorSubtitleGptSovits' : 'scriptDirectorSubtitle', { node });
// Generated here, the bundle preview only repeats the cards; the canvas buttons stay as a small row.
refs.footer.classList.toggle('is-direct', state.engine === 'gpt_sovits');
refs.dragHandle.title = t('scriptDirectorDragHint', { node });
refs.pushBtn.title = t('scriptDirectorPushHint', { node });
refs.pushBtn.setAttribute('aria-label', refs.pushBtn.title);
renderCharacterBar();
const hasLines = state.lines.length > 0;
refs.inputArea.hidden = hasLines && !state.editing;
refs.cancelEdit.hidden = !hasLines;
refs.linesBar.hidden = !hasLines || state.editing;
refs.linesContainer.hidden = !hasLines || state.editing;
refs.footer.hidden = !hasLines || state.editing;
refs.linesCount.textContent = t('scriptDirectorLinesCount', { count: state.lines.length });
if (hasLines && !state.editing) renderLines();
renderPackage();
}
function renderCharacterBar() {
const select = refs.characterSelect;
select.replaceChildren(...state.groups.map(group => {
const showFolder = group.engine !== 'gpt_sovits' && group.folder && group.folder !== 'F5-TTS';
const option = el('option', '', showFolder ? `${group.character} · ${group.folder}` : group.character);
option.value = group.group;
return option;
}));
select.disabled = state.groups.length === 0;
if (state.groupKey) select.value = state.groupKey;
const group = selectedGroup();
let hint = '';
if (!state.groups.length) hint = t(engineKey('scriptDirectorNoCharacters'));
else if (group && !usableEmotions(group).includes('main')) {
hint = state.engine === 'gpt_sovits' ? t('scriptDirectorMainMissingGptSovits') : t('audioMainMissing', { file: `${group.character}.wav` });
}
else if (group) hint = t('scriptDirectorEmotionsAvailable', { emotions: usableEmotions(group).map(emotionLabel).join(' · ') });
refs.characterHint.textContent = hint;
refs.characterHint.classList.toggle('is-warning', Boolean(group && !usableEmotions(group).includes('main')) || !state.groups.length);
}
function renderLines() {
const group = selectedGroup();
const emotions = usableEmotions(group);
const slices = new Map((group?.slices || []).map(slice => [slice.emotion, slice]));
const addLine = button('anomalous-sd-add-line', t('scriptDirectorAddLine'), () => {
state.lines.push({ text: '', emotion: state.lines.at(-1)?.emotion || 'main', take: 1 });
state.focusIndex = state.lines.length - 1;
renderAll();
});
refs.linesContainer.replaceChildren(...state.lines.map((line, index) => renderLineCard(line, index, emotions, slices)), addLine);
if (state.focusIndex !== null) {
refs.linesContainer.querySelectorAll('.anomalous-sd-line-text')[state.focusIndex]?.focus();
state.focusIndex = null;
}
}
function renderLineCard(line, index, emotions, slices) {
const card = el('div', 'anomalous-sd-line-card');
const head = el('div', 'anomalous-sd-line-head');
const previewBtn = button('anomalous-sd-icon-btn', '', () => previewLine(line, slices, previewBtn), t('scriptDirectorPreview'));
previewBtn.innerHTML = ICONS.PLAY;
head.append(
el('span', 'anomalous-sd-line-no', `#${index + 1}`),
previewBtn,
// Split at the text caret (the textarea keeps its caret after losing focus to this button).
button('anomalous-sd-icon-btn', '✂', async () => {
const parts = splitSegmentAt(line.text, text.selectionStart);
if (!parts) {
await anomalousAlert(t('scriptDirectorSplitHint'));
text.focus();
return;
}
line.text = parts[0];
state.lines.splice(index + 1, 0, { text: parts[1], emotion: line.emotion, take: 1 });
state.focusIndex = index + 1;
stopScriptDirectorPreview();
renderAll();
}, t('scriptDirectorSplitAtCaret')),
);
if (index < state.lines.length - 1) {
// Merge keeps this card's emotion: one card = one segment spoken with one voice.
head.append(button('anomalous-sd-icon-btn', '↧', () => {
line.text = joinSegments(line.text, state.lines[index + 1].text);
state.lines.splice(index + 1, 1);
stopScriptDirectorPreview();
renderAll();
}, t('scriptDirectorMergeNext')));
}
if (runSection?.canRetake(selectedGroup())) {
const retake = button('anomalous-sd-icon-btn', '↻', () => {
line.take = (line.take || 1) + 1;
runSection.generate();
}, t('scriptRunRetakeLine'));
if (line.take > 1) retake.append(el('span', 'anomalous-sd-take-no', String(line.take)));
head.append(retake);
}
head.append(
button('anomalous-sd-icon-btn is-danger', '×', () => {
state.lines.splice(index, 1);
stopScriptDirectorPreview();
renderAll();
}, t('scriptDirectorDeleteLine')),
);
const text = el('textarea', 'anomalous-sd-line-text');
text.rows = 1;
text.value = line.text;
text.oninput = () => {
line.text = text.value;
renderPackage();
};
const chips = el('div', 'anomalous-sd-emotion-row');
// A choice this character cannot voice stays visible (and blocks the bundle) instead of being silently reset.
const choices = emotions.includes(line.emotion) ? emotions : [...emotions, line.emotion];
for (const emotion of choices) {
const chip = button('anomalous-sd-emotion-chip', emotionLabel(emotion), () => {
line.emotion = emotion;
chips.querySelectorAll('.anomalous-sd-emotion-chip').forEach(item => item.classList.toggle('active', item === chip));
if (previewButton === previewBtn) stopScriptDirectorPreview();
renderPackage();
});
chip.classList.toggle('active', line.emotion === emotion);
chip.classList.toggle('is-main', emotion === 'main');
if (!emotions.includes(emotion)) {
chip.classList.add('is-missing');
chip.title = t('scriptDirectorEmotionMissing');
}
const sample = slices.get(emotion);
if (sample?.text) chip.title = sample.text;
chips.appendChild(chip);
}
card.append(head, text, chips);
return card;
}
function renderPackage() {
if (!refs) return;
const pkg = currentPackage();
const ok = !pkg.error;
refs.summary.classList.toggle('is-error', !ok);
if (ok) {
const used = [...new Set(state.lines.filter(line => line.text.trim()).map(line => emotionLabel(line.emotion)))];
refs.summary.textContent = t('scriptDirectorPackageSummary', { count: pkg.lineCount, emotions: used.join(' · ') });
refs.preview.textContent = pkg.speech;
} else {
refs.summary.textContent = t(engineKey(pkg.error), { node: nodeLabel() });
refs.preview.textContent = '';
}
refs.preview.hidden = !ok;
refs.copyBtn.disabled = !ok;
refs.pushBtn.disabled = !ok;
refs.dragHandle.draggable = ok;
refs.dragHandle.classList.toggle('is-disabled', !ok);
runSection.render();
}
// ---------- actions ----------
function parseScript(text) {
const parsed = splitScriptLines(text, state.splitMode);
if (!parsed.length) return;
const previous = new Map(state.lines.map(line => [line.text.trim(), line]));
state.lines = parsed.map(lineText => ({
text: lineText,
emotion: previous.get(lineText)?.emotion || 'main',
take: previous.get(lineText)?.take || 1,
}));
state.editing = false;
renderAll();
}
function previewLine(line, slices, btn) {
if (previewButton === btn) {
stopScriptDirectorPreview();
return;
}
stopScriptDirectorPreview();
const slice = slices.get(line.emotion);
if (!slice?.audio_url) return;
hooks.onPreviewStart?.();
const audio = new Audio(slice.audio_url);
previewAudio = audio;
previewButton = btn;
btn.innerHTML = ICONS.STOP;
btn.classList.add('is-playing');
const done = () => { if (previewAudio === audio) stopScriptDirectorPreview(); };
audio.onended = done;
audio.onerror = done;
audio.play().catch(done);
}
async function copyScript() {
const pkg = currentPackage();
if (pkg.error || !navigator.clipboard?.writeText) return;
try {
await navigator.clipboard.writeText(pkg.speech);
flash(refs.copyBtn, t('scriptDirectorCopied'), t('scriptDirectorCopy'));
} catch (_) {
// Clipboard permission refused: the preview text stays selectable.
}
}
function flash(btn, text, restore) {
btn.textContent = text;
btn.classList.add('is-success');
setTimeout(() => {
btn.textContent = restore;
btn.classList.remove('is-success');
}, 1800);
}
function findTargetNode() {
const fits = node => isScriptTarget(node, state.engine);
const nodes = (app.graph?._nodes || []).filter(fits);
if (!nodes.length) return { error: 'scriptDirectorNoNode' };
const selected = Object.values(app.canvas?.selected_nodes || {}).filter(fits);
if (selected.length === 1) return { node: selected[0] };
if (nodes.length === 1) return { node: nodes[0] };
return { error: 'scriptDirectorPickNode' };
}
async function pushToNode() {
const pkg = currentPackage();
if (pkg.error) return;
const { node, error } = findTargetNode();
if (error) {
await anomalousAlert(t(error, { node: nodeLabel() }));
return;
}
if (await applyPackageToNode(node, pkg)) flash(refs.pushBtn, t('scriptDirectorSuccess'), t('scriptDirectorInject'));
}
/** Write sample + speech into one node; re-checks the node after any dialog. Returns true when written. */
async function applyPackageToNode(node, pkg) {
const graph = app.graph;
const target = isScriptTarget(node, state.engine) ? targetForNode(node) : null;
const speechWidget = target && node.widgets?.find(w => w.name === target.speechWidget);
const sampleWidget = target && node.widgets?.find(w => w.name === target.voiceWidget);
if (!speechWidget || !sampleWidget) {
await anomalousAlert(t('scriptDirectorDropNotTts', { node: nodeLabel() }));
return false;
}
if (target.overriddenBy && node.inputs?.some(input => input?.name === target.overriddenBy && input.link != null)) {
await anomalousAlert(t('scriptDirectorOverridden', { input: target.overriddenBy }));
return false;
}
const sampleValue = comboValueForPath(sampleWidget, pkg.sample);
if (sampleValue == null) {
await anomalousAlert(t('scriptDirectorSampleNotListed', { file: pkg.sample, node: nodeLabel() }));
return false;
}
const previous = speechWidget.value;
if (typeof previous === 'string' && previous.trim() && previous.trim() !== pkg.speech) {
if (!await anomalousConfirm(t('scriptDirectorOverwriteConfirm'))) return false;
if (app.graph !== graph || graph.getNodeById(node.id) !== node || speechWidget.value !== previous) return false;
}
if (sampleWidget.value !== sampleValue) {
sampleWidget.value = sampleValue;
sampleWidget.callback?.(sampleValue, app.canvas, node);
}
speechWidget.value = pkg.speech;
speechWidget.callback?.(pkg.speech, app.canvas, node);
node.setDirtyCanvas?.(true, true);
return true;
}
// ---------- drag the bundle onto a canvas node ----------
function bindPackageDrag(handle) {
bindMaterialDrag(handle, dragOwner, {
payload: () => {
const pkg = currentPackage();
return pkg.error ? null : { ...pkg, dragHint: t('scriptDirectorDragHint', { node: nodeLabel() }) };
},
accepts: node => isScriptTarget(node, state.engine),
drop: async node => {
if (await applyPackageToNode(node, currentPackage())) flash(refs.pushBtn, t('scriptDirectorSuccess'), t('scriptDirectorInject'));
},
});
}