Voice-over: several characters in one script
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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
998f232307
commit
89fa1a93bf
+13
-7
@@ -363,12 +363,16 @@ covered by the `styles.css` manifest.
|
||||
(`buildScriptPackage`, with `[take:N]` for retakes), combo value
|
||||
matching, and `buildTtsPrompt` (a GPT-SoVITS script as a ComfyUI API prompt
|
||||
saving to `output/audio/<character>/`).
|
||||
`ui_script_director.js` owns the script director, the Voice-over view's body: one
|
||||
character, an emotion chip row per line card (with the chosen emotion's reference clip;
|
||||
none when the character has only its main voice), editing buttons shown on hover, and a
|
||||
bundle that is generated in the view, or, under "Into your own workflow", pushed to the selected/only target node,
|
||||
dragged onto one (writing the character and script widgets together, one Ctrl+Z
|
||||
step) or copied. The page feeds it the voice groups after each fetch.
|
||||
`ui_script_director.js` owns the script director, the Voice-over view's body: who reads
|
||||
by default, lines given to other characters (`castScriptCharacter`, called when the list
|
||||
on the left is clicked there; `ui_script_cast.js` holds the colours, the speaker chip and
|
||||
picking lines with Ctrl / Shift), an emotion chip row per line card for its character
|
||||
(with the chosen emotion's reference clip; none when the characters have only their main
|
||||
voice), editing buttons shown on hover, and a bundle (`[name]` where the speaker changes)
|
||||
that is generated in the view, or, under "Into your own workflow" (`ui_script_workflow.js`),
|
||||
pushed to the selected/only target node, dragged onto one (writing the character and
|
||||
script widgets together, one Ctrl+Z step) or copied. The page feeds it the voice groups
|
||||
after each fetch.
|
||||
It puts the cards on one side and generating on the other: beside
|
||||
them once the page is wide, below them when it is narrow (docked browser); on a
|
||||
wide page `ui_split_bar.js` is the divider between them (dragged, remembered).
|
||||
@@ -394,7 +398,9 @@ covered by the `styles.css` manifest.
|
||||
targeted `/interrupt`); it never touches the canvas.
|
||||
- In the audio domain the header **!** opens `AUDIO_USAGE_GUIDE` (a how-to, see
|
||||
`docs/guides/audio-studio.md`) instead of the visual update guide.
|
||||
- `ui_audio_sidebar.js` owns the audio navigation and the active audio filter:
|
||||
- `ui_audio_sidebar.js` owns the audio navigation and the active audio filter
|
||||
(on Voice-over it is the cast: narrower, characters with their colours only, a click
|
||||
gives the picked lines to one and never leaves the page):
|
||||
characters grouped by language, groups folded
|
||||
in `localStorage`, a character unfolds into its clips (a click plays the clip
|
||||
through its studio row, switching the studio to that character when needed), a
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# 📈 Anomalous Model Browser Changelog
|
||||
|
||||
## v1.61.0 — unreleased
|
||||
|
||||
### 🎭 Several characters in one script (多角色配音)
|
||||
- **Give lines to other characters**: on Voice-over, pick lines (a click; Ctrl / ⌘ adds one, Shift a run; Esc lets go), then click a character in the list on the left: those lines are read by it. With no line picked, a click there changes who reads by default. Each line card then shows who reads it, in that character's colour, and its emotions are that character's.
|
||||
- **The list on the left is the cast there**: narrower, the characters with their colours only; a click there never takes you back to the Characters page.
|
||||
- Generating, ▶ on a line, pushing, dragging and copying all follow who reads each line: the script carries `[name]` where the speaker changes (Anomalous_TTS reads it already), and in Auto mode each character uses its own generation settings. A file voiced again from the audio gallery comes back with its speakers.
|
||||
- The character cards' **Voice-over** button is gone: the switch at the top of the page and the cast do its job.
|
||||
|
||||
## v1.60.1 — unreleased
|
||||
|
||||
### 🔍 Scanning (扫描)
|
||||
|
||||
@@ -41,7 +41,7 @@ assert.deepEqual(buildScriptPackage([
|
||||
{ text: '早上好', emotion: 'main' },
|
||||
{ text: ' 好开心 ', emotion: 'happy' },
|
||||
{ text: ' ', emotion: 'happy' },
|
||||
], arona), { sample: 'Arona', speech: '{main} 早上好\n{happy} 好开心', lineCount: 2 });
|
||||
], arona), { sample: 'Arona', speech: '{main} 早上好\n{happy} 好开心', lineCount: 2, speakers: ['Arona'] });
|
||||
// All-main scripts stay plain text.
|
||||
assert.equal(buildScriptPackage([{ text: 'a', emotion: 'main' }, { text: 'b' }], arona).speech, 'a\nb');
|
||||
assert.equal(buildScriptPackage([{ text: 'a', emotion: 'main' }], null).error, 'scriptDirectorPickCharacter');
|
||||
@@ -50,6 +50,25 @@ assert.equal(buildScriptPackage([{ text: 'a', emotion: 'main' }], { ...arona, ha
|
||||
assert.equal(buildScriptPackage([{ text: 'a', emotion: 'main' }], { ...arona, slices: [slice('happy')] }).error, 'scriptDirectorMainMissing');
|
||||
assert.equal(buildScriptPackage([{ text: ' ', emotion: 'main' }], arona).error, 'scriptDirectorEmpty');
|
||||
|
||||
// Lines read by other characters: [name] where the speaker changes, back to the node's own by name too.
|
||||
const aris = { group: 'gpt_sovits:Aris', node_value: 'Aris/日配', has_main: true, slices: [slice('main')] };
|
||||
const cast = buildScriptPackage([
|
||||
{ text: '早上好', emotion: 'happy' },
|
||||
{ text: '你好', emotion: 'main', speaker: 'gpt_sovits:Aris' },
|
||||
{ text: '再见', emotion: 'main', speaker: 'gpt_sovits:Aris' },
|
||||
{ text: '嗯', emotion: 'main', speaker: 'gpt_sovits:Arona' },
|
||||
], arona, { groups: [arona, aris] });
|
||||
assert.equal(cast.speech, '{happy} 早上好\n[Aris/日配]{main} 你好\n{main} 再见\n[Arona]{main} 嗯');
|
||||
assert.deepEqual(cast.speakers, ['Arona', 'Aris/日配']);
|
||||
assert.equal(buildScriptPackage([{ text: 'a', speaker: 'gpt_sovits:Aris' }], arona, { groups: [arona, aris] }).speech, '[Aris/日配]a');
|
||||
assert.equal(buildScriptPackage([{ text: 'a', emotion: 'happy', speaker: 'gpt_sovits:Aris' }], arona, { groups: [arona, aris] }).error, 'scriptDirectorVoiceUnusable');
|
||||
assert.equal(buildScriptPackage([{ text: 'a', speaker: 'gpt_sovits:Gone' }], arona, { groups: [arona] }).error, 'scriptDirectorSpeakerUnusable');
|
||||
assert.deepEqual(parseTaggedSpeech(cast.speech), [
|
||||
{ emotion: 'happy', text: '早上好' }, { emotion: 'main', text: '你好', speaker: 'Aris/日配' },
|
||||
{ emotion: 'main', text: '再见', speaker: 'Aris/日配' }, { emotion: 'main', text: '嗯', speaker: 'Arona' },
|
||||
]);
|
||||
assert.deepEqual(parseTaggedSpeech('{main} [take:3]一。[pause:1s]二。'), [{ emotion: 'main', text: '一。[pause:1s]二。' }]);
|
||||
|
||||
// Combo values keep the node's own separator; unknown files are not written.
|
||||
const windowsCombo = { options: { values: ['阿罗娜\\日配', '普拉娜'] } };
|
||||
assert.equal(comboValueForPath(windowsCombo, '阿罗娜/日配'), '阿罗娜\\日配');
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// Run: node tests/script_cast.mjs (Voice-over's cast: picking lines, colours; no ComfyUI needed)
|
||||
import assert from 'node:assert/strict';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import path from 'node:path';
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const { pickLines, speakerColor } = await import(pathToFileURL(path.join(root, 'web/modules/ui_script_cast.js')).href);
|
||||
|
||||
// A click picks one line; Ctrl / ⌘ adds or takes one away; Shift picks the run from the anchor.
|
||||
assert.deepEqual([...pickLines(new Set([1, 2]), 4, {})], [4]);
|
||||
assert.deepEqual([...pickLines(new Set([1]), 3, { ctrlKey: true })].sort(), [1, 3]);
|
||||
assert.deepEqual([...pickLines(new Set([1, 3]), 3, { metaKey: true })], [1]);
|
||||
assert.deepEqual([...pickLines(new Set([5]), 2, { shiftKey: true }, 5)].sort(), [2, 3, 4, 5]);
|
||||
assert.deepEqual([...pickLines(new Set(), 2, { shiftKey: true }, null)], [2]);
|
||||
|
||||
// A character's colour follows its place in the voice list, the same on the list and on the cards.
|
||||
const groups = [{ group: 'a' }, { group: 'b' }, { group: 'c' }];
|
||||
assert.notEqual(speakerColor('a', groups), speakerColor('b', groups));
|
||||
assert.equal(speakerColor('b', groups), speakerColor('b', [...groups]));
|
||||
assert.equal(speakerColor('gone', groups), speakerColor('a', groups));
|
||||
|
||||
console.log('script_cast: all checks passed');
|
||||
+62
-22
@@ -88,7 +88,8 @@ export function joinSegments(first, second) {
|
||||
|
||||
/**
|
||||
* Once any line is tagged, untagged lines get {main} instead of inheriting the previous voice.
|
||||
* A line with `take` > 1 (GPT-SoVITS only) gets `[take:N]` right before its text.
|
||||
* A line with `take` > 1 (GPT-SoVITS only) gets `[take:N]` right before its text; a line's
|
||||
* `speaker` (`[name]`, where another character takes over) goes first.
|
||||
*/
|
||||
export function composeScript(lines) {
|
||||
const anyTagged = lines.some(line => line.voice?.tag);
|
||||
@@ -96,7 +97,7 @@ export function composeScript(lines) {
|
||||
.map(line => {
|
||||
const tag = line.voice?.tag || (anyTagged ? MAIN_TAG : '');
|
||||
const text = line.take > 1 ? `[take:${line.take}]${line.text}` : line.text;
|
||||
return tag ? `${tag} ${text}` : text;
|
||||
return `${line.speaker || ''}${tag ? `${tag} ${text}` : text}`;
|
||||
})
|
||||
.join('\n')
|
||||
.trim();
|
||||
@@ -107,27 +108,42 @@ export function usableEmotions(group) {
|
||||
return (group?.slices || []).map(slice => slice.emotion);
|
||||
}
|
||||
|
||||
const speaks = group => Boolean(group?.node_value) && group.has_main !== false && usableEmotions(group).includes('main');
|
||||
|
||||
/**
|
||||
* Bundle script lines for one TTS node. `lines` are `{ text, emotion }`; the node's
|
||||
* `character` widget gets the group's `node_value` and every emotion must be usable
|
||||
* for that group. `takes` (runs in the director) writes each line's `take` as `[take:N]`.
|
||||
* Returns `{ sample, speech, lineCount }` or `{ error: localeKey }`.
|
||||
* Bundle script lines for one TTS node. `lines` are `{ text, emotion, speaker? }`; the node's
|
||||
* `character` widget gets the group's `node_value`. A line's `speaker` (a voice group key from
|
||||
* `groups`) is read by that character instead: `[name]` goes in where the speaker changes
|
||||
* (Anomalous_TTS's script syntax). Every emotion must be usable for its line's character.
|
||||
* `takes` (runs in the director) writes each line's `take` as `[take:N]`.
|
||||
* Returns `{ sample, speech, lineCount, speakers }` (the characters heard, the node's first)
|
||||
* or `{ error: localeKey }`.
|
||||
*/
|
||||
export function buildScriptPackage(lines, group, { takes = false } = {}) {
|
||||
export function buildScriptPackage(lines, group, { takes = false, groups = [] } = {}) {
|
||||
if (!group) return { error: 'scriptDirectorPickCharacter' };
|
||||
const usable = new Set(usableEmotions(group));
|
||||
const voice = group.node_value;
|
||||
if (!voice || group.has_main === false || !usable.has('main')) return { error: 'scriptDirectorMainMissing' };
|
||||
if (!speaks(group)) return { error: 'scriptDirectorMainMissing' };
|
||||
const byKey = new Map(groups.map(item => [item.group, item]));
|
||||
const voiced = [];
|
||||
const speakers = [group.node_value];
|
||||
let current = group;
|
||||
for (const line of lines || []) {
|
||||
const text = String(line?.text || '').trim();
|
||||
if (!text) continue;
|
||||
const speaker = line.speaker && line.speaker !== group.group ? byKey.get(line.speaker) : group;
|
||||
if (!speaks(speaker)) return { error: 'scriptDirectorSpeakerUnusable' };
|
||||
const emotion = line.emotion || 'main';
|
||||
if (!usable.has(emotion)) return { error: 'scriptDirectorVoiceUnusable' };
|
||||
voiced.push({ text, voice: emotion === 'main' ? null : { tag: `{${emotion}}` }, take: takes ? line.take || 1 : 1 });
|
||||
if (!usableEmotions(speaker).includes(emotion)) return { error: 'scriptDirectorVoiceUnusable' };
|
||||
voiced.push({
|
||||
text,
|
||||
voice: emotion === 'main' ? null : { tag: `{${emotion}}` },
|
||||
take: takes ? line.take || 1 : 1,
|
||||
speaker: speaker === current ? '' : `[${speaker.node_value}]`,
|
||||
});
|
||||
current = speaker;
|
||||
if (!speakers.includes(speaker.node_value)) speakers.push(speaker.node_value);
|
||||
}
|
||||
if (!voiced.length) return { error: 'scriptDirectorEmpty' };
|
||||
return { sample: voice, speech: composeScript(voiced), lineCount: voiced.length };
|
||||
return { sample: group.node_value, speech: composeScript(voiced), lineCount: voiced.length, speakers };
|
||||
}
|
||||
|
||||
/** Save Audio prefix for a character: `output/audio/<character>/…`, keeping file names Windows accepts. */
|
||||
@@ -156,16 +172,40 @@ export function buildTtsPrompt({ character, speech, seed, language = 'auto', pre
|
||||
};
|
||||
}
|
||||
|
||||
const TAKE_TAG = /^\s*(?:take|版本)\s*[::]\s*\d+\s*$/i;
|
||||
const PAUSE_TAG = /^\s*(?:pause|wait|stop|停顿|暂停)\s*[::]/i;
|
||||
|
||||
/**
|
||||
* Read a tagged script back into segments, splitting at `{emotion}` tags.
|
||||
* Text before the first tag belongs to the main voice.
|
||||
* Read a tagged script back into segments, splitting at `{emotion}` and `[name]` tags
|
||||
* (`speaker`: the name, only on segments after one; the emotion goes back to main there).
|
||||
* Text before the first tag belongs to the main voice; `[take:N]` is left out, pauses stay.
|
||||
*/
|
||||
export function parseTaggedSpeech(speech) {
|
||||
return String(speech || '')
|
||||
.split(/(?=\{[^}]+\})/)
|
||||
.map(part => {
|
||||
const match = /^\{([^}]+)\}/.exec(part);
|
||||
return { emotion: match ? match[1].trim() : 'main', text: (match ? part.slice(match[0].length) : part).trim() };
|
||||
})
|
||||
.filter(segment => segment.text);
|
||||
const segments = [];
|
||||
let speaker = null;
|
||||
let emotion = 'main';
|
||||
let text = '';
|
||||
const flush = () => {
|
||||
const words = text.trim();
|
||||
if (words) segments.push({ emotion, text: words, ...(speaker ? { speaker } : {}) });
|
||||
text = '';
|
||||
};
|
||||
for (const part of String(speech || '').split(/(\{[^{}]+\}|\[[^[\]]+\])/)) {
|
||||
const tag = /^\{([^{}]+)\}$/.exec(part);
|
||||
const bracket = /^\[([^[\]]+)\]$/.exec(part);
|
||||
if (tag) {
|
||||
flush();
|
||||
emotion = tag[1].trim();
|
||||
} else if (bracket && TAKE_TAG.test(bracket[1])) {
|
||||
continue; // another take only meant something for that run
|
||||
} else if (bracket && !PAUSE_TAG.test(bracket[1])) {
|
||||
flush();
|
||||
speaker = bracket[1].trim();
|
||||
emotion = 'main';
|
||||
} else {
|
||||
text += part;
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return segments;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { followsSelection, pageForNode } from './shell_open_rules.js';
|
||||
import { renderAudioStudio, stopAudioStudioPlayback } from './ui_audio_studio.js';
|
||||
import { renderAudioGallery, stopGalleryAudio } from './ui_audio_gallery.js';
|
||||
import { renderScriptPage } from './ui_script_page.js';
|
||||
import { castScriptCharacter } from './ui_script_director.js';
|
||||
import { getActiveAudioFilter, setActiveAudioFilter, syncAudioSidebarSelection } from './ui_audio_sidebar.js';
|
||||
import { voiceGroupKey } from './audio_engines.js';
|
||||
import { getActiveDomain } from './ui_domain_switcher.js';
|
||||
@@ -118,8 +119,8 @@ export class AnomalousBrowser {
|
||||
this.pendingScript = null;
|
||||
renderScriptPage(this.scriptPanel, this, pending);
|
||||
} else if (filter?.type === 'group' && this.currentShellPage() === 'script') {
|
||||
// A character picked in the list while on Voice-over: it speaks there.
|
||||
this.openScript(filter.value);
|
||||
// On Voice-over the list is the cast: the character reads the picked lines, or by default.
|
||||
castScriptCharacter(filter.value);
|
||||
return;
|
||||
} else {
|
||||
if (filter) setActiveAudioFilter(filter);
|
||||
|
||||
+14
-6
@@ -2164,7 +2164,7 @@ export const i18n = {
|
||||
audioGuideTagsTitle: "情绪靠台词里的 {标签}",
|
||||
audioGuideTagsBody: "台词里的标签切换情绪:写 {开心} 或 {happy} 就换成这种情绪的参考音频,写 {main} 回到主声音。设了情绪的角色,每行右边有“复制 {标签}”按钮。还支持 [角色名] 换人说、[pause:1s] 插入停顿,直接写在台词里即可。",
|
||||
audioGuideScriptTitle: "配音:不用手写标签",
|
||||
audioGuideScriptBody: "在“角色语音”页顶部切到“配音”(或点角色卡片上的“配音”),左边列表或上方选谁来念,粘贴整段台词,点“分成句子”,每句会变成一张卡片,点一下就能选情绪,也可以拆分、合并、删除。然后点“生成语音”;想放进自己的工作流,下面“放进自己的工作流”里可以推送到节点、拖到节点或复制,角色和台词会一起填好。",
|
||||
audioGuideScriptBody: "在“角色语音”页顶部切到“配音”,上方选默认谁来念,粘贴整段台词,点“分成句子”,每句会变成一张卡片,点一下就能选情绪;卡片也可以拆分、合并、删除。想让某几句换人说:选中这些句子(Ctrl / Shift 可多选),再点左边列表里的角色。点句子上的 ▶ 可以试听,然后点“生成语音”;要用自己的工作流,下面的“放进自己的工作流”可以推送到节点、拖到节点或复制,角色和台词会一起填好。",
|
||||
audioGuideGenerateTitle: "生成与音频库",
|
||||
audioTourImportTitle: "导入角色",
|
||||
audioTourImportBody: "点这里导入 GPT、SoVITS 模型和参考音频;也可以直接把文件或整个文件夹拖到这一页。",
|
||||
@@ -2598,6 +2598,7 @@ export const i18n = {
|
||||
scriptRunModeAutoShort: "每句按生成预设和角色设置各自决定(这里是说{lang}时)。",
|
||||
scriptRunModeWhole: "{about}。整段都用这一档。",
|
||||
scriptRunListenLine: "试听第 {n} 句:{text}",
|
||||
scriptRunListenLineAs: "试听第 {n} 句({who}):{text}",
|
||||
scriptRunListenSample: "试听示例句:{text}",
|
||||
scriptRunListenLineHint: "点左边句子上的 ▶,就用这一档试听那句",
|
||||
scriptRunModeSaved: "已存进“{name}”,所有角色用这一档时都跟着变。",
|
||||
@@ -2629,8 +2630,6 @@ export const i18n = {
|
||||
scriptDirectorListenLine: "用右边选中的模式试听这句",
|
||||
scriptDirectorPreviewEmotion: "听「{emotion}」原本的参考声音",
|
||||
scriptDirectorOnlyMain: "这个角色只有主声音,每句都用它念。在“角色”里给它加情绪后,这里每句都能单独选。",
|
||||
scriptDirectorUseCharacter: "配音",
|
||||
scriptDirectorUseCharacterHint: "用{character}写台词、生成语音",
|
||||
scriptDirectorWorkflowTitle: "放进自己的工作流",
|
||||
scriptDirectorSplitter: "拖动调整左右宽度;双击恢复",
|
||||
scriptPageNoCharacters: "还没有角色。先在“角色”里导入一个,再回来写台词。",
|
||||
@@ -2669,6 +2668,11 @@ export const i18n = {
|
||||
scriptDirectorLinesCount: "{count} 句",
|
||||
scriptDirectorDeleteLine: "删除这句",
|
||||
scriptDirectorPackageSummary: "共 {count} 句 · 用到:{emotions}",
|
||||
scriptDirectorPackageSummaryCast: "共 {count} 句 · 角色:{speakers}",
|
||||
scriptDirectorSpeakerUnusable: "有句子交给了一个现在用不了的角色(删掉了,或没有主声音),请交给别的角色。",
|
||||
scriptCastHint: "选中句子(Ctrl / Shift 可多选),再点左边的角色,就交给 TA 念",
|
||||
scriptCastPicked: "已选 {count} 句:点左边的角色,就交给 TA 念(Esc 取消)",
|
||||
scriptCastListHint: "点角色:把选中的句子交给 TA;没选句子时,换成默认由 TA 念",
|
||||
scriptDirectorCopy: "复制剧本",
|
||||
scriptDirectorCopied: "已复制",
|
||||
scriptDirectorDragHandle: "拖到节点",
|
||||
@@ -4851,7 +4855,7 @@ export const i18n = {
|
||||
audioGuideTagsTitle: "Emotions come from {tags} in the text",
|
||||
audioGuideTagsBody: "Tags in the text switch emotion: {happy} (or {开心}) uses that emotion's reference, {main} goes back to the main voice. Characters with emotions have a “Copy {tag}” button on each row. [Character] changes speaker and [pause:1s] adds a pause, written straight into the text.",
|
||||
audioGuideScriptTitle: "Voice-over: no hand-written tags",
|
||||
audioGuideScriptBody: "On Voices, switch to \"Voice-over\" at the top (or press a character card's \"Voice-over\"), pick who speaks in the list or above, paste the script and press \"Split into lines\". Each sentence becomes a card where you click an emotion; you can split, merge or delete cards. Then press \"Generate\"; to use your own workflow instead, \"Into your own workflow\" below pushes to a node, drags onto one or copies, filling in both the character and the text.",
|
||||
audioGuideScriptBody: "On Voices, switch to \"Voice-over\" at the top, pick who speaks by default above, paste the script and press \"Split into lines\". Each sentence becomes a card where you click an emotion; you can split, merge or delete cards. To have other characters read some lines, pick those lines (Ctrl / Shift for more) and click a character in the list on the left. ▶ on a line lets you listen; then press \"Generate\". To use your own workflow instead, \"Into your own workflow\" below pushes to a node, drags onto one or copies, filling in both the character and the text.",
|
||||
audioGuideGenerateTitle: "Generate and find it in the audio gallery",
|
||||
audioTourImportTitle: "Import characters",
|
||||
audioTourImportBody: "Import the GPT and SoVITS models and reference clips here, or drop files or a whole folder onto this page.",
|
||||
@@ -5285,6 +5289,7 @@ export const i18n = {
|
||||
scriptRunModeAutoShort: "Each line follows the presets and the character's settings (shown: speaking {lang}).",
|
||||
scriptRunModeWhole: "{about}; the whole script uses it.",
|
||||
scriptRunListenLine: "Listening to line {n}: {text}",
|
||||
scriptRunListenLineAs: "Listening to line {n} ({who}): {text}",
|
||||
scriptRunListenSample: "Listening to a sample: {text}",
|
||||
scriptRunListenLineHint: "Click ▶ on a line on the left to hear it in this mode",
|
||||
scriptRunModeSaved: "Saved into \"{name}\", for every character using it.",
|
||||
@@ -5316,8 +5321,6 @@ export const i18n = {
|
||||
scriptDirectorListenLine: "Listen to this line in the mode picked on the right",
|
||||
scriptDirectorPreviewEmotion: "Hear the reference clip of “{emotion}”",
|
||||
scriptDirectorOnlyMain: "This character has only its main voice, so every line uses it. Add emotions under Characters and each line can pick one here.",
|
||||
scriptDirectorUseCharacter: "Voice-over",
|
||||
scriptDirectorUseCharacterHint: "Write lines for {character} and generate them",
|
||||
scriptDirectorWorkflowTitle: "Into your own workflow",
|
||||
scriptDirectorSplitter: "Drag to share the width; double-click to reset",
|
||||
scriptPageNoCharacters: "No characters yet. Import one under Characters, then come back to write lines.",
|
||||
@@ -5356,6 +5359,11 @@ export const i18n = {
|
||||
scriptDirectorLinesCount: "{count} {count|line|lines}",
|
||||
scriptDirectorDeleteLine: "Delete line",
|
||||
scriptDirectorPackageSummary: "{count} {count|line|lines} · using {emotions}",
|
||||
scriptDirectorPackageSummaryCast: "{count} {count|line|lines} · read by {speakers}",
|
||||
scriptDirectorSpeakerUnusable: "A line is given to a character that cannot read now (removed, or without a main voice); give it to another.",
|
||||
scriptCastHint: "Pick lines (Ctrl / Shift for more), then click a character on the left to give them to it",
|
||||
scriptCastPicked: "{count} {count|line|lines} picked: click a character on the left to give them to it (Esc lets go)",
|
||||
scriptCastListHint: "Click a character: it reads the picked lines; with none picked, it reads by default",
|
||||
scriptDirectorCopy: "Copy script",
|
||||
scriptDirectorCopied: "Copied",
|
||||
scriptDirectorDragHandle: "Drag to node",
|
||||
|
||||
@@ -4,13 +4,16 @@ import { bindVoiceDrag } from './audio_voice_drag.js';
|
||||
import { bindTtsFileDrop } from './ui_tts_file_drop.js';
|
||||
import { openTtsImport } from './ui_tts_import.js';
|
||||
import { openTtsSetup, setupAttention } from './ui_tts_setup.js';
|
||||
import { speakerColor } from './ui_script_cast.js';
|
||||
|
||||
/**
|
||||
* Audio sidebar: All Voices, the GPT-SoVITS characters (grouped by language; each
|
||||
* group folds, each character unfolds into its clips) and a settings entry at the
|
||||
* bottom. On Voice-over, picking a character makes it the one who speaks. Owns the active audio filter. Characters drag
|
||||
* onto canvas nodes like the studio cards; files dropped on a character open the
|
||||
* import form for it.
|
||||
* bottom. On Voice-over it is the cast (CSS: narrow, characters with their colours only):
|
||||
* a click gives the picked lines to a character, or makes it the one who reads by default
|
||||
* (ui_script_director.js castScriptCharacter), and never leaves the page. Owns the active audio
|
||||
* filter. Characters drag onto canvas nodes like the studio cards; files dropped on a
|
||||
* character open the import form for it.
|
||||
*/
|
||||
|
||||
let activeFilter = { type: 'all', value: null };
|
||||
@@ -114,8 +117,11 @@ function createNavItem(owner, { label, iconSvg, count, filter, tab }) {
|
||||
if (count !== undefined && count !== null) item.appendChild(el('span', 'anomalous-audio-nav-count', String(count)));
|
||||
|
||||
item.onclick = () => {
|
||||
activeFilter = filter;
|
||||
syncAudioSidebarSelection(owner);
|
||||
// On Voice-over a character click casts it (browser.js); the Characters page's filter stays.
|
||||
if (!(owner.currentShellPage?.() === 'script' && filter.type === 'group')) {
|
||||
activeFilter = filter;
|
||||
syncAudioSidebarSelection(owner);
|
||||
}
|
||||
owner.switchAudioTab?.(tab, filter);
|
||||
};
|
||||
return item;
|
||||
@@ -193,7 +199,7 @@ function renderClipRow(owner, group, slice) {
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderCharacter(owner, group, { canImport, forceOpen }) {
|
||||
function renderCharacter(owner, group, { canImport, forceOpen, color }) {
|
||||
const wrap = el('div', 'anomalous-audio-nav-character');
|
||||
const filter = { type: 'group', value: group.group, character: group.character };
|
||||
const item = createNavItem(owner, { label: group.character, iconSvg: SIDEBAR_SVG.USER, count: group.total_slices, filter, tab: 'presets' });
|
||||
@@ -207,10 +213,13 @@ function renderCharacter(owner, group, { canImport, forceOpen }) {
|
||||
e.stopPropagation();
|
||||
if (expanded.has(group.group)) expanded.delete(group.group);
|
||||
else expanded.add(group.group);
|
||||
wrap.replaceWith(renderCharacter(owner, group, { canImport, forceOpen: false }));
|
||||
wrap.replaceWith(renderCharacter(owner, group, { canImport, forceOpen: false, color }));
|
||||
};
|
||||
toggle.hidden = !group.slices.length;
|
||||
item.prepend(toggle);
|
||||
const dot = el('span', 'anomalous-audio-nav-cast-dot'); // its colour on Voice-over's line cards
|
||||
dot.style.background = color;
|
||||
item.querySelector('.anomalous-audio-nav-label').prepend(dot);
|
||||
|
||||
const problem = problemOf(group);
|
||||
if (problem) {
|
||||
@@ -233,7 +242,7 @@ function renderCharacter(owner, group, { canImport, forceOpen }) {
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function renderBucket(owner, bucket, { collapsed, canImport, term }) {
|
||||
function renderBucket(owner, bucket, { collapsed, canImport, term, colorOf }) {
|
||||
const box = el('div', 'anomalous-audio-nav-bucket');
|
||||
const isCollapsed = !term && collapsed.has(bucket.key);
|
||||
const head = el('button', `anomalous-audio-nav-bucket-head${isCollapsed ? ' is-collapsed' : ''}`);
|
||||
@@ -245,11 +254,11 @@ function renderBucket(owner, bucket, { collapsed, canImport, term }) {
|
||||
if (keys.has(bucket.key)) keys.delete(bucket.key);
|
||||
else keys.add(bucket.key);
|
||||
storeCollapsed(keys);
|
||||
box.replaceWith(renderBucket(owner, bucket, { collapsed: keys, canImport, term }));
|
||||
box.replaceWith(renderBucket(owner, bucket, { collapsed: keys, canImport, term, colorOf }));
|
||||
};
|
||||
box.append(head);
|
||||
if (!isCollapsed) {
|
||||
for (const group of bucket.groups) box.append(renderCharacter(owner, group, { canImport, forceOpen: false }));
|
||||
for (const group of bucket.groups) box.append(renderCharacter(owner, group, { canImport, forceOpen: false, color: colorOf(group.group) }));
|
||||
}
|
||||
return box;
|
||||
}
|
||||
@@ -258,12 +267,13 @@ function renderCharacters(owner, container, groups, { canImport, term }) {
|
||||
const shown = groups.filter(group => matches(group, term));
|
||||
const buckets = bucketsOf(shown);
|
||||
const collapsed = storedCollapsed();
|
||||
const colorOf = key => speakerColor(key, groups);
|
||||
if (!shown.length && term) {
|
||||
container.append(el('div', 'anomalous-audio-nav-empty', t('audioSidebarNoMatch')));
|
||||
} else if (buckets.length > 1) {
|
||||
for (const bucket of buckets) container.append(renderBucket(owner, bucket, { collapsed, canImport, term }));
|
||||
for (const bucket of buckets) container.append(renderBucket(owner, bucket, { collapsed, canImport, term, colorOf }));
|
||||
} else {
|
||||
for (const group of shown) container.append(renderCharacter(owner, group, { canImport, forceOpen: false }));
|
||||
for (const group of shown) container.append(renderCharacter(owner, group, { canImport, forceOpen: false, color: colorOf(group.group) }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,10 +330,10 @@ export async function renderAudioSidebar(owner) {
|
||||
characters.replaceChildren();
|
||||
renderCharacters(owner, characters, groups, { canImport, term: input.value.trim().toLowerCase() });
|
||||
};
|
||||
listContainer.append(allVoices(owner, totalSlices), createSectionLabel(t('audioSectionCharactersOf', { engine: TTS_ENGINE.label })), characters);
|
||||
listContainer.append(castHint(), allVoices(owner, totalSlices), createSectionLabel(t('audioSectionCharactersOf', { engine: TTS_ENGINE.label })), characters);
|
||||
renderCharacters(owner, characters, groups, { canImport, term: '' });
|
||||
} else {
|
||||
listContainer.append(allVoices(owner, totalSlices), createSectionLabel(t('audioSectionCharactersOf', { engine: TTS_ENGINE.label })));
|
||||
listContainer.append(castHint(), allVoices(owner, totalSlices), createSectionLabel(t('audioSectionCharactersOf', { engine: TTS_ENGINE.label })));
|
||||
renderCharacters(owner, listContainer, groups, { canImport, term: '' });
|
||||
}
|
||||
|
||||
@@ -336,8 +346,15 @@ export async function renderAudioSidebar(owner) {
|
||||
}
|
||||
|
||||
function allVoices(owner, count) {
|
||||
return createNavItem(owner, {
|
||||
const item = createNavItem(owner, {
|
||||
label: t('audioAllVoices'), iconSvg: SIDEBAR_SVG.ALL_VOICES, count,
|
||||
filter: { type: 'all', value: null }, tab: 'presets',
|
||||
});
|
||||
item.classList.add('anomalous-audio-nav-all'); // not on Voice-over (CSS): it would leave the page
|
||||
return item;
|
||||
}
|
||||
|
||||
/** What a click on a character does on Voice-over (shown there only, CSS). */
|
||||
function castHint() {
|
||||
return el('div', 'anomalous-audio-nav-cast-hint', t('scriptCastListHint'));
|
||||
}
|
||||
|
||||
@@ -33,7 +33,6 @@ const SVG = {
|
||||
GRIP: `<svg width="10" height="14" viewBox="0 0 10 14" fill="currentColor" aria-hidden="true"><circle cx="3" cy="3" r="1.2"/><circle cx="7" cy="3" r="1.2"/><circle cx="3" cy="7" r="1.2"/><circle cx="7" cy="7" r="1.2"/><circle cx="3" cy="11" r="1.2"/><circle cx="7" cy="11" r="1.2"/></svg>`,
|
||||
CHECK: `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`,
|
||||
SEARCH: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>`,
|
||||
SCRIPT: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20"/></svg>`,
|
||||
PLUS: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>`,
|
||||
TUNE: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h10"/><path d="M18 6h2"/><circle cx="16" cy="6" r="2"/><path d="M4 12h4"/><path d="M12 12h8"/><circle cx="10" cy="12" r="2"/><path d="M4 18h12"/><circle cx="18" cy="18" r="2"/></svg>`,
|
||||
PRONOUNCE: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7V5h10v2"/><path d="M9 5v14"/><path d="M7 19h4"/><path d="M15 13h6"/><path d="M18 10v9"/></svg>`,
|
||||
@@ -246,12 +245,6 @@ function renderCharacterCard(group, owner, { onChanged, canImport = false, tunin
|
||||
titleGroup.append(...(group.has_main ? [gripIcon()] : []), avatar, nameBox);
|
||||
const headerRight = document.createElement('div');
|
||||
headerRight.className = 'anomalous-character-voice-actions';
|
||||
if (group.has_main && owner?.openScript) {
|
||||
const voiceOver = createToolButton(SVG.SCRIPT, t('scriptDirectorUseCharacter'), 'is-compact is-accent');
|
||||
voiceOver.title = t('scriptDirectorUseCharacterHint', { character: group.character });
|
||||
voiceOver.onclick = () => owner.openScript(group.group);
|
||||
headerRight.append(voiceOver);
|
||||
}
|
||||
if (!group.raw.error) {
|
||||
const edit = createToolButton(SVG.EDIT, t('ttsEditorOpen'), 'is-compact');
|
||||
edit.onclick = () => openGptSovitsEditor(group, { onSaved: () => onChanged?.() });
|
||||
|
||||
@@ -77,6 +77,8 @@ export function bindBrowserEscape(owner) {
|
||||
.some(el => el !== owner.modal && el.getClientRects().length
|
||||
&& (el.getAttribute('role') === 'menu' || [...el.classList].some(c => c.startsWith('anomalous-') && c.endsWith('overlay'))));
|
||||
if (layered || document.querySelector('dialog[open], .p-dialog-mask')) return;
|
||||
// Voice-over lets go of its picked lines with this Esc (ui_script_director.js).
|
||||
if (owner.modal.querySelector('.anomalous-sd-line-card.is-picked')) return;
|
||||
// A model's Outputs window lies over its details inside the browser: Esc closes it, as its ✖ does.
|
||||
const outputs = document.getElementById('anomalous-generated-gallery-overlay');
|
||||
if (outputs?.getClientRects().length) {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Voice-over with several characters (the cast): each character's colour, its place in the voice
|
||||
* list, the same on the list (ui_audio_sidebar.js) and on the line cards; the speaker chip of a
|
||||
* line card; and picking lines, a click, Ctrl / ⌘ to add or take one away, Shift for a run, to
|
||||
* give to a character from the list (ui_script_director.js castScriptCharacter).
|
||||
*/
|
||||
|
||||
const COLORS = ['#5b9cf6', '#ef7ea5', '#3fbf91', '#e3a33b', '#a487f5', '#4fc2d4', '#ee8656', '#94c252'];
|
||||
|
||||
/** A character's colour by its place in `groups` (the voice list, the same order everywhere). */
|
||||
export function speakerColor(key, groups) {
|
||||
const index = groups.findIndex(group => group.group === key);
|
||||
return COLORS[Math.max(0, index) % COLORS.length];
|
||||
}
|
||||
|
||||
/** The lines picked after a click on line `index`; `anchor` is the line a Shift run starts from. */
|
||||
export function pickLines(picked, index, { ctrlKey = false, metaKey = false, shiftKey = false } = {}, anchor = null) {
|
||||
if (ctrlKey || metaKey) {
|
||||
const next = new Set(picked);
|
||||
if (next.has(index)) next.delete(index);
|
||||
else next.add(index);
|
||||
return next;
|
||||
}
|
||||
if (shiftKey && anchor !== null) {
|
||||
const next = new Set();
|
||||
for (let at = Math.min(anchor, index); at <= Math.max(anchor, index); at += 1) next.add(at);
|
||||
return next;
|
||||
}
|
||||
return new Set([index]);
|
||||
}
|
||||
|
||||
/** Who reads a line, on its card. */
|
||||
export function speakerChip(group, color) {
|
||||
const chip = document.createElement('span');
|
||||
chip.className = 'anomalous-sd-speaker';
|
||||
chip.textContent = group?.character || '?';
|
||||
chip.style.setProperty('--speaker', color);
|
||||
return chip;
|
||||
}
|
||||
+125
-122
@@ -1,20 +1,18 @@
|
||||
import { app } from '../../../scripts/app.js';
|
||||
import { t } from './interface_settings.js';
|
||||
import { anomalousAlert, anomalousConfirm } from './ui_dialog.js';
|
||||
import { buildScriptPackage, comboValueForPath, joinSegments, splitScriptLines, splitSegmentAt, usableEmotions } from './audio_script.js';
|
||||
import { targetForNode } from './audio_node_targets.js';
|
||||
import { TTS_ENGINE } from './audio_engines.js';
|
||||
import { bindMaterialDrag } from './material_drag.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 { recordCanvasStep } from './canvas_history.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 one character, paste a script, choose an emotion per line on the line cards, then
|
||||
* generate it right here (ui_script_run.js), or put the tagged script into the
|
||||
* workflow: push it to a GPT-SoVITS node, drag it onto one (supported nodes:
|
||||
* audio_node_targets.js) or copy it. The panel and its lines live for the page
|
||||
* 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.
|
||||
*/
|
||||
|
||||
@@ -32,11 +30,13 @@ const ICONS = {
|
||||
const state = {
|
||||
groups: [],
|
||||
groupKey: null,
|
||||
lines: [], // { text, emotion, take } — take > 1: that line was retaken
|
||||
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;
|
||||
@@ -60,14 +60,15 @@ export function scriptCharacter() {
|
||||
return state.groupKey;
|
||||
}
|
||||
|
||||
/** Lines to voice again (a generated file's `{ emotion, text }` segments): one card per line. */
|
||||
/** 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 })))
|
||||
.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();
|
||||
}
|
||||
@@ -98,6 +99,35 @@ export function updateScriptDirectorVoices(groups, preferredGroup = null, { choo
|
||||
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();
|
||||
@@ -121,10 +151,23 @@ 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, pkg: buildScriptPackage(state.lines, group, { takes: true }) };
|
||||
return {
|
||||
group, lines: state.lines, listen: state.listenIndex, speakerOf,
|
||||
pkg: buildScriptPackage(state.lines, group, { takes: true, groups: state.groups }),
|
||||
};
|
||||
}
|
||||
|
||||
function emotionLabel(emotion) {
|
||||
@@ -132,7 +175,7 @@ function emotionLabel(emotion) {
|
||||
}
|
||||
|
||||
function currentPackage() {
|
||||
return buildScriptPackage(state.lines, selectedGroup());
|
||||
return buildScriptPackage(state.lines, selectedGroup(), { groups: state.groups });
|
||||
}
|
||||
|
||||
// ---------- DOM ----------
|
||||
@@ -165,10 +208,9 @@ function createPanel() {
|
||||
const characterBar = el('label', 'anomalous-sd-character-bar');
|
||||
const characterSelect = el('select', 'anomalous-sd-character-select');
|
||||
characterSelect.onchange = () => {
|
||||
state.groupKey = characterSelect.value;
|
||||
stopScriptDirectorPreview();
|
||||
chooseDefault(characterSelect.value);
|
||||
renderAll();
|
||||
hooks.onCharacterChange?.();
|
||||
};
|
||||
const characterHint = el('div', 'anomalous-sd-character-hint');
|
||||
characterBar.append(el('span', 'anomalous-sd-step', t('scriptDirectorCharacter')), characterSelect);
|
||||
@@ -199,7 +241,8 @@ function createPanel() {
|
||||
|
||||
const linesBar = el('div', 'anomalous-sd-lines-bar');
|
||||
const linesCount = el('span', 'anomalous-sd-lines-count');
|
||||
linesBar.append(linesCount, button('anomalous-sd-link-btn', t('scriptDirectorEdit'), () => {
|
||||
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();
|
||||
@@ -217,9 +260,9 @@ function createPanel() {
|
||||
const dragHandle = el('div', 'anomalous-sd-drag-handle');
|
||||
dragHandle.innerHTML = ICONS.GRIP;
|
||||
dragHandle.append(el('span', '', t('scriptDirectorDragHandle')));
|
||||
bindPackageDrag(dragHandle);
|
||||
const copyBtn = button('anomalous-sd-btn', t('scriptDirectorCopy'), copyScript);
|
||||
const pushBtn = button('anomalous-sd-btn', t('scriptDirectorInject'), pushToNode);
|
||||
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');
|
||||
@@ -258,7 +301,14 @@ function createPanel() {
|
||||
new ResizeObserver(([entry]) => panel.classList.toggle('is-wide', entry.contentRect.width >= WIDE_FROM)).observe(panel);
|
||||
|
||||
panel.append(header, who, inputArea, body);
|
||||
refs = { subtitle, characterSelect, characterHint, inputArea, textarea, cancelEdit, body, linesCount, onlyMain, linesContainer, summary, workflowTitle, dragHandle, copyBtn, pushBtn };
|
||||
// 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() {
|
||||
@@ -300,36 +350,51 @@ function renderCharacterBar() {
|
||||
}
|
||||
|
||||
function renderLines() {
|
||||
const group = selectedGroup();
|
||||
const emotions = usableEmotions(group);
|
||||
const slices = new Map((group?.slices || []).map(slice => [slice.emotion, slice]));
|
||||
// Chips only when there is a choice, or a line still asks for an emotion this character lacks.
|
||||
const showChips = emotions.length > 1 || state.lines.some(line => !emotions.includes(line.emotion));
|
||||
// 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'), () => {
|
||||
state.lines.push({ text: '', emotion: state.lines.at(-1)?.emotion || 'main', take: 1 });
|
||||
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;
|
||||
refs.linesContainer.replaceChildren(...state.lines.map((line, index) => renderLineCard(line, index, emotions, slices, showChips)), addLine);
|
||||
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, emotions, slices, showChips) {
|
||||
const card = el('div', `anomalous-sd-line-card${index === state.listenIndex ? ' is-listen' : ''}`);
|
||||
// The line you are on is the one the modes are heard on (the run section's listen button).
|
||||
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', () => 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 () => {
|
||||
@@ -340,7 +405,8 @@ function renderLineCard(line, index, emotions, slices, showChips) {
|
||||
return;
|
||||
}
|
||||
line.text = parts[0];
|
||||
state.lines.splice(index + 1, 0, { text: parts[1], emotion: line.emotion, take: 1 });
|
||||
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();
|
||||
@@ -351,6 +417,7 @@ function renderLineCard(line, index, emotions, slices, showChips) {
|
||||
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')));
|
||||
@@ -373,6 +440,7 @@ function renderLineCard(line, index, emotions, slices, showChips) {
|
||||
head.append(
|
||||
button('anomalous-sd-icon-btn is-edit is-danger', '×', () => {
|
||||
state.lines.splice(index, 1);
|
||||
state.picked = new Set();
|
||||
stopScriptDirectorPreview();
|
||||
renderAll();
|
||||
}, t('scriptDirectorDeleteLine')),
|
||||
@@ -424,6 +492,23 @@ function renderLineCard(line, index, emotions, slices, showChips) {
|
||||
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;
|
||||
@@ -436,7 +521,9 @@ function renderPackage() {
|
||||
const pkg = currentPackage();
|
||||
const ok = !pkg.error;
|
||||
refs.summary.classList.toggle('is-error', !ok);
|
||||
if (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 {
|
||||
@@ -459,7 +546,9 @@ function parseScript(text) {
|
||||
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();
|
||||
}
|
||||
@@ -483,89 +572,3 @@ function previewLine(line, slices, btn) {
|
||||
audio.onerror = done;
|
||||
audio.play().catch(done);
|
||||
}
|
||||
|
||||
async function copyScript() {
|
||||
const pkg = currentPackage();
|
||||
if (pkg.error || !navigator.clipboard?.writeText) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(pkg.speech);
|
||||
flash(refs.copyBtn, t('scriptDirectorCopied'), t('scriptDirectorCopy'));
|
||||
} catch (_) {
|
||||
// Clipboard permission refused: the line cards still hold the text.
|
||||
}
|
||||
}
|
||||
|
||||
function flash(btn, text, restore) {
|
||||
btn.textContent = text;
|
||||
btn.classList.add('is-success');
|
||||
setTimeout(() => {
|
||||
btn.textContent = restore;
|
||||
btn.classList.remove('is-success');
|
||||
}, 1800);
|
||||
}
|
||||
|
||||
function findTargetNode() {
|
||||
const nodes = (app.graph?._nodes || []).filter(targetForNode);
|
||||
if (!nodes.length) return { error: 'scriptDirectorNoNode' };
|
||||
const selected = Object.values(app.canvas?.selected_nodes || {}).filter(targetForNode);
|
||||
if (selected.length === 1) return { node: selected[0] };
|
||||
if (nodes.length === 1) return { node: nodes[0] };
|
||||
return { error: 'scriptDirectorPickNode' };
|
||||
}
|
||||
|
||||
async function pushToNode() {
|
||||
const pkg = currentPackage();
|
||||
if (pkg.error) return;
|
||||
const { node, error } = findTargetNode();
|
||||
if (error) {
|
||||
await anomalousAlert(t(error, { node: TTS_ENGINE.label }));
|
||||
return;
|
||||
}
|
||||
if (await applyPackageToNode(node, pkg)) flash(refs.pushBtn, t('scriptDirectorSuccess'), t('scriptDirectorInject'));
|
||||
}
|
||||
|
||||
/** Write character + script into one node; re-checks the node after any dialog. Returns true when written. */
|
||||
async function applyPackageToNode(node, pkg) {
|
||||
const graph = app.graph;
|
||||
const target = targetForNode(node);
|
||||
const speechWidget = target && node.widgets?.find(w => w.name === target.speechWidget);
|
||||
const sampleWidget = target && node.widgets?.find(w => w.name === target.voiceWidget);
|
||||
if (!speechWidget || !sampleWidget) {
|
||||
await anomalousAlert(t('scriptDirectorDropNotTts', { node: TTS_ENGINE.label }));
|
||||
return false;
|
||||
}
|
||||
const sampleValue = comboValueForPath(sampleWidget, pkg.sample);
|
||||
if (sampleValue == null) {
|
||||
await anomalousAlert(t('scriptDirectorSampleNotListed', { file: pkg.sample, node: TTS_ENGINE.label }));
|
||||
return false;
|
||||
}
|
||||
const previous = speechWidget.value;
|
||||
if (typeof previous === 'string' && previous.trim() && previous.trim() !== pkg.speech) {
|
||||
if (!await anomalousConfirm(t('scriptDirectorOverwriteConfirm'))) return false;
|
||||
if (app.graph !== graph || graph.getNodeById(node.id) !== node || speechWidget.value !== previous) return false;
|
||||
}
|
||||
if (sampleWidget.value !== sampleValue) {
|
||||
sampleWidget.value = sampleValue;
|
||||
sampleWidget.callback?.(sampleValue, app.canvas, node);
|
||||
}
|
||||
speechWidget.value = pkg.speech;
|
||||
speechWidget.callback?.(pkg.speech, app.canvas, node);
|
||||
node.setDirtyCanvas?.(true, true);
|
||||
recordCanvasStep(app);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------- drag the bundle onto a canvas node ----------
|
||||
|
||||
function bindPackageDrag(handle) {
|
||||
bindMaterialDrag(handle, dragOwner, {
|
||||
payload: () => {
|
||||
const pkg = currentPackage();
|
||||
return pkg.error ? null : { ...pkg, dragHint: t('scriptDirectorDragHint', { node: TTS_ENGINE.label }) };
|
||||
},
|
||||
accepts: node => Boolean(targetForNode(node)),
|
||||
drop: async node => {
|
||||
if (await applyPackageToNode(node, currentPackage())) flash(refs.pushBtn, t('scriptDirectorSuccess'), t('scriptDirectorInject'));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -76,8 +76,8 @@ async function loadNodeSpec() {
|
||||
}
|
||||
|
||||
/**
|
||||
* `getScript()` -> `{ group, lines, listen, pkg }` for the chosen character (the director's lines,
|
||||
* the index of the one to listen with, pkg from buildScriptPackage with takes); `getGroups()` -> every voice group (for the presets' "used by");
|
||||
* `getScript()` -> `{ group, lines, listen, speakerOf, pkg }` for the chosen character (the director's
|
||||
* lines, the index of the one to listen with, who reads a line, pkg from buildScriptPackage with takes); `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);
|
||||
@@ -241,7 +241,13 @@ export function createRunSection({ getScript, getGroups, onPlay, resetTakes, onB
|
||||
if (!state.presets) return;
|
||||
const opts = currentOptions(group);
|
||||
const view = state.presets;
|
||||
const auto = autoTuning(group, opts);
|
||||
// The same line for every mode: the one you are on in the director, read by its own character;
|
||||
// an empty one, a line in the character's language.
|
||||
const script = getScript();
|
||||
const index = script?.listen ?? 0;
|
||||
const chosen = script?.lines?.[index];
|
||||
const speaker = (chosen && script.speakerOf?.(chosen)) || group;
|
||||
const auto = autoTuning(speaker, opts);
|
||||
const values = id => (id === AUTO ? auto.values : view.presets[id]);
|
||||
if (opts.preset !== AUTO && !view.presets[opts.preset]) opts.preset = AUTO; // deleted in the settings meanwhile
|
||||
const choices = [{ id: AUTO, label: t('scriptRunModeAuto') },
|
||||
@@ -251,20 +257,16 @@ export function createRunSection({ getScript, getGroups, onPlay, resetTakes, onB
|
||||
state.presetNote = '';
|
||||
render();
|
||||
};
|
||||
// The same line for every mode: the one you are on in the director; an empty one, a line in the character's language.
|
||||
const script = getScript();
|
||||
const index = script?.listen ?? 0;
|
||||
const chosen = script?.lines?.[index];
|
||||
const words = String(chosen?.text || '').trim();
|
||||
const refLang = group.raw?.reference?.language || group.language;
|
||||
const refLang = speaker.raw?.reference?.language || speaker.language;
|
||||
const text = words
|
||||
? `${chosen.emotion && chosen.emotion !== 'main' ? `{${chosen.emotion}}` : ''}${words}`
|
||||
: SAMPLE_LINES[refLang] || SAMPLE_LINES.zh;
|
||||
const lineLabel = el('span', 'anomalous-sd-run-listen-line', words
|
||||
? t('scriptRunListenLine', { n: index + 1, text: words })
|
||||
: t('scriptRunListenSample', { text }));
|
||||
const lineLabel = el('span', 'anomalous-sd-run-listen-line', !words ? t('scriptRunListenSample', { text })
|
||||
: speaker !== group ? t('scriptRunListenLineAs', { n: index + 1, text: words, who: speaker.character })
|
||||
: t('scriptRunListenLine', { n: index + 1, text: words }));
|
||||
lineLabel.title = t('scriptRunListenLineHint');
|
||||
const take = id => ({ character: group.character, text, language: opts.language, speed: opts.speed, mode: id, preset: id, numbers: values(id) });
|
||||
const take = id => ({ character: speaker.character, text, language: opts.language, speed: opts.speed, mode: id, preset: id, numbers: values(id) });
|
||||
state.takeNow = busy ? null : () => take(opts.preset);
|
||||
const lang = auto.key === 'all' ? t('ttsTuningAll') : t(`scriptRunLang_${auto.key}`);
|
||||
const modes = renderPresetSwitch({
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { app } from '../../../scripts/app.js';
|
||||
import { t } from './interface_settings.js';
|
||||
import { anomalousAlert, anomalousConfirm } from './ui_dialog.js';
|
||||
import { comboValueForPath } from './audio_script.js';
|
||||
import { targetForNode } from './audio_node_targets.js';
|
||||
import { TTS_ENGINE } from './audio_engines.js';
|
||||
import { bindMaterialDrag } from './material_drag.js';
|
||||
import { recordCanvasStep } from './canvas_history.js';
|
||||
|
||||
/**
|
||||
* The Script Director's "Into your own workflow": the bundle (buildScriptPackage) copied,
|
||||
* pushed to the selected / only GPT-SoVITS node, or dragged onto one (supported nodes:
|
||||
* audio_node_targets.js). Writing the character and script widgets together is one Ctrl+Z step.
|
||||
*/
|
||||
|
||||
function flash(btn, text, restore) {
|
||||
btn.textContent = text;
|
||||
btn.classList.add('is-success');
|
||||
setTimeout(() => {
|
||||
btn.textContent = restore;
|
||||
btn.classList.remove('is-success');
|
||||
}, 1800);
|
||||
}
|
||||
|
||||
export async function copyScript(pkg, btn) {
|
||||
if (pkg.error || !navigator.clipboard?.writeText) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(pkg.speech);
|
||||
flash(btn, t('scriptDirectorCopied'), t('scriptDirectorCopy'));
|
||||
} catch (_) {
|
||||
// Clipboard permission refused: the line cards still hold the text.
|
||||
}
|
||||
}
|
||||
|
||||
function findTargetNode() {
|
||||
const nodes = (app.graph?._nodes || []).filter(targetForNode);
|
||||
if (!nodes.length) return { error: 'scriptDirectorNoNode' };
|
||||
const selected = Object.values(app.canvas?.selected_nodes || {}).filter(targetForNode);
|
||||
if (selected.length === 1) return { node: selected[0] };
|
||||
if (nodes.length === 1) return { node: nodes[0] };
|
||||
return { error: 'scriptDirectorPickNode' };
|
||||
}
|
||||
|
||||
export async function pushToNode(pkg, btn) {
|
||||
if (pkg.error) return;
|
||||
const { node, error } = findTargetNode();
|
||||
if (error) {
|
||||
await anomalousAlert(t(error, { node: TTS_ENGINE.label }));
|
||||
return;
|
||||
}
|
||||
if (await applyPackageToNode(node, pkg)) flash(btn, t('scriptDirectorSuccess'), t('scriptDirectorInject'));
|
||||
}
|
||||
|
||||
/** Write character + script into one node; re-checks the node after any dialog. Returns true when written. */
|
||||
async function applyPackageToNode(node, pkg) {
|
||||
const graph = app.graph;
|
||||
const target = targetForNode(node);
|
||||
const speechWidget = target && node.widgets?.find(w => w.name === target.speechWidget);
|
||||
const sampleWidget = target && node.widgets?.find(w => w.name === target.voiceWidget);
|
||||
if (!speechWidget || !sampleWidget) {
|
||||
await anomalousAlert(t('scriptDirectorDropNotTts', { node: TTS_ENGINE.label }));
|
||||
return false;
|
||||
}
|
||||
const sampleValue = comboValueForPath(sampleWidget, pkg.sample);
|
||||
if (sampleValue == null) {
|
||||
await anomalousAlert(t('scriptDirectorSampleNotListed', { file: pkg.sample, node: TTS_ENGINE.label }));
|
||||
return false;
|
||||
}
|
||||
const previous = speechWidget.value;
|
||||
if (typeof previous === 'string' && previous.trim() && previous.trim() !== pkg.speech) {
|
||||
if (!await anomalousConfirm(t('scriptDirectorOverwriteConfirm'))) return false;
|
||||
if (app.graph !== graph || graph.getNodeById(node.id) !== node || speechWidget.value !== previous) return false;
|
||||
}
|
||||
if (sampleWidget.value !== sampleValue) {
|
||||
sampleWidget.value = sampleValue;
|
||||
sampleWidget.callback?.(sampleValue, app.canvas, node);
|
||||
}
|
||||
speechWidget.value = pkg.speech;
|
||||
speechWidget.callback?.(pkg.speech, app.canvas, node);
|
||||
node.setDirtyCanvas?.(true, true);
|
||||
recordCanvasStep(app);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Drag the bundle onto a canvas node; `dragOwner` hides the browser while dragging, `pushBtn()` flashes on success. */
|
||||
export function bindPackageDrag(handle, dragOwner, getPackage, pushBtn) {
|
||||
bindMaterialDrag(handle, dragOwner, {
|
||||
payload: () => {
|
||||
const pkg = getPackage();
|
||||
return pkg.error ? null : { ...pkg, dragHint: t('scriptDirectorDragHint', { node: TTS_ENGINE.label }) };
|
||||
},
|
||||
accepts: node => Boolean(targetForNode(node)),
|
||||
drop: async node => {
|
||||
if (await applyPackageToNode(node, getPackage())) flash(pushBtn(), t('scriptDirectorSuccess'), t('scriptDirectorInject'));
|
||||
},
|
||||
});
|
||||
}
|
||||
+3
-3
@@ -9,11 +9,11 @@
|
||||
@import url("./styles/08-theme-gallery-overrides.css?v=20261010-wfd-1");
|
||||
@import url("./styles/09-prompt-drawers.css?v=20261007-theme-2");
|
||||
@import url("./styles/10-model-sources.css?v=20261007-theme-2");
|
||||
@import url("./styles/11-audio-studio.css?v=20261017-tts-versions");
|
||||
@import url("./styles/11-audio-studio.css?v=20261017-cast");
|
||||
@import url("./styles/12-tts-setup.css?v=20261017-feel4");
|
||||
@import url("./styles/13-script-director.css?v=20261017-play");
|
||||
@import url("./styles/13-script-director.css?v=20261017-cast2");
|
||||
@import url("./styles/14-version-manager.css?v=20261007-theme-2");
|
||||
@import url("./styles/15-shell-rail.css?v=20261016-remote-3");
|
||||
@import url("./styles/15-shell-rail.css?v=20261017-cast");
|
||||
@import url("./styles/16-activity.css?v=20261007-theme-2");
|
||||
@import url("./styles/17-model-types.css?v=20261007-theme-2");
|
||||
@import url("./styles/18-scan-page.css?v=20261016-counting");
|
||||
|
||||
@@ -761,6 +761,43 @@
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
/* Voice-over: the list is the cast (ui_audio_sidebar.js): characters with their colours, nothing that leaves the page. */
|
||||
.anomalous-audio-nav-cast-dot,
|
||||
.anomalous-audio-nav-cast-hint {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#anomalous-container[data-page="script"] .anomalous-audio-nav-cast-dot {
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
#anomalous-container[data-page="script"] .anomalous-audio-nav-cast-hint {
|
||||
display: block;
|
||||
margin: 8px 12px 4px;
|
||||
color: var(--amb-text-dim);
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
#anomalous-container[data-page="script"] .anomalous-audio-nav-all,
|
||||
#anomalous-container[data-page="script"] .anomalous-audio-nav-chevron,
|
||||
#anomalous-container[data-page="script"] .anomalous-audio-nav-clips,
|
||||
#anomalous-container[data-page="script"] .anomalous-audio-nav-character .anomalous-audio-nav-count,
|
||||
#anomalous-container[data-page="script"] .anomalous-audio-nav-character .anomalous-audio-nav-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#anomalous-container[data-page="script"] .anomalous-audio-nav-character .anomalous-audio-nav-item.active {
|
||||
border-left-color: transparent;
|
||||
background: transparent;
|
||||
color: var(--amb-text-soft);
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
.anomalous-audio-nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -311,6 +311,38 @@
|
||||
border-color: color-mix(in srgb, var(--amb-link) 35%, transparent);
|
||||
}
|
||||
|
||||
/* Lines picked to give to a character (ui_script_cast.js), and who reads each once several do. */
|
||||
.anomalous-sd-line-card.is-picked {
|
||||
border-color: color-mix(in srgb, var(--amb-link) 75%, transparent);
|
||||
background: color-mix(in srgb, var(--amb-link) 13%, transparent);
|
||||
}
|
||||
|
||||
.anomalous-sd-line-card.has-speaker {
|
||||
border-left: 3px solid var(--speaker);
|
||||
}
|
||||
|
||||
/* Right after the number (above the rule that sends the head's buttons after the text). */
|
||||
.anomalous-sd-line-card > .anomalous-sd-line-head > .anomalous-sd-speaker {
|
||||
order: 0;
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--speaker) 24%, transparent);
|
||||
color: var(--amb-text-main);
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.anomalous-sd-cast-hint {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0 10px;
|
||||
color: var(--amb-text-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* The line heard on the right (ui_script_run.js): marked, with a word saying so. */
|
||||
.anomalous-sd-line-card.is-listen {
|
||||
border-color: color-mix(in srgb, var(--amb-link) 55%, transparent);
|
||||
|
||||
@@ -138,6 +138,11 @@
|
||||
transition: width 0.25s ease, margin 0.25s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
/* Voice-over: the cast column is narrower (11-audio-studio.css). */
|
||||
#anomalous-container.anomalous-has-rail[data-page="script"]:not(.anomalous-shell-narrow) #anomalous-sidebar-wrapper {
|
||||
width: 176px;
|
||||
}
|
||||
|
||||
#anomalous-container.anomalous-has-rail.anomalous-sidebar-closed #anomalous-sidebar-wrapper,
|
||||
#anomalous-container.anomalous-has-rail.anomalous-workspace-open #anomalous-sidebar-wrapper {
|
||||
width: 0;
|
||||
|
||||
Reference in New Issue
Block a user