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>
575 lines
25 KiB
JavaScript
575 lines
25 KiB
JavaScript
import { t } from './interface_settings.js';
|
||
import { anomalousAlert } from './ui_dialog.js';
|
||
import { buildScriptPackage, joinSegments, splitScriptLines, splitSegmentAt, usableEmotions } from './audio_script.js';
|
||
import { TTS_ENGINE, voiceGroupKey } from './audio_engines.js';
|
||
import { pickLines, speakerChip, speakerColor } from './ui_script_cast.js';
|
||
import { createRunSection } from './ui_script_run.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):
|
||
* pick who reads by default, paste a script, choose an emotion per line on the line cards,
|
||
* give picked lines to other characters from the list on the left (the cast, ui_script_cast.js),
|
||
* then generate it right here (ui_script_run.js), or put the tagged script into the
|
||
* workflow (ui_script_workflow.js). The panel and its lines live for the page
|
||
* session; the page mounts it again each time it is shown.
|
||
*/
|
||
|
||
// 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,
|
||
lines: [], // { text, emotion, take, speaker } — take > 1: retaken; speaker: a group key, null = the default
|
||
editing: true,
|
||
splitMode: 'sentence',
|
||
focusIndex: null, // card whose text box should receive focus after the next render
|
||
listenIndex: 0, // the line the modes are heard on (ui_script_run.js)
|
||
picked: new Set(), // lines picked to give to a character (castScriptCharacter)
|
||
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;
|
||
// 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 who speaks. */
|
||
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) {
|
||
const lines = segments.flatMap(segment => String(segment.text || '').split(/\n+/)
|
||
.map(text => ({ text: text.trim(), emotion: segment.emotion || 'main', take: 1, speaker: segment.speaker ? voiceGroupKey(segment.speaker) : null })))
|
||
.filter(line => line.text);
|
||
if (!lines.length) return;
|
||
stopScriptDirectorPreview();
|
||
state.lines = lines;
|
||
state.picked = new Set();
|
||
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: it reads the picked lines, or, with none
|
||
* picked, it becomes the one who reads by default.
|
||
*/
|
||
export function castScriptCharacter(key) {
|
||
const group = state.groups.find(item => item.group === key);
|
||
if (!group) return;
|
||
stopScriptDirectorPreview();
|
||
if (state.picked.size && state.lines.length && !state.editing) {
|
||
for (const index of state.picked) {
|
||
const line = state.lines[index];
|
||
if (!line) continue;
|
||
line.speaker = key === state.groupKey ? null : key;
|
||
if (!usableEmotions(group).includes(line.emotion)) line.emotion = 'main';
|
||
}
|
||
} else {
|
||
chooseDefault(key);
|
||
}
|
||
if (panel) renderAll();
|
||
}
|
||
|
||
/** Who reads the lines not given to anyone; lines given to that character before follow it now. */
|
||
function chooseDefault(key) {
|
||
if (state.groupKey === key) return;
|
||
state.groupKey = key;
|
||
for (const line of state.lines) if (line.speaker === key) line.speaker = null;
|
||
hooks.onCharacterChange?.();
|
||
}
|
||
|
||
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 selectedGroup() {
|
||
return state.groups.find(group => group.group === state.groupKey) || null;
|
||
}
|
||
|
||
/** Who reads `line`: the character it was given to, else the default one. */
|
||
function speakerOf(line) {
|
||
return (line?.speaker && state.groups.find(group => group.group === line.speaker)) || selectedGroup();
|
||
}
|
||
|
||
/** Whether some line is read by another character than the default one (the cards then say who). */
|
||
function hasCast() {
|
||
return state.lines.some(line => line.speaker && line.speaker !== state.groupKey);
|
||
}
|
||
|
||
/** The script with retakes, for a run in the director. */
|
||
function runScript() {
|
||
const group = selectedGroup();
|
||
return {
|
||
group, lines: state.lines, listen: state.listenIndex, speakerOf,
|
||
pkg: buildScriptPackage(state.lines, group, { takes: true, groups: state.groups }),
|
||
};
|
||
}
|
||
|
||
function emotionLabel(emotion) {
|
||
return emotion === 'main' ? t('scriptDirectorMainEmotion') : emotion;
|
||
}
|
||
|
||
function currentPackage() {
|
||
return buildScriptPackage(state.lines, selectedGroup(), { groups: state.groups });
|
||
}
|
||
|
||
// ---------- 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 characterBar = el('label', 'anomalous-sd-character-bar');
|
||
const characterSelect = el('select', 'anomalous-sd-character-select');
|
||
characterSelect.onchange = () => {
|
||
stopScriptDirectorPreview();
|
||
chooseDefault(characterSelect.value);
|
||
renderAll();
|
||
};
|
||
const characterHint = el('div', 'anomalous-sd-character-hint');
|
||
characterBar.append(el('span', 'anomalous-sd-step', t('scriptDirectorCharacter')), characterSelect);
|
||
// Who reads and what voices they have, on one row.
|
||
const who = el('div', 'anomalous-sd-who');
|
||
who.append(characterBar, characterHint);
|
||
|
||
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');
|
||
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');
|
||
// One voice only: emotion chips would all say "main", so one line says how to get more.
|
||
const onlyMain = el('div', 'anomalous-sd-only-main');
|
||
|
||
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,
|
||
getGroups: () => state.groups,
|
||
onPlay: () => {
|
||
stopLinePreview();
|
||
hooks.onPreviewStart?.();
|
||
},
|
||
resetTakes: () => state.lines.forEach(line => { line.take = 1; }),
|
||
onBusyChange: () => renderAll(),
|
||
openGallery: () => hooks.openGallery?.(),
|
||
});
|
||
scroll.append(linesContainer);
|
||
footer.append(summary, runSection.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, onlyMain, 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, who, inputArea, body);
|
||
// Esc lets go of the picked lines first; the browser's own Esc leaves it to this then (ui_browser_navigation.js).
|
||
window.addEventListener('keydown', (e) => {
|
||
if (e.key !== 'Escape' || !state.picked.size || !panel.getClientRects().length) return;
|
||
state.picked = new Set();
|
||
refs.linesContainer.querySelectorAll('.anomalous-sd-line-card.is-picked').forEach(card => card.classList.remove('is-picked'));
|
||
renderCastHint();
|
||
});
|
||
refs = { subtitle, characterSelect, characterHint, inputArea, textarea, cancelEdit, body, linesCount, castHint, onlyMain, linesContainer, summary, workflowTitle, dragHandle, copyBtn, pushBtn };
|
||
}
|
||
|
||
function renderAll() {
|
||
if (!panel) return;
|
||
const node = TTS_ENGINE.label;
|
||
refs.subtitle.textContent = t('scriptDirectorSubtitle');
|
||
refs.subtitle.hidden = 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);
|
||
renderCharacterBar();
|
||
const hasLines = state.lines.length > 0;
|
||
refs.inputArea.hidden = hasLines && !state.editing;
|
||
refs.cancelEdit.hidden = !hasLines;
|
||
refs.body.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 option = el('option', '', 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('scriptDirectorNoCharacters');
|
||
else if (group && !usableEmotions(group).includes('main')) hint = t('scriptDirectorMainMissing');
|
||
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() {
|
||
// 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);
|
||
});
|
||
refs.onlyMain.hidden = showChips;
|
||
refs.onlyMain.textContent = t('scriptDirectorOnlyMain');
|
||
const addLine = button('anomalous-sd-add-line', t('scriptDirectorAddLine'), () => {
|
||
const last = state.lines.at(-1);
|
||
state.lines.push({ text: '', emotion: last?.emotion || 'main', take: 1, speaker: last?.speaker || null });
|
||
state.focusIndex = 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 cast = hasCast();
|
||
refs.linesContainer.replaceChildren(...state.lines.map((line, index) => renderLineCard(line, index, showChips, cast)), addLine);
|
||
renderCastHint();
|
||
if (state.focusIndex !== null) {
|
||
refs.linesContainer.querySelectorAll('.anomalous-sd-line-text')[state.focusIndex]?.focus();
|
||
state.focusIndex = null;
|
||
}
|
||
}
|
||
|
||
function renderLineCard(line, index, showChips, cast) {
|
||
const speaker = speakerOf(line);
|
||
const emotions = usableEmotions(speaker);
|
||
const slices = new Map((speaker?.slices || []).map(slice => [slice.emotion, slice]));
|
||
const card = el('div', `anomalous-sd-line-card${index === state.listenIndex ? ' is-listen' : ''}${state.picked.has(index) ? ' is-picked' : ''}`);
|
||
// The line you are on is the one the modes are heard on; a click also picks it for the cast.
|
||
card.addEventListener('focusin', () => chooseListenLine(index));
|
||
card.addEventListener('click', (e) => {
|
||
if (!e.target.closest('button')) pickLine(index, e);
|
||
});
|
||
if (cast) {
|
||
card.classList.add('has-speaker');
|
||
card.style.setProperty('--speaker', speakerColor(speaker?.group, state.groups));
|
||
}
|
||
|
||
// Editing buttons (.is-edit) show on hover; another take of this line stays visible.
|
||
const head = el('div', 'anomalous-sd-line-head');
|
||
head.append(
|
||
el('span', 'anomalous-sd-line-no', `#${index + 1}`),
|
||
...(cast ? [speakerChip(speaker, speakerColor(speaker?.group, state.groups))] : []),
|
||
|
||
// Split at the text caret (the textarea keeps its caret after losing focus to this button).
|
||
button('anomalous-sd-icon-btn is-edit', '✂', 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, speaker: line.speaker || null });
|
||
state.picked = new Set();
|
||
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 is-edit', '↧', () => {
|
||
line.text = joinSegments(line.text, state.lines[index + 1].text);
|
||
state.lines.splice(index + 1, 1);
|
||
state.picked = new Set();
|
||
stopScriptDirectorPreview();
|
||
renderAll();
|
||
}, t('scriptDirectorMergeNext')));
|
||
}
|
||
if (runSection?.canListen()) {
|
||
// This line in the mode picked on the side; the side keeps the takes heard to compare.
|
||
head.append(button('anomalous-sd-icon-btn anomalous-sd-line-play', '▶', () => {
|
||
chooseListenLine(index);
|
||
runSection.listen();
|
||
}, t('scriptDirectorListenLine')));
|
||
}
|
||
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-edit is-danger', '×', () => {
|
||
state.lines.splice(index, 1);
|
||
state.picked = new Set();
|
||
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();
|
||
};
|
||
|
||
if (!showChips) {
|
||
card.append(head, text);
|
||
return card;
|
||
}
|
||
const chips = el('div', 'anomalous-sd-emotion-row');
|
||
// The reference clip of the chosen emotion: how this voice sounds, not this line's result.
|
||
const reference = button('anomalous-sd-ref-btn', '', () => previewLine(line, slices, reference),
|
||
t('scriptDirectorPreviewEmotion', { emotion: emotionLabel(line.emotion) }));
|
||
reference.innerHTML = ICONS.SPEAKER;
|
||
// 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 === reference) stopScriptDirectorPreview();
|
||
reference.title = t('scriptDirectorPreviewEmotion', { emotion: emotionLabel(emotion) });
|
||
reference.setAttribute('aria-label', reference.title);
|
||
reference.disabled = !slices.get(emotion)?.audio_url;
|
||
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);
|
||
}
|
||
reference.disabled = !slices.get(line.emotion)?.audio_url;
|
||
chips.appendChild(reference);
|
||
|
||
card.append(head, text, chips);
|
||
return card;
|
||
}
|
||
|
||
/** A click on a card: picks it (Ctrl / ⌘ adds, Shift a run) and makes it the line to listen with. */
|
||
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);
|
||
}
|
||
|
||
/** What a click on the list does now: give the picked lines, or (none picked) change who reads by default. */
|
||
function renderCastHint() {
|
||
refs.castHint.hidden = state.groups.length < 2;
|
||
refs.castHint.textContent = state.picked.size
|
||
? t('scriptCastPicked', { count: state.picked.size })
|
||
: t('scriptCastHint');
|
||
}
|
||
|
||
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('、') });
|
||
} 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);
|
||
}
|
||
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,
|
||
speaker: previous.get(lineText)?.speaker || null,
|
||
}));
|
||
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);
|
||
}
|