From 89fa1a93bf95cff27b2b3c0975868019aa4d53c5 Mon Sep 17 00:00:00 2001 From: DemonGatanjieu <207458273+DemonGatanjieu@users.noreply.github.com> Date: Sat, 17 Oct 2026 22:53:03 +0800 Subject: [PATCH] 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 --- ARCHITECTURE.md | 20 ++- CHANGELOG.md | 8 + tests/audio_script_rules.mjs | 21 ++- tests/script_cast.mjs | 21 +++ web/modules/audio_script.js | 84 ++++++--- web/modules/browser.js | 5 +- web/modules/locales.js | 20 ++- web/modules/ui_audio_sidebar.js | 47 +++-- web/modules/ui_audio_studio.js | 7 - web/modules/ui_browser_navigation.js | 2 + web/modules/ui_script_cast.js | 39 +++++ web/modules/ui_script_director.js | 247 ++++++++++++++------------- web/modules/ui_script_run.js | 26 +-- web/modules/ui_script_workflow.js | 97 +++++++++++ web/styles.css | 6 +- web/styles/11-audio-studio.css | 37 ++++ web/styles/13-script-director.css | 32 ++++ web/styles/15-shell-rail.css | 5 + 18 files changed, 527 insertions(+), 197 deletions(-) create mode 100644 tests/script_cast.mjs create mode 100644 web/modules/ui_script_cast.js create mode 100644 web/modules/ui_script_workflow.js diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ba5a225..5fe04b4 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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//`). - `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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ecfbe7..018f512 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 (扫描) diff --git a/tests/audio_script_rules.mjs b/tests/audio_script_rules.mjs index 0a33fb8..caae318 100644 --- a/tests/audio_script_rules.mjs +++ b/tests/audio_script_rules.mjs @@ -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, '阿罗娜/日配'), '阿罗娜\\日配'); diff --git a/tests/script_cast.mjs b/tests/script_cast.mjs new file mode 100644 index 0000000..5e01dff --- /dev/null +++ b/tests/script_cast.mjs @@ -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'); diff --git a/web/modules/audio_script.js b/web/modules/audio_script.js index 3c5bd52..4cb3233 100644 --- a/web/modules/audio_script.js +++ b/web/modules/audio_script.js @@ -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//…`, 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; } diff --git a/web/modules/browser.js b/web/modules/browser.js index eb5b981..cc5c6e4 100644 --- a/web/modules/browser.js +++ b/web/modules/browser.js @@ -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); diff --git a/web/modules/locales.js b/web/modules/locales.js index 105d860..cd663ae 100644 --- a/web/modules/locales.js +++ b/web/modules/locales.js @@ -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", diff --git a/web/modules/ui_audio_sidebar.js b/web/modules/ui_audio_sidebar.js index 2f8a17d..dbc0784 100644 --- a/web/modules/ui_audio_sidebar.js +++ b/web/modules/ui_audio_sidebar.js @@ -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')); } diff --git a/web/modules/ui_audio_studio.js b/web/modules/ui_audio_studio.js index f4fa7cb..87f3e89 100644 --- a/web/modules/ui_audio_studio.js +++ b/web/modules/ui_audio_studio.js @@ -33,7 +33,6 @@ const SVG = { GRIP: ``, CHECK: ``, SEARCH: ``, - SCRIPT: ``, PLUS: ``, TUNE: ``, PRONOUNCE: ``, @@ -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?.() }); diff --git a/web/modules/ui_browser_navigation.js b/web/modules/ui_browser_navigation.js index 30c4a31..0af1e21 100644 --- a/web/modules/ui_browser_navigation.js +++ b/web/modules/ui_browser_navigation.js @@ -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) { diff --git a/web/modules/ui_script_cast.js b/web/modules/ui_script_cast.js new file mode 100644 index 0000000..bc91854 --- /dev/null +++ b/web/modules/ui_script_cast.js @@ -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; +} diff --git a/web/modules/ui_script_director.js b/web/modules/ui_script_director.js index c006081..3d224c8 100644 --- a/web/modules/ui_script_director.js +++ b/web/modules/ui_script_director.js @@ -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')); - }, - }); -} diff --git a/web/modules/ui_script_run.js b/web/modules/ui_script_run.js index 25add81..f4f8ce5 100644 --- a/web/modules/ui_script_run.js +++ b/web/modules/ui_script_run.js @@ -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({ diff --git a/web/modules/ui_script_workflow.js b/web/modules/ui_script_workflow.js new file mode 100644 index 0000000..dd36363 --- /dev/null +++ b/web/modules/ui_script_workflow.js @@ -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')); + }, + }); +} diff --git a/web/styles.css b/web/styles.css index 43f23e8..4d5f297 100644 --- a/web/styles.css +++ b/web/styles.css @@ -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"); diff --git a/web/styles/11-audio-studio.css b/web/styles/11-audio-studio.css index b5f95d6..a44f68b 100644 --- a/web/styles/11-audio-studio.css +++ b/web/styles/11-audio-studio.css @@ -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; diff --git a/web/styles/13-script-director.css b/web/styles/13-script-director.css index 1b53d29..abcbd3b 100644 --- a/web/styles/13-script-director.css +++ b/web/styles/13-script-director.css @@ -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); diff --git a/web/styles/15-shell-rail.css b/web/styles/15-shell-rail.css index a977f78..98f473b 100644 --- a/web/styles/15-shell-rail.css +++ b/web/styles/15-shell-rail.css @@ -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;