Every page in both languages, all themes and at 480: - Give the window its own text colour: text with none took ComfyUI's, white on the light theme (Tidy headings and file names, the site chip). - Keep a task's link chip in its row, its x in reach, and colour the next-task chip. - Let choices in Settings wrap, the Models search shrink, and a workflow's preset row go to two lines. - Add --amb-on-link for text on the accent: pale in the dark themes, so white was hard to read. - Lists and colons follow the language (listSeparator, wordColon); tests/words_in_language.mjs keeps it so. - Right plurals in English, "AI" on the rail, one line left in Chinese translated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
566 lines
24 KiB
JavaScript
566 lines
24 KiB
JavaScript
import { t } from './interface_settings.js';
|
|
import { buildScriptPackage, joinSegments, splitScriptLines, usableEmotions } from './audio_script.js';
|
|
import { TTS_ENGINE, voiceGroupKey } from './audio_engines.js';
|
|
import { pickLines } from './ui_script_cast.js';
|
|
import { emotionLabel, renderLineCard } from './ui_script_cards.js';
|
|
import { createDrafts } from './ui_script_drafts.js';
|
|
import { createRunSection } from './ui_script_run.js';
|
|
import { ownTuneText } from './ui_script_tune.js';
|
|
import { bindSplitBar } from './ui_split_bar.js';
|
|
import { bindPackageDrag, copyScript, pushToNode } from './ui_script_workflow.js';
|
|
|
|
/**
|
|
* The script director, the body of the Voices page's Voice-over view (ui_script_page.js):
|
|
* paste a script, and every line is read by a character (at first the one picked in the list
|
|
* on the left, the cast). The left side only picks: lines (ui_script_cards.js, ui_script_cast.js)
|
|
* or a character in the cast; the side panel follows it (ui_script_tune.js): the whole script,
|
|
* one character in it, or the picked lines, where who reads them and how (language, speed, mode,
|
|
* each line following its character until set for itself) are changed. Choose an emotion per
|
|
* line, then generate it right here (ui_script_run.js), or put the tagged script into the
|
|
* workflow (ui_script_workflow.js). The node's own character is the first line's. The panel and
|
|
* its lines live for the page session; the page mounts it again each time it is shown. Every
|
|
* script is a draft in the drafts box (ui_script_drafts.js, kept in ComfyUI's user folder): the
|
|
* bar on top names it and leads to New, the box and Close; a reload opens it again.
|
|
*/
|
|
|
|
// From this width on, the cards get the full height and the generate side sits beside them.
|
|
const WIDE_FROM = 760;
|
|
const SIDE_WIDTH_KEY = 'anomalous_sd_side_width';
|
|
const WORKFLOW_OPEN_KEY = 'anomalous_sd_workflow_open';
|
|
|
|
const ICONS = {
|
|
SPEAKER: `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.5 8.5a5 5 0 0 1 0 7"/></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 = {
|
|
groups: [],
|
|
groupKey: null, // the character picked in the cast: new and pasted lines go to it
|
|
focus: null, // { character: key }: the side panel shows it (while no line is picked)
|
|
lines: [], // { text, emotion, take, speaker, tune? } — take > 1: retaken; speaker: its character's group key; tune: script_tune.js
|
|
editing: true,
|
|
splitMode: 'sentence',
|
|
editLine: null, // the card whose text is being edited in place
|
|
listenIndex: 0, // the line the modes are heard on (ui_script_run.js)
|
|
picked: new Set(), // lines picked: the side panel changes them
|
|
anchor: null, // where a Shift run of picked lines starts
|
|
};
|
|
|
|
let panel = null;
|
|
let refs = null;
|
|
let hooks = {};
|
|
let previewAudio = null;
|
|
let previewButton = null;
|
|
let runSection = null;
|
|
let drafts = 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, onPreviewStart(), onCharacterChange(), openGallery() }` from the page. */
|
|
export function setScriptDirectorHooks(next) {
|
|
hooks = next || {};
|
|
}
|
|
|
|
/** The voice group key of the character picked in the cast. */
|
|
export function scriptCharacter() {
|
|
return state.groupKey;
|
|
}
|
|
|
|
/** Lines to voice again (a generated file's `{ emotion, text, speaker? }` segments): one card per line. */
|
|
export function loadScriptLines(segments) {
|
|
// A segment's own settings were written for its first line only.
|
|
const lines = segments.flatMap(segment => String(segment.text || '').split(/\n+/)
|
|
.map((text, at) => ({
|
|
text: text.trim(), emotion: segment.emotion || 'main', take: 1, speaker: segment.speaker ? voiceGroupKey(segment.speaker) : state.groupKey,
|
|
...(segment.tune && at === 0 ? { tune: { ...segment.tune } } : {}),
|
|
})))
|
|
.filter(line => line.text);
|
|
if (!lines.length) return;
|
|
stopScriptDirectorPreview();
|
|
// A script of its own: the open draft stays as it was.
|
|
if (drafts) {
|
|
drafts.detach();
|
|
drafts.showing = false;
|
|
}
|
|
state.lines = lines;
|
|
state.picked = new Set();
|
|
state.focus = null;
|
|
state.editing = false;
|
|
if (panel) renderAll();
|
|
}
|
|
|
|
/** Puts the director into `parentContainer` (the Voice-over page). */
|
|
export function mountScriptDirector(parentContainer) {
|
|
if (!panel) createPanel();
|
|
if (panel.parentNode !== parentContainer) parentContainer.appendChild(panel);
|
|
renderAll();
|
|
}
|
|
|
|
/**
|
|
* The characters, after each voice fetch. Keeps the chosen character while it exists;
|
|
* `choose`: switch to `preferredGroup` (a character card's "Voice-over").
|
|
*/
|
|
export function updateScriptDirectorVoices(groups, preferredGroup = null, { choose = false } = {}) {
|
|
state.groups = Array.isArray(groups) ? groups : [];
|
|
const exists = key => state.groups.some(group => group.group === key);
|
|
if (choose && exists(preferredGroup)) {
|
|
if (state.groupKey !== preferredGroup) stopScriptDirectorPreview();
|
|
state.groupKey = preferredGroup;
|
|
} else 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();
|
|
}
|
|
|
|
/**
|
|
* A character clicked in the list on Voice-over: the side panel shows how it reads in this
|
|
* script (the picked lines are let go), and it reads the lines pasted or added from now on.
|
|
*/
|
|
export function castScriptCharacter(key) {
|
|
if (!state.groups.some(group => group.group === key)) return;
|
|
stopScriptDirectorPreview();
|
|
state.picked = new Set();
|
|
state.focus = { character: key };
|
|
state.groupKey = key;
|
|
hooks.onCharacterChange?.();
|
|
if (panel) renderAll();
|
|
}
|
|
|
|
/** Back to the whole script in the side panel: nothing picked, no character shown. */
|
|
function focusScript() {
|
|
state.picked = new Set();
|
|
state.focus = null;
|
|
if (panel) renderAll();
|
|
}
|
|
|
|
/** Lines `indexes` read by `key` from now on; emotions it lacks go back to its main voice. */
|
|
function giveLines(indexes, key) {
|
|
const group = state.groups.find(item => item.group === key);
|
|
for (const index of indexes) {
|
|
const line = state.lines[index];
|
|
if (!line || !group) continue;
|
|
line.speaker = key;
|
|
if (!usableEmotions(group).includes(line.emotion)) line.emotion = 'main';
|
|
}
|
|
}
|
|
|
|
export function stopScriptDirectorPreview() {
|
|
stopLinePreview();
|
|
runSection?.stopPlayback();
|
|
}
|
|
|
|
function stopLinePreview() {
|
|
if (previewAudio) {
|
|
previewAudio.pause();
|
|
previewAudio = null;
|
|
}
|
|
if (previewButton) {
|
|
previewButton.innerHTML = ICONS.SPEAKER;
|
|
previewButton.classList.remove('is-playing');
|
|
previewButton = null;
|
|
}
|
|
}
|
|
|
|
// ---------- state helpers ----------
|
|
|
|
function pickedGroup() {
|
|
return state.groups.find(group => group.group === state.groupKey) || null;
|
|
}
|
|
|
|
/** Who reads `line`: its character, else the one picked in the cast. */
|
|
function speakerOf(line) {
|
|
return (line?.speaker && state.groups.find(group => group.group === line.speaker)) || pickedGroup();
|
|
}
|
|
|
|
/** The node's own character: the first line's (the others are tagged where they take over). */
|
|
function nodeGroup() {
|
|
return speakerOf(state.lines.find(line => line.text.trim()) || null);
|
|
}
|
|
|
|
/** How each line is read, as tags (the run section knows the characters' values). */
|
|
const tagsOf = (line, speaker) => runSection?.tagsOf(line, speaker) || '';
|
|
|
|
/** The script with retakes, for a run in the director, and what the side panel shows. */
|
|
function runScript() {
|
|
const group = nodeGroup();
|
|
return {
|
|
group, lines: state.lines, listen: state.listenIndex, speakerOf,
|
|
pkg: buildScriptPackage(state.lines, group, { takes: true, groups: state.groups, tagsOf }),
|
|
groups: state.groups,
|
|
picked: [...state.picked].sort((a, b) => a - b),
|
|
focus: state.focus,
|
|
assign: (indexes, key) => {
|
|
giveLines(indexes, key);
|
|
stopScriptDirectorPreview();
|
|
renderAll();
|
|
},
|
|
focusCharacter: castScriptCharacter,
|
|
focusScript,
|
|
};
|
|
}
|
|
|
|
function currentPackage() {
|
|
return buildScriptPackage(state.lines, nodeGroup(), { groups: state.groups, tagsOf });
|
|
}
|
|
|
|
// ---------- 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');
|
|
|
|
const header = el('div', 'anomalous-sd-header');
|
|
const subtitle = el('p', 'anomalous-sd-subtitle');
|
|
header.append(subtitle);
|
|
|
|
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)),
|
|
);
|
|
const pasteFor = el('div', 'anomalous-sd-paste-for');
|
|
inputArea.append(pasteFor, textarea, inputActions);
|
|
|
|
const linesBar = el('div', 'anomalous-sd-lines-bar');
|
|
const linesCount = el('span', 'anomalous-sd-lines-count');
|
|
const castHint = el('span', 'anomalous-sd-cast-hint');
|
|
linesBar.append(linesCount, castHint, 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 scroll = el('div', 'anomalous-sd-scroll');
|
|
|
|
const footer = el('div', 'anomalous-sd-footer');
|
|
const summary = el('div', 'anomalous-sd-package-summary');
|
|
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, dragOwner, currentPackage, () => refs.pushBtn);
|
|
const copyBtn = button('anomalous-sd-btn', t('scriptDirectorCopy'), () => copyScript(currentPackage(), refs.copyBtn));
|
|
const pushBtn = button('anomalous-sd-btn', t('scriptDirectorInject'), () => pushToNode(currentPackage(), refs.pushBtn));
|
|
actions.append(dragHandle, pushBtn, copyBtn);
|
|
// Into your own workflow: second to generating here, so it sits below it, folded (remembered).
|
|
const workflow = el('details', 'anomalous-sd-workflow');
|
|
const workflowTitle = el('summary', 'anomalous-sd-workflow-title');
|
|
workflow.append(workflowTitle, actions);
|
|
try { workflow.open = localStorage.getItem(WORKFLOW_OPEN_KEY) === '1'; } catch (_) { /* folded */ }
|
|
workflow.addEventListener('toggle', () => {
|
|
try { localStorage.setItem(WORKFLOW_OPEN_KEY, workflow.open ? '1' : '0'); } catch (_) { /* convenience only */ }
|
|
});
|
|
runSection = createRunSection({
|
|
getScript: runScript,
|
|
getNodeGroup: nodeGroup,
|
|
getGroups: () => state.groups,
|
|
onPlay: () => {
|
|
stopLinePreview();
|
|
hooks.onPreviewStart?.();
|
|
},
|
|
resetTakes: () => state.lines.forEach(line => { line.take = 1; }),
|
|
onBusyChange: () => renderAll(),
|
|
onLinesChange: () => {
|
|
renderLines();
|
|
drafts?.changed();
|
|
},
|
|
openGallery: () => hooks.openGallery?.(),
|
|
});
|
|
scroll.append(linesContainer);
|
|
footer.append(summary, runSection.bar);
|
|
drafts = createDrafts({
|
|
capture: captureDraft,
|
|
apply: applyDraft,
|
|
nameOf: characterName,
|
|
busy: () => runSection.busy(),
|
|
redraw: () => renderAll(),
|
|
});
|
|
header.prepend(drafts.bar);
|
|
|
|
// Cards on one side, everything about generating on the other: below the cards
|
|
// on a narrow page (docked browser), beside them once the page is wide.
|
|
const main = el('div', 'anomalous-sd-main');
|
|
main.append(linesBar, scroll);
|
|
const side = el('div', 'anomalous-sd-side');
|
|
side.append(footer, runSection.settings, workflow);
|
|
const body = el('div', 'anomalous-sd-body');
|
|
const splitter = el('div', 'anomalous-sd-splitter');
|
|
splitter.title = t('scriptDirectorSplitter');
|
|
body.append(main, splitter, side);
|
|
bindSplitBar(splitter, { owner: panel, body, prop: '--sd-side-width', key: SIDE_WIDTH_KEY });
|
|
// The panel lives for the page session, so the observer does too.
|
|
new ResizeObserver(([entry]) => panel.classList.toggle('is-wide', entry.contentRect.width >= WIDE_FROM)).observe(panel);
|
|
|
|
panel.append(header, drafts.view, inputArea, body);
|
|
// Esc lets go of the picked lines first; the browser's own Esc leaves it to this then (ui_browser_navigation.js),
|
|
// and with a character shown it presses the panel's back link.
|
|
window.addEventListener('keydown', (e) => {
|
|
if (e.key !== 'Escape' || !state.picked.size || !panel.getClientRects().length) return;
|
|
if (e.target?.closest?.('input, textarea, select')) return;
|
|
focusScript();
|
|
});
|
|
refs = { subtitle, pasteFor, inputArea, textarea, cancelEdit, body, linesCount, castHint, linesContainer, summary, workflowTitle, dragHandle, copyBtn, pushBtn };
|
|
drafts.start();
|
|
}
|
|
|
|
function renderAll() {
|
|
if (!panel) return;
|
|
const node = TTS_ENGINE.label;
|
|
refs.subtitle.textContent = t('scriptDirectorSubtitle');
|
|
const inBox = drafts.showing; // the drafts box in place of the script
|
|
refs.subtitle.hidden = inBox || (state.lines.length > 0 && !state.editing); // the how-to, until there are lines
|
|
refs.workflowTitle.textContent = t('scriptDirectorWorkflowTitle');
|
|
refs.dragHandle.title = t('scriptDirectorDragHint', { node });
|
|
refs.pushBtn.title = t('scriptDirectorPushHint', { node });
|
|
refs.pushBtn.setAttribute('aria-label', refs.pushBtn.title);
|
|
for (const line of state.lines) line.speaker ||= state.groupKey; // lines from before a character was picked
|
|
const picked = pickedGroup();
|
|
refs.pasteFor.textContent = !state.groups.length ? t('scriptDirectorNoCharacters')
|
|
: picked ? t('scriptDirectorPasteFor', { name: picked.character }) : '';
|
|
const hasLines = state.lines.length > 0;
|
|
refs.inputArea.hidden = inBox || (hasLines && !state.editing);
|
|
refs.cancelEdit.hidden = !hasLines;
|
|
refs.body.hidden = inBox || !hasLines || state.editing;
|
|
drafts.render();
|
|
refs.linesCount.textContent = t('scriptDirectorLinesCount', { count: state.lines.length });
|
|
if (hasLines && !state.editing) renderLines();
|
|
renderPackage();
|
|
}
|
|
|
|
function renderLines() {
|
|
// Chips only when there is a choice, or a line still asks for an emotion its character lacks.
|
|
const showChips = state.lines.some((line) => {
|
|
const emotions = usableEmotions(speakerOf(line));
|
|
return emotions.length > 1 || !emotions.includes(line.emotion);
|
|
});
|
|
const addLine = button('anomalous-sd-add-line', t('scriptDirectorAddLine'), () => {
|
|
const last = state.lines.at(-1);
|
|
state.lines.push({ text: '', emotion: last?.speaker === state.groupKey ? last.emotion : 'main', take: 1, speaker: state.groupKey });
|
|
state.editLine = state.lines.length - 1;
|
|
state.picked = new Set();
|
|
renderAll();
|
|
});
|
|
if (state.listenIndex >= state.lines.length) state.listenIndex = 0;
|
|
state.picked = new Set([...state.picked].filter(index => index < state.lines.length));
|
|
const ctx = {
|
|
groups: state.groups,
|
|
speakerOf,
|
|
picked: state.picked,
|
|
listenIndex: state.listenIndex,
|
|
editing: state.editLine,
|
|
showSpeakers: state.groups.length > 1,
|
|
showChips,
|
|
count: state.lines.length,
|
|
canListen: Boolean(runSection?.canListen()),
|
|
canRetake: Boolean(runSection?.canRetake(nodeGroup())),
|
|
tuneText: line => ownTuneText(runSection?.presetsView(), line),
|
|
onPick: pickLine,
|
|
onEdit: (index, quiet = false) => {
|
|
state.editLine = index;
|
|
if (index !== null) chooseListenLine(index);
|
|
if (!quiet) renderLines();
|
|
},
|
|
onText: (index, text) => {
|
|
state.lines[index].text = text;
|
|
renderPackage();
|
|
},
|
|
onSplit: (index, first, second) => {
|
|
const line = state.lines[index];
|
|
line.text = first;
|
|
state.lines.splice(index + 1, 0, { text: second, emotion: line.emotion, take: 1, speaker: line.speaker, ...(line.tune ? { tune: { ...line.tune } } : {}) });
|
|
changeLines(index + 1);
|
|
},
|
|
onMerge: (index) => {
|
|
const line = state.lines[index];
|
|
line.text = joinSegments(line.text, state.lines[index + 1].text);
|
|
state.lines.splice(index + 1, 1);
|
|
changeLines(null);
|
|
},
|
|
onDelete: (index) => {
|
|
state.lines.splice(index, 1);
|
|
changeLines(null);
|
|
},
|
|
// The name on a card: that line, or every picked one when it is one of them.
|
|
onSpeaker: (index, key) => {
|
|
giveLines(state.picked.has(index) ? [...state.picked] : [index], key);
|
|
stopScriptDirectorPreview();
|
|
renderAll();
|
|
},
|
|
onEmotion: (reference) => {
|
|
if (previewButton === reference) stopScriptDirectorPreview();
|
|
renderPackage();
|
|
},
|
|
onListen: (index) => {
|
|
chooseListenLine(index);
|
|
runSection.listen();
|
|
},
|
|
onRetake: (index) => {
|
|
const line = state.lines[index];
|
|
line.take = (line.take || 1) + 1;
|
|
runSection.generate();
|
|
},
|
|
onPreview: previewLine,
|
|
};
|
|
refs.linesContainer.replaceChildren(...state.lines.map((line, index) => renderLineCard(line, index, ctx)), addLine);
|
|
renderCastHint();
|
|
}
|
|
|
|
/** After a split, merge or delete: the numbers move, so nothing stays picked; `edit` opens a card. */
|
|
function changeLines(edit) {
|
|
state.picked = new Set();
|
|
state.editLine = edit;
|
|
stopScriptDirectorPreview();
|
|
renderAll();
|
|
}
|
|
|
|
/** A click on a card: picks it (Ctrl / ⌘ adds, Shift a run), the side panel shows the picked lines. */
|
|
function pickLine(index, event) {
|
|
state.picked = pickLines(state.picked, index, event, state.anchor);
|
|
if (!event.shiftKey) state.anchor = index;
|
|
refs.linesContainer.querySelectorAll('.anomalous-sd-line-card').forEach((card, at) => card.classList.toggle('is-picked', state.picked.has(at)));
|
|
renderCastHint();
|
|
chooseListenLine(index);
|
|
runSection?.render();
|
|
}
|
|
|
|
/** How to work the cards: picking (the side panel changes them) and editing. */
|
|
function renderCastHint() {
|
|
refs.castHint.textContent = state.picked.size
|
|
? t('scriptCastPicked', { count: state.picked.size })
|
|
: t(state.groups.length > 1 ? 'scriptCastHint' : 'scriptDirectorCardsHint');
|
|
}
|
|
|
|
function chooseListenLine(index) {
|
|
if (state.listenIndex === index) return;
|
|
state.listenIndex = index;
|
|
refs.linesContainer.querySelectorAll('.anomalous-sd-line-card').forEach((card, at) => card.classList.toggle('is-listen', at === index));
|
|
runSection?.render();
|
|
}
|
|
|
|
function renderPackage() {
|
|
if (!refs) return;
|
|
const pkg = currentPackage();
|
|
const ok = !pkg.error;
|
|
refs.summary.classList.toggle('is-error', !ok);
|
|
if (ok && pkg.speakers.length > 1) {
|
|
refs.summary.textContent = t('scriptDirectorPackageSummaryCast', { count: pkg.lineCount, speakers: pkg.speakers.join(t('listSeparator')) });
|
|
} else 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(' · ') });
|
|
} else {
|
|
refs.summary.textContent = t(pkg.error);
|
|
}
|
|
drafts.changed();
|
|
refs.copyBtn.disabled = !ok;
|
|
refs.pushBtn.disabled = !ok;
|
|
refs.dragHandle.draggable = ok;
|
|
refs.dragHandle.classList.toggle('is-disabled', !ok);
|
|
runSection.render();
|
|
}
|
|
|
|
// ---------- the drafts box ----------
|
|
|
|
/** The script as the drafts box keeps it (ui_script_drafts.js). */
|
|
function captureDraft() {
|
|
const lines = state.lines.map(line => ({ text: line.text, emotion: line.emotion, speaker: line.speaker, ...(line.tune ? { tune: line.tune } : {}) }));
|
|
return { format: 1, lines, groupKey: state.groupKey, ...runSection.draft() };
|
|
}
|
|
|
|
/** A draft opened from the box (null: a blank script); nothing stays picked or generated. */
|
|
function applyDraft(script) {
|
|
stopScriptDirectorPreview();
|
|
state.lines = (script?.lines || []).map(line => ({ ...line, take: 1, ...(line.tune ? { tune: { ...line.tune } } : {}) }));
|
|
// A character missing now leaves the pick as it is (the voices may not be read yet).
|
|
if (script?.groupKey && (!state.groups.length || state.groups.some(group => group.group === script.groupKey))) state.groupKey = script.groupKey;
|
|
state.picked = new Set();
|
|
state.focus = null;
|
|
state.editLine = null;
|
|
state.listenIndex = 0;
|
|
state.editing = !state.lines.length;
|
|
runSection.restoreDraft({ script: script?.script, cast: script?.cast });
|
|
if (refs) refs.textarea.value = '';
|
|
}
|
|
|
|
const characterName = key => state.groups.find(group => group.group === key)?.character || String(key).replace(/^gpt_sovits:/, '');
|
|
|
|
// ---------- actions ----------
|
|
|
|
function parseScript(text) {
|
|
const parsed = splitScriptLines(text, state.splitMode);
|
|
if (!parsed.length) return;
|
|
// A line kept word for word keeps who reads it and how.
|
|
const previous = new Map(state.lines.map(line => [line.text.trim(), line]));
|
|
state.lines = parsed.map((lineText) => {
|
|
const before = previous.get(lineText);
|
|
return {
|
|
text: lineText,
|
|
emotion: before?.emotion || 'main',
|
|
take: before?.take || 1,
|
|
speaker: before?.speaker || state.groupKey,
|
|
...(before?.tune ? { tune: { ...before.tune } } : {}),
|
|
};
|
|
});
|
|
state.picked = new Set();
|
|
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);
|
|
}
|