The side panel changes one layer at a time under a path (Whole script › character › line); each layer has the same rows (speed, tone, language), each saying where its value comes from, with ↺ to follow the layer above. The more particular wins: line, character, whole script, the character's usual settings. The whole script's values go into the node's inputs and only lines that differ get tags. Lasting settings are links at the foot. The script is kept as a draft in this browser and comes back after a reload; editing the text keeps unchanged lines' own settings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
369 lines
20 KiB
JavaScript
369 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));
|
||
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: 'scriptTuneListen',
|
||
}), 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: {},
|
||
speaker: 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);
|
||
}
|