Files
DemonGatanjieuandClaude Opus 5.5 31b3925805 Combos: the browser no longer opens blank after Put on canvas; Voice-over says when ▶ reads a sample
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>
2026-10-20 13:53:35 +08:00

373 lines
20 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}