From 9e0062f331d9aca01f996d7ee68fa62da2323f8d Mon Sep 17 00:00:00 2001 From: DemonGatanjieu <207458273+DemonGatanjieu@users.noreply.github.com> Date: Mon, 9 Nov 2026 17:32:28 +0800 Subject: [PATCH] Give voice characters a cover, and lay their cards out like a profile A click on a card's avatar picks a picture of the gallery or the computer; the page cuts it square (a tall one keeps its top) and the server keeps it small in user/anomalous/voice_covers, one per character's name, never in Anomalous_TTS's folders; one taken away goes to the Recycle Bin. Without one, the name's first letter shows, and "Change the cover" shows over it under the pointer. The card: cover, name big, version in grey, one blue line of facts, plain buttons, voices as thin-line rows; flat, no glow. Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 8 +- CHANGELOG.md | 2 + api/__init__.py | 6 +- api/voice_covers.py | 155 ++++++++++++++++++++++++++++++ docs/architecture/ui-flows.md | 8 ++ tests/test_voice_covers.py | 61 ++++++++++++ tests/voice_covers.mjs | 13 +++ web/modules/locales.js | 16 +++ web/modules/ui_ai_gallery_pick.js | 5 +- web/modules/ui_audio_studio.js | 56 ++++++----- web/modules/voice_covers.js | 150 +++++++++++++++++++++++++++++ web/styles/11-audio-studio.css | 125 ++++++++++++++++-------- 12 files changed, 533 insertions(+), 72 deletions(-) create mode 100644 api/voice_covers.py create mode 100644 tests/test_voice_covers.py create mode 100644 tests/voice_covers.mjs create mode 100644 web/modules/voice_covers.js diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 60f9b31..1830dc8 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -278,6 +278,11 @@ covered by the `styles.css` manifest. language, `api/description_translation.py` translates it paragraph by paragraph through `translation_routes.translate_chain` (DeepL with a key, Google, MyMemory; long pieces cut smaller) and keeps the answer in user/anomalous/translations by the text's hash, never beside the models (`tests/test_description_translation.py`). +- Covers of the voice characters: `api/voice_covers.py` keeps one small square PNG per character's name (its + versions share it) in user/anomalous/voice_covers, never in Anomalous_TTS's folders, and takes one away to the + Recycle Bin; `web/modules/voice_covers.js` draws the cover or the name's first letter in a card's avatar and, on + a click, picks a picture from the gallery (`ui_ai_gallery_pick.js` pictureGrid) or the computer and cuts it + square, a tall one keeping its top (`tests/test_voice_covers.py`, `tests/voice_covers.mjs`). - A prompt the canvas's model cannot read. `api/prompt_language.py` says whether the prompts a run sends are mostly Chinese, Japanese or Korean while a loader's model is of a family marked "reads": "English" in `api/model_profiles.json` (SD 1.5, SDXL, Pony, Illustrious, Flux, Chroma: CLIP and T5 read English only, and the picture would follow nothing). @@ -859,7 +864,8 @@ covered by the `styles.css` manifest. page `script`) and Audio (shell page `audio-gallery`, `ui_audio_gallery.js`). `ui_shell_nav.js` keeps the rail on Voices and reopens the view used last, from the rail and from the 9 key; the rail stays within the nine number keys. - `ui_audio_studio.js` owns the Characters view: the character cards, preview playback, + `ui_audio_studio.js` owns the Characters view: the character cards (laid out like a profile: cover, name big, + version under it, one line of facts, plain buttons, voices as thin-line rows), preview playback, tag copying, dragging a card header onto a node through the shared `bindMaterialDrag` with `targetHint`/`rejectHint` telling the user what releasing does, each card's "Voice-over" button (`owner.openScript(group)`), and its Delete character button diff --git a/CHANGELOG.md b/CHANGELOG.md index f52dc27..9bab428 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ - 新的「任务单」:画布右键「任务单(批量换提示词)」。一次贴很多条提示词(一行一条),每条跑几次、每张之间歇几秒、要不要每条用同一组种子,然后「开始」。跑的是开始时那张画布的副本,你的画布不动;可以暂停、继续、停止,出错会自己暂停并写明原因,刷新页面会接着跑。一条也不写,就是照画布原样隔一段时间跑一张。AI 也能排任务单:跟它说"这几条提示词各跑 4 张"或"cfg 4、5、6 各跑 3 张",它交给 AMB 一张张跑,工作台里看进度和图。 - New "Job sheet": right-click the canvas, "Job sheet (many prompts)". Paste many prompts (a line each), say how many runs each gets, the seconds to rest between pictures and whether every prompt gets the same seeds, then Start. It runs copies of the canvas as it was at the start, so your canvas stays as it is; pause, go on and stop any time, a failed run pauses it with the reason, and a reload goes on. With no prompt it runs the canvas as it is, with a rest between pictures. An AI can hand over a sheet too ("these prompts, 4 each"; "cfg 4, 5 and 6, 3 each"): AMB runs it one by one and the workbench shows how far it is and its pictures. - 模型详情页的开头重新排了:大字写模型名,版本放在下面一行灰色小字(原来那串长文件名挪到下面的清单里),一行蓝色小字写类型、大小、底模和 Civitai 链接,按钮只留「插入节点」一个实心的。训练词、作者常用设置、还需要的文件、版本、文件名排成一张细线隔开的清单;作者的介绍默认收起,点开再看。中文界面里那行 “Size:” “Base:” 也改成了中文。 +- 角色语音的角色卡可以设封面了:点卡片左边的头像,从图库里点一张或从电脑选一张,自动裁成方形(竖图保留上面一截),同一个角色的几个版本共用。没设封面时显示名字的第一个字。卡片也按模型详情的思路重新排了:大字角色名、灰色版本、一行蓝色小字,按钮变成文字按钮,声音列表用细线隔开。 +- Voice characters can have a cover: click a card's avatar and pick a picture of the gallery or the computer; it is cut square (a tall one keeps its top) and a character's versions share it. Without one, the name's first letter shows. The cards are laid out like the model details too: the name big, the version in grey, one small blue line, plain buttons, voices parted by thin lines. - 模型详情里「作者的介绍」:记住你上次是展开还是收起;英文的介绍旁边多了「翻译」,一段一段翻成中文(有 DeepL 的 key 用 DeepL,没有用谷歌,再不行用 MyMemory,走设置里的代理),「看原文」切回去。翻过的存在 AMB 自己的文件夹里,下次打开直接显示。 - In a model's details, its author's text remembers whether you left it open or folded, and English text gets a Translate (Chinese text on the English window too): paragraph by paragraph through DeepL with your key, else Google, else MyMemory, by your proxy; Original goes back. A translation is kept in AMB's own folder and shows at once next time. - The model details open like a profile: the model's name big, its version under it in small grey (the long file name moved to the list below), one small blue line with its type, size, base and the Civitai link, and only Add node filled among the buttons. Trigger words, its author's settings, the files it needs, its versions and its file sit in one list parted by thin lines; its author's text from Civitai starts folded. diff --git a/api/__init__.py b/api/__init__.py index d0f7be5..7fbef2b 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -3,7 +3,7 @@ from . import ( model_folder, model_media, model_metadata, model_resolution, model_type_listing, kept_images, quick_index, node_material, recipe_packages, recipes, scan_choice, scan_report, scan_summary, local_guard, lora_info, mcp_server, model_download, model_import, model_placement, translation_routes, version_manager, tts_version, backup, network_check, model_heads, model_usage, usage_ledger, sidecar_layout, ai_tasks_routes, snapshot_routes, workflow_saves, example_workflows, - model_guide, job_sheets, prompt_language, description_translation, + model_guide, job_sheets, prompt_language, description_translation, voice_covers, ) from .scanner import * from .config import * @@ -56,6 +56,10 @@ def setup_routes(app): app.router.add_post('/anomalous/job_sheet/clear', job_sheets.api_clear) app.router.add_post('/anomalous/prompt_language', prompt_language.api_check) app.router.add_post('/anomalous/translate_description', description_translation.api_translate_description) + app.router.add_get('/anomalous/voice_covers', voice_covers.api_list) + app.router.add_get('/anomalous/voice_cover', voice_covers.api_get) + app.router.add_post('/anomalous/voice_cover', voice_covers.api_set) + app.router.add_post('/anomalous/voice_cover/remove', voice_covers.api_remove) try: folder_types.upgrade_folder_defaults() except Exception as error: diff --git a/api/voice_covers.py b/api/voice_covers.py new file mode 100644 index 0000000..7df758d --- /dev/null +++ b/api/voice_covers.py @@ -0,0 +1,155 @@ +"""Covers of the voice characters (the Voices page's cards, web/modules/voice_covers.js): a picture the user picks +for a character, from the gallery or the computer, cropped square and made small by the page and checked and +made again here (no metadata kept). One per character's name: its versions ("角色/日配", "角色/中配") share it. +Kept in user/anomalous/voice_covers, a PNG per name by its hash and index.json naming them, never in +Anomalous_TTS's character folders. A cover taken away goes to the Recycle Bin (trash.py). +GET /anomalous/voice_covers -> {covers: {name: stamp}}; GET /anomalous/voice_cover?name= -> the PNG; +POST /anomalous/voice_cover {name, image: data URL} -> {stamp}; POST /anomalous/voice_cover/remove {name}.""" + +import asyncio +import base64 +import hashlib +import io +import json +import os +import threading +import time + +from aiohttp import web +from PIL import Image + +from . import ai_tasks +from .path_utils import atomic_write_json + +SIZE = 256 +MAX_BYTES = 8 * 1024 * 1024 +MAX_NAME = 200 +INDEX = "index.json" + +_lock = threading.Lock() + + +class CoverError(ValueError): + pass + + +def _folder(): + return os.path.join(os.path.dirname(ai_tasks.tasks_dir()), "voice_covers") + + +def base_name(character): + """The character's name without its version ("角色/日配" -> "角色").""" + name = str(character or "").strip() + return name.rsplit("/", 1)[0].strip() if "/" in name else name + + +def _file(name): + return os.path.join(_folder(), hashlib.sha1(name.encode("utf-8")).hexdigest()[:20] + ".png") + + +def _index(): + try: + with open(os.path.join(_folder(), INDEX), encoding="utf-8") as source: + data = json.load(source) + return data if isinstance(data, dict) else {} + except (OSError, ValueError): + return {} + + +def covers(): + """{name: stamp} of the covers there are; a stamp changes with the picture.""" + return {name: stamp for name, stamp in _index().items() if os.path.isfile(_file(name))} + + +def _checked(name): + name = base_name(name) + if not name or len(name) > MAX_NAME: + raise CoverError("name is a character's name") + return name + + +def set_cover(character, data_url): + """Keeps the picture in `data_url` as `character`'s cover: a square, SIZE across, made again as a PNG.""" + name = _checked(character) + if not isinstance(data_url, str) or "," not in data_url or not data_url.startswith("data:image/"): + raise CoverError("image is a picture as a data URL") + try: + raw = base64.b64decode(data_url.split(",", 1)[1], validate=True) + except (ValueError, TypeError) as error: + raise CoverError("The picture could not be read") from error + if len(raw) > MAX_BYTES: + raise CoverError("The picture is too big") + try: + with Image.open(io.BytesIO(raw)) as source: + source.load() + picture = source.convert("RGBA" if "A" in source.getbands() else "RGB") + except Exception as error: # noqa: BLE001 - whatever it is, it is no picture to keep + raise CoverError("The picture could not be read") from error + side = min(picture.size) + left, top = (picture.width - side) // 2, (picture.height - side) // 2 + picture = picture.crop((left, top, left + side, top + side)).resize((SIZE, SIZE), Image.LANCZOS) + os.makedirs(_folder(), exist_ok=True) + stamp = str(int(time.time() * 1000)) + with _lock: + target = _file(name) + temp = f"{target}.tmp" + picture.save(temp, "PNG") + os.replace(temp, target) + index = _index() + index[name] = stamp + atomic_write_json(os.path.join(_folder(), INDEX), index) + return {"name": name, "stamp": stamp} + + +def remove_cover(character): + name = _checked(character) + from .trash import move_to_trash + + with _lock: + if os.path.isfile(_file(name)): + move_to_trash(_file(name)) + index = _index() + index.pop(name, None) + if os.path.isdir(_folder()): + atomic_write_json(os.path.join(_folder(), INDEX), index) + return {"name": name} + + +async def _body(request): + try: + body = await request.json() + except ValueError: + body = None + if not isinstance(body, dict): + raise CoverError("Expected a JSON object") + return body + + +async def api_list(request): + return web.json_response({"covers": await asyncio.to_thread(covers)}) + + +async def api_get(request): + name = base_name(request.query.get("name", "")) + path = _file(name) if name else "" + if not name or not os.path.isfile(path): + return web.json_response({"error": "No cover"}, status=404) + return web.FileResponse(path, headers={"Cache-Control": "no-cache"}) + + +async def api_set(request): + try: + body = await _body(request) + return web.json_response(await asyncio.to_thread(set_cover, body.get("name"), body.get("image"))) + except CoverError as error: + return web.json_response({"error": str(error)}, status=400) + + +async def api_remove(request): + try: + body = await _body(request) + return web.json_response(await asyncio.to_thread(remove_cover, body.get("name"))) + except CoverError as error: + return web.json_response({"error": str(error)}, status=400) + except Exception as error: # noqa: BLE001 - no Recycle Bin here (trash.py): it stays, and the page says why + return web.json_response({"error": str(error)[:300]}, status=500) diff --git a/docs/architecture/ui-flows.md b/docs/architecture/ui-flows.md index 9290f98..b7df426 100644 --- a/docs/architecture/ui-flows.md +++ b/docs/architecture/ui-flows.md @@ -194,6 +194,14 @@ AI 这边:没有合适的已存工作流时,`list_workflows` 给它这里能 开头下面是一张细线隔开的清单,每行左边名字、右边内容。有 C 站的训练词就是「训练词」一行;没有的 LoRA 是「常用词」一行(虚线圆角,点一下复制,下面小字说是从文件里读的推测)。每个词点一下复制,「复制」复制全部。扫描时拿到作者例图的,是「作者常用」一行:采样器 · 调度器 · 步数 · CFG · 尺寸,LoRA 还有权重,后面写几张例图。放在 diffusion_models / unet 里、要分开加载的模型(Anima、Flux 等)每个要的文件一行(「文本编码器」「VAE」),写有没有这个名字的文件,只按名字找,不保证就是对的那个。最后是「版本」(C 站上的全部版本,点开在这一行下面列出来)和「文件名」(大字的名字不是文件名时才有)。都来自 `/anomalous/model_guide`;给 AI 的注意事项不在这里显示。清单下面是「作者的介绍」:C 站上作者写的说明,只露开头几行,下面一个蓝色的「展开全文」(展开后是「收起」),鼠标移到折着的正文上底色会变亮,点标题、正文或「展开全文」都能展开;你展开过,下次打开别的模型也是展开的,收起也一样(只记在这个浏览器里)。介绍是英文而界面是中文时(英文界面遇到中文、日文也一样),旁边有「翻译」:一段一段翻成界面的语言,翻完显示「看原文」可以切回去,旁边小字写是谁翻的(DeepL、Google 或 MyMemory)。翻过的存在 AMB 自己的文件夹里,下次直接显示,不再联网,也不往模型文件夹里写东西;翻不出来时写明原因,原文不动。 +## 角色语音的角色卡(`ui_audio_studio.js`、`voice_covers.js`) + +每张卡像一页小档案:左边是封面,没设封面时是名字的第一个字;右边大字写角色名,版本(日配、中配)在下面一行灰色小字,再下面一行蓝色小字写模型版本、几条声音、“拖标题栏 → GPT-SoVITS 节点”。按钮在名字下面,是没有边框的文字按钮。下面一条条声音用细线隔开:播放、情绪、台词、「复制」标签。卡片是平的,不发光。 + +点封面(悬停写「换封面」)打开一个小窗:上面「从电脑选一张」「去掉封面」(有封面时才有),下面是图库,点一张就换上。图自动裁成方形,竖着的图保留上面一截。同一个角色的几个版本共用一张封面。封面存在 AMB 自己的文件夹里,不碰 Anomalous_TTS 的角色文件夹;去掉的封面进回收站。 + +为什么:角色一多,全是同一个图标,只能读名字找;有了封面一眼认出是谁,也和模型卡片一致。 + ## 设计上的固定做法 - **从上往下读**:时间线、展台都是旧的在上、新的在下;看最新的时候自动跟着往下,往上翻着看时不打断,只出现“去最新”的按钮。 diff --git a/tests/test_voice_covers.py b/tests/test_voice_covers.py new file mode 100644 index 0000000..1087b0e --- /dev/null +++ b/tests/test_voice_covers.py @@ -0,0 +1,61 @@ +"""Covers of the voice characters (api/voice_covers.py): one per character's name (its versions share it), made +again as a small square PNG, listed with a stamp that changes with the picture, taken away to the Recycle Bin +(a stand-in here), and anything that is no picture refused. Kept in a temporary folder.""" +import base64 +import io +import os +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from PIL import Image + +PLUGIN = Path(__file__).resolve().parents[1] +sys.path[:0] = [str(PLUGIN), str(PLUGIN.parents[1])] + +from api import ai_tasks, voice_covers # noqa: E402 + +TMP = tempfile.mkdtemp(prefix="amb_voice_covers_") + + +def data_url(width, height, color=(200, 40, 40)): + out = io.BytesIO() + Image.new("RGB", (width, height), color).save(out, "PNG") + return "data:image/png;base64," + base64.b64encode(out.getvalue()).decode() + + +class CoverTests(unittest.TestCase): + def setUp(self): + ai_tasks.tasks_dir = lambda: os.path.join(TMP, "ai_tasks") + folder = os.path.join(TMP, "voice_covers") + for name in os.listdir(folder) if os.path.isdir(folder) else []: + os.remove(os.path.join(folder, name)) + + def test_one_cover_a_name_its_versions_share(self): + self.assertEqual(voice_covers.base_name("阿罗娜/日配"), "阿罗娜") + made = voice_covers.set_cover("阿罗娜/日配", data_url(300, 500)) + self.assertEqual(made["name"], "阿罗娜") + self.assertEqual(list(voice_covers.covers()), ["阿罗娜"]) + with Image.open(voice_covers._file("阿罗娜")) as kept: + self.assertEqual((kept.size, kept.format), ((voice_covers.SIZE, voice_covers.SIZE), "PNG")) + again = voice_covers.set_cover("阿罗娜/中配", data_url(64, 64, (10, 200, 10))) + self.assertNotEqual(again["stamp"], made["stamp"], "a new picture, a new stamp") + + def test_removed_to_the_bin(self): + voice_covers.set_cover("Aris", data_url(80, 80)) + with mock.patch("api.trash.move_to_trash", side_effect=os.remove) as moved: + voice_covers.remove_cover("Aris") + moved.assert_called_once() + self.assertEqual(voice_covers.covers(), {}) + + def test_what_is_refused(self): + for name, image, words in (("", data_url(8, 8), "character's name"), ("Aris", "hello", "data URL"), + ("Aris", "data:image/png;base64,bm90IGEgcGljdHVyZQ==", "could not be read")): + with self.assertRaisesRegex(voice_covers.CoverError, words): + voice_covers.set_cover(name, image) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/voice_covers.mjs b/tests/voice_covers.mjs new file mode 100644 index 0000000..95e7b3a --- /dev/null +++ b/tests/voice_covers.mjs @@ -0,0 +1,13 @@ +// Voice character covers on the page (web/modules/voice_covers.js): the name without its version, where a picture is cut square. +import assert from 'node:assert/strict'; +import { baseName, squareCut } from '../web/modules/voice_covers.js'; + +assert.equal(baseName('阿罗娜/日配'), '阿罗娜'); +assert.equal(baseName(' Aris '), 'Aris'); +assert.equal(baseName('/odd'), '/odd', 'a slash first is part of the name'); + +assert.deepEqual(squareCut(400, 400), { x: 0, y: 0, side: 400 }); +assert.deepEqual(squareCut(800, 400), { x: 200, y: 0, side: 400 }, 'a wide one: its middle'); +assert.deepEqual(squareCut(400, 1000), { x: 0, y: 72, side: 400 }, 'a tall one keeps its top, a little below the edge'); + +console.log('voice_covers: ok'); diff --git a/web/modules/locales.js b/web/modules/locales.js index 6a80c72..fb6c4fa 100644 --- a/web/modules/locales.js +++ b/web/modules/locales.js @@ -3164,6 +3164,14 @@ export const i18n = { audioEngineOpenRepo: "打开项目主页", audioSectionCharactersOf: "{engine} 角色", audioDragCardHint: "拖标题栏 → GPT-SoVITS 节点", + voiceCoverChange: "换封面", + voiceCoverTitle: "{name} 的封面", + voiceCoverLead: "从下面的图库里点一张,或者从电脑里选一张。会自动裁成方形,竖着的图保留上面一截(脸一般在那里)。同一个角色的几个版本共用这张封面。", + voiceCoverFromFile: "从电脑选一张", + voiceCoverRemove: "去掉封面", + voiceCoverSaving: "正在保存…", + voiceCoverFailed: "封面没换成:{why}", + voiceCoverUnreadable: "这张图读不出来", audioDragCharacterTitle: "拖到 GPT-SoVITS 节点:角色设为 {character}。情绪在台词里用 {标签} 切换。", ttsPronounceOpen: '读音', ttsPronounceTitle: '读音:{name}', @@ -6935,6 +6943,14 @@ export const i18n = { audioEngineOpenRepo: "Open project page", audioSectionCharactersOf: "{engine} characters", audioDragCardHint: "Drag the header → GPT-SoVITS node", + voiceCoverChange: "Change the cover", + voiceCoverTitle: "Cover of {name}", + voiceCoverLead: "Click a picture of the gallery below, or pick one from the computer. It is cut square; a tall one keeps its top, where a face mostly is. A character's versions share it.", + voiceCoverFromFile: "From the computer", + voiceCoverRemove: "Remove the cover", + voiceCoverSaving: "Saving…", + voiceCoverFailed: "The cover was not changed: {why}", + voiceCoverUnreadable: "This picture could not be read", audioDragCharacterTitle: "Drop onto a GPT-SoVITS node to set its character to {character}. Switch emotions with {tags} in the text.", ttsPronounceOpen: 'Pronunciation', ttsPronounceTitle: 'Pronunciation: {name}', diff --git a/web/modules/ui_ai_gallery_pick.js b/web/modules/ui_ai_gallery_pick.js index 2850784..62418c3 100644 --- a/web/modules/ui_ai_gallery_pick.js +++ b/web/modules/ui_ai_gallery_pick.js @@ -43,9 +43,10 @@ export async function galleryPictures(page, limit, query = '', signal = undefine /** * The gallery as a grid of pictures to pick: a search line and the newest pictures, more as it * scrolls. `picked(id)` says whether one is picked, `pick(item)` picks or drops it (the caller - * keeps what is picked). Returns {search, grid, mark() (shows what is picked again), stop()}. + * keeps what is picked). Returns {search, grid, mark() (shows what is picked again), stop()}. Also the + * voice characters' cover picker's (voice_covers.js). */ -function pictureGrid({ picked, pick }) { +export function pictureGrid({ picked, pick }) { const abort = new AbortController(); let page = 0; let pages = 1; diff --git a/web/modules/ui_audio_studio.js b/web/modules/ui_audio_studio.js index 91b09a5..52e3f20 100644 --- a/web/modules/ui_audio_studio.js +++ b/web/modules/ui_audio_studio.js @@ -12,6 +12,7 @@ import { bindTtsFileDrop } from './ui_tts_file_drop.js'; import { openTtsImport } from './ui_tts_import.js'; import { stopScriptDirectorPreview } from './ui_script_director.js'; import { renderVoiceTabs } from './ui_voice_tabs.js'; +import { changeCover, drawAvatar, loadCovers } from './voice_covers.js'; // The node interface that removes a whole character (POST /anomalous_tts/character/remove). const REMOVE_CHARACTER_FORMAT = 19; @@ -20,7 +21,9 @@ const REMOVE_CHARACTER_FORMAT = 19; * The Voices page's Characters view: GPT-SoVITS character cards (Anomalous_TTS, detected * at runtime), preview playback, tag copying, dropping a character onto a TTS node, the * editors, and each card's way to the Voice-over view (ui_script_page.js), where scripts - * are written and generated. + * are written and generated. A card reads like a profile: its cover (voice_covers.js: a click + * changes it), the name big with its version under it, one small line of facts, plain buttons, + * and its voices as rows parted by thin lines. */ const renderTokens = new WeakMap(); @@ -208,46 +211,45 @@ function renderCharacterCard(group, owner, { onChanged, canImport = false, tunin const titleGroup = document.createElement('div'); titleGroup.className = 'anomalous-character-voice-title'; - const avatar = document.createElement('div'); + // The cover, else the name's first letter; a click changes it (a button: no drag starts there). + const avatar = document.createElement('button'); + avatar.type = 'button'; avatar.className = 'anomalous-character-voice-avatar'; - avatar.innerHTML = SVG.MIC; + avatar.title = t('voiceCoverChange'); + avatar.dataset.hint = t('voiceCoverChange'); // shown over it under the pointer: it can be clicked + drawAvatar(avatar, group.character); + avatar.onclick = (event) => { + event.stopPropagation(); + changeCover(group.character); + }; const nameBox = document.createElement('div'); nameBox.className = 'anomalous-character-voice-names'; const name = document.createElement('span'); name.className = 'anomalous-character-voice-name'; - // GPT-SoVITS versions are named "角色/日配"; show the version as a quiet suffix. + // GPT-SoVITS versions are named "角色/日配": the name big, the version under it in grey. const cut = group.character.lastIndexOf('/'); name.textContent = cut > 0 ? group.character.slice(0, cut) : group.character; + name.title = group.character; + nameBox.append(name); if (cut > 0) { const version = document.createElement('span'); version.className = 'anomalous-character-voice-folder'; version.textContent = group.character.slice(cut + 1); - name.append(' ', version); - } - name.title = group.character; - if (group.model_version) { - const model = document.createElement('span'); - model.className = 'anomalous-character-voice-model'; - model.textContent = group.model_version; - model.title = t('ttsModelVersion', { version: group.model_version }); - name.append(model); + nameBox.append(version); } - const sub = document.createElement('span'); - sub.className = 'anomalous-character-voice-hint'; - sub.textContent = t('audioDragCardHint'); - sub.title = sub.textContent; - - nameBox.append(name, sub); - - const countBadge = document.createElement('span'); - countBadge.className = 'anomalous-character-voice-count'; - countBadge.textContent = t('audioVoiceClipCount', { count: group.total_slices }); + // One small line of facts: the model version, how many voices, and that the header drags onto a node. + const facts = document.createElement('span'); + facts.className = 'anomalous-character-voice-hint'; + facts.textContent = [group.model_version, t('audioVoiceClipCount', { count: group.total_slices }), group.has_main ? t('audioDragCardHint') : ''] + .filter(Boolean).join(' · '); + facts.title = group.model_version ? t('ttsModelVersion', { version: group.model_version }) : ''; + nameBox.append(facts); // Only a draggable header shows a grip; without a main voice there is nothing to drag. - titleGroup.append(...(group.has_main ? [gripIcon()] : []), avatar, nameBox); + titleGroup.append(avatar, nameBox, ...(group.has_main ? [gripIcon()] : [])); const headerRight = document.createElement('div'); headerRight.className = 'anomalous-character-voice-actions'; if (!group.raw.error) { @@ -273,8 +275,8 @@ function renderCharacterCard(group, owner, { onChanged, canImport = false, tunin remove.onclick = () => requestRemoveCharacter(group, onChanged); headerRight.append(remove); } - headerRight.append(countBadge); - header.append(titleGroup, headerRight); + nameBox.append(headerRight); // the buttons under the name, plain words + header.append(titleGroup); if (group.has_main) { header.classList.add('is-draggable'); header.title = t('audioDragCharacterTitle', { character: group.character }); @@ -489,6 +491,8 @@ export async function renderAudioStudio(container, { filter = null, owner = null studioWrapper.lastChild.replaceWith(renderEmptyGuide(canImport ? onImport : null)); return; } + await loadCovers(); + if (renderTokens.get(container) !== token) return; const tuning = Number(ttsStatus?.format) >= PRESETS_FORMAT; // generation settings: Anomalous_TTS 1.1.0+ const removable = canImport && Number(ttsStatus?.format) >= REMOVE_CHARACTER_FORMAT; characters.forEach(group => grid.appendChild(renderCharacterCard(group, owner, { onChanged, canImport, tuning, removable }))); diff --git a/web/modules/voice_covers.js b/web/modules/voice_covers.js new file mode 100644 index 0000000..edbfdc9 --- /dev/null +++ b/web/modules/voice_covers.js @@ -0,0 +1,150 @@ +/** + * Covers of the voice characters (api/voice_covers.py) on the Voices page's cards (ui_audio_studio.js): the cover, + * or the name's first letter, in the card's avatar, and a click there to change it: a picture from the gallery (the + * AI box's grid, ui_ai_gallery_pick.js) or from the computer, cropped square by the page (a tall one keeps its top, + * where a face mostly is) and sent small; or taken away. One per character's name: its versions share it. + */ + +import { translate as t } from './locales.js'; +import { aiWindow } from './ui_ai_connect.js'; +import { pictureGrid, thumbUrl } from './ui_ai_gallery_pick.js'; +import { anomalousAlert } from './ui_dialog.js'; + +const SIZE = 256; +const TOP_SHARE = 0.12; // of what a tall picture loses, taken from its top +let known = null; // {name: stamp} + +function el(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; +} + +function button(className, label, run) { + const node = el('button', className, label); + node.type = 'button'; + node.onclick = run; + return node; +} + +/** The character's name without its version ("角色/日配" -> "角色"). */ +export function baseName(character) { + const name = String(character || '').trim(); + const cut = name.lastIndexOf('/'); + return cut > 0 ? name.slice(0, cut).trim() : name; +} + +/** Where a picture of `width` x `height` is cut square: {x, y, side}; a tall one keeps its top. */ +export function squareCut(width, height) { + const side = Math.min(width, height); + return { x: Math.round((width - side) / 2), y: Math.round(height > width ? (height - side) * TOP_SHARE : (height - side) / 2), side }; +} + +/** The covers there are, read once (`again` reads them anew). */ +export async function loadCovers(again = false) { + if (known && !again) return known; + try { + known = (await (await fetch('/anomalous/voice_covers')).json()).covers || {}; + } catch { + known = {}; + } + return known; +} + +const coverUrl = name => (known?.[name] ? `/anomalous/voice_cover?${new URLSearchParams({ name, v: known[name] })}` : ''); + +/** Fills avatar `box` for `character`: its cover, else its name's first letter. */ +export function drawAvatar(box, character) { + const name = baseName(character); + const url = coverUrl(name); + box.dataset.voiceCover = name; + box.classList.toggle('has-cover', Boolean(url)); + if (url) { + const picture = el('img'); + picture.src = url; + picture.alt = ''; + box.replaceChildren(picture); + } else { + box.replaceChildren(el('span', 'anomalous-voice-initial', Array.from(name)[0] || '?')); + } +} + +/** Every avatar of `name` on the page, drawn again. */ +function redraw(name) { + document.querySelectorAll('[data-voice-cover]').forEach((box) => { + if (box.dataset.voiceCover === name) drawAvatar(box, name); + }); +} + +const loadPicture = url => new Promise((resolve, reject) => { + const picture = new Image(); + picture.onload = () => resolve(picture); + picture.onerror = () => reject(new Error(t('voiceCoverUnreadable'))); + picture.src = url; +}); + +async function keep(name, url) { + const picture = await loadPicture(url); + const { x, y, side } = squareCut(picture.naturalWidth, picture.naturalHeight); + const canvas = document.createElement('canvas'); + canvas.width = SIZE; + canvas.height = SIZE; + canvas.getContext('2d').drawImage(picture, x, y, side, side, 0, 0, SIZE, SIZE); + const response = await fetch('/anomalous/voice_cover', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, image: canvas.toDataURL('image/png') }) }); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`); + known = { ...known, [name]: data.stamp }; + redraw(name); +} + +/** The window to change `character`'s cover: the gallery to pick from, a picture from the computer, or none. */ +export function changeCover(character) { + const name = baseName(character); + const body = el('div', 'anomalous-voice-cover-pick'); + const said = el('div', 'anomalous-voice-cover-said'); + let close = () => {}; + let pictures = null; + const done = async (work) => { + said.textContent = t('voiceCoverSaving'); + try { + await work(); + pictures?.stop(); + close(); + } catch (error) { + said.textContent = ''; + await anomalousAlert(t('voiceCoverFailed', { why: String(error.message || error).slice(0, 200) })); + } + }; + const file = el('input'); + file.type = 'file'; + file.accept = 'image/*'; + file.hidden = true; + file.onchange = () => { + const picked = file.files?.[0]; + if (!picked) return; + const url = URL.createObjectURL(picked); + void done(() => keep(name, url)).finally(() => URL.revokeObjectURL(url)); + }; + const actions = el('div', 'anomalous-voice-cover-actions'); + actions.append(button('anomalous-btn', t('voiceCoverFromFile'), () => file.click())); + if (known?.[name]) { + actions.append(button('anomalous-btn', t('voiceCoverRemove'), () => done(async () => { + const response = await fetch('/anomalous/voice_cover/remove', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`); + const { [name]: _gone, ...rest } = known; + known = rest; + redraw(name); + }))); + } + pictures = pictureGrid({ picked: () => false, pick: item => void done(() => keep(name, thumbUrl(item.image))) }); + body.append(el('p', 'anomalous-voice-cover-lead', t('voiceCoverLead')), actions, file, pictures.search, pictures.grid, said); + const shut = aiWindow(t('voiceCoverTitle', { name }), body); + close = () => { + pictures.stop(); + shut(); + }; + pictures.search.focus(); +} diff --git a/web/styles/11-audio-studio.css b/web/styles/11-audio-studio.css index 9a6a09c..d2380af 100644 --- a/web/styles/11-audio-studio.css +++ b/web/styles/11-audio-studio.css @@ -223,38 +223,36 @@ gap: 16px; } +/* A character's card reads like a profile (ui_audio_studio.js): its cover, the name big with its version + under it, one small line of facts, plain buttons, and its voices as rows parted by thin lines. Flat: no glow. */ .anomalous-character-voice-card { - background: var(--amb-lt-surface, rgba(18, 18, 23, 0.75)); + background: var(--amb-bg-card); border: 1px solid var(--amb-border); border-radius: 12px; - padding: 16px; + padding: 18px; display: flex; flex-direction: column; - gap: 12px; - backdrop-filter: blur(20px); - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); - transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.2s ease, box-shadow 0.2s ease; + gap: 14px; + transition: border-color 0.2s ease; } .anomalous-character-voice-card:hover { - border-color: color-mix(in srgb, var(--amb-link) 25%, transparent); - box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45), 0 0 15px color-mix(in srgb, var(--amb-link) 8%, transparent); + border-color: var(--amb-border-strong); } .anomalous-voice-slice-row { display: flex; align-items: center; gap: 10px; - padding: 8px 12px; - border-radius: 8px; - background: rgba(var(--amb-ink-rgb), 0.02); - border: 1px solid var(--amb-border); - transition: all 0.18s cubic-bezier(0.16, 1, 0.3, 1); + padding: 9px 4px; + border-top: 1px solid var(--amb-border); + transition: background-color 0.15s ease; } +.anomalous-voice-slice-row:last-child { border-bottom: 1px solid var(--amb-border); } + .anomalous-voice-slice-row:hover { - background: rgba(var(--amb-ink-rgb), 0.06); - border-color: var(--amb-border-strong); + background: rgba(var(--amb-ink-rgb), 0.04); } .anomalous-character-voice-header { @@ -268,41 +266,68 @@ .anomalous-character-voice-title { display: flex; flex: 1 1 160px; - align-items: center; - gap: 10px; + align-items: flex-start; + gap: 16px; min-width: 0; } +/* The cover (voice_covers.js), or the name's first letter; a click changes it. */ .anomalous-character-voice-avatar { - width: 32px; - height: 32px; - border-radius: 8px; - background: rgba(var(--amb-ink-rgb), 0.06); + width: 76px; + height: 76px; + padding: 0; + overflow: hidden; + border-radius: 14px; + background: color-mix(in srgb, var(--amb-link) 12%, transparent); border: 1px solid var(--amb-border); display: flex; align-items: center; justify-content: center; - color: var(--amb-text-muted); + color: var(--amb-link); flex-shrink: 0; + cursor: pointer; + transition: border-color 0.15s ease, filter 0.15s ease; } +.anomalous-character-voice-avatar:hover { border-color: var(--amb-link); } +.anomalous-character-voice-avatar { position: relative; } +.anomalous-character-voice-avatar::after { + content: attr(data-hint); + position: absolute; + inset: auto 0 0; + padding: 3px 0; + background: rgba(0, 0, 0, 0.55); + color: #fff; + font-size: 11px; + text-align: center; + opacity: 0; + transition: opacity 0.15s ease; +} +.anomalous-character-voice-avatar:hover::after, +.anomalous-character-voice-avatar:focus-visible::after { opacity: 1; } +.anomalous-character-voice-avatar img { width: 100%; height: 100%; object-fit: cover; } +.anomalous-voice-initial { font-size: 30px; font-weight: 600; line-height: 1; } + .anomalous-character-voice-names { display: flex; + flex: 1; flex-direction: column; + gap: 2px; min-width: 0; } .anomalous-character-voice-name { - font-size: 14px; + font-size: 22px; font-weight: 600; + line-height: 1.2; color: var(--amb-text-main); overflow-wrap: anywhere; } .anomalous-character-voice-folder { - font-size: 11px; + font-size: 13px; font-weight: 400; - color: var(--amb-link); + color: var(--amb-text-muted); } /* GPT-SoVITS model version (v2, v4, v5dev…) after the name */ @@ -318,11 +343,10 @@ } .anomalous-character-voice-hint { - overflow: hidden; - font-size: 11px; - color: var(--amb-text-dim); - text-overflow: ellipsis; - white-space: nowrap; + margin-top: 4px; + font-size: 12px; + color: var(--amb-link); + line-height: 1.5; } .anomalous-character-voice-count { @@ -348,7 +372,6 @@ .anomalous-character-voice-slices { display: flex; flex-direction: column; - gap: 6px; } .anomalous-audio-eq-bars { @@ -391,18 +414,19 @@ display: inline-flex; align-items: center; gap: 4px; - padding: 2px 8px; + padding: 2px 4px; border-radius: 4px; - border: 1px solid var(--amb-border); - background: var(--amb-lt-sunken, rgba(0, 0, 0, 0.3)); - color: var(--amb-text-muted); - font-size: 11px; - font-family: monospace; + border: 0; + background: transparent; + color: var(--amb-link); + font-size: 11.5px; cursor: pointer; flex-shrink: 0; - transition: color 0.15s ease, border-color 0.15s ease; + transition: color 0.15s ease; } +.anomalous-voice-copy-btn:hover { text-decoration: underline; } + .anomalous-voice-copy-btn.is-copied { color: var(--amb-ok); border-color: rgba(52, 211, 153, 0.4); @@ -1241,15 +1265,32 @@ /* ---------- GPT-SoVITS cards ---------- */ /* Wraps in a narrow card instead of pushing the page sideways. */ .anomalous-character-voice-actions { - display: inline-flex; + display: flex; flex-wrap: wrap; - justify-content: flex-end; + justify-content: flex-start; align-items: center; - gap: 6px 8px; + gap: 2px; min-width: 0; - margin-left: auto; + margin: 8px 0 0 -8px; } +/* On a card the tool buttons are plain words: the card is not a toolbar. */ +.anomalous-character-voice-actions .anomalous-audio-tool-btn { + border-color: transparent; + background: transparent; + color: var(--amb-text-soft); +} + +.anomalous-character-voice-actions .anomalous-audio-tool-btn:hover { background: rgba(var(--amb-ink-rgb), 0.08); color: var(--amb-text-main); } + +/* The cover picker (voice_covers.js): the computer or none, then the gallery to pick from. */ +.anomalous-voice-cover-pick { display: flex; flex-direction: column; gap: 10px; width: min(620px, 86vw); } +.anomalous-voice-cover-lead { margin: 0; color: var(--amb-text-soft); font-size: 12.5px; line-height: 1.6; } +.anomalous-voice-cover-actions { display: flex; flex-wrap: wrap; gap: 8px; } +.anomalous-voice-cover-pick .anomalous-ai-pics-grid { max-height: min(420px, 55vh); overflow-y: auto; } +.anomalous-voice-cover-said { color: var(--amb-text-muted); font-size: 12px; } +.anomalous-voice-cover-said:empty { display: none; } + .anomalous-audio-tool-btn.is-compact { padding: 3px 8px; font-size: 11px;