Files
DemonGatanjieu-Anomalous_Mo…/web/modules/ui_script_director.js
T
DemonGatanjieuandClaude Opus 5.5 66979f698a fix(audio): align voices with ComfyUI-F5-TTS multi-voice layout and repair studio UI
ComfyUI-F5-TTS resolves {happy} to <sample stem>.happy.wav beside the selected
sample, so `Arona_happy.wav` + `{Arona_happy}` could never run. Voices now use
Character.wav (main, {main}) and Character.<emotion>.wav ({emotion}); the
catalog groups a main voice with its variants and marks tags that F5-TTS
cannot resolve (missing main, missing .txt, different format).

- script director: one character per script, untagged lines pinned to {main},
  pushes sample (main voice, node's own combo spelling) and speech together,
  confirms before overwriting and re-checks the node after the dialog
- pure rules (splitting, composing, planning, combo spelling) in audio_script.js
- one module instance per file: drop ?v= imports that duplicated
  ui_audio_gallery/ui_audio_studio state, so studio playback stops the gallery
- all user/file-derived text goes through textContent (no innerHTML)
- studio/sidebar/gallery renders ignore stale responses; errors are shown
- gallery: count badge no longer overwrites the title, paging via "load more",
  deletion errors surfaced, timeupdate instead of a 100ms timer
- sidebar: filter selection updates in place instead of refetching; the header
  tabs, sidebar and domain switch share browser.switchAudioTab
- uploader: createViewScope lifecycle (Esc/overlay/close release listeners and
  object URLs, Esc no longer reaches the parent), 409 -> confirm -> overwrite,
  sends the pre-romanization text as original_text, no hard-coded character
- workflow template: confirm before replacing the canvas, clear message when
  the template file is missing, align sample paths to the node's combo values
- static styles moved from inline JS into 11-audio-studio.css; strings localized

Verified: tests/audio_script_rules.mjs, tests/test_audio_catalog.py (15), a
jsdom walkthrough of studio/gallery/director/uploader with a stubbed app
(outside the repo), JS syntax and import/export link check. Not yet checked in
a live ComfyUI session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeqEub5
2026-09-23 08:02:33 +00:00

227 lines
8.5 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 { comboValueForPath, composeScript, planScriptVoices, splitScriptLines } from './audio_script.js';
/**
* Script Director: split a script into lines, tag each line with a voice from
* the Audio Studio, and write the tagged script into an F5TTSAudio node.
*
* ComfyUI-F5-TTS resolves `{happy}` to `<sample stem>.happy.wav` beside the
* selected sample, so one script can only use one character's voices and the
* node's `sample` must be that character's main file (`Arona.wav`).
* The panel and its lines live for the page session; the studio re-attaches it.
*/
const TARGET_NODE_TYPE = 'F5TTSAudio';
const TARGET_WIDGET = 'speech';
const SAMPLE_WIDGET = 'sample';
let scriptDirectorPanel = null;
let linesContainer = null;
let injectButton = null;
let scriptLines = [];
let activeLineIndex = null;
let stateListener = null;
function notifyState() {
stateListener?.(isScriptDirectorActive());
}
export function setScriptDirectorStateListener(listener) {
stateListener = typeof listener === 'function' ? listener : null;
}
export function openScriptDirector(parentContainer) {
if (!scriptDirectorPanel) createScriptDirectorUI();
if (scriptDirectorPanel.parentNode !== parentContainer) parentContainer.appendChild(scriptDirectorPanel);
scriptDirectorPanel.hidden = false;
notifyState();
}
export function closeScriptDirector() {
if (scriptDirectorPanel) scriptDirectorPanel.hidden = true;
notifyState();
}
export function isScriptDirectorActive() {
return Boolean(scriptDirectorPanel && !scriptDirectorPanel.hidden && scriptDirectorPanel.isConnected);
}
/** Called by the studio when a voice row is clicked while the director is open. */
export function handleVoiceSelectionForScript({ character, group, mainPath, tag, usable }) {
if (activeLineIndex === null || !scriptLines[activeLineIndex] || !tag) return;
scriptLines[activeLineIndex].voice = { character, group, mainPath, tag, usable: Boolean(usable) };
const next = scriptLines.findIndex((line, index) => index > activeLineIndex && !line.voice);
if (next !== -1) activeLineIndex = next;
renderLines();
}
function createButton(label, className, onClick) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = `anomalous-sd-btn ${className}`.trim();
btn.textContent = label;
btn.onclick = onClick;
return btn;
}
function createScriptDirectorUI() {
scriptDirectorPanel = document.createElement('div');
scriptDirectorPanel.className = 'anomalous-script-director-panel';
scriptDirectorPanel.hidden = true;
const header = document.createElement('div');
header.className = 'anomalous-sd-header';
const titleGroup = document.createElement('div');
const title = document.createElement('div');
title.className = 'anomalous-sd-title';
title.textContent = t('scriptDirectorTitle');
const subtitle = document.createElement('div');
subtitle.className = 'anomalous-sd-subtitle';
subtitle.textContent = t('scriptDirectorSubtitle');
titleGroup.append(title, subtitle);
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'anomalous-sd-close-btn';
closeBtn.textContent = '×';
closeBtn.title = t('scriptDirectorClose');
closeBtn.setAttribute('aria-label', t('scriptDirectorClose'));
closeBtn.onclick = closeScriptDirector;
header.append(titleGroup, closeBtn);
const inputContainer = document.createElement('div');
inputContainer.className = 'anomalous-sd-input-area';
const textarea = document.createElement('textarea');
textarea.className = 'anomalous-sd-textarea';
textarea.placeholder = t('scriptDirectorInputPlaceholder');
inputContainer.append(textarea, createButton(t('scriptDirectorParse'), 'primary', () => parseScriptText(textarea.value)));
linesContainer = document.createElement('div');
linesContainer.className = 'anomalous-sd-lines-container';
const footer = document.createElement('div');
footer.className = 'anomalous-sd-footer';
injectButton = createButton(t('scriptDirectorInject'), 'accent', injectToNode);
footer.append(createButton(t('scriptDirectorClear'), '', () => {
scriptLines = [];
activeLineIndex = null;
textarea.value = '';
renderLines();
}), injectButton);
scriptDirectorPanel.append(header, inputContainer, linesContainer, footer);
renderLines();
}
function parseScriptText(text) {
const lines = splitScriptLines(text);
if (!lines.length) return;
scriptLines = lines.map(line => ({ text: line, voice: null }));
activeLineIndex = 0;
renderLines();
}
function renderLines() {
if (!linesContainer) return;
if (!scriptLines.length) {
const empty = document.createElement('div');
empty.className = 'anomalous-sd-empty';
empty.textContent = t('scriptDirectorEmpty');
linesContainer.replaceChildren(empty);
return;
}
linesContainer.replaceChildren(...scriptLines.map((line, index) => {
const card = document.createElement('div');
card.className = 'anomalous-sd-line-card';
card.classList.toggle('active', activeLineIndex === index);
card.onclick = () => {
activeLineIndex = index;
renderLines();
};
const badge = document.createElement('div');
badge.className = 'anomalous-sd-voice-badge';
const voiceName = document.createElement('span');
voiceName.className = 'voice-name';
if (line.voice) {
badge.classList.add('has-voice');
voiceName.textContent = `${line.voice.character} · ${line.voice.tag}`;
} else {
voiceName.classList.add('placeholder');
voiceName.textContent = t('scriptDirectorNoVoice');
}
badge.appendChild(voiceName);
const textElem = document.createElement('div');
textElem.className = 'anomalous-sd-line-text';
textElem.textContent = line.text;
card.append(badge, textElem);
return card;
}));
}
function findTargetNode() {
const nodes = app.graph?.findNodesByType?.(TARGET_NODE_TYPE) || [];
if (!nodes.length) return { error: 'scriptDirectorNoNode' };
const selected = Object.values(app.canvas?.selected_nodes || {}).filter(node => node?.type === TARGET_NODE_TYPE);
if (selected.length === 1) return { node: selected[0] };
if (nodes.length === 1) return { node: nodes[0] };
return { error: 'scriptDirectorPickNode' };
}
async function injectToNode() {
if (!scriptLines.length) return;
const plan = planScriptVoices(scriptLines);
if (plan.error) {
await anomalousAlert(t(plan.error));
return;
}
const script = composeScript(scriptLines);
const graph = app.graph;
const { node, error } = findTargetNode();
if (error) {
await anomalousAlert(t(error));
return;
}
const speechWidget = node.widgets?.find(w => w.name === TARGET_WIDGET);
const sampleWidget = node.widgets?.find(w => w.name === SAMPLE_WIDGET);
if (!speechWidget || (plan.mainPath && !sampleWidget)) {
await anomalousAlert(t('scriptDirectorNoNode'));
return;
}
const sampleValue = plan.mainPath ? comboValueForPath(sampleWidget, plan.mainPath) : null;
if (plan.mainPath && sampleValue == null) {
await anomalousAlert(t('scriptDirectorSampleNotListed', { file: plan.mainPath }));
return;
}
const previous = speechWidget.value;
if (typeof previous === 'string' && previous.trim() && previous.trim() !== script) {
if (!await anomalousConfirm(t('scriptDirectorOverwriteConfirm'))) return;
// The canvas may have changed while the dialog was open.
if (app.graph !== graph || graph.getNodeById(node.id) !== node || speechWidget.value !== previous) return;
}
if (sampleValue != null && sampleWidget.value !== sampleValue) {
sampleWidget.value = sampleValue;
sampleWidget.callback?.(sampleValue, app.canvas, node);
}
speechWidget.value = script;
speechWidget.callback?.(script, app.canvas, node);
node.setDirtyCanvas?.(true, true);
const originalText = t('scriptDirectorInject');
injectButton.textContent = t('scriptDirectorSuccess');
injectButton.classList.add('is-success');
setTimeout(() => {
injectButton.textContent = originalText;
injectButton.classList.remove('is-success');
}, 2000);
}