Every script is a draft, saved on its own in ComfyUI's user folder (anomalous_model_browser/voice_over_drafts.json through /userdata), read again and joined before each write so two tabs keep each other's drafts. A bar over the script names it and leads to New, Drafts and Close; the box shows a card per draft (open, rename, copy, delete) and Recently deleted (30 days). A reload opens the draft that was open; a file voiced again from the gallery becomes a draft of its own; the one localStorage draft of the earlier build moves in once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
504 lines
24 KiB
JavaScript
504 lines
24 KiB
JavaScript
import { t } from './interface_settings.js';
|
|
import { anomalousAlert } from './ui_dialog.js';
|
|
import { buildTtsPrompt, TTS_NODE_CLASS } from './audio_script.js';
|
|
import { startPromptJob } from './audio_tts_run.js';
|
|
import { fetchGptSovitsCharacter, loadGptSovitsStatus, saveGptSovitsSettings } from './audio_engines.js';
|
|
import {
|
|
PRESETS_FORMAT, SAMPLE_LINES, lineTuning, loadPresets, presetLabel, presetsFile, savePresets, stateFromView, tuningRows,
|
|
} from './tts_presets_api.js';
|
|
import {
|
|
AUTO_MODE, LINE_TAGS_FORMAT, TUNE_FIELDS, characterLayer, clearLineTune, lineLayer, lineValues, scriptLayer, setLineTune, setOwn, tuneTags,
|
|
} from './script_tune.js';
|
|
import { keepParamFocus } from './ui_tts_preset_switch.js';
|
|
import { createModePreview } from './ui_tts_mode_preview.js';
|
|
import { openTtsSetup } from './ui_tts_setup.js';
|
|
import { openTuningEditor } from './ui_tts_tuning.js';
|
|
import { LANGUAGES, renderTunePanel } from './ui_script_tune.js';
|
|
|
|
/**
|
|
* The Script Director's "Generate" section for GPT-SoVITS characters: runs the
|
|
* script through ComfyUI's queue (audio_tts_run.js) without touching the canvas,
|
|
* plays the result, and offers another take of the whole script (new seed) or of
|
|
* one line (`[take:N]`, the other lines come from the node's cache).
|
|
*
|
|
* How each line is read: its language, speed and tone in three layers changed for this script
|
|
* (script_tune.js): the line, its character (kept here), the whole script (kept here); then the
|
|
* character's usual `defaults` (language and speed, which can be saved back) and the usual tone
|
|
* (the presets and the character's own settings decide line by line). The side panel showing
|
|
* them follows what is picked on the left (ui_script_tune.js). Anomalous_TTS interface 17 gets
|
|
* the whole script's values through the node's inputs and the rest as tags on the lines that
|
|
* need them; an older node reads the whole script with the first line's character's values
|
|
* through its inputs. The tones can be heard on a line (ui_tts_mode_preview.js); a preset's
|
|
* numbers changed here are saved into that preset.
|
|
*
|
|
* The director owns the lines; this section owns the job, the characters' values and the
|
|
* result, which live for the page session like the director's panel. It has two parts that
|
|
* the director places together: `bar` (generate / retake / cancel and the result, with a way to
|
|
* the audio gallery) and `settings` (the side panel).
|
|
*/
|
|
|
|
const MIN_FORMAT = 11; // Anomalous_TTS interface with [take:N] and `defaults`
|
|
const NUMBERS = ['speed'];
|
|
|
|
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.onclick = onClick;
|
|
return btn;
|
|
}
|
|
|
|
function newSeed() {
|
|
return Math.floor(Math.random() * 2 ** 31);
|
|
}
|
|
|
|
function audioUrl(audio) {
|
|
const query = new URLSearchParams({ filename: audio.filename, subfolder: audio.subfolder || '', type: audio.type || 'output' });
|
|
return `/view?${query}`;
|
|
}
|
|
|
|
/** `{ speed: { default, min, max, step } }` from ComfyUI's description of the node. */
|
|
async function loadNodeSpec() {
|
|
const resp = await fetch(`/object_info/${TTS_NODE_CLASS}`);
|
|
const info = (await resp.json())?.[TTS_NODE_CLASS]?.input || {};
|
|
const inputs = { ...(info.required || {}), ...(info.optional || {}) };
|
|
const spec = {};
|
|
for (const name of NUMBERS) {
|
|
const options = inputs[name]?.[1];
|
|
if (options && typeof options.default === 'number') spec[name] = options;
|
|
}
|
|
return spec;
|
|
}
|
|
|
|
/**
|
|
* `getScript()` -> `{ group, lines, listen, speakerOf, pkg, groups, picked, focus, assign, focusCharacter,
|
|
* focusScript }` (the node's character = the first line's, the director's lines, the one to listen
|
|
* with, who reads a line, pkg from buildScriptPackage with takes, the characters, the picked line
|
|
* indexes, the character shown, and the director's calls); `getNodeGroup()` -> the node's character
|
|
* alone (the tags need it, and getScript builds the tagged script); `getGroups()` -> every voice group (for
|
|
* the presets' "used by"); `onPlay()` when the result starts playing; `resetTakes()` when a new take
|
|
* of the whole script starts; `onBusyChange()` when a job starts or ends (the director re-renders its
|
|
* line cards, whose retake buttons depend on it); `onLinesChange()` after a line's values changed (the
|
|
* cards' tags); `openGallery()` for the result's "saved to the audio gallery".
|
|
*/
|
|
export function createRunSection({ getScript, getNodeGroup, getGroups, onPlay, resetTakes, onBusyChange, onLinesChange, openGallery }) {
|
|
const state = {
|
|
seed: newSeed(),
|
|
spec: null, // node input ranges/defaults; null until loaded
|
|
presets: null, // loadPresets(); null without interface 16
|
|
format: 0, // the node's interface
|
|
local: false, // this computer (presets can be changed)
|
|
script: {}, // the whole script's values changed here (only the fields set)
|
|
cast: new Map(), // character -> its values changed here for this script (only the fields set)
|
|
settings: new Map(), // character -> its settings as saved in this session
|
|
saved: new Map(), // character -> options saved in this session
|
|
savedNow: null, // the character just saved ("Saved" on its button for a moment)
|
|
saving: false,
|
|
presetNote: '',
|
|
job: null,
|
|
status: null, // { state: 'queued' | 'running' | 'done' | 'cancelled', value?, max?, seconds? }
|
|
result: null, // { audio, url, warnings, character }
|
|
supported: null, // TTS interface >= MIN_FORMAT; null while unknown
|
|
};
|
|
|
|
const bar = el('div', 'anomalous-sd-run');
|
|
const hint = el('div', 'anomalous-sd-run-hint is-warning', t('scriptRunNeedsUpdate'));
|
|
|
|
// How it is read: what is picked on the left (ui_script_tune.js).
|
|
const settings = el('div', 'anomalous-sd-run-settings');
|
|
|
|
const actions = el('div', 'anomalous-sd-run-actions');
|
|
const generateBtn = button('anomalous-sd-btn accent', t('scriptRunGenerate'), () => generate());
|
|
const newTakeBtn = button('anomalous-sd-btn', t('scriptRunNewTake'), () => {
|
|
state.seed = newSeed();
|
|
resetTakes?.();
|
|
generate();
|
|
}, t('scriptRunNewTakeHint'));
|
|
const cancelBtn = button('anomalous-sd-btn', t('scriptRunCancel'), () => state.job?.cancel());
|
|
const status = el('span', 'anomalous-sd-run-status');
|
|
actions.append(generateBtn, newTakeBtn, cancelBtn, status);
|
|
|
|
const player = el('audio', 'anomalous-sd-run-player');
|
|
player.controls = true;
|
|
player.preload = 'auto';
|
|
player.addEventListener('play', () => onPlay?.());
|
|
const savedTo = button('anomalous-sd-link-btn anomalous-sd-run-saved', t('scriptRunSavedLink'), () => openGallery?.());
|
|
const warnings = el('div', 'anomalous-sd-run-warnings');
|
|
|
|
bar.append(hint, actions, player, savedTo, warnings);
|
|
|
|
const characterSettings = group => state.settings.get(group.character) ?? group?.raw?.settings ?? {};
|
|
const preview = createModePreview(() => render());
|
|
const canTag = () => state.format >= LINE_TAGS_FORMAT;
|
|
|
|
/** The character's saved language and speed over the node's own defaults; the mode starts on Auto. */
|
|
function storedOptions(group) {
|
|
if (state.saved.has(group.character)) return state.saved.get(group.character);
|
|
const defaults = characterSettings(group).defaults || {};
|
|
return {
|
|
language: LANGUAGES.includes(defaults.language) ? defaults.language : 'auto',
|
|
speed: typeof defaults.speed === 'number' ? defaults.speed : state.spec?.speed?.default ?? null,
|
|
preset: AUTO_MODE,
|
|
};
|
|
}
|
|
|
|
const base = () => ({ language: 'auto', speed: state.spec?.speed?.default ?? 1, preset: AUTO_MODE });
|
|
|
|
function castOf(group) {
|
|
if (!state.cast.has(group.character)) state.cast.set(group.character, {});
|
|
return state.cast.get(group.character);
|
|
}
|
|
|
|
/** A character's values in this script with where each comes from; a preset deleted meanwhile reads as the usual tone. */
|
|
function layerOf(group) {
|
|
const layer = characterLayer(castOf(group), state.script, storedOptions(group), base());
|
|
if (layer.values.preset !== AUTO_MODE && !state.presets?.presets?.[layer.values.preset]) layer.values.preset = AUTO_MODE;
|
|
return layer;
|
|
}
|
|
|
|
const currentOptions = group => (group ? layerOf(group).values : scriptLayer(state.script, base()).values);
|
|
const sameOptions = (a, b) => a.language === b.language && a.speed === b.speed;
|
|
const valuesOfLine = line => lineValues(line, currentOptions(getScript().speakerOf(line)));
|
|
|
|
/**
|
|
* The node's whole-script speed: its input counts only when it differs from the node's default
|
|
* and from the first character's own speed (Anomalous_TTS reads it that way), else undefined.
|
|
*/
|
|
function nodeSpeed() {
|
|
const speed = state.script.speed;
|
|
const first = getNodeGroup();
|
|
return speed === undefined || speed === base().speed || (first && speed === storedOptions(first).speed) ? undefined : speed;
|
|
}
|
|
|
|
/** What the node gives a line of `speaker` by itself: the whole script's values as inputs, else the character's own. */
|
|
const nodeValues = speaker => ({
|
|
language: state.script.language ?? 'auto',
|
|
speed: nodeSpeed() ?? storedOptions(speaker).speed,
|
|
preset: state.script.preset ?? AUTO_MODE,
|
|
});
|
|
|
|
/** The tags `line` needs (script_tune.js), for the director's packages; none for an older node. */
|
|
function tagsOf(line, speaker) {
|
|
if (!canTag() || !speaker) return '';
|
|
return tuneTags(lineValues(line, currentOptions(speaker)), nodeValues(speaker));
|
|
}
|
|
|
|
/** The node's inputs for the script: the whole script's values (interface 17), else the first line's character's. */
|
|
function scriptInputs(group) {
|
|
if (!canTag()) {
|
|
const { language, speed, preset } = currentOptions(group);
|
|
return { language, speed, ...(state.presets && preset !== AUTO_MODE ? { preset } : {}) };
|
|
}
|
|
const speed = nodeSpeed();
|
|
return {
|
|
language: state.script.language ?? 'auto',
|
|
...(speed !== undefined ? { speed } : {}),
|
|
...(state.presets && state.script.preset ? { preset: state.script.preset } : {}),
|
|
};
|
|
}
|
|
|
|
/** The language Auto's numbers are shown for: the one chosen, else the character's own. */
|
|
function autoKey(group, values) {
|
|
const refLang = group.raw?.reference?.language || group.language || '';
|
|
return values.language !== 'auto' ? values.language : (refLang || 'all');
|
|
}
|
|
|
|
/** A mode's four numbers for `group` reading with `values`: Auto's from the presets and its settings. */
|
|
function modeNumbers(group, values, id) {
|
|
if (id !== AUTO_MODE) return state.presets?.presets?.[id];
|
|
const refLang = group.raw?.reference?.language || group.language || '';
|
|
return lineTuning(state.presets, tuningRows(characterSettings(group)), refLang, autoKey(group, values)).values;
|
|
}
|
|
|
|
/**
|
|
* One line read by its character (`speaker`) in tone `mode`, for the listen row and a card's ▶.
|
|
* With interface 17 the values go as the line's tags (exactly as they will be read), else as inputs.
|
|
*/
|
|
function takeFor(line, mode, speaker = null, values = null) {
|
|
const who = speaker || getScript().speakerOf(line);
|
|
const own = values || (line ? valuesOfLine(line) : currentOptions(who));
|
|
const id = mode ?? own.preset;
|
|
const words = String(line?.text || '').trim();
|
|
const refLang = who.raw?.reference?.language || who.language;
|
|
const emotion = line?.emotion && line.emotion !== 'main' ? `{${line.emotion}}` : '';
|
|
const numbers = modeNumbers(who, own, id);
|
|
if (canTag()) {
|
|
const tags = tuneTags({ ...own, preset: id }, { speed: storedOptions(who).speed });
|
|
return { character: who.character, text: `${emotion}${tags}${words || SAMPLE_LINES[refLang] || SAMPLE_LINES.zh}`, language: 'auto', mode: id, preset: '', numbers };
|
|
}
|
|
const text = words ? `${emotion}${words}` : SAMPLE_LINES[refLang] || SAMPLE_LINES.zh;
|
|
return { character: who.character, text, language: own.language, speed: own.speed, mode: id, preset: id, numbers };
|
|
}
|
|
|
|
async function reloadPresets() {
|
|
try {
|
|
state.presets = await loadPresets();
|
|
} catch (_) { /* keep the last ones */ }
|
|
render();
|
|
}
|
|
|
|
async function checkSupport() {
|
|
try {
|
|
const [status, spec] = await Promise.all([loadGptSovitsStatus(), loadNodeSpec()]);
|
|
state.spec = spec;
|
|
state.format = Number(status?.format) || 0;
|
|
state.supported = state.format >= MIN_FORMAT && NUMBERS.every(name => spec[name]);
|
|
state.local = status?.local !== false;
|
|
if (state.format >= PRESETS_FORMAT) state.presets = await loadPresets();
|
|
} catch (_) {
|
|
state.supported = false;
|
|
}
|
|
render();
|
|
onBusyChange?.(); // the director draws its cards again, now with their ▶
|
|
}
|
|
|
|
/** A preset's number changed here goes into the preset itself, for every character. */
|
|
async function editPreset(name, key, value) {
|
|
const next = stateFromView(state.presets);
|
|
next.numbers[name][key] = value;
|
|
try {
|
|
state.presets = await savePresets(presetsFile(state.presets, next));
|
|
state.presetNote = t('scriptRunModeSaved', { name: presetLabel(state.presets.labels, name, t) });
|
|
} catch (error) {
|
|
state.presetNote = t('ttsPresetsSaveFailed', { error: error.message });
|
|
}
|
|
keepParamFocus(settings, render);
|
|
}
|
|
|
|
async function generate() {
|
|
const script = getScript();
|
|
if (!script?.group || script.pkg.error || state.job || !state.supported) return;
|
|
const character = script.group.character;
|
|
const prompt = buildTtsPrompt({ character, speech: script.pkg.speech, seed: state.seed, ...scriptInputs(script.group) });
|
|
state.status = { state: 'queued' };
|
|
const started = Date.now();
|
|
state.job = startPromptJob(prompt, {
|
|
onStatus: next => {
|
|
state.status = next;
|
|
renderStatus();
|
|
},
|
|
});
|
|
onBusyChange?.();
|
|
try {
|
|
const { outputs } = await state.job.result;
|
|
const audio = outputs['2']?.audio?.[0];
|
|
if (!audio) throw new Error(t('scriptRunNoAudio'));
|
|
// The first line is the node's own count (its sentences, batches, cache), not the reader's lines.
|
|
const [, ...rest] = String(outputs['1']?.text?.[0] || '').split('\n');
|
|
state.result = { audio, url: `${audioUrl(audio)}&t=${Date.now()}`, warnings: rest.filter(Boolean), character };
|
|
state.status = { state: 'done', seconds: Math.max(1, Math.round((Date.now() - started) / 1000)) };
|
|
state.job = null;
|
|
onBusyChange?.();
|
|
player.play().catch(() => {});
|
|
} catch (error) {
|
|
state.job = null;
|
|
state.status = error.cancelled ? { state: 'cancelled' } : null;
|
|
onBusyChange?.();
|
|
if (!error.cancelled) await anomalousAlert(t('scriptRunFailed', { error: error.message }));
|
|
}
|
|
}
|
|
|
|
async function storeDefaults(group) {
|
|
const { character } = group;
|
|
const opts = { ...currentOptions(group) };
|
|
state.saving = true;
|
|
render();
|
|
try {
|
|
// Start from the file as it is now, so edits made elsewhere (the generation settings) are kept.
|
|
const fresh = await fetchGptSovitsCharacter(character);
|
|
if (fresh.settings_error) throw new Error(t('scriptRunSettingsBroken', { error: fresh.settings_error }));
|
|
const saved = { ...(fresh.settings || {}), format: 1 };
|
|
// Only what differs from the node's own defaults, so the file stays short.
|
|
const defaults = { ...(saved.defaults || {}) };
|
|
if (opts.language !== 'auto') defaults.language = opts.language; else delete defaults.language;
|
|
if (opts.speed !== state.spec.speed.default) defaults.speed = opts.speed; else delete defaults.speed;
|
|
if (Object.keys(defaults).length) saved.defaults = defaults; else delete saved.defaults;
|
|
await saveGptSovitsSettings(character, saved);
|
|
state.settings.set(character, saved);
|
|
state.saved.set(character, { ...opts, preset: AUTO_MODE });
|
|
// What was changed for it here is its usual now, unless it still has to go over the whole script's.
|
|
for (const field of ['language', 'speed']) if (state.script[field] === undefined) delete castOf(group)[field];
|
|
state.savedNow = character;
|
|
setTimeout(() => { state.savedNow = null; render(); }, 1600);
|
|
} catch (error) {
|
|
await anomalousAlert(t('scriptRunDefaultsFailed', { error: error.message }));
|
|
} finally {
|
|
state.saving = false;
|
|
render();
|
|
}
|
|
}
|
|
|
|
function saveButton(group) {
|
|
const label = state.savedNow === group.character ? t('scriptRunDefaultsSaved') : t('scriptRunSaveDefaults');
|
|
const btn = button('anomalous-sd-link-btn', label, () => storeDefaults(group), t('scriptRunSaveDefaultsHint'));
|
|
btn.disabled = state.saving;
|
|
return btn;
|
|
}
|
|
|
|
/** The side panel's view of the moment (ui_script_tune.js). */
|
|
function renderSettings(script, busy) {
|
|
const changed = () => {
|
|
onLinesChange?.();
|
|
render();
|
|
};
|
|
renderTunePanel(settings, {
|
|
script,
|
|
view: state.presets,
|
|
spec: state.spec,
|
|
busy,
|
|
local: state.local,
|
|
canTag: canTag(),
|
|
presetNote: state.presetNote,
|
|
preview,
|
|
redraw: render,
|
|
options: currentOptions,
|
|
lineValues: valuesOfLine,
|
|
layers: {
|
|
script: () => scriptLayer(state.script, base()),
|
|
character: layerOf,
|
|
line: line => lineLayer(line, layerOf(script.speakerOf(line))),
|
|
},
|
|
changedFor: {
|
|
script: () => TUNE_FIELDS.some(field => state.script[field] !== undefined),
|
|
character: group => TUNE_FIELDS.some(field => castOf(group)[field] !== undefined),
|
|
},
|
|
setScript: (field, value) => {
|
|
setOwn(state.script, field, value, base()[field]);
|
|
state.presetNote = '';
|
|
changed();
|
|
},
|
|
setCharacter: (group, field, value) => {
|
|
setOwn(castOf(group), field, value, state.script[field] ?? storedOptions(group)[field]);
|
|
state.presetNote = '';
|
|
changed();
|
|
},
|
|
setLines: (indexes, field, value) => {
|
|
setLineTune(indexes.map(index => script.lines[index]), field, value, line => currentOptions(script.speakerOf(line))[field]);
|
|
state.presetNote = '';
|
|
changed();
|
|
},
|
|
clearScript: () => {
|
|
state.script = {};
|
|
changed();
|
|
},
|
|
clearCharacter: (group) => {
|
|
state.cast.delete(group.character);
|
|
changed();
|
|
},
|
|
clearLines: (indexes) => {
|
|
clearLineTune(indexes.map(index => script.lines[index]));
|
|
changed();
|
|
},
|
|
modeNumbers,
|
|
autoLanguage: (group, values) => {
|
|
const key = autoKey(group, values);
|
|
return key === 'all' ? t('ttsTuningAll') : t(`scriptRunLang_${key}`);
|
|
},
|
|
takeFor,
|
|
editPreset,
|
|
canSave: group => state.savedNow === group.character || !sameOptions(currentOptions(group), storedOptions(group)),
|
|
saveButton,
|
|
openPresets: () => openTtsSetup({ groups: getGroups?.() || [], showPresets: true, onChanged: reloadPresets }),
|
|
openTuning: group => openTuningEditor(group, {
|
|
onSaved: (saved) => {
|
|
if (saved?.raw?.settings) state.settings.set(group.character, saved.raw.settings);
|
|
render();
|
|
},
|
|
}),
|
|
});
|
|
}
|
|
|
|
function renderStatus() {
|
|
const s = state.status;
|
|
let text = '';
|
|
if (s?.state === 'queued') text = t('scriptRunQueued');
|
|
else if (s?.state === 'running') text = s.max ? t('scriptRunProgress', { value: s.value, max: s.max }) : t('scriptRunRunning');
|
|
else if (s?.state === 'done') text = t('scriptRunDone', { seconds: s.seconds });
|
|
else if (s?.state === 'cancelled') text = t('scriptRunCancelled');
|
|
status.textContent = text;
|
|
status.hidden = !text;
|
|
}
|
|
|
|
function render() {
|
|
const script = getScript();
|
|
if (state.supported === null) {
|
|
state.supported = false;
|
|
checkSupport();
|
|
}
|
|
const { group, pkg } = script;
|
|
const busy = Boolean(state.job);
|
|
const ready = Boolean(state.supported && group);
|
|
hint.hidden = Boolean(state.supported);
|
|
generateBtn.title = t('scriptRunHint', { character: group?.character || '' });
|
|
|
|
settings.hidden = actions.hidden = !ready;
|
|
if (ready) renderSettings(script, busy);
|
|
|
|
generateBtn.disabled = !ready || Boolean(pkg.error) || busy;
|
|
const hasResult = Boolean(state.result && group && state.result.character === group.character);
|
|
newTakeBtn.hidden = !hasResult;
|
|
newTakeBtn.disabled = generateBtn.disabled;
|
|
cancelBtn.hidden = !busy;
|
|
|
|
renderStatus();
|
|
player.hidden = !hasResult;
|
|
if (hasResult && player.getAttribute('src') !== state.result.url) player.src = state.result.url;
|
|
savedTo.hidden = !hasResult;
|
|
if (hasResult) {
|
|
const folder = String(state.result.audio.subfolder || '').replace(/\\/g, '/');
|
|
savedTo.title = `output/${folder}/${state.result.audio.filename}`;
|
|
}
|
|
warnings.hidden = !hasResult || !state.result.warnings.length;
|
|
if (hasResult) warnings.textContent = state.result.warnings.join('\n');
|
|
}
|
|
|
|
return {
|
|
settings,
|
|
bar,
|
|
render,
|
|
generate,
|
|
tagsOf,
|
|
/** A card's ▶: its line (the director set it as the one to listen with) as it will be read. */
|
|
listen() {
|
|
const script = getScript();
|
|
const line = script?.lines?.[script.listen];
|
|
if (!line || !this.canListen()) return;
|
|
preview.play(takeFor(line));
|
|
},
|
|
/** Whether the cards show ▶ (the node has modes, a character is chosen, nothing runs). */
|
|
canListen() {
|
|
return Boolean(state.presets && state.supported && !state.job && getScript()?.group);
|
|
},
|
|
/** A line can be retaken once this character has a result and nothing is running. */
|
|
canRetake(group) {
|
|
return Boolean(state.supported && !state.job && state.result && group && state.result.character === group.character);
|
|
},
|
|
/** The presets, for the tags on the cards (ui_script_tune.js ownTuneText). */
|
|
presetsView: () => state.presets,
|
|
/** The whole script's and the characters' values changed here, for the director's draft. */
|
|
draft: () => ({ script: { ...state.script }, cast: Object.fromEntries([...state.cast].filter(([, own]) => Object.keys(own).length)) }),
|
|
/** Another script opened (ui_script_drafts.js): its values, and nothing generated yet. */
|
|
restoreDraft({ script = {}, cast = {} } = {}) {
|
|
state.script = { ...script };
|
|
state.cast = new Map(Object.entries(cast).map(([character, own]) => [character, { ...own }]));
|
|
player.pause();
|
|
preview.stop();
|
|
state.result = null;
|
|
state.status = null;
|
|
},
|
|
/** A line is being generated (another script waits until it is done). */
|
|
busy: () => Boolean(state.job),
|
|
stopPlayback() {
|
|
player.pause();
|
|
preview.stop();
|
|
},
|
|
};
|
|
}
|