Put on canvas hid the combo page before closing the browser, so the next open showed the Combos title over nothing; now only the browser closes and it opens again on the list or the combo. A character with no line in the script hears a sample sentence on ▶, which the panel now says; on the whole script ▶ reads the line with its own character. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
373 lines
20 KiB
JavaScript
373 lines
20 KiB
JavaScript
import { t } from './interface_settings.js';
|
||
import { button, el } from './usage_view.js';
|
||
import { isBuiltin, presetLabel } from './tts_presets_api.js';
|
||
import { AUTO_MODE, hasOwnTune, lineNumbers, ownTune, sharedValue } from './script_tune.js';
|
||
import { speakerColor } from './ui_script_cast.js';
|
||
import { openSpeakerMenu } from './ui_script_cards.js';
|
||
import { renderPresetSwitch } from './ui_tts_preset_switch.js';
|
||
import { remember, remembered } from './ui_tab_strip.js';
|
||
|
||
/**
|
||
* The Script Director's side panel, in three layers for this script, each read the same way:
|
||
* the whole script (what everyone reads with), a character in the cast (only its lines), the
|
||
* picked lines (only them). On top a path, Whole script › character › line, says which layer is
|
||
* being changed and leads back up. Each layer has the same three rows, speed, tone and language,
|
||
* and each row says where its value comes from (this line, the character, the whole script, the
|
||
* character's usual settings, the default; a click there goes to that layer) with ↺ when this
|
||
* layer set it. Setting a value to what it would get anyway lets it follow again. Settings that
|
||
* last beyond this script (the tones, a character's own settings) are links at the foot.
|
||
* ui_script_run.js keeps the state and draws this again after each change (`ctx`, see
|
||
* renderTunePanel); the lines are the director's.
|
||
*/
|
||
|
||
export const LANGUAGES = ['auto', 'zh', 'ja', 'en'];
|
||
const ALL_WHO_UP_TO = 6; // more characters than this: "Who reads" lists the script's own, the rest in a menu
|
||
const NUMBERS_KEY = 'anomalous_sd_tone_numbers';
|
||
|
||
/** "1.2×" */
|
||
export const speedText = speed => `${Math.round(Number(speed) * 100) / 100}×`;
|
||
|
||
/** A tone's name: the usual one, a built-in preset or one made in Anomalous. */
|
||
export function modeName(view, id) {
|
||
return id === AUTO_MODE || !view ? t('scriptRunModeAuto') : presetLabel(view.labels, id, t);
|
||
}
|
||
|
||
/** "中文 · 1.2× · 生动": a set of values in a few words. */
|
||
export function valuesText(view, values) {
|
||
return [t(`scriptRunLang_${values.language || 'auto'}`), speedText(values.speed), modeName(view, values.preset ?? AUTO_MODE)].join(' · ');
|
||
}
|
||
|
||
/** What a line set for itself, for the tag on its card; '' when it follows its character. */
|
||
export function ownTuneText(view, line) {
|
||
if (!hasOwnTune(line)) return '';
|
||
const own = ownTune(line);
|
||
const parts = [];
|
||
if (own.language !== undefined) parts.push(t(`scriptRunLang_${own.language}`));
|
||
if (own.speed !== undefined) parts.push(speedText(own.speed));
|
||
if (own.preset !== undefined) parts.push(modeName(view, own.preset));
|
||
return parts.join(' · ');
|
||
}
|
||
|
||
/** Choices in a segment; `checked(id)`; a click on the checked one does nothing. */
|
||
function segment(choices, checked, onPick, disabled) {
|
||
const box = el('div', 'anomalous-sd-run-segment');
|
||
box.setAttribute('role', 'radiogroup');
|
||
for (const choice of choices) {
|
||
const chip = button(`anomalous-sd-run-choice${choice.className ? ` ${choice.className}` : ''}`, choice.label, () => {
|
||
if (!checked(choice.id)) onPick(choice.id);
|
||
});
|
||
chip.setAttribute('role', 'radio');
|
||
chip.setAttribute('aria-checked', String(checked(choice.id)));
|
||
chip.disabled = disabled;
|
||
if (choice.title) chip.title = choice.title;
|
||
if (choice.color) {
|
||
const dot = el('span', 'anomalous-sd-speaker-dot');
|
||
dot.style.background = choice.color;
|
||
chip.prepend(dot);
|
||
chip.style.setProperty('--speaker', choice.color);
|
||
}
|
||
box.append(chip);
|
||
}
|
||
return box;
|
||
}
|
||
|
||
/** The path on top: Whole script › character › lines; `steps`: `[{ label, go?, current? }]`. */
|
||
function path(steps) {
|
||
const box = el('nav', 'anomalous-sd-tune-path');
|
||
steps.forEach((step, at) => {
|
||
if (at) box.append(el('span', 'anomalous-sd-tune-path-sep', '›'));
|
||
// The first step is the way back up: the browser's Esc presses it (ui_browser_navigation.js).
|
||
const item = step.go && !step.current
|
||
? button(`anomalous-sd-tune-path-step${at === 0 ? ' anomalous-sd-tune-back' : ''}`, step.label, step.go)
|
||
: el('span', `anomalous-sd-tune-path-step${step.current ? ' is-current' : ''}`, step.label);
|
||
if (step.color) item.style.setProperty('--speaker', step.color);
|
||
box.append(item);
|
||
});
|
||
return box;
|
||
}
|
||
|
||
/**
|
||
* Where a value comes from, as a chip in the layer's colour: `from` ('line', 'character',
|
||
* 'script', 'usual', 'default'), `who` the character's name; a click goes to that layer.
|
||
*/
|
||
function sourceChip(ctx, from, who, go, mixed = false) {
|
||
if (mixed) return el('span', 'anomalous-sd-tune-from is-mixed', t('scriptTuneFromMixed'));
|
||
const label = {
|
||
line: t('scriptTuneFromLine'),
|
||
character: who,
|
||
script: t('scriptTuneScript'),
|
||
usual: t('scriptTuneFromUsual', { name: who }),
|
||
default: t('scriptTuneFromDefault'),
|
||
}[from];
|
||
const target = go?.[from];
|
||
const chip = target ? button(`anomalous-sd-tune-from is-${from}`, label, target) : el('span', `anomalous-sd-tune-from is-${from}`, label);
|
||
chip.title = t(`scriptTuneFromHint_${from}`, { name: who });
|
||
return chip;
|
||
}
|
||
|
||
/** One row: its name, the control, where the value comes from, and ↺ when this layer set it (`reset`). */
|
||
function row(label, control, chip, reset) {
|
||
const line = el('div', 'anomalous-sd-tune-row');
|
||
const end = el('span', 'anomalous-sd-tune-row-end');
|
||
end.append(chip);
|
||
if (reset) end.append(button('anomalous-sd-icon-btn anomalous-sd-tune-reset', '↺', reset, t('scriptTuneReset')));
|
||
line.append(el('span', 'anomalous-sd-run-label', label), control, end);
|
||
return line;
|
||
}
|
||
|
||
/** The speed slider; `value` null when the lines differ. */
|
||
function speedSlider(ctx, value, onChange) {
|
||
const range = ctx.spec.speed;
|
||
const box = el('span', 'anomalous-sd-tune-speed');
|
||
const slider = el('input', 'anomalous-sd-run-slider');
|
||
Object.assign(slider, { type: 'range', min: String(range.min), max: String(range.max), step: String(range.step ?? 1) });
|
||
slider.value = String(value ?? range.default);
|
||
slider.title = t('scriptRunParamHint_speed');
|
||
slider.disabled = ctx.busy;
|
||
const shown = el('span', 'anomalous-sd-run-value', value === null ? '–' : speedText(value));
|
||
slider.oninput = () => { shown.textContent = speedText(slider.value); };
|
||
slider.onchange = () => {
|
||
const number = Math.min(range.max, Math.max(range.min, Number(slider.value)));
|
||
onChange(Number.isFinite(number) ? number : range.default);
|
||
};
|
||
box.append(slider, shown);
|
||
return box;
|
||
}
|
||
|
||
const toneChoices = view => [{ id: AUTO_MODE, label: t('scriptRunModeAuto'), title: t('scriptTuneUsualHint') },
|
||
...Object.keys(view?.presets || {}).map(name => ({ id: name, label: presetLabel(view.labels, name, t), badge: isBuiltin(name) ? '' : t('ttsPresetCustom') }))];
|
||
|
||
const presetAbout = id => (isBuiltin(id) ? t(`ttsPresetDesc_${id}`) : t('ttsPresetDescCustom'));
|
||
|
||
/**
|
||
* The three rows for one layer. `value(field)` → `{ value, from, mixed }`, `set(field, value)`
|
||
* (undefined: follow the layer below), `own(field)`: this layer set it; `who`: the character the
|
||
* sources name; `go`: where a source chip leads; `speaker` / `line`: who is heard on ▶ and which
|
||
* line (null: a sample); `values`: the values heard with (null: the line's own).
|
||
*/
|
||
function layerRows(ctx, { value, set, own, who, go, speaker, line, values = null }) {
|
||
const reset = field => (own(field) ? () => set(field, undefined) : null);
|
||
const chip = field => sourceChip(ctx, value(field).from, who, go, value(field).mixed);
|
||
const rows = [];
|
||
|
||
const speed = value('speed');
|
||
rows.push(row(t('scriptRunSpeed'), speedSlider(ctx, speed.mixed ? null : speed.value, number => set('speed', number)), chip('speed'), reset('speed')));
|
||
|
||
if (ctx.view) {
|
||
const tone = value('preset');
|
||
const detail = remembered(NUMBERS_KEY, ['simple', 'numbers'], 'simple') === 'numbers';
|
||
const lang = ctx.autoLanguage(speaker, values || ctx.lineValues(line) || {});
|
||
const take = id => ctx.takeFor(line, id, speaker, values);
|
||
const switcher = renderPresetSwitch({
|
||
choices: toneChoices(ctx.view),
|
||
selected: tone.mixed ? null : tone.value,
|
||
base: AUTO_MODE,
|
||
baseName: t('scriptRunModeAutoBase'),
|
||
baseText: t('scriptRunModeIsAuto'),
|
||
values: id => ctx.modeNumbers(speaker, values || ctx.lineValues(line), id ?? AUTO_MODE),
|
||
describe: id => (id === AUTO_MODE ? t('scriptRunModeAutoShort', { lang }) : presetAbout(id)),
|
||
builtin: id => (isBuiltin(id) ? ctx.view.builtin[id] : null),
|
||
editable: id => id !== AUTO_MODE && ctx.local,
|
||
onPick: id => set('preset', id),
|
||
onEdit: ctx.editPreset,
|
||
detail,
|
||
disabled: ctx.busy,
|
||
});
|
||
rows.push(row(t('scriptRunMode'), switcher, chip('preset'), reset('preset')));
|
||
const under = el('div', 'anomalous-sd-tune-under');
|
||
const numbers = button(`anomalous-sd-run-choice anomalous-sd-tune-numbers${detail ? ' is-on' : ''}`, t('scriptTuneNumbers'), () => {
|
||
remember(NUMBERS_KEY, detail ? 'simple' : 'numbers');
|
||
ctx.redraw();
|
||
});
|
||
numbers.setAttribute('aria-pressed', String(detail));
|
||
// Without a line of its own in the script (a character with none yet), ▶ reads a sample
|
||
// sentence, and says so: it is not a line of the script.
|
||
const lead = line ? [] : [el('span', 'anomalous-sd-tune-sample', t('scriptTuneListenSampleNote', { name: speaker?.character || '' }))];
|
||
under.append(ctx.preview.row({
|
||
takeFor: take, selected: tone.mixed ? AUTO_MODE : tone.value, modes: toneChoices(ctx.view),
|
||
onPick: id => set('preset', id), disabled: ctx.busy, label: line ? 'scriptTuneListen' : 'scriptTuneListenSample', lead,
|
||
}), numbers);
|
||
rows.push(under);
|
||
if (detail && ctx.presetNote) rows.push(el('div', 'anomalous-sd-run-note', ctx.presetNote));
|
||
}
|
||
|
||
const language = value('language');
|
||
rows.push(row(t('scriptRunLanguage'),
|
||
segment(LANGUAGES.map(code => ({ id: code, label: t(`scriptRunLang_${code}`) })), code => !language.mixed && language.value === code, code => set('language', code), ctx.busy),
|
||
chip('language'), reset('language')));
|
||
return rows;
|
||
}
|
||
|
||
/** The foot: the settings that last beyond this script. */
|
||
function lasting(ctx, speaker) {
|
||
const box = el('div', 'anomalous-sd-tune-lasting');
|
||
box.append(el('span', '', t('scriptTuneLasting')),
|
||
button('anomalous-sd-link-btn', t('scriptRunOpenPresets'), ctx.openPresets));
|
||
if (speaker) box.append(button('anomalous-sd-link-btn', t('scriptRunOpenTuning', { name: speaker.character }), () => ctx.openTuning(speaker)));
|
||
return box;
|
||
}
|
||
|
||
function resetLink(label, onClick) {
|
||
const box = el('div', 'anomalous-sd-run-links');
|
||
box.append(button('anomalous-sd-link-btn', label, onClick));
|
||
return box;
|
||
}
|
||
|
||
/** The whole script: what everyone reads with, then each character in it (a click opens it). */
|
||
function renderScriptView(ctx) {
|
||
const { script } = ctx;
|
||
const layer = ctx.layers.script();
|
||
const spoken = script.lines.filter(line => line.text.trim());
|
||
const cast = script.groups.filter(group => spoken.some(line => script.speakerOf(line) === group));
|
||
const first = cast[0] || script.group;
|
||
const parts = [
|
||
path([{ label: t('scriptTuneScript'), current: true }]),
|
||
el('div', 'anomalous-sd-tune-meta', t('scriptTuneScriptMeta', { count: spoken.length, speakers: cast.length })),
|
||
...layerRows(ctx, {
|
||
value: field => ({ value: layer.values[field], from: layer.from[field] }),
|
||
set: ctx.setScript,
|
||
own: field => layer.from[field] === 'script',
|
||
who: '',
|
||
go: {},
|
||
// The line heard is read by its own character, with the whole script's values.
|
||
speaker: script.lines[script.listen] ? script.speakerOf(script.lines[script.listen]) || first : first,
|
||
line: script.lines[script.listen] || null,
|
||
values: layer.values,
|
||
}),
|
||
];
|
||
if (ctx.changedFor.script()) parts.push(resetLink(t('scriptTuneResetScript'), ctx.clearScript));
|
||
const list = el('div', 'anomalous-sd-tune-cast');
|
||
for (const group of cast) {
|
||
const lines = spoken.filter(line => script.speakerOf(line) === group);
|
||
const own = lines.filter(hasOwnTune).length;
|
||
const item = button('anomalous-sd-tune-person', '', () => script.focusCharacter(group.group));
|
||
item.style.setProperty('--speaker', speakerColor(group.group, script.groups));
|
||
const name = el('span', 'anomalous-sd-tune-person-name');
|
||
name.append(el('span', 'anomalous-sd-speaker-dot'), el('span', '', group.character));
|
||
name.firstChild.style.background = speakerColor(group.group, script.groups);
|
||
const about = el('span', 'anomalous-sd-tune-person-about', valuesText(ctx.view, ctx.options(group)));
|
||
const count = el('span', 'anomalous-sd-tune-person-count', t('scriptTuneLineCount', { count: lines.length }));
|
||
item.append(name, count, about);
|
||
const changed = [ctx.changedFor.character(group) ? t('scriptTuneChangedHere') : '', own ? t('scriptTuneOwnCount', { count: own }) : ''].filter(Boolean);
|
||
if (changed.length) item.append(el('span', 'anomalous-sd-tune-person-own', changed.join(' · ')));
|
||
list.append(item);
|
||
}
|
||
parts.push(el('div', 'anomalous-sd-tune-section', t('scriptTuneCast')), list, lasting(ctx, null));
|
||
return parts;
|
||
}
|
||
|
||
/** One character in this script: its three rows for every line of it that follows. */
|
||
function renderCharacterView(ctx, group) {
|
||
const { script } = ctx;
|
||
const layer = ctx.layers.character(group);
|
||
const indexes = script.lines.map((line, index) => [line, index]).filter(([line]) => line.text.trim() && script.speakerOf(line) === group).map(([, index]) => index);
|
||
const listened = script.lines[script.listen];
|
||
const line = listened && script.speakerOf(listened) === group ? listened : (script.lines[indexes[0]] || null);
|
||
const parts = [
|
||
path([{ label: t('scriptTuneScript'), go: script.focusScript }, { label: group.character, current: true, color: speakerColor(group.group, script.groups) }]),
|
||
el('div', 'anomalous-sd-tune-meta', indexes.length ? t('scriptTuneCharacterMeta', { lines: lineNumbers(indexes), count: indexes.length }) : t('scriptTuneCharacterNone')),
|
||
...layerRows(ctx, {
|
||
value: field => ({ value: layer.values[field], from: layer.from[field] }),
|
||
set: (field, value) => ctx.setCharacter(group, field, value),
|
||
own: field => layer.from[field] === 'character',
|
||
who: group.character,
|
||
go: { script: script.focusScript },
|
||
speaker: group,
|
||
line,
|
||
values: layer.values,
|
||
}),
|
||
];
|
||
const own = indexes.filter(index => hasOwnTune(script.lines[index]));
|
||
const links = el('div', 'anomalous-sd-run-links');
|
||
if (ctx.changedFor.character(group)) links.append(button('anomalous-sd-link-btn', t('scriptTuneResetCharacter'), () => ctx.clearCharacter(group)));
|
||
if (own.length) links.append(button('anomalous-sd-link-btn', t('scriptTuneResetLines', { count: own.length }), () => ctx.clearLines(own)));
|
||
if (ctx.canSave(group)) links.append(ctx.saveButton(group));
|
||
if (links.childNodes.length) parts.push(links);
|
||
parts.push(lasting(ctx, group));
|
||
return parts;
|
||
}
|
||
|
||
/**
|
||
* Who reads the picked lines: every character when there are a few; with many, the ones reading
|
||
* in this script (and the picked lines' own), the others in a menu with a search box.
|
||
*/
|
||
function whoReads(ctx, indexes, speakers) {
|
||
const { script } = ctx;
|
||
const reading = new Set([...script.lines.filter(line => line.text.trim()).map(line => script.speakerOf(line)), ...speakers]);
|
||
const many = script.groups.length > ALL_WHO_UP_TO;
|
||
const listed = many ? script.groups.filter(group => reading.has(group)) : script.groups;
|
||
const others = many ? script.groups.filter(group => !reading.has(group)) : [];
|
||
const assign = key => script.assign(indexes, key);
|
||
const box = segment(listed.map(group => ({
|
||
id: group.group, label: group.character, className: 'anomalous-sd-tune-who', color: speakerColor(group.group, script.groups),
|
||
})), key => speakers.length === 1 && speakers[0].group === key, assign, ctx.busy);
|
||
if (others.length) {
|
||
const more = button('anomalous-sd-run-choice anomalous-sd-tune-more', t('scriptTuneOtherCharacters', { count: others.length }),
|
||
() => openSpeakerMenu(more, script.groups, null, assign, others));
|
||
more.disabled = ctx.busy;
|
||
box.append(more);
|
||
}
|
||
return box;
|
||
}
|
||
|
||
/** Picked lines: who reads them, then their three rows, each following the character until set here. */
|
||
function renderLinesView(ctx, indexes) {
|
||
const { script } = ctx;
|
||
const lines = indexes.map(index => script.lines[index]).filter(Boolean);
|
||
const speakers = [...new Set(lines.map(line => script.speakerOf(line)).filter(Boolean))];
|
||
const single = speakers.length === 1 ? speakers[0] : null;
|
||
const title = lines.length === 1 ? t('scriptTuneLine', { n: indexes[0] + 1 }) : t('scriptTuneLines', { count: lines.length });
|
||
const layers = lines.map(line => ctx.layers.line(line));
|
||
const heard = lines.includes(script.lines[script.listen]) ? script.lines[script.listen] : lines[0];
|
||
const value = (field) => {
|
||
const shared = sharedValue(lines, field, line => ctx.lineValues(line));
|
||
const froms = new Set(layers.map(layer => layer.from[field]));
|
||
return { value: shared.value, mixed: shared.mixed || froms.size > 1, from: [...froms][0] };
|
||
};
|
||
const steps = [{ label: t('scriptTuneScript'), go: script.focusScript }];
|
||
if (single) steps.push({ label: single.character, go: () => script.focusCharacter(single.group), color: speakerColor(single.group, script.groups) });
|
||
steps.push({ label: title, current: true });
|
||
const parts = [path(steps)];
|
||
if (script.groups.length > 1) {
|
||
const who = el('div', 'anomalous-sd-tune-row');
|
||
who.append(el('span', 'anomalous-sd-run-label', t('scriptTuneWho')), whoReads(ctx, indexes, speakers));
|
||
parts.push(who);
|
||
}
|
||
parts.push(...layerRows(ctx, {
|
||
value,
|
||
set: (field, next) => ctx.setLines(indexes, field, next),
|
||
own: field => lines.some(line => ownTune(line)[field] !== undefined),
|
||
who: single?.character || '',
|
||
go: single ? { character: () => script.focusCharacter(single.group), usual: () => script.focusCharacter(single.group), script: script.focusScript } : { script: script.focusScript },
|
||
speaker: script.speakerOf(heard) || single,
|
||
line: heard,
|
||
}));
|
||
if (lines.some(hasOwnTune)) parts.push(resetLink(t('scriptTuneResetAll'), () => ctx.clearLines(indexes)));
|
||
parts.push(lasting(ctx, single));
|
||
return parts;
|
||
}
|
||
|
||
/**
|
||
* Draws the panel into `box`: the picked lines' layer, else the character's shown, else the whole
|
||
* script's. `ctx`: `script` (the director's: lines, groups, group, speakerOf, listen, picked
|
||
* indexes, focus `{ character }` or null, assign / focusCharacter / focusScript), `view` (the
|
||
* presets or null), `spec`, `busy`, `local`, `canTag` (the node reads a line's own values),
|
||
* `presetNote`, `preview`, `redraw()`, and the run section's calls: `layers` (`script()`,
|
||
* `character(group)`, `line(line)`, each `{ values, from }`), `changedFor` (`script()`,
|
||
* `character(group)`), `options(group)`, `lineValues(line)`, `setScript(field, value)`,
|
||
* `setCharacter(group, field, value)`, `setLines(indexes, field, value)`, `clearScript()`,
|
||
* `clearCharacter(group)`, `clearLines(indexes)`, `modeNumbers(group, values, id)`,
|
||
* `autoLanguage(group, values)`, `takeFor(line, mode, speaker, values)`, `editPreset`,
|
||
* `canSave(group)`, `saveButton(group)`, `openPresets()`, `openTuning(group)`.
|
||
*/
|
||
export function renderTunePanel(box, ctx) {
|
||
const { script } = ctx;
|
||
const group = script.focus?.character ? script.groups.find(item => item.group === script.focus.character) : null;
|
||
let parts;
|
||
if (script.picked.length) parts = renderLinesView(ctx, script.picked);
|
||
else if (group) parts = renderCharacterView(ctx, group);
|
||
else parts = renderScriptView(ctx);
|
||
if (!ctx.canTag && (script.picked.length || group)) parts.splice(1, 0, el('div', 'anomalous-sd-run-hint is-warning', t('scriptTuneOldTts')));
|
||
box.replaceChildren(...parts);
|
||
}
|