Compare commits

...
40 Commits
Author SHA1 Message Date
DemonGatanjieuandClaude Opus 5.5 730aec54f7 v1.61.0
Update guide for 1.61 (quick search, canvas menu, combos kept from pictures, tidier model
folders, multi-character voice-over), CHANGELOG heading dated, README badge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 22:04:01 +08:00
DemonGatanjieuandClaude Opus 5.5 0e1fc778d5 Hide the floating button while a model picker is open
Opened from the canvas's right-click menu the browser is closed, so the floating button
(topmost on purpose) sat on the picker's head.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:27:30 +08:00
DemonGatanjieuandClaude Opus 5.5 8df38d1c8d Clear detached graphs after reading them (prompt boxes left on the page)
A workflow read off a picture (keep as combo / workflow, parameter compare) was configured
on a detached LiteGraph graph whose nodes still made ComfyUI DOM widgets; they stayed on
the page at its top left. readDetached reads, then clears the graph.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:25:28 +08:00
DemonGatanjieu cac468c808 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	CHANGELOG.md
2026-10-25 15:05:02 +08:00
Claude 1f6bdd501f Merge branch 'feature/remote-access' into feature/1.61
# Conflicts:
#	CHANGELOG.md
2026-10-22 21:46:41 +00:00
Claude 6634f17bdd Merge branch 'feature/remote-access' into feature/1.61
# Conflicts:
#	CHANGELOG.md
2026-10-22 21:42:45 +00:00
DemonGatanjieuandClaude Opus 5.5 dd2ea0e353 Merge feature/remote-access: the Put on canvas fix now listed under 1.60.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:03:36 +08:00
DemonGatanjieuandClaude Opus 5.5 31b3925805 Combos: the browser no longer opens blank after Put on canvas; Voice-over says when ▶ reads a sample
Put on canvas hid the combo page before closing the browser, so the next
open showed the Combos title over nothing; now only the browser closes and
it opens again on the list or the combo. A character with no line in the
script hears a sample sentence on ▶, which the panel now says; on the
whole script ▶ reads the line with its own character.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:53:35 +08:00
DemonGatanjieuandClaude Opus 5.5 596ac9728d Reference audio editor: move a clip to the Recycle Bin from its row
🗑 on a row asks first (saying where the clip is used), then has the node
move it with its own .txt / .lab to the Recycle Bin (never a permanent
delete) and take it out of the settings; the window drops it from its
voices too. Needs Anomalous_TTS interface 18.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:38:03 +08:00
DemonGatanjieuandClaude Opus 5.5 a3ce3cae94 Reference audio editor: every clip listed, main and auxiliary references
"Reference audio and emotions" replaces Edit emotions: a large window with
the voices on top (main voice, emotions, new emotion) and every clip of the
character below, one row each (listen, file and length, its line editable
in place, where else it is used, Main / Aux for the voice picked), with
filters, search and paging. Auxiliary references (timbre averaged in, v1 /
v2 / v2Pro), fixed lines (`lines`) and lengths need Anomalous_TTS
interface 18. tts_voice_refs.js holds the rules; the old clip search and
merge helper go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:52:19 +08:00
DemonGatanjieuandClaude Opus 5.5 0a315bf1f8 Voice-over: a drafts box
Every script is a draft, saved on its own in ComfyUI's user folder
(anomalous_model_browser/voice_over_drafts.json through /userdata), read
again and joined before each write so two tabs keep each other's drafts.
A bar over the script names it and leads to New, Drafts and Close; the box
shows a card per draft (open, rename, copy, delete) and Recently deleted
(30 days). A reload opens the draft that was open; a file voiced again
from the gallery becomes a draft of its own; the one localStorage draft
of the earlier build moves in once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 05:01:26 +08:00
DemonGatanjieuandClaude Opus 5.5 2a7cfe5075 Voice-over: three layers, whole script / character / line, and a draft
The side panel changes one layer at a time under a path (Whole script ›
character › line); each layer has the same rows (speed, tone, language),
each saying where its value comes from, with ↺ to follow the layer above.
The more particular wins: line, character, whole script, the character's
usual settings. The whole script's values go into the node's inputs and
only lines that differ get tags. Lasting settings are links at the foot.
The script is kept as a draft in this browser and comes back after a
reload; editing the text keeps unchanged lines' own settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 04:21:00 +08:00
DemonGatanjieuandClaude Opus 5.5 db045b8f57 Voice pages: tabs instead of folds
GPT-SoVITS Settings gets a status chip by the title and tabs (tones, other
languages, base files with a dot when they need the user, where things are,
version); the tones have a Simple / Numbers switch. Voice-over's side panel
gets How it reads / Numbers / Settings tabs for a character or picked lines.
The More fold is gone; ui_tab_strip.js replaces ui_tts_fold.js.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 02:39:53 +08:00
DemonGatanjieuandClaude Opus 5.5 0c1287ddc2 Voice pages: less on screen, plainer words
Voice-over's side panel shows who reads, speed, the language (one line
while detected) and the tone with a listen button; the comparison, the
numbers and the links fold under More. GPT-SoVITS Settings opens with one
status line and the tone to use; the language rules, base files, places
and version are folded and open by themselves when they need the user.
Plainer words throughout: tone, Steady / Natural / Lively, As the
character, Always read like this, Own settings, base files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 02:19:32 +08:00
DemonGatanjieuandClaude Opus 5.5 31ab335a25 Voice-over: with many characters, Read by lists the script's own and a searchable menu for the rest
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 01:55:37 +08:00
DemonGatanjieuandClaude Opus 5.5 09ae7c6b33 Voice-over: pick on the left, change it on the right; settings per character and per line
The side panel follows what is picked: the whole script, one character in
it (its language, speed and mode for this script), or the picked lines (who
reads them, and their own values, each following the character until set).
A line set for itself carries a tag on its card. The values reach
Anomalous_TTS interface 17 as [lang:…][speed:…][preset:…] on each line;
an older node reads the whole script as before. Clicking a character in the
cast now shows it instead of giving it the picked lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 01:28:22 +08:00
DemonGatanjieuandClaude Opus 5.5 f3517a986d Tidy: drop CSS no element can match any more
69 rules and 21 selectors in lists named plugin classes no script makes (old paper-note
cards, the old drawer header, the combo editor's old toolbar, icon buttons, tag colours…).
No behaviour or look changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 00:10:39 +08:00
DemonGatanjieuandClaude Opus 5.5 bd156625fc Tidy: split the two files over 600 lines, drop 221 unused UI texts
ui_tts_import.js gives its frame and toolbar to ui_tts_import_screens.js and its window
drop to ui_tts_file_drop.js; material_inspector.js gives the PNG chunk reader to
png_metadata.js. 221 texts no code uses any more leave locales.js (both languages).
No behaviour changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 00:05:40 +08:00
DemonGatanjieuandClaude Opus 5.5 806bf07565 Merge branch 'feature/remote-access' into feature/1.61
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:54:13 +08:00
DemonGatanjieuandClaude Opus 5.5 4f9f1bddba Decisions: AD-020 (downloads and ComfyUI-Manager), AD-021 (a combo never changes a value)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:38:44 +08:00
DemonGatanjieuandClaude Opus 5.5 0d4d174e7f Model Check: Search Hugging Face for a foundation component no source knows
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:33:15 +08:00
DemonGatanjieuandClaude Opus 5.5 1da4386d42 Docs: Model Check's rules where the repository keeps guides (docs/guides/model-check-rules.md)
docs/ itself is ignored but for its folders, so the page was never committed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:10:10 +08:00
DemonGatanjieuandClaude Opus 5.5 c62fe4fb5e Model Check: ComfyUI-Manager's place in the rules
A download by name says where the list's file is; an entry of no stated type no longer
fits a node of known type; without the Manager, the page says it would be one more
source. docs/model-check-rules.md gets a section on the Manager.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:08:29 +08:00
DemonGatanjieuandClaude Opus 5.5 db3e94a8dc Docs: Model Check's rules in one place (docs/model-check-rules.md)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 22:40:30 +08:00
DemonGatanjieuandClaude Opus 5.5 7a59068ebb A picture kept as a combo keeps its nodes, every value as it was
Keep -> Its combo now saves the nodes upstream of the picture's samplers' model and prompt
inputs (loaders, LoRA chains, CLIP skip, both prompts) as a node-structure combo, values
untouched; models are named by where they are here, missing ones kept by the picture's name.
A workflow without a sampler still gives the simple combo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:37:46 +08:00
DemonGatanjieuandClaude Opus 5.5 b5f66fae91 Rules: feature principles (Model Check, combos never change a value); Manager's model list in the docs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:16:52 +08:00
DemonGatanjieuandClaude Opus 5.5 2f7cda709c Right-click menu settings: placement as two drawn cards, a preview line
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:02:51 +08:00
DemonGatanjieuandClaude Opus 5.5 2836cceb7c A combo's page redone; the gallery's Kept view
The combo editor gets a head with its picture (See the picture), a large name with a
Rename button, chips of what it holds and Put on canvas; models show only what is chosen,
Change / Add open the grid of compatible ones. No Save button: everything saves itself
(a tag changed in the bilingual view now saves too). The gallery's ★ Kept lists only the
pictures kept as a workflow, combo or prompt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:52:03 +08:00
DemonGatanjieuandClaude Opus 5.5 58b11deae0 Quick search on the canvas (Alt+B)
A bar over the canvas finds combos, models and saved prompts as you type
(GET /anomalous/quick_index lists the models in a few words) and puts the chosen one
where the pointer was. Also in the empty canvas's right-click menu, now registered.
Saved model paths in a linked model folder get their real place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:34:41 +08:00
DemonGatanjieuandClaude Opus 5.5 de94e090bb Canvas right-click menus: AMB's entries, set in Settings -> Canvas
A node's menu offers Open in AMB, change model by picture, insert a LoRA before / after,
saved values, the model's pictures and details, save values and save as combo, each where
it applies; the empty canvas's menu opens AMB's pages. Entries can be switched off, moved,
and put in one AMB submenu or right in the menu. The model picker's Esc closes only the
picker, wherever the focus is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:19:43 +08:00
DemonGatanjieuandClaude Opus 5.5 74beded02c Combos kept from a picture show it and get a telling name
The card shows the output image with the main model's cover as a badge; the name is the
first LoRA (else the main model) and two telling tags; the toast after keeping shows the
picture and renames the combo on the spot, and the editor's title renames on a click and
opens the picture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:06:32 +08:00
DemonGatanjieuandClaude Opus 5.5 b899348435 Changelog: the narrow-window reference fix ships in 1.60, said once
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 02:02:27 +08:00
DemonGatanjieu 2206f78f7e Merge branch 'feature/model-import' into feature/1.61 2026-10-19 02:02:17 +08:00
DemonGatanjieuandClaude Opus 5.5 6a9f2f7ed4 Tests: side files beside the model whatever this computer chose
With Settings set to the hidden .amb folder on this computer, nine tests
read the real api/config.json and looked for side files in the wrong
place. tests/isolated_layout.py points model_sidecars at no config, so
the tests that write side files always get the default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 01:54:05 +08:00
DemonGatanjieuandClaude Opus 5.5 61c5affc23 Prompt Studio: in a narrow window the reference takes the cards' place
Three columns in a side-panel window left each about 200px, tags one per
line and a sideways scroll. Below 980px the open reference now replaces
the card list (two columns of half the width each), and "‹ Cards" brings
the list back; wide windows keep three columns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 01:54:05 +08:00
DemonGatanjieuandClaude Opus 5.5 48ad658d0c Prompt Studio: cards drawn as notebook pages
The card list read as plain grey rows. Each card is now a page with a
bound spine in its role's colour, its title, word count and role label,
and its first tags on two ruled lines; Common and My prompts are index
tabs with their counts, and the card open as the reference wears a
ribbon (deck.setOpen, fed by the reference column's onShow).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 01:16:27 +08:00
DemonGatanjieuandClaude Opus 5.5 9c814ff0c1 Tidier model folders: side files in a hidden .amb, no duplicate covers
A user found model folders cluttered after scans: four files per model.
model_sidecars.py now says where the plugin's own side files are (.info,
.anomalous.json, .civitai_bak.*): beside the model, or in the folder's
hidden .amb (Settings -> Models -> Files that come with models). Every
reader and writer goes through it and reads both places; covers stay
beside the model for ComfyUI and other plugins.

Scans no longer keep a copy of Civitai's picture identical to the cover:
the cover is noted as Civitai's (SHA-256 in the .info) and a copy is kept
aside only while the user's own cover shows. api/sidecar_layout.py moves
existing files and drops the duplicates to the Recycle Bin. The folder
tree leaves .amb out; the duplicated tree builder and cover owner check
are shared now (model_catalog.py, backup.py back under 600 lines).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:54:28 +08:00
DemonGatanjieu 761575acf6 Merge branch 'feature/model-import' into feature/1.61 2026-10-18 23:31:30 +08:00
DemonGatanjieuandClaude Opus 5.5 fe60ede301 Voice-over cards: pick with a click, edit with a double-click; each line has its character
Every line is read by its own character: pasted and added lines go to
the one picked in the list on the left, the node's character is the
first line's, and the "Who speaks" bar is gone. Cards (ui_script_cards.js)
show the text as text: a click picks, a double-click or the pencil edits
it in place (Enter keeps, Esc puts back), and the name on a card opens a
menu of characters (all picked lines when it is one of them). Language
and speed are no longer offered to keep when several characters read;
the settings link names the listened line's character.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 00:20:01 +08:00
DemonGatanjieuandClaude Opus 5.5 89fa1a93bf Voice-over: several characters in one script
Pick lines on Voice-over (click; Ctrl / Cmd adds, Shift a run; Esc lets
go) and click a character in the list on the left to have it read them;
with none picked, the click changes who reads by default. On Voice-over
the list is the cast: narrower, characters with their colours, and a
click never leaves the page. Cards show who reads each line in its
colour, with that character's emotions; the bundle carries [name] where
the speaker changes, so generating, the cards' listen button, pushing,
dragging and copying follow it, and a file voiced again from the audio
gallery keeps its speakers. The character cards' Voice-over button is
gone. The workflow actions move to ui_script_workflow.js and the cast
helpers to ui_script_cast.js, keeping the director under 600 lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:53:03 +08:00
140 changed files with 9098 additions and 3235 deletions
+7
View File
@@ -122,6 +122,13 @@ ComfyUI 的内存、显存和 CPU 是留给出图的。插件闲着的时候应
3. **数据跟着界面走。** 界面打开时才读,关掉就释放;跨次使用的缓存要小、有上限,写清楚何时失效。长列表只渲染看得到的或前几十条。
4. **图片用缩略图。** 网格和列表用缓存的缩略图,原图只在看大图或拖到画布时读取;浏览器里不常驻大量原图。
### 功能原则
几个功能对用户的承诺。改这些功能时不能破坏;确需例外,先问维护者:
1. **模型检查(模型医生)从不自己改工作流。** 检查只读;只有用户按了“换上”或“下载”才动节点,而且只换成同一个文件(SHA-256 指纹一致)。只是同名、没法核对的候选,标明“按文件名”由用户决定。下载的来源(C 站按指纹、工作流带的链接、ComfyUI-Manager 模型清单)在界面上写明。完整的判断规则(三档可信度、基础组件只认指纹、下载来源与核对)见 `docs/guides/model-check-rules.md`,改模型检查前先读、改了规则同时改它。
2. **搭配只记住,从不修改参数数值。** 存的时候是什么,放回画布就是什么:强度、CLIP 层数、提示词等一律原样,不用节点默认值顶替,也不替用户“调”。想要别的数值,在画布上调好再存一个新搭配。放上画布只新放一组节点,从不改画布上已有的节点。(理由见 `docs/decisions/README.md` AD-021;模型检查的见 AD-005、AD-006、AD-020。)
## 6. 清理旧实现
- 替换一条路径时搜索它的导入、导出、调用、字段、事件和选择器;确认无用后一起移除。
+114 -40
View File
@@ -103,6 +103,9 @@ covered by the `styles.css` manifest.
model located so the browser can open it (`/anomalous/scan_model`).
`api/kept_images.py` says what each output image was kept as, for the gallery's star: the
Workflow Recipe, combo or saved prompt that names it as its source image.
`api/quick_index.py` (`GET /anomalous/quick_index`) lists every model file in a few words
(type, place, shown name, base model, a few trigger words, cover URL) for the canvas's
quick search, read once when the bar opens.
`api/scan_summary.py` counts models matched on Civitai, unmatched and not yet scanned
for the scan page and lists the last two, by the same sidecar rules the scraper uses
(`sidecar_info`, `is_unmatched`, `unmatched_reason` in `model_identity.py`); it also
@@ -188,6 +191,14 @@ covered by the `styles.css` manifest.
- `api/media_routes.py`, `api/gallery_routes.py`, `api/translation_routes.py`, and
`api/folder_types.py` own the formerly mixed utility route families.
- `model_policies.py` owns shared backend rename and protected-category policy.
- `model_sidecars.py` says where a model's side files are, shared with the scanner: beside it,
or the plugin's own ones (`.info`, `.anomalous.json`, `.civitai_bak.*`) in the folder's hidden
`.amb` (`SIDECAR_LAYOUT` in api/config.json); reading looks in both places. Covers stay beside
the model. It also tells Civitai's cover from the user's (the SHA-256 a scan notes in the
`.info`, or a copy kept aside, kept only while the user's own cover shows).
`api/sidecar_layout.py` is Settings → Models → "Files that come with models"
(`/anomalous/sidecars/plan|apply`, apply this computer only): saves the place, moves the files
there and drops Civitai pictures kept twice to the Recycle Bin.
- `model_identity.py` owns file SHA-256 evidence and the base model a safetensors header
tells, shared with the standalone scanner (which runs as its own process, so the API
never imports `scraper.py`). `model_kind.py` reads a .safetensors / .gguf header (never
@@ -238,16 +249,18 @@ covered by the `styles.css` manifest.
next to the general ones, a character's `defaults` / `defaults.by_language` as
rows and back, keeping every other field, where a mode sits between steady and
lively, a line to listen with in each language). `ui_tts_preset_switch.js`
draws the modes as choices and the picked one against one fixed mode (the
default preset, or Auto): a steady-to-lively scale, a sentence, and folded, the
four numbers as the Current node card's bars, each with its plain name and the
node's own name (`top_k` …), editable in place. `ui_tts_mode_preview.js` makes
draws the tones (the presets) as choices with the caller's listen row, and when
asked for (Settings' Numbers switch, Voice-over's Numbers tab), the picked one
against one fixed tone (the default one, or a character's usual): a sentence, a
steady-to-lively scale and the four numbers as the Current node card's bars, each
with its plain name and the node's own name (`top_k` …), editable in place. `ui_tts_mode_preview.js` makes
and keeps the takes for listening (one line, one seed, only the mode changing,
through Preview Audio). `ui_tts_presets.js` is the group in the GPT-SoVITS
settings dialog (that switch with a character and a line to listen with, make
default / reset / new / rename / delete, rules per pair), saving each change at once; `ui_tts_tuning.js` is the
per-character dialog behind the card's Generation settings button, shown only
with interface 16.
default / reset / new / rename / delete; the rules per pair as a second part), saving each change at once; `ui_tts_tuning.js` is the
per-character dialog behind the card's Own settings button, shown only
with interface 16. `ui_tab_strip.js` is the row of tabs over one part at a time, shared by
that dialog and Voice-over's side panel (the tab left open is remembered).
- `web/main.js` coordinates extension registration (with `?v=...` versioned module imports busting aggressive browser ES Module caching and unconditional legacy storage key purging). `browser.js` owns the shared
browser class and extracted-method wiring; `browser_entry.js` owns the single
@@ -283,12 +296,20 @@ covered by the `styles.css` manifest.
requests; `invalidateEngineCache()` drops them after a save or an added voice,
and `invalidateEngineCache({ rescan: true })` (Refresh button) also makes the node
re-read its folders (`?refresh=1`).
`ui_audio_tts_editor.js` edits a character's emotion references through that API:
it opens at once, loads the character's audio list and their lines
(`/anomalous_tts/reference_lines`, interface 14; optional) in the background (save
waits for the list), and keeps settings fields it does not know.
`ui_tts_clip_search.js` is its clip search: by file name or line, the first 30
matches with listen, use as main voice and add as emotion.
`ui_audio_tts_editor.js` is the reference audio editor (a character card's "Reference audio
and emotions"): a large window with the voices on top (main voice, each emotion, new
emotion) and every clip of the character below, one row each (listen, file and length, its
line editable in place, where else it is used, Main / Aux for the voice picked), with filters,
a search and the first 60 rows then more. It opens at once and loads the character's clips,
their lines and lengths (`/anomalous_tts/reference_lines`) in the background (save waits for
them), and keeps settings fields it does not know; 🗑 on a row moves that clip to the
Recycle Bin through the node (`removeGptSovitsClips`, which also takes it out of the saved
settings) and out of the window's voices. `tts_voice_refs.js` holds its rules without
DOM: the voices read from the settings and the node's view (an emotion found from a file
name is written only once changed), one main reference and any number of auxiliary ones per
voice, the lines fixed here (`lines`; a voice's own `text` moves there), what keeps it from
saving, the settings written back, and the order of the list. Auxiliary references and
fixed lines need Anomalous_TTS interface 18 (auxiliary ones a v1 / v2 / v2Pro model).
`tts_setup_api.js` holds the other calls (pretrained downloads, chunked
upload, inspect, commit, discard) and the pure import-form rules (`importKind`,
`pickWeights`, `nameConflict`, `textFromFile`, `buildImportBody`, `importProblem`,
@@ -297,9 +318,12 @@ covered by the `styles.css` manifest.
`ui_tts_setup.js` is the GPT-SoVITS settings dialog, opened from the sidebar
footer (`setupAttention` gives that entry its dot). Missing pretrained files only get a dismissable dot
when the studio's characters need them (dismissed ids in `localStorage`; a newly
needed file brings it back); missing packages get a red one. Inside: the storage
place and other places with characters (shown, not changed), pretrained files
and package sources, packages, and the settings file where folders are changed.
needed file brings it back); missing packages get a red one. Inside: a status chip
by the title (ready, files to download, packages missing), then tabs: the tones, the
tones for speaking another language, the base files (pretrained files and package
sources, packages; a dot when they need the user, and the dialog opens there), where
things are (the storage place and other places with characters, shown, not changed,
and the settings file where folders are changed) and the version.
It polls the status only while a download runs and it is open.
`ui_tts_import.js` is the import window. It first asks
what the user has (`mode`: `single` = one card led by its checklist, `batch` = a
@@ -336,10 +360,10 @@ covered by the `styles.css` manifest.
"show all", left-out files folded, line files, language) and the unassigned card
(one folded line per folder when large), and builds each row's elements once, so
they survive redraws and moves; `ui_tts_import_screens.js` holds the fixed
screens (the first question, the batch drop area, the "add more" menu) and the
tour steps. `ui_tts_file_drop.js` reads OS drops (walking dropped folders, only
files an import can use, up to 5000) for the studio, the sidebar and the
workbench.
screens (the window's frame, the toolbar over the cards, the first question, the
batch drop area, the "add more" menu) and the tour steps. `ui_tts_file_drop.js`
reads OS drops (walking dropped folders, only files an import can use, up to 5000)
for the studio, the sidebar and the workbench, and binds the import window's own drop.
- `audio_node_targets.js` is the single table of canvas nodes the audio studio
writes into (the character widget and the script widget of
`AnomalousTTS_CharacterSpeech`).
@@ -363,23 +387,46 @@ covered by the `styles.css` manifest.
(`buildScriptPackage`, with `[take:N]` for retakes), combo value
matching, and `buildTtsPrompt` (a GPT-SoVITS script as a ComfyUI API prompt
saving to `output/audio/<character>/`).
`ui_script_director.js` owns the script director, the Voice-over view's body: one
character, an emotion chip row per line card (with the chosen emotion's reference clip;
none when the character has only its main voice), editing buttons shown on hover, and a
bundle that is generated in the view, or, under "Into your own workflow", pushed to the selected/only target node,
dragged onto one (writing the character and script widgets together, one Ctrl+Z
step) or copied. The page feeds it the voice groups after each fetch.
`ui_script_director.js` owns the script director, the Voice-over view's body: the lines,
each read by its character (the one picked in the list on the left at first; the node's
own character is the first line's), and a bundle (`[name]` where the speaker changes, a
line's own `[lang:…][speed:…][preset:…]`) 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. `ui_script_cards.js` draws the line cards: a
click picks (`ui_script_cast.js`: Ctrl / Shift, the characters' colours), a double-click or
✎ edits the text in place, the name opens a menu of characters, emotion chips per the
line's character, a tag when the line is set for itself, buttons on hover.
The left side only picks; the side panel follows it (`ui_script_tune.js`) in three
layers for this script, under a path (Whole script › character › line): nothing picked,
the whole script (and each character in it); a character clicked in the list
(`castScriptCharacter`), that character in this script; picked lines, who reads them and
how. Each layer has the same rows (speed, tone, language) with where each value comes
from; lasting settings are links at the foot. `script_tune.js` holds the rules without
DOM: the layers (line over character over whole script over the character's usual
values), each holding only the fields set on it, and the tags Anomalous_TTS interface 17
reads (only where a line differs from what the node gives by itself; the whole script's
values go into the node's inputs). Every script is a draft in the drafts box
(`ui_script_drafts.js`): one file in ComfyUI's user folder
(`user/<user>/anomalous_model_browser/voice_over_drafts.json`, through ComfyUI's
`/userdata` API), saved half a second after a change, read again and joined before each
write so two tabs do not undo each other; a bar over the script names the open draft
and leads to New, the box (cards: open, rename, copy, delete) and Close; deleted drafts
stay in Recently deleted for 30 days. `script_drafts.js` holds its rules without DOM or
storage (the shapes, the merge, titles, when a draft changed). A reload opens the draft
that was open; the one draft the earlier version kept in `localStorage` moves in once.
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).
A card is one row (number, text, buttons); "Into your own workflow" is folded.
`ui_script_run.js` is the director's "Generate" section (the bar with the result and
a way to the audio gallery; language, speed and the mode: Auto, or one preset for
every line through the node's `preset` input, in the same switch measured
against Auto (compact: scale and numbers folded) and heard from the ▶ on a
line card (the takes heard stay on the side to compare), a preset's numbers
changed there saved into the preset; links to the presets and to the
character's generation settings): it runs
a way to the audio gallery; each character's values in this script, starting from its
saved defaults and Auto, which it draws through `ui_script_tune.js`; the modes in the
same switch measured against Auto (compact: scale and numbers folded) and heard on a
line from the panel or the ▶ on its card (the takes heard stay on the side to compare),
a preset's numbers changed there saved into the preset; links to the presets and to the
character's generation settings; a node older than interface 17 reads the whole script
as the first line's character reads, through its inputs): it runs
the script without the canvas, plays the result, retakes the whole script
(new seed) or one line (`[take:N]`, the node caches the rest), and saves a
character's language and speed to its `defaults` (keeping the rest of them)
@@ -394,7 +441,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
@@ -428,12 +477,28 @@ covered by the `styles.css` manifest.
`canvas_undo.js` keeps what each canvas entry did, in full, while the page stays open,
and undoes an entry from the log while nothing has changed it since (entries that
removed nodes or rewired existing ones are never offered).
- `canvas_menu.js` is AMB's part of the canvas's right-click menus (the extension's `getNodeMenuItems`
and `getCanvasMenuItems`, browser_entry.js): for a node, Open in AMB (`showForNode`: the page
opening with it selected shows; its saved values on Current node), change its model by picture
(`_openGalleryReplacer`), insert a LoRA before / after, the model's pictures and details
(`/anomalous/find_model`, `openModelDetail`), save its values or the picked nodes as a combo, each
only where it applies; for the empty canvas, AMB's pages. `canvas_menu_settings.js` keeps which
entries are on, their order and whether they sit in one "AMB" submenu or right in the menu
(localStorage, no DOM); `ui_canvas_menu_settings.js` is its Settings -> Canvas group (two placement cards drawing a small menu each, a preview line per list).
`ui_quick_search.js` is the canvas's quick search (Alt+B, the command
`Anomalous.ModelBrowser.QuickSearch`, or the empty canvas's menu): a bar that finds combos,
models and saved prompts as you type and puts the chosen one where the pointer was (a combo
through `sendNotebookToCanvas`, a model through `applyModelToCanvas`, both following the
pointer; a prompt as a new prompt node, `prompt_card_drag.js` `addPromptNode`); it takes every
key while open. `quick_search_data.js` reads its lists (kept a minute) and matches without
DOM; `MODEL_LOADERS` there is also the loader node `applyModelToCanvas` uses for each type.
`ui_settings_hub.js` owns the display preferences (view mode, scale, atmosphere, window
layout) and their application, the gear and language redraws; `ui_settings_page.js`
is the settings page the gear opens (a tool page with Back), in tabs: Look (look and
language, model cards and memory with the card image cache, opening mode and window),
Models (folders, where downloads and imports go), Workflows (fingerprints), Node packs
(those not used lately, `ui_pack_usage.js`), Prompts
Models (folders, where downloads and imports go, where the side files go through
`sidecar_layout.js`), Workflows (fingerprints), Node packs
(those not used lately, `ui_pack_usage.js`), Canvas (the right-click menus, `ui_canvas_menu_settings.js`), Prompts
(translation), Backup (`ui_backup.js`), Help; `ui_feedback_dialog.js` is the feedback window (Home and the
settings page open it): one text box, then `feedback.js` opens a GitHub issue with it in
the browser's language, the environment folded at the end when attached (versions and
@@ -490,9 +555,16 @@ covered by the `styles.css` manifest.
`ui_keep_menu.js` is the star's keep menu (also the workbench's Keep): the whole workflow, the combo or the prompts,
each saying where it goes, opening what was kept already or removing it (to the Recycle Bin through the recipe,
notebook or material delete route); `image_keep.js` does the keeping (an image as a combo,
its prompts as a saved prompt, reading what was kept) without DOM.
named by `comboTitle` — first LoRA or main model · two telling tags — and renamed by `renameCombo`;
its prompts as a saved prompt, reading what was kept) without DOM. A new combo's toast is
`ui_kept_toast.js` (the image's thumbnail, the name in a box to rename it on the spot, Open).
An image becomes a node-structure combo: `sampler_inputs.js` (no DOM) picks the nodes upstream
of its samplers' model / positive / negative / conditioning inputs (not through images,
latents or masks), and `image_keep_nodes.js` saves them with `captureStructure`, every value
as the image had it, models renamed to where they are on this computer; a workflow with no
sampler gives the older main model + LoRAs + prompt combo.
- `ui_combos.js` renders the Combos page's list (搭配: a main model, LoRAs and a prompt, formerly Prompt Notes): search,
? (what a combo is), New, and a card per combo with its model's cover and Put on canvas; a card opens the combo's editor, and dragged onto the
? (what a combo is), New, and a card per combo with its model's cover (one kept from an output image shows that image, the model's cover as a badge) and Put on canvas; a card opens the combo's editor, and dragged onto the
canvas (`material_drag.js`) becomes a new group of nodes where it is dropped. Combos keep the notes' files.
A combo can instead hold a node structure (`data.kind === 'nodes'`): `combo_structure.js`
captures the canvas's picked nodes (subgraphs aside) with the links between them and puts
@@ -563,7 +635,7 @@ covered by the `styles.css` manifest.
- `ui_recipes.js` / `ui_recipe_detail.js` and `ui_notebooks.js`
own their respective workspace surfaces and persistence flows. `ui_recipes.js` hands the
Workflows page (its catalog) the recipe services (save, edit, fetch) and opens a recipe by name. `ui_notebooks.js` owns the Combos workspace (filling the page; its list or one combo's editor,
with Back to the list) and combo persistence; `ui_notebook_editor.js` owns unfolded card editing (modularized into single-responsibility
with Back to the list) and combo persistence; `ui_combo_hero.js` is the head of a combo's editor (its picture: the image it was kept from with See the picture, else its model's cover; the name with Rename; chips of what it holds; Put on canvas and More; no Save, every change saves itself), also over node-structure combos; `ui_combo_models.js` is the editor's models (the base model, the chosen main model and LoRAs as rows, Change / Add opening a grid of compatible models read only then); `ui_notebook_editor.js` puts them together with the prompt and owns unfolded card editing (modularized into single-responsibility
sub-functions adhering to the 50-line rule: sticky top action toolbar with floating More popover dropdown and timed two-step delete safety guard,
unfolded companion models card with unconstrained multi-column tile flow eliminating nested gallery scrollbars,
prompt composer with dynamic field-sizing and compact inline find & replace toolbar, and unified dark slim scrollbar ergonomics with complete bilingual dictionary coverage in `locales.js`), and
@@ -630,7 +702,8 @@ covered by the `styles.css` manifest.
box as tags (select, weight, edit, remove, reorder, drop, type, translate to English) or text.
`prompt_tags.js` splits and joins a prompt's tags keeping their separators, and
`prompt_gloss.js` looks up and keeps the tags' meanings in the picked language. `ui_prompt_source_deck.js`
owns the card list (three built-in cards and the saved prompts), search, new-card form,
owns the card list (three built-in cards and the saved prompts, drawn as notebook pages: the role
on the spine, the first tags, a ribbon on the one open as the reference), search, new-card form,
library sync and renaming / deleting a saved prompt; `prompt_material_source.js` reads
saved prompts as cards and saves one; `ui_prompt_reference.js` is the reference column a
clicked card opens in, between the cards and the boxes (its tags taken one by one or all, ticks
@@ -654,7 +727,8 @@ covered by the `styles.css` manifest.
types a loader widget holds, which are never physically renamed, and which
need a workflow-carried hash before Model Check recovers them.
- `ui_dom.js` provides small DOM/JSON helpers; `material_inspector.js` owns the image
workbench's metadata and node-parameter rendering.
workbench's metadata and node-parameter rendering; `png_metadata.js` reads the workflow a
PNG's text chunks carry, in the browser (the gallery detail's fallback).
- `ui_first_run.js` is Home's first-run setup while nothing is scanned (check that Civitai answers
through `api/network_check.py`, the optional key, a scan of every model, the tour); a card, never
a popup, hidden per browser.
+71
View File
@@ -1,12 +1,83 @@
# 📈 Anomalous Model Browser Changelog
## v1.61.0 (Quick Search, Canvas Menu, Combos From Pictures, Tidier Model Folders, Multi-Character Voice-over) — 2026-10-25
### 🔎 Quick search on the canvas: Alt+B (画布快速搜索)
- **Press Alt+B over the canvas** (or right-click the empty canvas → AMB → *Quick search…*) and type: combos, models and saved prompts that match show at once, with their pictures. A model is found by its name, file, base model or trigger words; a combo by its name, models or prompt; a prompt by its text.
- **Enter puts it where your pointer was**: a combo as its group of nodes and a model in its loader node, both following the pointer until you click; a prompt as a new prompt node. ↑ ↓ pick, **Tab** shows only combos, models or prompts, Esc closes.
- The shortcut is a ComfyUI command (*AMB quick search*), so it can be changed in ComfyUI's keybinding settings like any other. Nothing is read until the bar opens.
### 🖱️ AMB in the canvas's right-click menu (画布右键菜单)
- **Right-click a node → AMB** offers what Current node does for it, without opening the browser first: **Open in AMB** (a prompt node opens Prompts on it, any other node Current node), **Change model by picture**, **Insert a LoRA before / after**, **Apply saved values**, **Pictures made with this model**, **Model details**, **Save this node's values** and **Save as combo**. Each entry shows only where it applies: a checkpoint loader has no "before", a prompt node no model entries.
- **Right-click the empty canvas → AMB** opens any of AMB's pages.
- **Make it yours: Settings → Canvas → Right-click menu**. Switch each entry on or off and move it up or down; a line above each list shows what the menu will offer. Two cards, each with a small drawing of the menu, choose whether AMB's entries sit **together under "AMB"** (the default, ComfyUI's menu gets one line more) or are **listed in the menu**. *Adjust this menu…* at the end of the submenu leads there. **Save as combo** moved into the AMB submenu with the rest.
- In the model picker, Esc now closes the picker wherever the focus is, and only the picker: it used to close the browser behind it when the search box had lost the focus.
- The floating AMB button no longer sits on the head of a model picker opened from this menu (with the browser closed).
- Fixed: when a model folder is a link (a junction to another drive, say), a combo kept from a picture and other lookups of saved model paths got a wrong place for the model, or failed.
### 🧩 A combo's page, redone (搭配详情重做)
- **A head that says what it is**: the picture (the one you kept it from, with a **See the picture** button; else the main model's cover), the name in large type with a **✎ Rename** button beside it, and chips for its base model, main model and LoRAs. **Put on canvas** and More (delete) sit under them.
- **Only what is chosen**: the main model and the LoRAs are short rows with their covers; **Change** and **+ Add a LoRA** open the grid of compatible models only when you want it, instead of every model of that base filling the page. ✕ takes a LoRA out; the numbers say their order.
- **No Save button**: every change (models, name, prompt) saves itself, as it already did. Fixed: a tag changed in the bilingual view was not saved into the prompt.
### ★ See only the kept pictures (图库只看收藏)
- **★ Kept** beside the gallery's search lists only the pictures you kept as a workflow, combo or prompt (the ★ on their cards). Works with the search and with Pictures / Videos. Pointing at a card's ★ says what it was kept as.
### 📌 A picture kept as a combo keeps its nodes as they were (图片存搭配,节点原样记住)
- **Keep → Its combo** now keeps the nodes that made the picture's model and prompts: the model and CLIP loaders, the whole LoRA chain (Power Lora Loader and other packs' nodes too), CLIP skip and model patches, the positive **and** negative prompt nodes, with every value as the picture had it. The sampler, size and decoding are left out, so Put on canvas gives a group you wire to your own sampler, like a combo saved from the canvas.
- It used to keep only the main model, the LoRAs' names and the positive prompt: LoRA strengths came back as 1.0, and the negative prompt and CLIP skip were lost. **A combo only remembers; it never changes a value** (now one of the plugin's rules). Combos kept before stay as they are.
- Models are named by where they are on this computer; one this computer lacks is kept by the picture's name and said after keeping. A workflow without a sampler to read from still gives the simple kind.
- Fixed: after a picture was kept (as a workflow, combo or prompt) or its parameters compared, the prompt boxes of its workflow could stay on ComfyUI's page and show at its top left, over the tabs. The workflow read off the picture is now taken away once it is read.
### 🩺 Model Check and ComfyUI-Manager's list (模型检查与 Manager 清单)
- A **Download by name** from ComfyUI-Manager's model list now also says where the list's file is (the site and repository), to help you decide.
- A list entry that does not say its type is no longer offered for a node whose type is known.
- Without ComfyUI Manager, Model Check says it would add one more place to download from when a model's source is not found.
- A foundation component (VAE, text encoder, CLIP Vision…) no source was found for gets **Search Hugging Face** (its mirror when that is on): the search page for its file name, for you to pick from.
- The rules of Model Check (what counts as the same model, the confidence levels, foundation components, download sources and where ComfyUI-Manager fits) are now written in one place: `docs/guides/model-check-rules.md`.
### ⭐ Combos kept from a picture are easy to tell apart (图片存的搭配一眼认得出)
- **The card shows the picture you kept it from**, with the main model's cover as a small round badge in its corner, instead of the model's cover alone (several combos of one model used to look the same).
- **A name that says which one it is**: the first LoRA (or the main model) and the prompt's first two telling tags, like *Ahri · fox ears, kimono*, skipping words nearly every picture has (*1girl*, *solo*, *looking at viewer*…).
- **Rename it right away**: the message after keeping shows the picture and the name in a box; type a new one and press Enter. A combo's page has a Rename button too.
### 📒 Prompt cards look like notebook pages (提示词卡片换成笔记本样式)
- In Prompt Studio each card is a page with a bound spine in its colour (green positive, red negative), its title, a Positive / Negative label and its first words as small tags instead of a cut-off line of text. The card open as the reference wears a ribbon; Common and My prompts are index tabs with how many cards they hold. Dark and light themes alike.
### 🗂️ Tidier model folders (模型文件夹更清爽)
- **Settings → Models → Files that come with models**: what a scan found (`.info`), your names and notes (`.anomalous.json`) and Civitai's picture kept aside can go **in a hidden `.amb` folder** inside each model folder instead of beside every model, leaving only the model and its cover there. Covers stay beside the model, where ComfyUI and other plugins look. Choosing a place says how many files will move, then moves them (only moves; switch back any time); a file that cannot move still works where it is.
- **No more duplicate cover copies**: scans used to keep a copy of Civitai's picture identical to the cover for every model. Now the cover is noted as Civitai's and a copy is kept only when you put your own cover in its place (so you can still bring Civitai's back). **Check and tidy** in the same place sends the existing duplicates to the Recycle Bin.
### 🎭 Several characters in one script (多角色配音)
- **Every line has its character**: on Voice-over, click a character in the list on the left, then paste: the lines are its. To give lines to another, pick them (a click; Ctrl / ⌘ adds one, Shift a run; Esc lets go) and choose who reads them on the right, or click the name on a line for a menu of characters. Each line shows who reads it in that character's colour, and its emotions are that character's. The *Who speaks* bar at the top is gone: the list does its job.
- **Three layers: the whole script, a character, a line (整份剧本 / 角色 / 句子,三层分清)**: the left side picks, the side panel changes. Nothing picked: the **whole script**, what everyone reads with, and each character in it. A character clicked in the list: that character **in this script** (only its lines). Picked lines: **only them**, with who reads them. A path on top, *Whole script › Aris › Line 2*, says which layer you are changing and leads back up (Esc too). Every layer has the same three rows, speed, tone and language, and each row says where its value comes from: **this line** (yellow), **the character** (purple), **the whole script** or **the default** (grey), or the character's **usual** setting; a click on it goes to that layer, ↺ lets this layer follow the one above. The more particular wins: a line over its character over the whole script over the usual settings. The settings that last beyond this script (the tones, a character's own settings) are links at the foot, apart from the rest. The tone can be heard on that line right there; *Numbers* shows how it compares and its numbers. With many characters, *Read by* lists the ones in this script (with their colours) and the rest under *Other characters*, a menu with a search box; a card's name menu gets the same search box.
- **Drafts (草稿箱)**: every script is kept as a draft on its own, half a second after a change, in ComfyUI's user folder (so clearing the browser or using another one loses nothing). A bar over the script names it (a click renames it; unnamed, its first line names it) and has **New** (a blank script, this one stays), **Drafts** and **Close script**. Drafts shows a card per script, the last changed first, with who reads it and how many lines: open, rename, copy, delete. Deleted scripts wait in *Recently deleted* for 30 days. A reload opens the script that was open; a file voiced again from the audio gallery becomes a script of its own. Two tabs saving at once keep each other's drafts. The script the earlier 1.61 build kept in the browser moves in by itself.
- Editing the script (*Edit text*) keeps each unchanged line's own settings; it used to drop them.
- A character with no line in the script yet (clicked in the list) hears a **sample sentence** on ▶, and the panel now says so (*▶ Listen to a sample*); it used to look like a line that was not in the script. On the whole script, ▶ reads the line heard with its own character.
- **Plainer words (更好懂)**: the mode is now the **tone** (语气), the presets are **Steady / Natural / Lively** (平稳 / 自然 / 生动), *Follow* is *As the character* (同角色), *Save as default* is *Always read like this* (以后都这样念), a character's *Generation settings* are its *Own settings* (专属设置), and the GPT-SoVITS pretrained files are *base files* (基础文件). The names inside the node (stable / standard / lively) are unchanged, so saved workflows and settings keep working.
- **GPT-SoVITS Settings, in tabs (GPT-SoVITS 设置分类)**: a chip beside the title says everything is ready, or what is missing; tabs show one part at a time: **Tones** (which one to use, with a *Simple / Numbers* switch), **Other languages**, **Base files** (a dot when something there needs you, and the window opens there), **Where things are**, **Version**.
- So one script can have a calm narrator, a lively character and one line read slower, all in one go. This needs Anomalous_TTS interface 17 (1.1.0): the whole script's values go into the node's own inputs, and only the lines that differ get `[lang:…]`, `[speed:…]` or `[preset:…]`, so the text pushed or copied to a node stays readable; a file voiced again from the audio gallery comes back with them. With an older Anomalous_TTS the whole script is read as before, as its first line's character reads, and the panel says so.
- **Picking and editing no longer get in each other's way**: a click on a line picks it; a double-click (or ✎) edits its text in place, Enter keeps it and Esc puts it back.
- **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.2 (Fix: the browser opens whatever the plugin folder is called) — 2026-10-25
### 💬 From a Bilibili comment (来自 B 站评论)
- **The browser opens when the plugin folder has another name (插件文件夹改了名也能打开)**: a plugin unpacked from a ZIP of GitHub sits in `Anomalous_Model_Browser-main`; the browser's look did not load there, so a click on **Model Browser** seemed to do nothing (the browser lay under the canvas, without its look) and that button showed in the top bar although the floating button was chosen. The plugin now finds its files under whatever name its folder has. Updates to the plugin also reach the page at once there again, as with the usual name.
## v1.60.1 (Cloud Servers, Generation Presets, Full Window, Faster Scan Page) — 2026-10-22
### 🎚️ Reference audio: every clip in a list, main and auxiliary references (参考音频:全部列出,主参考和副参考)
- **Reference audio and emotions** (the character card's button, formerly *Edit emotions*) is now a large window. On top, the **voices**: the main voice, each emotion with how many references it has (*1 main, 2 aux*), and **+ New emotion**. Below, **every clip** of the character, one row each: ▶ to hear it, its file and length, its line, and where else it is used (*Main voice · aux*). For the voice picked, a row is its **Main** reference or one of its **Aux** references, or nothing; unused clips just stay in the list. Filters (*All*, *This voice*, *In use*, *No line*) and a search by line or file name; the first 60 rows, then more.
- **Auxiliary references (副参考音频)**: a voice keeps one main reference (its tone and way of speaking, 3–10 s, with a line) and can take any number of auxiliary ones, whose timbre is averaged in when it speaks, as GPT-SoVITS does with several references; they need no line. Only v1 / v2 / v2Pro models use them (the controls say so for v3 / v4).
- **A clip's line can be fixed right in its row** (改参考台词): the fixed line is kept in the character's settings and used before its .txt / .list files, which stay as they are. A line that is missing shows *No line found: click to type it*.
- **🗑 on a row moves that clip to the Recycle Bin** (删除参考音频), with its own .txt / .lab, after saying where it is used; it is taken out of those voices and the settings. Never a permanent delete: where there is no Recycle Bin (a USB stick, a network drive) nothing is deleted and the window says so.
- Clips too long or too short to be a main reference are marked (they can still be auxiliary). The main voice can go back to the node's own pick; an emotion can be renamed or deleted (one found from a file name is changed by renaming the file). Closing with unsaved changes asks first.
- Auxiliary references, fixed lines, lengths and 🗑 need Anomalous_TTS interface 18 (1.1.0); with an older version the window works as before for main references and says what needs the update.
### 🔍 Scanning (扫描)
- **The scan page opens at once, also with many models**: it shows the last counts right away, says it is updating them, and shows the fresh ones when they come. What each model file's header says is now kept on disk (`user/anomalous/model_heads.json`, by file, read again only when the file changes), so a big library or a network drive is not read again after every restart either; requests that come while the page counts share that one count.
- **See the models found on Civitai**: the scan page's "Found on Civitai" count opens a list of them, with each model's Civitai name and base model, a box to find one by name, and a way to open each.
+5 -5
View File
@@ -9,7 +9,7 @@
[![ComfyUI Manager](https://img.shields.io/badge/ComfyUI-Manager-green?style=for-the-badge&logo=comfyui)](https://github.com/ltdrdata/ComfyUI-Manager)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow?style=for-the-badge)](LICENSE)
[![Changelog](https://img.shields.io/badge/📖_Changelog-v1.60.2-blue?style=for-the-badge)](CHANGELOG.md)
[![Changelog](https://img.shields.io/badge/📖_Changelog-v1.61.0-blue?style=for-the-badge)](CHANGELOG.md)
[![Bilibili Video](https://img.shields.io/badge/Bilibili-视频演示-00A1D6?style=for-the-badge&logo=bilibili)](https://www.bilibili.com/video/BV1boHx61Enc/)
[![Bilibili Voices](https://img.shields.io/badge/Bilibili-配音演示-00A1D6?style=for-the-badge&logo=bilibili)](https://www.bilibili.com/video/BV1bkpZ6uEZQ/)
[![Reddit](https://img.shields.io/badge/Reddit-r%2Fcomfyui-FF4500?style=for-the-badge&logo=reddit)](https://www.reddit.com/r/comfyui/s/n38ihf3DLf)
@@ -40,7 +40,7 @@
<p align="center"><img src="docs/images/en-scan.webp" width="520" alt="Scan results: each file matched on Civitai"></p>
**2. Someone else's workflow opens with red nodes.** Model Check says which models are missing. When the workflow carries model fingerprints (the plugin writes them into the workflows and images of everyone who has it, unless they turn it off), the ones you already have under another name or in another folder go back in with one press, and the rest can be downloaded from Civitai or Hugging Face, each download checked against its fingerprint. A workflow without fingerprints can only be matched by file name: a file of the same name elsewhere on your computer is offered for you to confirm.
**2. Someone else's workflow opens with red nodes.** Model Check says which models are missing. When the workflow carries model fingerprints (the plugin writes them into the workflows and images of everyone who has it, unless they turn it off), the ones you already have under another name or in another folder go back in with one press, and the rest can be downloaded from Civitai, Hugging Face or ComfyUI-Manager's model list, each download checked against its fingerprint (a Manager entry that only shares the file name is marked so, for you to decide). A workflow without fingerprints can only be matched by file name: a file of the same name elsewhere on your computer is offered for you to confirm.
<p align="center"><img src="docs/images/en-model-check.webp" width="760" alt="Model Check puts back three missing models with one press"></p>
@@ -48,7 +48,7 @@
<p align="center"><img src="docs/images/en-gallery.webp" width="760" alt="Gallery search, then drag an image onto the canvas"></p>
**4. The same model, LoRAs and prompt, again and again.** Pick those nodes on the canvas, right-click → **Save as combo**, and they are kept as a card beside the canvas. Next time press **Put on canvas** (or drag the card) and the wired group comes back where you click; nothing already on the canvas changes. Models and prompts in a combo can be changed later.
**4. The same model, LoRAs and prompt, again and again.** Pick those nodes on the canvas, right-click → **AMB** → **Save as combo**, and they are kept as a card beside the canvas. Next time press **Put on canvas** (or drag the card) and the wired group comes back where you click; nothing already on the canvas changes. Models and prompts in a combo can be changed later.
<p align="center"><img src="docs/images/en-combo.webp" width="760" alt="Picked nodes saved as a combo, then put back on the canvas as wired nodes"></p>
@@ -149,7 +149,7 @@ Open the browser with **Shift + B** (again to close), the floating button on the
<p align="center"><img src="docs/images/scan.webp" width="520" alt="扫描结果:每个文件都在 C 站认出来了"></p>
**2. 打开别人的工作流,一片红框。** 模型检查会告诉你缺哪些模型。如果工作流里带着模型指纹(装了本插件的人出图、保存工作流时会自动记下,除非他在设置里关掉),你电脑里其实有、只是改了名或换了文件夹的,点一下就换上;真没有的可以从 C 站或 Hugging Face 下载,下载完按指纹核对过才用。没有指纹的工作流只能按文件名找:别处有同名文件,会列出来让你确认。
**2. 打开别人的工作流,一片红框。** 模型检查会告诉你缺哪些模型。如果工作流里带着模型指纹(装了本插件的人出图、保存工作流时会自动记下,除非他在设置里关掉),你电脑里其实有、只是改了名或换了文件夹的,点一下就换上;真没有的可以从 C 站、Hugging Face 或 ComfyUI-Manager 的模型清单下载,下载完按指纹核对过才用(Manager 清单里只是同名、没法核对的,会标出来让你决定)。没有指纹的工作流只能按文件名找:别处有同名文件,会列出来让你确认。
<p align="center"><img src="docs/images/model-check.webp" width="760" alt="模型检查一键换上 3 个缺失的模型"></p>
@@ -157,7 +157,7 @@ Open the browser with **Shift + B** (again to close), the floating button on the
<p align="center"><img src="docs/images/gallery.webp" width="760" alt="图库搜索后把图拖到画布"></p>
**4. 每次都要重新配一遍模型、LoRA 和提示词。** 在画布上选中这几个节点,右键 **存为搭配**,它们就成了画布旁的一张卡片。下次点 **放到画布**(或者直接拖卡片),点哪里,那组连好线的节点就出现在哪里,画布上原有的东西不动。搭配里的模型和提示词以后还能换。
**4. 每次都要重新配一遍模型、LoRA 和提示词。** 在画布上选中这几个节点,右键 → **AMB** → **存为搭配**,它们就成了画布旁的一张卡片。下次点 **放到画布**(或者直接拖卡片),点哪里,那组连好线的节点就出现在哪里,画布上原有的东西不动。搭配里的模型和提示词以后还能换。
<p align="center"><img src="docs/images/combo.webp" width="760" alt="选中的节点存为搭配,再放回画布变成连好线的节点"></p>
+4 -2
View File
@@ -1,8 +1,8 @@
from . import (
activity_log, audio_catalog, folder_types, gallery_routes, materials, media_routes, model_catalog,
model_folder, model_media, model_metadata, model_resolution, model_type_listing, kept_images, node_material, recipe_packages, recipes, scan_choice, scan_report, scan_summary,
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,
network_check, model_heads, model_usage, usage_ledger, sidecar_layout,
)
from .scanner import *
from .config import *
@@ -111,6 +111,7 @@ def setup_routes(app):
app.router.add_post('/anomalous/delete_gallery_image', gallery_routes.api_delete_gallery_image)
app.router.add_get('/anomalous/output_thumbnail', gallery_routes.api_output_thumbnail)
app.router.add_get('/anomalous/kept_images', kept_images.api_kept_images)
app.router.add_get('/anomalous/quick_index', quick_index.api_quick_index)
app.router.add_get('/anomalous/resolve_hash', model_resolution.api_resolve_hash)
app.router.add_post('/anomalous/resolve_hash_batch', model_resolution.api_resolve_hash_batch)
app.router.add_get('/anomalous/all_hashes', model_resolution.api_get_all_hashes)
@@ -145,6 +146,7 @@ def setup_routes(app):
network_check.register_routes(app)
model_usage.register_routes(app)
usage_ledger.register_routes(app)
sidecar_layout.register_routes(app)
model_import.register_routes(app)
model_placement.register_routes(app)
backup.register_routes(app)
+24 -31
View File
@@ -44,8 +44,10 @@ from . import version_manager
try:
from ..model_identity import USER_INFO_SUFFIX, read_json, scan_info_path
from .. import model_sidecars as sidecars
except ImportError:
from model_identity import USER_INFO_SUFFIX, read_json, scan_info_path
import model_sidecars as sidecars
FORMAT = 1
APP = "Anomalous Model Browser"
@@ -90,13 +92,6 @@ def _sha256_file(path):
return digest.hexdigest()
def _same_bytes(a, b):
try:
return os.path.getsize(a) == os.path.getsize(b) and _sha256_file(a) == _sha256_file(b)
except OSError:
return False
def _store_files(directory):
"""(path, '/'-relative path) of every file kept in a store."""
if not os.path.isdir(directory):
@@ -137,14 +132,6 @@ def _models():
yield folder_type, root, path, os.path.relpath(path, root).replace(os.sep, "/")
def cover_owners(base):
"""(Civitai's, the user's) covers of a model: Civitai's are copies of <model>.civitai_bak.*."""
backups = [base + suffix for suffix in CIVITAI_BACKUP_SUFFIXES if os.path.isfile(base + suffix)]
covers = [base + suffix for suffix in ACTIVE_COVER_SUFFIXES if os.path.isfile(base + suffix)]
civitai = [cover for cover in covers if any(_same_bytes(cover, backup) for backup in backups)]
return civitai, [cover for cover in covers if cover not in civitai]
def _model_facts(path):
"""(SHA-256, download page) from the model's sidecars; nothing is computed."""
try:
@@ -203,12 +190,12 @@ def build_backup(include_models=True, include_comfy=True, include_library=True,
if not include_models:
continue
base = os.path.splitext(model_path)[0]
user_file = base + USER_INFO_SUFFIX
user_data = read_json(user_file) if os.path.isfile(user_file) else None
civitai, covers = cover_owners(base)
user_file = sidecars.find(base, USER_INFO_SUFFIX)
user_data = read_json(user_file) if user_file else None
civitai, covers = sidecars.cover_owners(base)
scan_info = scan_info_path(base) if include_scan else None
backups = [base + suffix for suffix in CIVITAI_BACKUP_SUFFIXES if os.path.isfile(base + suffix)]
civitai_cover = civitai[0] if include_civitai_covers and civitai and backups else None
backups = sidecars.backups(base)
civitai_cover = civitai[0] if include_civitai_covers and civitai else None
if not user_data and not covers and not scan_info and not civitai_cover:
continue
entry = {"id": len(manifest["models"]), "type": folder_type, "rel": rel,
@@ -220,12 +207,14 @@ def build_backup(include_models=True, include_comfy=True, include_library=True,
entry["cover"] = covers[0][len(base):]
archive.write(covers[0], f"{folder}cover{entry['cover']}")
if scan_info:
entry["info"] = scan_info[len(base):]
entry["info"] = sidecars.suffix_of(scan_info, base)
archive.write(scan_info, f"{folder}scan{entry['info']}")
if civitai_cover:
# One copy: it is both the cover and Civitai's saved image (<model>.civitai_bak.*).
entry["civitai_cover"], entry["civitai_bak"] = civitai_cover[len(base):], backups[0][len(base):]
archive.write(backups[0], f"{folder}civitai{entry['civitai_bak']}")
# One copy: it is both the cover and Civitai's picture (kept aside or not).
picture = backups[0] if backups else civitai_cover
entry["civitai_cover"] = civitai_cover[len(base):]
entry["civitai_bak"] = sidecars.suffix_of(backups[0], base) if backups else ".civitai_bak" + os.path.splitext(civitai_cover)[1]
archive.write(picture, f"{folder}civitai{entry['civitai_bak']}")
manifest["models"].append(entry)
archive.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=1))
os.replace(temporary, path)
@@ -418,20 +407,22 @@ def _restore_scan(archive, entry, base, folder, replace, result):
if not local or (replace and _sha256_file(local) != _member_sha256(archive, info)):
if local:
move_to_trash(local)
_write_member(archive, info, base + suffix)
_write_member(archive, info, sidecars.target(base, suffix))
result["model_infos"] += 1
cover, saved = str(entry.get("civitai_cover") or ""), str(entry.get("civitai_bak") or "")
if cover and saved:
if cover not in ACTIVE_COVER_SUFFIXES or saved not in CIVITAI_BACKUP_SUFFIXES:
raise BackupError(f"unknown cover name: {cover}")
info = archive.getinfo(folder + "civitai" + saved)
civitai, mine = cover_owners(base)
has_saved = any(os.path.isfile(base + suffix) for suffix in CIVITAI_BACKUP_SUFFIXES)
if not has_saved:
_write_member(archive, info, base + saved)
civitai, mine = sidecars.cover_owners(base)
if not civitai and not mine and not entry.get("cover"):
# Civitai's picture becomes the cover, noted as Civitai's (no second copy aside).
_write_member(archive, info, base + cover)
if not sidecars.note_cover_hash(base, base + cover) and not sidecars.backups(base):
_write_member(archive, info, sidecars.target(base, saved))
result["model_civitai_covers"] += 1
elif mine and not sidecars.backups(base):
_write_member(archive, info, sidecars.target(base, saved)) # aside, while the user's cover shows
def _restore_model(archive, entry, model_path, replace, result):
@@ -440,7 +431,7 @@ def _restore_model(archive, entry, model_path, replace, result):
_restore_scan(archive, entry, base, folder, replace, result)
if entry.get("user"):
backup = json.loads(archive.read(folder + "user.json"))
user_file = base + USER_INFO_SUFFIX
user_file = sidecars.target(base, USER_INFO_SUFFIX)
local = read_json(user_file) if os.path.isfile(user_file) else None
merged = dict(local or {"format": 1})
for key in USER_FIELDS:
@@ -457,13 +448,15 @@ def _restore_model(archive, entry, model_path, replace, result):
if suffix not in ACTIVE_COVER_SUFFIXES:
raise BackupError(f"unknown cover name: {suffix}")
info = archive.getinfo(folder + "cover" + suffix)
civitai, mine = cover_owners(base)
civitai, mine = sidecars.cover_owners(base)
member_hash = _member_sha256(archive, info)
if any(_sha256_file(cover) == member_hash for cover in mine):
return
if mine and not replace:
result["covers_kept"] += 1
return
if civitai and not sidecars.backups(base):
sidecars.keep_aside(base, civitai.pop(0)) # Civitai's picture stays, to bring back
for cover in mine + civitai: # Civitai's own copy stays as <model>.civitai_bak.*
move_to_trash(cover)
_write_member(archive, info, base + suffix)
+3 -1
View File
@@ -11,8 +11,10 @@ import struct
try:
from ..net_proxy import CONFIG_KEY as PROXY_KEY, configured as configured_proxy, normalize as normalize_proxy
from ..model_sidecars import layout as _side_file_layout
except ImportError: # loaded outside the package (tests)
from net_proxy import CONFIG_KEY as PROXY_KEY, configured as configured_proxy, normalize as normalize_proxy
from model_sidecars import layout as _side_file_layout
async def api_get_config(request):
script_dir = os.path.dirname(os.path.abspath(__file__))
@@ -43,7 +45,7 @@ async def api_get_config(request):
except (OSError, ValueError, AttributeError):
pass
return web.json_response({"has_api_key": has_key, "has_deepl_key": bool(isinstance(deepl_key, str) and deepl_key.strip()),
"proxy": configured_proxy(), "folder_types_config": folder_types})
"proxy": configured_proxy(), "sidecar_layout": _side_file_layout(), "folder_types_config": folder_types})
async def api_save_config(request):
try:
+4 -2
View File
@@ -311,14 +311,16 @@ def _from_manager_list(sites, name, folder_type, file_hash, hf_host):
for entry in entries[:6]:
found = _by_link(sites, entry["url"], name, "", hf_host)
if found and found.get("found") and same_file(file_hash, found.get("sha256")):
return {**found, "via": "manager_list", "list_name": entry["name"], "by_name": False}
return {**found, "via": "manager_list", "list_name": entry["name"], "by_name": False,
"list_base": entry["base"], "list_site": manager_catalog.site_of(entry["url"])}
return None
if len(entries) != 1:
return None # several files of that name: which one is unknowable
found = _by_link(sites, entries[0]["url"], name, "", hf_host)
if found and found.get("found"):
return {**found, "via": "manager_list", "list_name": entries[0]["name"], "by_name": True,
"base_model": found.get("base_model") or entries[0]["base"]}
"base_model": found.get("base_model") or entries[0]["base"],
"list_base": entries[0]["base"], "list_site": manager_catalog.site_of(entries[0]["url"])}
return found
+11 -6
View File
@@ -86,18 +86,23 @@ def _gallery_listing(output_dir, refresh, query, media="image", hidden=frozenset
return filter_images(os.path.realpath(output_dir), images, query) if query else images
def _gallery_page_source(output_dir, refresh, query, media, hidden):
"""The listing, and whether the output folder has any video (the gallery offers its
Pictures / Videos switch only then)."""
def _gallery_page_source(output_dir, refresh, query, media, hidden, kept_only=False):
"""The listing (only the images kept as a recipe, combo or prompt when `kept_only`), and
whether the output folder has any video (the gallery offers its Pictures / Videos switch
only then)."""
images = _gallery_listing(output_dir, refresh, query, media, hidden)
if kept_only:
from .kept_images import kept_keys # here: kept_images reaches back to this module through the stores
kept = kept_keys()
images = [item for item in images if (item.get("subfolder") or "", item["filename"]) in kept]
has_videos = any(item["media_type"] == "video" for item in _gallery_images(output_dir))
return images, has_videos
async def api_get_gallery_images(request):
"""GET /anomalous/gallery_images?page&limit&refresh&term=..&term=.. (or q)&media=all|image|video&hide=mkv,gif
"""GET /anomalous/gallery_images?page&limit&refresh&term=..&term=.. (or q)&media=all|image|video&hide=mkv,gif&kept=1
- Outputs (pictures only unless `media` says otherwise), optionally searched by embedded
generation info, without the `hide` formats."""
generation info, without the `hide` formats; with `kept=1` only those kept as something."""
try:
page = max(1, int(request.query.get('page', 1)))
limit = min(200, max(1, int(request.query.get('limit', 50))))
@@ -110,7 +115,7 @@ async def api_get_gallery_images(request):
hidden = frozenset(part.strip().lower().lstrip('.') for part in request.query.get('hide', '').split(','))
images, has_videos = await asyncio.to_thread(
_gallery_page_source, folder_paths.get_output_directory(), request.query.get('refresh') == '1',
query, media, hidden - {''})
query, media, hidden - {''}, request.query.get('kept') == '1')
total = len(images)
start = (page - 1) * limit
return web.json_response({"images": images[start:start + limit], "total": total,
+3 -1
View File
@@ -17,10 +17,12 @@ from .path_utils import atomic_write_json
try:
from ..model_identity import USER_INFO_SUFFIX, read_json
from ..model_sidecars import target
from ..civitai_client import USER_AGENT
from ..net_proxy import urlopen
except ImportError: # loaded outside the package (tests)
from model_identity import USER_INFO_SUFFIX, read_json
from model_sidecars import target
from civitai_client import USER_AGENT
from net_proxy import urlopen
@@ -110,7 +112,7 @@ def notes_text(repo, card):
def save_notes(base_path, repo, card):
"""The card's words as the model's notes and the repository as its link, where both are empty."""
path = base_path + USER_INFO_SUFFIX
path = target(base_path, USER_INFO_SUFFIX)
if os.path.exists(path) and read_json(path) is None:
return False # unreadable: leave it to the user
user = read_json(path) or {"format": 1}
+5
View File
@@ -35,6 +35,11 @@ def _kept_images():
return [{"subfolder": subfolder, "filename": filename, **entry} for (subfolder, filename), entry in kept.items()]
def kept_keys():
"""{(subfolder, filename)} of every output image kept as anything (the gallery's ★ view)."""
return {(item["subfolder"], item["filename"]) for item in _kept_images()}
async def api_kept_images(request):
try:
images = await asyncio.to_thread(_kept_images)
+12 -2
View File
@@ -53,6 +53,15 @@ def list_path():
return ""
def site_of(url):
"""Where a list entry's file is: the host and, on Hugging Face or GitHub, the repository."""
parts = str(url or "").split("/")
if len(parts) < 3:
return ""
host = parts[2].lower()
return "/".join([host, *parts[3:5]]) if host in ("huggingface.co", "hf-mirror.com", "github.com") and len(parts) >= 5 else host
def _folder(entry):
path = str(entry.get("save_path") or "").replace("\\", "/").strip("/")
first = path.split("/")[0].lower() if path and path != "default" else ""
@@ -86,11 +95,12 @@ def _index():
def candidates(file_name, folder_type=""):
"""The list's entries for a file of this name (any case) that fit `folder_type`, one per URL."""
"""The list's entries for a file of this name (any case) that fit `folder_type`, one per URL.
When the node's folder type is known, an entry whose type the list does not say does not fit."""
wanted = SAME_FOLDER.get(folder_type, folder_type)
seen, found = set(), []
for entry in _index().get(os.path.basename(str(file_name)).lower(), []):
if wanted and entry["folder"] and SAME_FOLDER.get(entry["folder"], entry["folder"]) != wanted:
if wanted and SAME_FOLDER.get(entry["folder"], entry["folder"]) != wanted:
continue
if entry["url"] not in seen:
seen.add(entry["url"])
+8 -5
View File
@@ -12,8 +12,10 @@ import folder_paths
import struct
try:
from ..model_identity import USER_INFO_SUFFIX, is_unmatched, read_json, sidecar_file_hash, sidecar_info, unmatched_reason
from ..model_sidecars import find
except ImportError:
from model_identity import USER_INFO_SUFFIX, is_unmatched, read_json, sidecar_file_hash, sidecar_info, unmatched_reason
from model_sidecars import find
from .model_heads import inspect as inspect_file # the header, read once per file version
# Fields the model editor sets: <model>.anomalous.json key -> metadata key.
@@ -90,7 +92,7 @@ def _info_base(file_path, base_path):
"""Where the model's info files are read: beside the model as named; for a link with none
beside it, beside the file it links to, where another tool (A1111's Civitai Helper) may
have left them. Only read there: covers, edits and scans stay beside the link."""
if os.path.islink(file_path) and not any(os.path.exists(base_path + suffix) for suffix in (".info", ".civitai.info")):
if os.path.islink(file_path) and not (find(base_path, ".info") or os.path.exists(base_path + ".civitai.info")):
return os.path.splitext(os.path.realpath(file_path))[0]
return base_path
@@ -122,7 +124,7 @@ def _read_metadata(file_path):
"not_drawing": False, # not scanned yet, and its header looks like no drawing model
}
info_files = [f"{info_base}.info", f"{info_base}.civitai.info"]
info_files = [path for path in (find(info_base, ".info"), f"{info_base}.civitai.info") if path]
for info_file in info_files:
if os.path.exists(info_file):
try:
@@ -191,7 +193,8 @@ def _read_metadata(file_path):
# A header that reads but names no drawing model kind (a voice model, say).
metadata["not_drawing"] = found["format"] == "safetensors" and not found["kind"]
# The user's own layer comes last and wins, also when a field was cleared on purpose ("").
user = read_json(base_path + USER_INFO_SUFFIX) or {}
user_file = find(base_path, USER_INFO_SUFFIX)
user = (read_json(user_file) if user_file else None) or {}
if user.get("scan") in ("skip", "scan"):
metadata["scan_choice"] = user["scan"]
for key, field in USER_FIELDS.items():
@@ -213,9 +216,9 @@ def _metadata_signature(file_path):
base_path = os.path.splitext(file_path)[0]
return (
_file_signature(file_path),
_file_signature(f"{base_path}.info"),
_file_signature(find(base_path, ".info") or f"{base_path}.info"),
_file_signature(f"{base_path}.civitai.info"),
_file_signature(base_path + USER_INFO_SUFFIX),
_file_signature(find(base_path, USER_INFO_SUFFIX) or base_path + USER_INFO_SUFFIX),
)
+23 -29
View File
@@ -8,6 +8,10 @@ from aiohttp import web
import folder_paths
from .metadata import get_metadata
try:
from ..model_sidecars import HIDDEN_DIR
except ImportError:
from model_sidecars import HIDDEN_DIR
from .model_constants import MEDIA_EXTENSIONS, PREVIEW_SUFFIXES, is_model_file
from .model_media import _cache_token, _preview_url_for_model
from .folder_types import (
@@ -16,6 +20,20 @@ from .folder_types import (
)
from .path_utils import model_reference_within, resolve_folder_subdir
def _folder_tree(base_dir):
"""{'/'-relative folder: {path, name, has_models, model_count}} under a model root; the
.amb folders hold side files only (model_sidecars)."""
tree = {}
for root, dirs, files in os.walk(base_dir):
dirs[:] = [name for name in dirs if name != HIDDEN_DIR]
count = sum(1 for f in files if is_model_file(f))
rel = os.path.relpath(root, base_dir)
rel = '/' if rel == '.' else '/' + rel.replace('\\', '/')
tree[rel] = {"path": rel, "name": os.path.basename(root) if rel != '/' else '[Root]',
"has_models": count > 0, "model_count": count}
return tree
def _collect_folder_models(target_dir, folder_type, path_idx, rel_subfolder, page, limit):
"""Collect one folder in a worker thread with a single directory listing."""
try:
@@ -120,20 +138,7 @@ def _collect_folders():
for item in matched:
base_dir = item["base_dir"]
tree = {}
for root, dirs, files in os.walk(base_dir):
has_models = any(is_model_file(f) for f in files)
rel = os.path.relpath(root, base_dir)
if rel == '.':
rel = '/'
else:
rel = '/' + rel.replace('\\', '/')
tree[rel] = {
"path": rel,
"name": os.path.basename(root) if rel != '/' else '[Root]',
"has_models": has_models,
"model_count": sum(1 for f in files if is_model_file(f))
}
tree = _folder_tree(base_dir)
label = item["bn"]
if len(matched) > 1:
@@ -163,20 +168,7 @@ def _collect_folders():
continue
seen_dirs.add(real_path)
tree = {}
for root, dirs, files in os.walk(base_dir):
has_models = any(is_model_file(f) for f in files)
rel = os.path.relpath(root, base_dir)
if rel == '.':
rel = '/'
else:
rel = '/' + rel.replace('\\', '/')
tree[rel] = {
"path": rel,
"name": os.path.basename(root) if rel != '/' else '[Root]',
"has_models": has_models,
"model_count": sum(1 for f in files if is_model_file(f))
}
tree = _folder_tree(base_dir)
try:
folder_basename = os.path.basename(os.path.normpath(base_dir))
@@ -452,7 +444,9 @@ def _resolve_paths_to_model_info_sync(paths, folder_types=None, exact_only=False
except ValueError:
continue
if os.path.isfile(candidate) and is_model_file(candidate):
exact_results[original] = _model_info_for_path(folder_type, path_idx, base_dir, candidate)
# The candidate lies under the real folder: a folder that is a link (a junction
# to another drive, a short 8.3 name) would give a subfolder of "../.." or fail.
exact_results[original] = _model_info_for_path(folder_type, path_idx, real_base_dir, candidate)
unresolved = [(original, normalized) for original, normalized, _ in requested if original not in exact_results]
if exact_only or not unresolved:
+4 -2
View File
@@ -29,7 +29,7 @@ import uuid
from aiohttp import web
import folder_paths
from . import hf_card
from . import hf_card, manager_catalog
from .activity_log import add_entry, log_path
from .download_sources import (
SHA256, USER_AGENT, allowed_download_url, find_source, host_of, is_civitai, same_file, _load_api_key,
@@ -193,7 +193,9 @@ async def api_lookup(request):
if folder_type and folder_type not in types:
types[folder_type] = await asyncio.to_thread(roots_of, folder_type)
results.append(result)
return web.json_response({"results": results, "roots": types, "settings": read_settings()})
# Whether ComfyUI-Manager's model list is here: without it, Model Check says it is one more source.
manager_list = bool(await asyncio.to_thread(manager_catalog.list_path))
return web.json_response({"results": results, "roots": types, "settings": read_settings(), "manager_list": manager_list})
# --- downloads --------------------------------------------------------------------------
+18 -9
View File
@@ -1,7 +1,6 @@
"""Model preview lookup and bounded custom-cover operations."""
import asyncio
import filecmp
import os
import shutil
import urllib.parse
@@ -9,7 +8,11 @@ import urllib.parse
from aiohttp import web
import folder_paths
from .model_constants import CIVITAI_BACKUP_SUFFIXES, MEDIA_EXTENSIONS, PREVIEW_SUFFIXES
from .model_constants import MEDIA_EXTENSIONS, PREVIEW_SUFFIXES
try:
from ..model_sidecars import backups, is_civitai_cover, keep_aside
except ImportError:
from model_sidecars import backups, is_civitai_cover, keep_aside
from .trash import move_to_trash
from .utils import model_file_within, require_filename, resolve_folder_subdir, resolve_within
@@ -31,6 +34,14 @@ async def _handle_custom_cover(target_dir, filename, save_func, source_ext='.png
dest_path = os.path.join(target_dir, f"{base_name}{preview_ext}")
# Civitai's picture, when it is the cover, is kept aside first, to bring back from the editor.
base = os.path.join(target_dir, base_name)
covers = [path for path in (os.path.join(target_dir, f"{base_name}{ext}") for ext in PREVIEW_SUFFIXES) if os.path.isfile(path)]
if not backups(base):
civitai = next((path for path in covers if is_civitai_cover(path, base, [])), None)
if civitai:
keep_aside(base, civitai)
# Only one cover is active: the others go to the Recycle Bin (a failure keeps them).
older = [os.path.join(target_dir, f"{base_name}{ext}") for ext in PREVIEW_SUFFIXES]
try:
@@ -150,15 +161,13 @@ def _media_url(folder_type, path_idx, base_dir, media_path):
def _model_covers(folder_type, path_idx, base_dir, file_path):
"""Civitai's own image a scan kept (<model>.civitai_bak.*) as a URL, or "", and whether the
cover shown is another picture (the user's): a cover is Civitai's when it is a copy of it."""
"""Civitai's own image kept aside (<model>.civitai_bak.*, beside or in .amb) as a URL, or "",
and whether the cover shown is another picture (the user's), not Civitai's (model_sidecars)."""
base = os.path.splitext(file_path)[0]
saved = next((base + suffix for suffix in CIVITAI_BACKUP_SUFFIXES if os.path.isfile(base + suffix)), "")
kept = backups(base)
saved = kept[0] if kept else ""
shown = next((base + suffix for suffix in PREVIEW_SUFFIXES + MEDIA_EXTENSIONS if os.path.isfile(base + suffix)), "")
try:
custom = bool(shown) and not (saved and filecmp.cmp(shown, saved, shallow=False))
except OSError:
custom = bool(shown)
custom = bool(shown) and not is_civitai_cover(shown, base, kept)
return {"civitai": _media_url(folder_type, path_idx, base_dir, saved) if saved else "", "custom": custom}
+15 -8
View File
@@ -22,8 +22,10 @@ from .trash import move_to_trash, trash_failure
from .utils import atomic_write_json
try:
from ..model_identity import USER_INFO_SUFFIX, read_json
from ..model_sidecars import drop_empty_hidden_dir, existing, follow, is_civitai_cover, note_cover_hash, target
except ImportError:
from model_identity import USER_INFO_SUFFIX, read_json
from model_sidecars import drop_empty_hidden_dir, existing, follow, is_civitai_cover, note_cover_hash, target
from .utils import model_file_within, require_filename, resolve_folder_subdir
def _first_existing_sidecar(base_path, suffixes):
@@ -35,8 +37,9 @@ def _first_existing_sidecar(base_path, suffixes):
def _reset_model_cover(base_path):
"""Restore a recoverable cover without destroying the only existing image."""
backup_path, backup_suffix = _first_existing_sidecar(base_path, CIVITAI_BACKUP_SUFFIXES)
"""Restore a recoverable cover without destroying the only existing image. Civitai's picture
kept aside becomes the cover again and is then noted as Civitai's instead of kept twice."""
backup_path, backup_suffix = next(iter(existing(base_path, CIVITAI_BACKUP_SUFFIXES)), (None, None))
original_path, _ = _first_existing_sidecar(base_path, MEDIA_EXTENSIONS)
preview_paths = [
f"{base_path}{suffix}"
@@ -54,6 +57,8 @@ def _reset_model_cover(base_path):
shutil.copy2(backup_path, temp_path)
os.replace(temp_path, restored_path)
move_to_trash(*[path for path in preview_paths if path != restored_path])
if note_cover_hash(base_path, restored_path):
move_to_trash(backup_path) # the cover is Civitai's picture now: no second copy
except Exception as exc:
try:
if os.path.isfile(temp_path):
@@ -63,6 +68,9 @@ def _reset_model_cover(base_path):
return False, 'restore_failed', str(exc)
return True, 'civitai_backup', None
if any(is_civitai_cover(path, base_path, []) for path in preview_paths):
return True, 'civitai_backup', None # Civitai's picture is the cover already
if original_path:
try:
move_to_trash(*preview_paths)
@@ -112,13 +120,14 @@ async def api_delete_model(request):
for model_ext in MODEL_EXTENSIONS if base_name + model_ext != filename
)
sidecars = [] if shared_stem_in_use else [
os.path.join(target_dir, base_name + suffix) for suffix in SIDECAR_SUFFIXES
path for path, _ in existing(os.path.join(target_dir, base_name), SIDECAR_SUFFIXES)
]
# The model and its covers and info go to the Recycle Bin together, or nothing moves.
try:
moved = move_to_trash(model_path, *sidecars)
except Exception as e:
return web.json_response({"status": "error", "message": trash_failure(e)})
drop_empty_hidden_dir(target_dir)
deleted_files = [os.path.basename(path) for path in moved]
request[ACTIVITY_DETAIL] = {"files": deleted_files}
@@ -201,7 +210,7 @@ async def api_update_metadata(request):
model_ext = os.path.splitext(file_path)[1]
# The user's edits have a file of their own (<model>.anomalous.json): a scan never
# writes it, and other tools that rewrite .civitai.info cannot lose them.
user_file = base_name + USER_INFO_SUFFIX
user_file = target(base_name, USER_INFO_SUFFIX)
if os.path.exists(user_file) and read_json(user_file) is None:
return web.json_response({"status": "error", "message": f"{os.path.basename(user_file)} is unreadable; nothing was changed."})
user_data = read_json(user_file) or {"format": 1}
@@ -234,10 +243,8 @@ async def api_update_metadata(request):
if new_file_path != file_path and not os.path.exists(new_file_path):
os.rename(file_path, new_file_path)
for suffix in SIDECAR_SUFFIXES:
old_sidecar = f"{base_name}{suffix}"
if os.path.isfile(old_sidecar):
os.rename(old_sidecar, os.path.join(target_dir, f"{safe_name}{suffix}"))
for old_sidecar, suffix in existing(base_name, SIDECAR_SUFFIXES):
os.rename(old_sidecar, follow(old_sidecar, os.path.join(target_dir, safe_name), suffix))
new_filename = f"{safe_name}{model_ext}"
elif os.path.exists(new_file_path) and new_file_path != file_path:
+7 -5
View File
@@ -27,9 +27,11 @@ import folder_paths
try:
from ..model_identity import file_sha256
from ..model_kind import inspect_file
from ..model_sidecars import drop_empty_hidden_dir, existing, follow
except ImportError: # loaded outside the package (tests)
from model_identity import file_sha256
from model_kind import inspect_file
from model_sidecars import drop_empty_hidden_dir, existing, follow
from .activity_log import add_entry
from .local_guard import may_change
from .metadata import get_metadata
@@ -195,14 +197,13 @@ def check(deep=False):
def _sidecars(path):
"""The model's covers, info and notes: files named by its stem, unless another model in
its folder has that stem too (then they are shared, and stay)."""
"""The model's covers, info and notes: files named by its stem, beside it or in the folder's
.amb, unless another model in its folder has that stem too (then they are shared, and stay)."""
folder, name = os.path.split(path)
stem = os.path.splitext(name)[0]
if any(os.path.isfile(os.path.join(folder, stem + ext)) for ext in MODEL_EXTENSIONS if stem + ext != name):
return []
return [(os.path.join(folder, stem + suffix), suffix) for suffix in SIDECAR_SUFFIXES
if os.path.isfile(os.path.join(folder, stem + suffix))]
return existing(os.path.join(folder, stem), SIDECAR_SUFFIXES)
def move(data):
@@ -226,7 +227,7 @@ def move(data):
moved, left = [os.path.basename(dest)], []
dest_stem = os.path.splitext(dest)[0]
for path, suffix in sidecars:
target = dest_stem + suffix
target = follow(path, dest_stem, suffix)
try:
if os.path.exists(target):
raise FileExistsError(target)
@@ -235,6 +236,7 @@ def move(data):
except OSError as error:
left.append(os.path.basename(path))
print(f"[Anomalous Browser] Tidy: {os.path.basename(path)} stayed where it was: {error}")
drop_empty_hidden_dir(os.path.dirname(source))
forget_file_lists()
to_rel = os.path.relpath(dest, root_path).replace(os.sep, "/")
add_entry("file", "model_move", to_rel, {"move": {
+5 -4
View File
@@ -10,8 +10,10 @@ import folder_paths
try:
from ..model_identity import computed_file_identity, file_sha256, infer_base_model_from_header, normalise_sha256
from ..model_sidecars import find, target
except ImportError:
from model_identity import computed_file_identity, file_sha256, infer_base_model_from_header, normalise_sha256
from model_sidecars import find, target
try:
from ..model_policies import requires_hash_for_model_recovery
except ImportError:
@@ -105,10 +107,9 @@ def _size_candidate_payload(candidate, require_hash=False):
def _compute_and_save_fallback_info(file_path, file_hash):
try:
base_path = os.path.splitext(file_path)[0]
info_path = base_path + ".info"
civitai_info_path = base_path + ".civitai.info"
if os.path.exists(info_path) or os.path.exists(civitai_info_path):
info_path = info_path if os.path.exists(info_path) else civitai_info_path
info_path = find(base_path, ".info") or (civitai_info_path if os.path.exists(civitai_info_path) else None)
if info_path:
with open(info_path, encoding='utf-8') as source:
info_data = json.load(source)
else:
@@ -129,7 +130,7 @@ def _compute_and_save_fallback_info(file_path, file_hash):
"files": [{"hashes": {"SHA256": file_hash.lower()}}]
}
info_data["anomalous_file_identity"] = computed_file_identity(file_path, file_hash)
atomic_write_json(info_path, info_data)
atomic_write_json(info_path or target(base_path, ".info"), info_data)
except Exception:
pass
+50
View File
@@ -0,0 +1,50 @@
"""Every model file in a few words, for the canvas's quick search (GET /anomalous/quick_index,
web/modules/quick_search_data.js): its folder type, place and file, the name it shows, its
base model, a few trigger words and its cover's URL. Read once when the search bar opens;
the bar keeps it a little while. Files a folder type shares with another (unet and
diffusion_models) are listed once; node packs' own files are not models."""
import asyncio
from aiohttp import web
from .model_catalog import _collect_all_scan_models
MAX_WORDS = 6
def _words(meta):
words = meta.get("trainedWords") or meta.get("trigger_words") or []
if isinstance(words, str):
words = [words]
return [str(word) for word in words if str(word).strip()][:MAX_WORDS]
def compact_models(models):
"""`models` (the shape GET /anomalous/all_scan_models gives) in a few words each."""
seen, found = set(), []
for model in models:
if model.get("type") == "custom_nodes":
continue
key = (model.get("subfolder") or "", model.get("filename"), model.get("size_bytes"))
if key in seen:
continue
seen.add(key)
meta = model.get("metadata") or {}
found.append({
"type": model.get("type"),
"path_idx": model.get("path_idx", 0),
"subfolder": model.get("subfolder") or "",
"filename": model.get("filename"),
"name": str(meta.get("custom_name") or "").strip(),
"civitai_name": str(meta.get("name") or "").strip(),
"base": str(meta.get("baseModel") or "").strip(),
"words": _words(meta),
"preview_url": model.get("preview_url") or "",
})
return found
async def api_quick_index(request):
payload = await asyncio.to_thread(_collect_all_scan_models, 1, 0)
return web.json_response({"models": compact_models(payload.get("models") or [])})
+3 -1
View File
@@ -13,8 +13,10 @@ from .utils import model_file_within
try:
from ..model_identity import USER_INFO_SUFFIX, read_json
from ..model_sidecars import target as sidecar_target
except ImportError: # loaded outside the package (tests)
from model_identity import USER_INFO_SUFFIX, read_json
from model_sidecars import target as sidecar_target
MAX_CHOICE_FILES = 2000
@@ -42,7 +44,7 @@ def _set_scan_choice(targets, choice):
except ValueError:
failed.append(name)
continue
user_file = os.path.splitext(path)[0] + USER_INFO_SUFFIX
user_file = sidecar_target(os.path.splitext(path)[0], USER_INFO_SUFFIX)
user = read_json(user_file)
if not name.endswith(".safetensors") or not os.path.isfile(path) or (user is None and os.path.exists(user_file)):
failed.append(name)
+134
View File
@@ -0,0 +1,134 @@
"""Settings → Models → "Side files": where the files only this plugin uses sit beside each model
(model_sidecars.py), and putting the ones already there in place.
GET /anomalous/sidecars/plan?layout=beside|hidden -> {layout, moves, duplicates, models}
What putting them in `layout` would do over every model folder: side files to move, and
Civitai pictures kept twice (a copy kept aside that is the cover as well) that can go.
POST /anomalous/sidecars/apply {layout} -> {layout, moved, deduped, failed: [names], more}
Saves the setting first (new files go there at once), drops the extra Civitai copies to the
Recycle Bin once the cover is noted as Civitai's, then moves each file, never over another:
one that cannot move stays where it is and is still read there. Empty .amb folders go when
the files move back beside the models. This computer only (a POST, local_guard.py).
"""
import asyncio
import json
import os
from aiohttp import web
from .backup import _models
from .path_utils import atomic_write_json
from .trash import move_to_trash, trash_failure
try:
from .. import model_sidecars as sidecars
except ImportError:
import model_sidecars as sidecars
FAILED_SHOWN = 20
def _work(layout):
"""(moves {from: to}, duplicates [(base, cover, copies kept aside)], models looked at)."""
moves, duplicates, models = {}, {}, 0
for _type, _root, path, _rel in _models():
models += 1
base = os.path.splitext(path)[0]
for found, suffix in sidecars.existing(base, sidecars.OWN_SUFFIXES):
wanted = sidecars.hidden_path(base, suffix) if layout == 'hidden' else base + suffix
if found != wanted:
moves[found] = wanted
kept = sidecars.backups(base)
if kept and base not in duplicates and sidecars.find(base, '.info'):
covers = [base + suffix for suffix in sidecars.ACTIVE_COVER_SUFFIXES if os.path.isfile(base + suffix)]
cover = next((c for c in covers if any(sidecars.same_file(c, copy) for copy in kept)), None)
if cover:
duplicates[base] = (cover, kept)
return moves, duplicates, models
def plan(layout):
moves, duplicates, models = _work(layout)
dropped = {copy for _cover, kept in duplicates.values() for copy in kept}
return {"layout": layout, "moves": len([path for path in moves if path not in dropped]),
"duplicates": len(dropped), "models": models}
def _save_layout(layout):
try:
with open(sidecars.CONFIG_PATH, encoding='utf-8') as f:
config = json.load(f)
except (OSError, ValueError):
config = {}
if not isinstance(config, dict):
config = {}
config['SIDECAR_LAYOUT'] = layout
atomic_write_json(sidecars.CONFIG_PATH, config)
def apply(layout):
_save_layout(layout)
moves, duplicates, _models_seen = _work(layout)
failed = []
dropped = []
for base, (cover, kept) in duplicates.items():
try:
if sidecars.note_cover_hash(base, cover):
dropped += kept
except OSError as error:
failed.append(f"{os.path.basename(base)}: {error}")
deduped = 0
if dropped:
try:
move_to_trash(*dropped)
deduped = len(dropped)
except Exception as error: # noqa: BLE001 - kept, and said why
failed.append(trash_failure(error))
folders = set()
moved = 0
for source, destination in moves.items():
if source in dropped or not os.path.isfile(source):
continue
try:
if os.path.exists(destination):
raise FileExistsError(os.path.basename(destination))
if sidecars.is_hidden(destination):
sidecars.make_hidden_dir(os.path.dirname(os.path.dirname(destination)))
os.replace(source, destination)
moved += 1
folders.add(os.path.dirname(source))
except OSError as error:
failed.append(f"{os.path.basename(source)}: {error}")
for folder in folders:
if os.path.basename(folder) == sidecars.HIDDEN_DIR:
sidecars.drop_empty_hidden_dir(os.path.dirname(folder))
return {"layout": layout, "moved": moved, "deduped": deduped,
"failed": failed[:FAILED_SHOWN], "more": max(0, len(failed) - FAILED_SHOWN)}
def _layout_of(value):
return value if value in sidecars.LAYOUTS else None
async def api_plan(request):
layout = _layout_of(request.query.get('layout'))
if not layout:
return web.json_response({"error": "bad_layout"}, status=400)
return web.json_response(await asyncio.to_thread(plan, layout))
async def api_apply(request):
try:
data = await request.json()
except ValueError:
data = None
layout = _layout_of(data.get('layout')) if isinstance(data, dict) else None
if not layout:
return web.json_response({"error": "bad_layout"}, status=400)
return web.json_response(await asyncio.to_thread(apply, layout))
def register_routes(app):
app.router.add_get('/anomalous/sidecars/plan', api_plan)
app.router.add_post('/anomalous/sidecars/apply', api_apply)
+21 -6
View File
@@ -61,6 +61,8 @@ a request other than GET must be local unless its path is in `OPEN_POSTS` (reads
`resolve_paths_to_previews`, `resolve_hash_batch`, `inspect_image_material`); a GET is open
unless its path is in `LOCAL_GETS` (`backup/file`, `version/releases`, `import/folders`,
`civitai_check`: the first-run setup's one request to Civitai, `api/network_check.py`).
`GET /anomalous/quick_index` (`api/quick_index.py`) is an open read like `/anomalous/models`:
every model file in a few words for the canvas's quick search.
`/anomalous/mcp` answers other devices itself. Local means: a loopback peer, no proxy headers
(`Forwarded`, `X-Forwarded-For`, `X-Real-IP`, `X-Forwarded-Host`) and a loopback `Host`, so a
page that reached the server under another name (DNS rebinding) is refused too. A refusal is
@@ -317,7 +319,9 @@ workflow carries — a Civitai link naming a model version (the file of that nam
its primary file), a Hugging Face or hf-mirror file link (size and SHA-256 from the
site's headers), a GitHub release asset or raw file — else ComfyUI-Manager's model list
(`api/manager_catalog.py`, the installed Manager's `model-list.json`, entries of the same
file name fitting the node's folder type). With the workflow's hash only a list entry
file name fitting the node's folder type; an entry of no stated type fits only a node of unknown
type; `list_site` says where its file is). The lookup also says whether the list is here
(`manager_list`). With the workflow's hash only a list entry
whose file has that SHA-256 counts (several of one name are each asked); without one,
a single entry of that name is offered marked `by_name`, never in "Download all" or
the MCP's download_missing_models. A link whose file has another SHA-256 than the
@@ -433,9 +437,19 @@ fall back to the original (a video without a readable frame has no poster).
Sidecar handling is non-negotiable because it can destroy user metadata or
media.
- `.civitai_bak.*` is a persistent restore source created from a real Civitai
download. Setting a custom cover changes only `.preview.*`.
- Cover reset first restores `.civitai_bak.*` to `.preview.*`. Otherwise it may
- Where the side files are is `model_sidecars.py`'s to say: every path to `.info`,
`.anomalous.json` and `.civitai_bak.*` goes through it (`find` to read, `target` to write,
`existing` / `follow` to move or delete), since they may be beside the model or in the
folder's `.amb` (Settings → Models, `SIDECAR_LAYOUT`). A file is written where it is
already, so a folder moved half way never has two copies. Covers and other tools' files
(`.civitai.info`, `.yaml`, `.txt`) are always beside the model. The folder tree leaves `.amb` out.
- A cover is Civitai's when its SHA-256 is the one the scan noted in the `.info`
(`anomalous_cover_sha256`) or it is a copy of a `.civitai_bak.*`. A scan saves Civitai's
picture straight as the cover (fetched under a working name first) and notes it; it keeps a
`.civitai_bak.*` only while the user's own cover shows. Setting a custom cover first moves
Civitai's picture aside as `.civitai_bak.*` (the restore source), then changes `.preview.*`.
- Cover reset first restores `.civitai_bak.*` to `.preview.*` (then notes it and drops the
copy aside); a cover that is Civitai's already stays. Otherwise it may
remove `.preview.*` only when a bare original cover can take over. If the
preview is the only image, preserve it and return a visible warning. Reset
never silently downloads from the network.
@@ -450,8 +464,9 @@ media.
sidecar suffixes. Cleanup must not delete a same-stem model with another
extension. If such a sibling remains, ambiguous stem-keyed sidecars remain.
- Rename/delete/reset uses centralized immutable suffix tuples and a constant
number of exact-path checks. Do not walk, glob, index, hash, or call the
network for these operations.
number of exact-path checks (both places for the plugin's own files). Do not walk,
glob, index or call the network for these operations; only covers (small) are hashed,
to tell Civitai's picture from the user's.
Keep product wording precise: deletion cleans sidecars; rename migrates them.
+7 -4
View File
@@ -81,7 +81,9 @@ The main surfaces are:
full-screen Image Detail Studio Workbench, for pictures only.
`ui_gallery_card.js` builds each output's card; its star and the workbench's Keep open
`ui_keep_menu.js`, which keeps the image as a Workflow Recipe, a combo or its prompts
(`image_keep.js`; `GET /anomalous/kept_images` says what each image was kept as).
(`image_keep.js`; `GET /anomalous/kept_images` says what each image was kept as). The
gallery's ★ Kept switch (`ui_gallery_filter.js`, `gallery_filter.js`) lists only the kept
images (`gallery_images?kept=1`, `kept_images.kept_keys`).
`ui_gallery_detail.js` owns its singleton lifecycle and cache, `ui_image_stage.js`
owns header/zoom/filmstrip interaction, and `ui_image_inspector.js` owns metadata tabs.
- Workspace: Combos (搭配, formerly Prompt Notes) and Workflow Recipes. The combo list, workspace/persistence, editing, and canvas use are separated
@@ -94,7 +96,8 @@ The main surfaces are:
(`addNewLoraWidget`) before the slots are filled. Put on canvas lays a combo out as a new wired group of nodes (Esc while it
follows the pointer removes it; the activity log holds back while `owner.placingCombo` is set) and
never changes nodes already on the canvas (values of an existing node are changed in
Current node). A combo with a node structure (`combo_structure.js`, `ui_combo_structure.js`)
Current node). A combo never changes a value either: what was saved is what goes down
(AGENTS.md, 功能原则). A combo with a node structure (`combo_structure.js`, `ui_combo_structure.js`)
keeps every picked node but subgraphs, and the links between them. Its slots are the model
drop-downs (a drop-down of model files) and text boxes (a multiline text box by the node's
definition); a node without slots goes back exactly as saved. A link from a node that was not
@@ -106,7 +109,7 @@ The main surfaces are:
name in their order, fills the slots by name, then links by port name and type; a slot or
link that cannot be restored is reported after placing, never guessed. Values other than
the slots cannot be edited: a combo puts down exactly what was saved. The canvas's node menu
(`getNodeMenuItems`, browser_entry.js) offers "Save as combo" for the picked nodes, or the
(`getNodeMenuItems`, browser_entry.js → `canvas_menu.js`) offers "Save as combo" for the picked nodes, or the
right-clicked node when it is not among them, when any of them is not a subgraph.
Nodes are kept in flow order (`chainOrder`, combo_slots.js) so slot numbers read along the
chain; the editor groups slots into models, LoRAs and prompts in that order, renumbers names
@@ -136,7 +139,7 @@ The main surfaces are:
`ui_dom.js` owns generic `text` and `jsonResponse` helpers.
`material_inspector.js` holds the image workbench's metadata helpers and
node-parameter rendering. See `material-library.md` for the material files' API and
node-parameter rendering; `png_metadata.js` reads a PNG's workflow in the browser. See `material-library.md` for the material files' API and
persistence contract.
## Entry modes and host integration
+8 -3
View File
@@ -116,10 +116,15 @@ override, which travels into the saved record but never writes back to a Recipe.
The gallery's star and the image workbench's Keep (`ui_keep_menu.js`) keep an output PNG
as one of three things, each recording the image as its source: the whole workflow as a
Workflow Recipe (`recipe_save.js`, `source_image`), its main model, LoRAs and positive
prompt as a combo (`image_keep.js`; models are looked up among this computer's files with
Workflow Recipe (`recipe_save.js`, `source_image`), the nodes that make its sampler's model
and prompts as a node-structure combo (`image_keep_nodes.js`, `sampler_inputs.js`: loaders,
LoRA chains, CLIP skip, model patches and both prompt nodes, every value as it was; a combo
never changes a value) — or, for a workflow with no sampler, its main model, LoRAs and
positive prompt as a combo (`image_keep.js`; models are looked up among this computer's files with
`/anomalous/resolve_paths_to_previews`, missing ones are left out; the notebook file keeps
`source_image`), or its positive and negative prompts as a `prompt_plan` material
`source_image`, which the Combos card and the editor's title show; the name is the first
LoRA's (else the main model's) · the prompt's first two tags that are neither quality words
nor ones nearly every picture has, and the toast lets the user rename it at once), or its positive and negative prompts as a `prompt_plan` material
(`/anomalous/save_prompt_plan` stores `source.image`; the same plan saved again answers 409
with the existing one). The image's workflow is laid on a detached graph and read with
`extractRecipeMetadata`, so prompts follow the wiring as in a recipe. `GET
+9 -1
View File
@@ -1,7 +1,9 @@
# Model Identity and Scanning
Read this document for Model Check, workflow provenance, missing-model recovery,
hash caches, Civitai metadata resolution, or model scanning.
hash caches, Civitai metadata resolution, or model scanning. The rules as a whole, in plain
words (what counts as the same model, the confidence levels, foundation components, download
sources), are in `docs/guides/model-check-rules.md`; keep both in step.
## Identity boundary
@@ -151,6 +153,12 @@ as one: ComfyUI's LoRA list as its choices, a fix changing only the row's LoRA).
fingerprints a saved workflow carries (`hash_resolver.js`) and a picture's model search
(`api/image_search.py`) read those rows too.
A model that is not here can also be found in ComfyUI-Manager's model list (the installed
Manager's `model-list.json`, `api/manager_catalog.py`): an entry of the same file name whose
SHA-256 matches the workflow's fingerprint is a certain source and is downloaded like the
others; an entry of the same name with no fingerprint to check is offered as "Download by
name", marked as such, for the user to decide. The page says where each link comes from.
Checking never changes the workflow (`model_check.js`). Every opened workflow is
checked without reloading ComfyUI's model lists; when models are missing, a bar
over the canvas says how many and how many hash matches can be put in. Only a
+29
View File
@@ -163,3 +163,32 @@ show its picture feed inside the plugin. Instead the plugin takes a file from wh
came from: dropped on the models page, it is told apart by its header (never by running
it), checked against Civitai when that answers, and moved into the folder its type and
base model call for, with every guess shown and changeable before anything moves.
## AD-020 — Downloads extend Model Check's evidence rules; ComfyUI-Manager is a catalog
A missing model is downloaded only from a source that names one file, asked in turn:
Civitai by the workflow's hash, a link the workflow carries, then ComfyUI-Manager's model
list by file name. The list carries no hashes: it answers where a file of that name can be
fetched, never whether a local file is that model, so it is never evidence for local
recovery (AD-005). With the workflow's hash every source must show the same SHA-256 (read
from the host, Hugging Face's headers) before the file counts; a different one is refused.
Without a hash nothing can be checked before or after the download, so a name is at most
"maybe": one list entry of that name and of the node's type is offered as "Download by
name", marked as such, never in Download all nor the MCP's downloads. Foundation components
(AD-006) follow one rule: a name alone only ever proposes, and their same-name candidate
needs a second, independent piece of evidence: the same size for a local file, the
curated list's entry of the right type for a download; the user still presses. Where no
source answers, the plugin opens a search (Hugging Face) for the user rather than pick a
file of a common name itself. The full rules in plain words are in
`docs/guides/model-check-rules.md`.
## AD-021 — A combo remembers; it never changes a value
A combo puts back exactly what was saved: models, LoRA strengths, CLIP skip, prompts and
every other value of its nodes, never node defaults in their place and never values the
plugin chose. A different value is a different combo, made by setting it on the canvas
and saving again. A picture kept as a combo therefore keeps the nodes that made its
sampler's model and prompts as a node structure, values untouched (a workflow without a
sampler falls back to the older main model, LoRAs and positive prompt). Placing a combo
only adds a new group; nodes already on the canvas are never changed (their values are
changed in Current node). This is one of the feature principles in AGENTS.md.
+38 -24
View File
@@ -31,7 +31,7 @@
```
┌───────────────────────────────────────────────┐
│ ⠿ 🎙 Arona 编辑情绪 读音 添加文件 3 角色音色预设 │ ← 标题栏:代表“整个角色”
│ ⠿ 🎙 Arona 参考音频 读音 添加文件 3 角色音色预设 │ ← 标题栏:代表“整个角色”
├───────────────────────────────────────────────┤
│ ▶ MAIN “それぞれの学園…” [{main}] │ ← 每一行:一条参考音频
│ ▶ ANGRY “相手の雰囲気が…” [{angry}] │
@@ -44,7 +44,7 @@
| 点 ▶ | 试听这一条 |
| 点右边的 **复制 `{标签}`** 按钮(角色设了情绪才有) | 复制标签,粘贴到台词里 |
| **拖标题栏** → 放到 **角色语音 (GPT-SoVITS)** 节点 | 节点的“角色”设为这个角色 |
| **编辑情绪** / **读音** / **添加文件** | 见第 2、4 节 |
| **参考音频与情绪** / **读音** / **添加文件** | 见第 2、4 节 |
情绪靠台词里的 `{happy}` 这种标签来切换,所以拖的是整个角色,不是某一条音频。
@@ -59,20 +59,29 @@
## 2. 配音:一段话多种情绪
1. 在 **角色语音** 页顶部从 **角色** 切到 **配音**,或者在角色卡片上点 **配音**,直接带着这个角色过去。谁来念,在左边列表里点角色或在上方选。
1. 在 **角色语音** 页顶部从 **角色** 切到 **配音**。先在左边列表里点一个角色,贴进来的台词就由 TA 念。
2. 把整段台词粘贴进去,点 **分成句子**。每句话会变成一张卡片。
3. 每张卡片点一下情绪(主声音 / happy / angry……),旁边的小喇叭能听这个情绪原本的参考声音。角色只有主声音时不显示情绪按钮。也可以直接改字;鼠标移到卡片上会出现 ✂(在光标处拆开)、↧(和下一句合并)、×(删除)。
### 直接生成,不用画布
页面够宽时分成两栏:左边是台词卡片,右边是 **生成语音** 和 **念法**。窗口窄或者浏览器停靠在侧边时,生成按钮和设置放在台词下面。两种情况都只有台词这一块会滚动,句子再多,按钮也一直看得见。
页面够宽时分成两栏:左边是台词卡片,右边是 **生成语音** 和设置面板。窗口窄或者浏览器停靠在侧边时,这些放在台词下面。两种情况都只有台词这一块会滚动,句子再多,按钮也一直看得见。
1. **念法** 一直显示:语言(自动 / 中 / 日 / 英)点一下就选,语速用滑块。第一次用的是这个角色的默认值。
下面的 **细调(一般不用动)** 收着:候选数量 `top_k`、候选范围 `top_p`、随机程度 `temperature`、防重复 `repetition_penalty`,鼠标停在输入框上有说明。调过时那一行后面会写“调过”。
2. 某次效果满意,点 **把这套设置存为这个角色的默认**:语言、语速和细调都会记住,下次打开这个角色直接用。种子不存,它只对当时那段台词有意义。
3. 点 **生成语音**。插件直接交给 ComfyUI 生成,画布上的工作流一个不动;ComfyUI 正在出图时会先排队。
4. 生成完自动播放,写着“生成好了,用时几秒”。文件存在 `output/audio/角色名/`;点 **已存进音频库 ›** 直接去音频库。
5. 不满意:
**左边选,右边改;设置分三层。** 右边的面板跟着你选了什么变,最上面一条路径告诉你现在改的是哪一层,比如 `整份剧本 › Aris › 第 2 句`,点前面的就回到上一层(Esc 也行):
- **整份剧本**(什么都没选):所有人默认按这个念。下面列出剧本里的每个角色,点一个就只改 TA。
- **角色**(在左边列表里点角色):这个角色在这份剧本里怎么念,只影响 TA 的句子。点 **以后都这样念**,把 TA 的快慢和语言存成平时的,以后的剧本也这样。
- **句子**(点句子,Ctrl / Shift 多选):**谁念** 点名字就换人;只影响选中的这几句,卡片上会出现一个小黄标签写着改了什么。
每一层都是同样三行:**快慢、语气、语言**。每行右边的小标签写着这个值是哪一层定的:黄色“这一句”、紫色角色名、灰色“整份剧本”或“默认”,还有“TA 平时”(存过的默认)。点标签就跳到那一层;点 ↺ 让这一层不再单独设,跟上一层走。越具体的越优先:这一句 > 角色 > 整份剧本 > 角色平时的设置。
语气下面有 **▶ 试听这句** 和 **看数字**(和平时比差在哪、具体数字,top_k、temperature 这些可以直接改)。面板最下面一行是 **永久设置**(语气档位、角色的专属设置),它们不只管这份剧本,所以单独放着。
**草稿箱**:每份剧本都会自动存成一份草稿(改动后半秒就存,存在 ComfyUI 的 user 文件夹里,清浏览器缓存、换浏览器都不会丢)。配音页最上面一条写着这份剧本的名字(点一下改名;没起名就用第一句台词),旁边是 **新建**(开一份空白的,这份留着)、**草稿箱** 和 **关闭剧本**。草稿箱里每份剧本一张卡片,写着谁念、几句、什么时候改的,可以打开、改名、复制一份、删除;删掉的放在 **最近删除** 里 30 天,随时找回。刷新或重启 ComfyUI 后,打开的还是刚才那份;从音频库“重新配音”会另开一份,不会盖掉手上的。按角色、按句分开设置需要 Anomalous_TTS 接口版本 17(1.1.0);旧版本时整段按第一句的角色念,面板上会提示。
1. 点 **生成语音**。插件直接交给 ComfyUI 生成,画布上的工作流一个不动;ComfyUI 正在出图时会先排队。
2. 生成完自动播放,写着“生成好了,用时几秒”。文件存在 `output/audio/角色名/`;点 **已存进音频库 ›** 直接去音频库。
3. 不满意:
- 某一句不好,点那张卡片上的 **↻**,只把这一句换一版,其他句子不变(直接用缓存,很快)。
- 整段都不好,点 **整段换一版**,换一个种子全部重新生成。
@@ -112,14 +121,16 @@
工具栏上有 **导入角色**。还没有角色时,中间是一块空的导入区,直接把文件拖进去或点 **+ 导入角色**。
存放位置、底模这些设置在左边侧栏的最下面:**GPT-SoVITS 设置**,点开是一个窗口。底模缺了不会挡路(第一次生成时会自动下载),所以只在这个入口上放一个小黄点:已有角色用到的底模还没下载时才出现。窗口里第一行写着缺几个、多大,可以 **现在下载**,也可以点 **不再提醒**。以后导入了要用新底模的角色(比如第一个中文角色),小黄点会再出现。缺 Python 包时是小红点,不能关掉。下载的进度也写在这个入口下面。
存放位置、基础文件这些设置在左边侧栏的最下面:**GPT-SoVITS 设置**,点开是一个窗口。基础文件(GPT-SoVITS 生成时要用的几个公用模型)缺了不会挡路(第一次生成时会自动下载),所以只在这个入口上放一个小黄点:已有角色用到的基础文件还没下载时才出现。窗口里第一行写着缺几个、多大,可以 **现在下载**,也可以点 **不再提醒**;都齐了时写着“一切就绪”。以后导入了要用新基础文件的角色(比如第一个中文角色),小黄点会再出现。缺 Python 包时是小红点,不能关掉。下载的进度也写在这个入口下面。
窗口里有:
标题旁边的小标签写着状态(一切就绪 / 缺几个基础文件 / 缺 Python 包)。下面分成几类,一次看一类;哪一类需要你处理,名字旁边有个小黄点,打开窗口时直接停在那一类:
- **角色存放位置**:导入的角色都放在这里,默认 `ComfyUI/models/gpt_sovits/`。
- **其他位置**:设置文件里写的其他角色文件夹,以及 `extra_model_paths.yaml` 里的文件夹。
- **底模**:每个底模是已就绪、缺少还是下载中。可以单个 **下载** 或 **全部下载**。只说日语的角色不需要中文底模;标“可选”的缺了也能用。
- **Python 包**:缺了哪种语言要的包,会给出安装命令,复制到命令行运行后重启 ComfyUI。
- **语气**:平时用哪种语气(平稳 / 自然 / 生动 / 自建的),可以试听、设为默认;右上角 **简单 / 看数字** 切换要不要显示具体数字。
- **说别的语言**:角色说的不是参考音频的语言(比如日语角色说中文)时用哪种语气。
- **基础文件**:每一项是已就绪、缺少还是下载中,可以单个 **下载** 或 **全部下载**。只说日语的角色不需要中文那几项;标“可选”的缺了也能用。缺 Python 包时这里给出安装命令,复制到命令行运行后重启 ComfyUI。
- **存放位置**:
- **角色存放位置**:导入的角色都放在这里,默认 `ComfyUI/models/gpt_sovits/`。
- **其他位置**:设置文件里写的其他角色文件夹,以及 `extra_model_paths.yaml` 里的文件夹。
- **设置文件**:Anomalous_TTS 的设置文件 `ComfyUI/user/anomalous_tts.json` 的位置,可以复制路径。存放位置、其他角色文件夹和已有的 GPT-SoVITS 整合包(用包里的底模,不用再下一遍)都在这个文件里改,窗口里改不了:节点不接受网页指定的本机路径,这样网页就决定不了它在哪里建、删文件夹或读哪些文件。路径写完整,改完不用重启:
```json
@@ -194,16 +205,19 @@
- 角色文件夹放在存放位置里(默认 `ComfyUI/models/gpt_sovits/`),也可以放在 `extra_model_paths.yaml` 里写的 `gpt_sovits` 目录。自己往存放位置里放整理好的角色文件夹也可以,点 **刷新音色** 就能看到。
- 同一个角色有日配、中配两个版本时,会显示成 `阿罗娜/日配`、`阿罗娜/中配` 两个角色。
### 编辑情绪
### 参考音频与情绪
点角色卡片上的 **编辑情绪**:
点角色卡片上的 **参考音频与情绪**,打开一个大窗口:
最上面是搜索框:输入一句台词里的几个字或文件名,下面列出找到的音频和它的台词,可以试听(▶),点 **设为主声音** 或 **加为情绪**(新加一行情绪,音频和台词都填好,再写个情绪名)。搜台词需要 Anomalous_TTS 接口版本 14,旧版本只搜文件名。
1. **上面一排是声音**:主声音、每个情绪(后面写着“1 主 2 副”),最后是 **+ 新情绪**。点一个声音,下面的勾选就只管它。情绪可以改名、删除。
2. **下面是这个角色的全部参考音频**,每条一行:▶ 试听、文件名和长度、台词、还用在哪(比如“主声音·副”)。没用上的音频就留在列表里。可以按 **全部 / 这个声音用的 / 已用上 / 没台词** 筛选,也能搜台词或文件名;先显示 60 条,往下点“再显示一些”。
3. **主参考**:决定这个声音的语气和念法。每个声音一条,要 3~10 秒、有台词(太长太短的会标黄,不能当主参考)。主声音也可以 **改回自动挑**,让节点自己选。
4. **副参考**:只用来融合音色,让声音更稳、更像本人。可以勾好几条,不需要台词,建议同一个人、音质干净的 2~4 条。只有 v1 / v2 / v2Pro 模型用得上,v3 / v4 的角色这一列是灰的。
5. **改台词**:直接点一行的台词就能改;找不到台词的会写“没找到台词,点这里填”。改过的台词存在角色的设置里,比 `.txt` / 标注文件优先,原来的文件不动。清空就换回文件里的台词。
6. **删除**:点一行最右边的 🗑,确认后这条音频和它同名的 `.txt` / `.lab` 一起**移到系统回收站**(随时能还原,从不永久删除;U 盘、网络盘上没有回收站就不删,会说明)。它用在哪些声音里,确认框里会写,删掉后自动从这些声音和设置里去掉。
7. 点 **保存**。设置会写进角色文件夹里的 `anomalous_tts.json`,由节点检查后保存。有问题(比如某个情绪还没选主参考)会提示,什么都不会改。没保存就关窗口会先问一句。
1. **主声音 `{main}`**:选一条 3~10 秒的参考音频。留空的话,节点会自己挑一条。
2. **情绪**:每行填一个情绪名(例如“开心”),再从角色文件夹里选一条参考音频。点一下音频框会列出全部音频(现在用的那条灰着显示,没选新的就还原);也可以直接打字搜索文件名。
3. **参考台词**:换了音频,台词框会自动填上这条音频的台词(节点从同名 `.txt` / `.lab` 或 GPT-SoVITS 的标注文件里找到的,需要 Anomalous_TTS 接口版本 14)。找不到就留空,节点说话时会再找一遍。也可以自己改。
4. 点 **保存**。设置会写进角色文件夹里的 `anomalous_tts.json`,由节点检查后保存。选了不存在的文件时,会提示原因,什么都不会改。
副参考、改台词、音频长度和删除需要 Anomalous_TTS 接口版本 18(1.1.0);旧版本只能选主参考,窗口里会说明。
文件名本身带情绪的音频(例如 `Arona_Talk.开心.wav`)会被自动识别,不用再填一遍。只是扩展名的部分不算情绪,例如 `Arona_Talk.ogg.wav`、`Arona_Talk.ogg (1).ogg`。
@@ -221,7 +235,7 @@
| 拖到节点上提示“列表里还没有 …” | ComfyUI 还没刷新节点定义。按 **R** 后再拖 |
| 标题栏拖不动 | 这个角色没有主声音 |
| 拖到别的语音节点被拒绝 | 目前只支持“角色语音 (GPT-SoVITS)”节点 |
| 卡片上提示“没有找到合适的主声音” | 角色文件夹里没有 3~10 秒、找得到台词的音频。点“编辑情绪”手动选一条 |
| 卡片上提示“没有找到合适的主声音” | 角色文件夹里没有 3~10 秒、找得到台词的音频。点“参考音频与情绪”手动选一条 |
| 新放进去的角色或音频没出现 | 点 **刷新音色**。节点那边在 ComfyUI 里按 **R** 刷新下拉框 |
| 侧栏里看不到“GPT-SoVITS 设置”,工具栏上没有“导入角色” | Anomalous_TTS 版本太旧(没有接口版本 3)。更新 Anomalous_TTS 后重启 ComfyUI |
| 同名文件导入时报“有重名的文件” | Anomalous_TTS 早于接口版本 9。更新后重启 ComfyUI |
+92
View File
@@ -0,0 +1,92 @@
# 模型检查(模型医生)的判断规则
模型检查是本插件最复杂的部分之一。这一页是它判断规则的**唯一总览**:什么算“同一个模型”,什么只是“可能”,缺的模型从哪里下载、怎么核对。改模型检查之前先读这一页;改了规则,同时改这一页。
一句话:**模型检查只认“同一个文件”,唯一可靠的证据是文件指纹(SHA-256 哈希)。** 名字、路径、封面都不能证明是同一个模型。
## 1. 指纹从哪里来
装了本插件的人出图、保存工作流时,插件会把用到的每个模型的指纹(SHA-256)和文件大小写进工作流(设置里可以关)。别人打开这个工作流,模型检查就能按指纹在自己电脑上找同一个文件,不管它被改了什么名、放在哪个文件夹。
没装本插件的人存的工作流没有指纹,只剩模型的文件名,能做的事就少得多(见下面的表)。
## 2. 两类模型
| 类别 | 包括 | 规则 |
|---|---|---|
| 普通模型 | 大模型(Checkpoint)、UNET / 扩散模型、LoRA、ControlNet、放大模型等 | 指纹最可靠;没有指纹时,大小或名字可以给出“候选”,由用户确认 |
| 基础组件 | VAE、TAESD(vae_approx)、CLIP / 文本编码器、CLIP Vision | **只认指纹**。不按名字猜;大小只能作为手动候选显示 |
基础组件只认指纹的原因:它们的文件名高度重复(`ae.safetensors`、`clip_l.safetensors`、`t5xxl_fp16.safetensors`……),同名的经常是不同的文件,按名字猜错的风险太大。
## 3. 本机找回:三档可信度
打开工作流时,模型检查逐个看模型下拉框(包括 rgthree Power Lora Loader 里开着的每一行):
| 看到的情况 | 结论 | 怎么处理 | 普通模型 | 基础组件 |
|---|---|---|---|---|
| 下拉框里就有这个值 | 正常 | 不动 | ✓ | ✓ |
| 只是路径里斜杠写法不同 | 同一个(确定) | 可一键换上 | ✓ | ✓ |
| 本机有指纹一致的文件,只是名字或文件夹不同 | 同一个(确定) | 可一键换上,也可用画布上方提示条的“换上 N 个”一起换 | ✓ | ✓ |
| 工作流没记指纹但记了大小;本机有**同名且大小相同**的文件 | 很可能(半信) | 逐行按一下才换,不算进“换上 N 个” | ✓ | ✓(只手动) |
| 本机只有**大小完全相同**的文件 | 可能 | 逐行按一下才换 | ✓ | ✓(只手动) |
| 工作流什么都没记(不是本插件存的);下拉框里**恰好一个同名**文件 | 可能 | 逐行按一下才换 | ✓ | ✗ 不提供 |
| 好几个文件都可能是它 | 说不清 | 不换,让用户自己挑 | ✓ | ✓ |
| 按指纹和按大小找到的是两个不同文件 | 冲突 | 拒绝替换 | ✓ | ✓ |
| 文件还在,但指纹和工作流记的不一样 | 文件变了 | 只提醒,不算缺失、不替换,ComfyUI 照常用它 | — | ✓ |
所以普通模型实际上是**三档**:指纹一致 = 信;同名且同大小 = 半信(很可能);只同大小或只同名 = 可能。只有第一档能一键或批量换上,后两档都要用户逐行确认。基础组件只有第一档能换上;大小一致(无论名字是否也一样)只作手动候选;只凭名字永远不算。
永远不当作证据的:文件名(除了上表里那两种要确认的情况)、显示名、路径、封面、“看起来像”。确定是同一个文件之后,名字和路径只用来找到下拉框里对应的那一项。
换上的结果只能是这个节点下拉框里本来就有的选项;插件不会往下拉框里塞别的值来假装找到了。确认一个候选只对这一个节点有效,不会被记成以后的规则。
## 4. 本机没有:从哪里下载
本机确实没有时,模型检查页可以直接下载。来源按这个顺序问,问到就停:
1. **Civitai(C 站),按指纹**:工作流记了指纹,就问 C 站有没有这个指纹的文件。
2. **工作流带着的链接**:保存时记下的下载链接(C 站某个版本、Hugging Face / hf-mirror 的文件、GitHub 上的文件)。
3. **ComfyUI-Manager 的模型清单**:本机装的 Manager 自带的 `model-list.json`,几百个常用模型(大模型、VAE、文本编码器、放大模型、ControlNet……,大多在 Hugging Face 上),按**文件名**和节点需要的文件夹类型对上。
核对规则:
| 情况 | 结果 |
|---|---|
| 工作流有指纹,来源文件的 SHA-256 一致 | 确定,可以直接下载,也算进“下载全部” |
| 工作流有指纹,来源文件的 SHA-256 不一致 | 拒绝(不是同一个文件) |
| 工作流有指纹,Manager 清单里有几个同名的 | 逐个核对指纹,只用对上的那个 |
| 工作流没有指纹,Manager 清单里**恰好一个**同名文件 | 标成“按文件名下载”,写明只是同名、核对不了,由用户决定;**不算进“下载全部”**,AI 助手(MCP)也不会自动下 |
| 工作流没有指纹,清单里有好几个同名的 | 不提供(不知道是哪一个) |
没有指纹时,下载前没有任何东西能拿来核对:所以只是同名,永远只到“可能”,由用户决定。下载下来的文件从此有了自己的指纹,但没有原始指纹可比,证明不了它就是工作流原来用的那个。
## 5. ComfyUI Manager 的位置
ComfyUI Manager 是一本“官方常用模型的下载目录”:它只回答“这个文件名去哪里下”,不回答“你电脑里这个文件是不是它”(清单里没有指纹,只有文件名、类型、底模和下载地址)。所以:
- **只用在下载这一步**,排在 C 站(按指纹)和工作流自带的链接后面;本机找回从不用它当证据。
- **有指纹**:清单里同名的文件逐个去下载站点读出 SHA-256 核对,对上才算,和别的来源一样。
- **没指纹**:清单里恰好一个同名、类型也对得上的,才提供“按文件名下载”,并写出清单里的名字和文件在哪个网站 / 仓库,供用户判断。清单没写类型的条目,只在节点类型也不明时才考虑。
- **基础组件也提供“按文件名下载”**。统一的规矩是:名字只能提出候选;基础组件的同名候选还要再有一条独立证据。本机找回时这条证据是“大小一致”(所以只同名不提供);下载时这条证据是“官方清单收录了它,而且类型对得上”。再加上下载是用户自己看清“只是同名”后按的,不会自动发生。
- **哪里都没找到的基础组件**:那一行有“去 Hugging Face 搜”按钮(开了国内镜像就去 hf-mirror),按文件名打开搜索页,由用户自己找、自己下。插件不替用户挑。通用的名字(`model.safetensors`、`ae.safetensors`)可能搜出很多,这正是它不自动做的原因。
- **没装 Manager**:就少这一个来源;模型检查页在有模型找不到下载地址时提示“装了 ComfyUI Manager,还能多一个下载来源”。
- 清单从本机装的 Manager 里读(`model-list.json`),不联网。
下载本身:只从 https 的 C 站、Hugging Face、hf-mirror、GitHub 下;先写 `.part`,有指纹时核对 SHA-256 一致才改成正式文件名;从不覆盖已有文件;先检查磁盘空间。开了国内镜像时,在 C 站找到的文件如果在 Hugging Face 上有同一个(同一 SHA-256),就改从 hf-mirror 下。某个网站连不上就跳过它,继续问别的。
## 6. 不变的承诺
(同 AGENTS.md“功能原则”第 1 条)
- 检查只读,从不自己改工作流。只有用户按了“换上”或“下载”才动节点。
- 只换成同一个文件。“可能”和“很可能”都要用户逐行确认。
- 每一项都在界面上说清楚为什么(找到了同一个 / 只是大小相同 / 只是同名……)和下载地址来自哪里。
## 7. 代码在哪(给维护的 AI)
- 前端判断:`web/modules/model_check.js`(各种状态和 `via`)、`web/modules/model_policies.js`(哪些类型只认指纹)、`web/modules/ui_doctor.js`(每一项的说明文字)。
- 后端按指纹 / 大小找文件:`api/model_resolution.py`(`/anomalous/resolve_hash_batch`)。
- 下载来源:`api/download_sources.py`(`find_source` 的顺序和核对)、`api/manager_catalog.py`(Manager 清单)、`api/model_download.py`。
- 英文的实现细节:`docs/architecture/model-resolution.md`、`docs/architecture/backend.md` 的 “Model downloads”。
- 为什么这么定:`docs/decisions/README.md` 的 AD-005(认模型不看名字)、AD-006(基础组件只认指纹)、AD-020(下载与 ComfyUI Manager)。
+8 -6
View File
@@ -6,6 +6,7 @@ A model's information comes in three layers, the user's first:
- <model>.info what a scan found: Civitai's record, or (id -1) what the file
itself tells; <model>.civitai.info is the same from other tools;
- the model file itself.
The first two are beside the model or in its folder's .amb (model_sidecars.py).
"""
import hashlib
@@ -16,11 +17,10 @@ import struct
try:
from .model_kind import MAX_HEADER, inspect_file
from .model_sidecars import USER_INFO_SUFFIX, find
except ImportError: # the standalone scanner imports this file on its own
from model_kind import MAX_HEADER, inspect_file
USER_INFO_SUFFIX = ".anomalous.json"
from model_sidecars import USER_INFO_SUFFIX, find
def read_json(path):
@@ -40,7 +40,8 @@ def scan_choice(base):
"""What the user chose about scanning a model, kept in <model>.anomalous.json ("scan"):
"skip" (a scan of its folder leaves it out; picking it by hand still scans it), "scan"
(asked once, scanned like any other) or "" (not chosen). ``base``: the path without extension."""
choice = (read_json(base + USER_INFO_SUFFIX) or {}).get("scan")
user = find(base, USER_INFO_SUFFIX)
choice = (read_json(user) or {}).get("scan") if user else None
return choice if choice in SCAN_CHOICES else ""
@@ -48,8 +49,9 @@ def scan_info_path(base):
"""The sidecar holding a scan result: <model>.info, else another tool's <model>.civitai.info.
A .civitai.info with only edits made in older versions (no Civitai ids) does not count,
so editing a model's name never makes it look scanned. ``base``: the path without extension."""
if os.path.exists(base + ".info"):
return base + ".info"
info = find(base, ".info")
if info:
return info
data = read_json(base + ".civitai.info")
if data is not None and ("id" in data or "modelId" in data):
return base + ".civitai.info"
+202
View File
@@ -0,0 +1,202 @@
"""Where a model's side files are kept, as Settings → Models → "Side files" says (SIDECAR_LAYOUT
in api/config.json):
- "beside" (the default): every side file next to the model, <folder>/<stem><suffix>;
- "hidden": the files only this plugin uses (OWN_SUFFIXES: what a scan found, the user's edits,
Civitai's cover kept aside) in the folder's hidden .amb folder, <folder>/.amb/<stem><suffix>.
Covers (<stem>.preview.*, <stem>.<image>) and other tools' files (.civitai.info, .yaml, .txt…)
always stay beside the model, where ComfyUI and other plugins look for them.
Reading looks in both places, the setting's first, so a folder moved half way, or by hand, still
reads; a file is written where it is already, else where the setting says. Shared by the
standalone scanner and the API (no ComfyUI imports).
Civitai's cover is kept aside (<stem>.civitai_bak.*) only while the user's own cover is shown:
otherwise the cover itself is Civitai's, and the scan notes its SHA-256 in the .info
(COVER_HASH_KEY) to tell it from a picture the user puts there later.
"""
import ctypes
import hashlib
import json
import os
MEDIA_EXTENSIONS = ('.png', '.jpg', '.jpeg', '.webp', '.gif', '.avif', '.mp4', '.webm', '.mov', '.avi') # as api/model_constants.py
CIVITAI_BACKUP_SUFFIXES = tuple(f'.civitai_bak{ext}' for ext in MEDIA_EXTENSIONS)
ACTIVE_COVER_SUFFIXES = tuple(f'.preview{ext}' for ext in MEDIA_EXTENSIONS) + MEDIA_EXTENSIONS # <model>.preview.png, else <model>.png
USER_INFO_SUFFIX = '.anomalous.json'
OWN_SUFFIXES = ('.info', USER_INFO_SUFFIX, *CIVITAI_BACKUP_SUFFIXES)
HIDDEN_DIR = '.amb'
LAYOUTS = ('beside', 'hidden')
COVER_HASH_KEY = 'anomalous_cover_sha256'
CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'api', 'config.json')
FILE_ATTRIBUTE_HIDDEN = 0x2
_layout = {'mtime': None, 'value': 'beside'}
def layout():
"""'beside' or 'hidden', as api/config.json says (read again when the file changes)."""
try:
mtime = os.stat(CONFIG_PATH).st_mtime_ns
except OSError:
return 'beside'
if mtime != _layout['mtime']:
try:
with open(CONFIG_PATH, encoding='utf-8') as f:
data = json.load(f)
except (OSError, ValueError):
data = {}
value = data.get('SIDECAR_LAYOUT') if isinstance(data, dict) else None
_layout.update(mtime=mtime, value=value if value in LAYOUTS else 'beside')
return _layout['value']
def hidden_path(base, suffix):
folder, stem = os.path.split(base)
return os.path.join(folder, HIDDEN_DIR, stem + suffix)
def is_hidden(path):
return os.path.basename(os.path.dirname(path)) == HIDDEN_DIR
def places(base, suffix, wanted=None):
"""Where a side file of `suffix` may be, the place `wanted` (the setting when None) first."""
if suffix not in OWN_SUFFIXES:
return [base + suffix]
pair = [hidden_path(base, suffix), base + suffix]
return pair if (wanted or layout()) == 'hidden' else pair[::-1]
def find(base, suffix):
"""The side file of `suffix` where it is, or None."""
return next((path for path in places(base, suffix) if os.path.exists(path)), None)
def make_hidden_dir(folder):
"""<folder>/.amb, made when missing (and hidden from Explorer on Windows)."""
path = os.path.join(folder, HIDDEN_DIR)
if not os.path.isdir(path):
os.makedirs(path, exist_ok=True)
if os.name == 'nt':
try:
ctypes.windll.kernel32.SetFileAttributesW(path, FILE_ATTRIBUTE_HIDDEN)
except (OSError, AttributeError):
pass # only how Explorer shows it
return path
def target(base, suffix):
"""Where the side file of `suffix` is written: where it is already, else where the setting says."""
found = find(base, suffix)
if found:
return found
path = places(base, suffix)[0]
if is_hidden(path):
make_hidden_dir(os.path.dirname(base))
return path
def existing(base, suffixes):
"""[(path, suffix)] of the side files a model has, in either place."""
return [(path, suffix) for suffix in suffixes for path in places(base, suffix) if os.path.isfile(path)]
def follow(path, new_base, suffix):
"""Where side file `path` goes when its model becomes `new_base`: the same kind of place."""
if is_hidden(path):
make_hidden_dir(os.path.dirname(new_base))
return hidden_path(new_base, suffix)
return new_base + suffix
def drop_empty_hidden_dir(folder):
"""Removes <folder>/.amb when nothing is left in it."""
try:
os.rmdir(os.path.join(folder, HIDDEN_DIR))
except OSError:
pass # missing, or still holds files
def file_sha256(path):
digest = hashlib.sha256()
with open(path, 'rb') as f:
for block in iter(lambda: f.read(1024 * 1024), b''):
digest.update(block)
return digest.hexdigest()
def same_file(a, b):
"""Byte-for-byte equal (covers are small; size is checked first)."""
try:
return os.path.getsize(a) == os.path.getsize(b) and file_sha256(a) == file_sha256(b)
except OSError:
return False
def _read_json(path):
try:
with open(path, encoding='utf-8') as f:
data = json.load(f)
except (OSError, ValueError):
return None
return data if isinstance(data, dict) else None
def civitai_cover_hash(base):
"""The SHA-256 a scan noted for the Civitai picture it made the cover, or ''."""
info = find(base, '.info')
value = (_read_json(info) or {}).get(COVER_HASH_KEY) if info else None
return value if isinstance(value, str) else ''
def note_cover_hash(base, cover):
"""Notes in the model's .info that `cover` is Civitai's picture (False without a .info)."""
info = find(base, '.info')
data = _read_json(info) if info else None
if data is None:
return False
data[COVER_HASH_KEY] = file_sha256(cover)
temporary = f'{info}.{os.getpid()}.tmp'
with open(temporary, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=True, indent=4)
os.replace(temporary, info)
return True
def backups(base):
"""Civitai's covers kept aside (<stem>.civitai_bak.*), in either place."""
return [path for path, _ in existing(base, CIVITAI_BACKUP_SUFFIXES)]
def cover_owners(base):
"""(Civitai's, the user's) active covers: Civitai's is the picture a scan noted or a copy of
the one kept aside; any other is the user's."""
kept, noted = backups(base), civitai_cover_hash(base)
covers = [base + suffix for suffix in ACTIVE_COVER_SUFFIXES if os.path.isfile(base + suffix)]
civitai = [cover for cover in covers if is_civitai_cover(cover, base, kept, noted)]
return civitai, [cover for cover in covers if cover not in civitai]
def is_civitai_cover(cover, base, kept=None, noted=None):
"""Whether `cover` is Civitai's picture: a copy of one kept aside, or the one the scan noted."""
kept = backups(base) if kept is None else kept
if any(same_file(cover, backup) for backup in kept):
return True
noted = civitai_cover_hash(base) if noted is None else noted
try:
return bool(noted) and file_sha256(cover) == noted
except OSError:
return False
def keep_aside(base, cover):
"""Moves Civitai's picture `cover` aside (<stem>.civitai_bak.<ext>), to bring back later."""
path = target(base, '.civitai_bak' + os.path.splitext(cover)[1])
os.replace(cover, path)
return path
def suffix_of(path, base):
"""What follows the model's name in a side file's name ('.info', '.civitai_bak.png'…)."""
return os.path.basename(path)[len(os.path.basename(base)):]
+46 -35
View File
@@ -21,6 +21,7 @@ from model_policies import is_physical_rename_protected
from recycle_bin import move_to_trash
from model_identity import USER_INFO_SUFFIX, computed_file_identity, file_sha256, infer_base_model_from_header, is_unmatched, safetensors_damage, scan_choice, scan_info_path, sidecar_file_hash, sidecar_info as read_local_info, unmatched_reason
from civitai_client import CIVITAI_API_KEY, CivitaiUnreachable, download_media, fetch_civitai_info, fetch_model_description
import model_sidecars as sidecars
# Fixed tuples avoid rebuilding long extension lists for every scanned model.
@@ -29,7 +30,6 @@ MEDIA_EXTENSIONS = (".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif", ".mp4", "
PREVIEW_SUFFIXES = tuple(f".preview{ext}" for ext in MEDIA_EXTENSIONS)
CIVITAI_BACKUP_SUFFIXES = tuple(f".civitai_bak{ext}" for ext in MEDIA_EXTENSIONS)
COVER_SUFFIXES = MEDIA_EXTENSIONS + PREVIEW_SUFFIXES
ACTIVE_COVER_SUFFIXES = PREVIEW_SUFFIXES + MEDIA_EXTENSIONS
SIDECAR_SUFFIXES = (
".info", ".civitai.info", USER_INFO_SUFFIX, ".json", ".txt", ".yaml",
*MEDIA_EXTENSIONS,
@@ -38,21 +38,13 @@ SIDECAR_SUFFIXES = (
)
def same_file(a, b):
"""Byte-for-byte equal (covers are small; size is checked first)."""
try:
return os.path.getsize(a) == os.path.getsize(b) and calculate_sha256(a) == calculate_sha256(b)
except OSError:
return False
def cover_owners(base):
"""(Civitai's, the user's) active covers. A cover is Civitai's only when it is a copy of the
Civitai image saved last time (<model>.civitai_bak.*); any other cover is the user's."""
backups = [base + ext for ext in CIVITAI_BACKUP_SUFFIXES if os.path.exists(base + ext)]
covers = [base + ext for ext in ACTIVE_COVER_SUFFIXES if os.path.exists(base + ext)]
civitai = [c for c in covers if any(same_file(c, b) for b in backups)]
return civitai, [c for c in covers if c not in civitai]
def drop_partial(base):
"""A download that did not finish (<base>.<ext>) goes."""
for ext in MEDIA_EXTENSIONS:
try:
os.remove(base + ext)
except OSError:
pass
def write_scan_progress(progress_file, phase, total=0, current=0, filename=""):
@@ -239,8 +231,11 @@ def scan_one(args, state, rename_log, root, filename, previous):
info_data.pop("anomalous_custom_name", None)
elif args.virtual_rename:
info_data["anomalous_custom_name"] = f"{model_name}_{version_name}"
noted = sidecars.civitai_cover_hash(old_base)
if noted and sidecars.COVER_HASH_KEY not in info_data:
info_data[sidecars.COVER_HASH_KEY] = noted # fresh data keeps knowing the cover until a new one comes
if not args.dry_run:
with open(old_base + ".info", 'w', encoding='utf-8') as f:
with open(sidecars.target(old_base, ".info"), 'w', encoding='utf-8') as f:
json.dump(info_data, f, ensure_ascii=True, indent=4)
print(f"[+] 写入纯净版 Civitai 描述 -> .info")
else:
@@ -249,12 +244,32 @@ def scan_one(args, state, rename_log, root, filename, previous):
media_url = None
if not args.skip_media:
media_url = next((img.get("url") for img in civitai_data.get("images") or [] if img.get("url")), None)
civitai_covers, user_covers = cover_owners(old_base)
civitai_covers, user_covers = sidecars.cover_owners(old_base)
# Download when the data is fresh, or when the model has no cover at all.
if media_url and not state["civitai_down"] and not state["covers_down"] and (fresh or not (civitai_covers or user_covers)):
if not args.dry_run:
print(f"[*] 正在下载预览媒体...")
saved_path = download_media(media_url, old_base + ".civitai_bak")
# Fetched under a working name, so a broken download never replaces a cover.
working = old_base + ".amb_download"
fetched = download_media(media_url, working)
if not fetched:
drop_partial(working)
saved_path = None
if fetched and user_covers:
# Civitai's picture is kept aside (in .amb when the setting says), to bring back later.
kept_before = sidecars.backups(old_base)
aside = sidecars.target(old_base, ".civitai_bak" + os.path.splitext(fetched)[1])
os.replace(fetched, aside)
saved_path = aside
try:
older = [path for path in kept_before if path != aside]
if older:
move_to_trash(*older)
except Exception as e:
print(f"[-] 旧的 Civitai 封面备份没有移走: {e}")
elif fetched:
ext = os.path.splitext(fetched)[1]
saved_path = old_base + (ext if ext.startswith('.preview.') else f".preview{ext}")
state["cover_failures"] = 0 if saved_path else state["cover_failures"] + 1
if state["cover_failures"] >= COVERS_DOWN_AFTER:
state["covers_down"] = True
@@ -266,16 +281,16 @@ def scan_one(args, state, rename_log, root, filename, previous):
# The user's cover always stays; Civitai's image is kept as the backup.
print(f"[*] 保留你自己的封面,Civitai 封面存为备份")
else:
ext = os.path.splitext(saved_path)[1]
preview_path = old_base + (ext if ext.startswith('.preview.') else f".preview{ext}")
# Earlier copies of Civitai's image under another name give way to the new one.
older = [c for c in civitai_covers if c != preview_path]
# Earlier Civitai pictures, as covers or kept aside, give way to the new one:
# the cover is Civitai's, so nothing needs keeping aside.
older = [c for c in civitai_covers if c != saved_path] + sidecars.backups(old_base)
try:
if older:
move_to_trash(*older)
except Exception as e:
print(f"[-] 旧的 Civitai 封面没有移走: {e}")
shutil.copy2(saved_path, preview_path)
os.replace(fetched, saved_path)
sidecars.note_cover_hash(old_base, saved_path)
else:
print(f"[Dry-Run] 拟下载预览媒体...")
@@ -299,7 +314,7 @@ def scan_one(args, state, rename_log, root, filename, previous):
if not args.dry_run:
try:
print(f"[*] Hash 一致,把已确认的重复副本移到回收站: {filename}")
move_to_trash(file_path, *[old_base + ext for ext in SIDECAR_SUFFIXES])
move_to_trash(file_path, *[path for path, _ in sidecars.existing(old_base, SIDECAR_SUFFIXES)])
entry["duplicate_of"] = new_file_path
except Exception as e:
print(f"[-] 重复副本没有移走,两份都保留 (可能文件被占用或这个盘没有回收站): {e}")
@@ -309,9 +324,8 @@ def scan_one(args, state, rename_log, root, filename, previous):
elif not args.dry_run:
os.rename(file_path, new_file_path)
rename_log[file_path] = new_file_path
for ext in SIDECAR_SUFFIXES:
if os.path.exists(old_base + ext):
os.replace(old_base + ext, new_base + ext)
for path, ext in sidecars.existing(old_base, SIDECAR_SUFFIXES):
os.replace(path, sidecars.follow(path, new_base, ext))
print(f"[+] 物理重命名完成: {filename} ==> {new_filename}")
entry["renamed_to"] = new_file_path
else:
@@ -381,13 +395,10 @@ def main():
old_base = os.path.splitext(old_path)[0]
new_base = os.path.splitext(new_path)[0]
for ext in SIDECAR_SUFFIXES:
new_ext_path = new_base + ext
old_ext_path = old_base + ext
if os.path.exists(new_ext_path):
print(f"[*] 恢复配套文件: {os.path.basename(new_ext_path)} -> {os.path.basename(old_ext_path)}")
if not args.dry_run:
os.replace(new_ext_path, old_ext_path)
for new_ext_path, ext in sidecars.existing(new_base, SIDECAR_SUFFIXES):
print(f"[*] 恢复配套文件: {os.path.basename(new_ext_path)} -> {os.path.basename(old_base + ext)}")
if not args.dry_run:
os.replace(new_ext_path, sidecars.follow(new_ext_path, old_base, ext))
print("[+] 回滚完成!")
sys.exit(0)
-11
View File
@@ -59,15 +59,4 @@ const detail = await engines.fetchGptSovitsCharacter('阿罗娜/日配');
assert.deepEqual(detail.audio, ['ref/a.wav', 'ref/a.开心.wav']);
assert.equal(calls.at(-1), `/anomalous_tts/characters?name=${encodeURIComponent('阿罗娜/日配')}`);
// Saving keeps fields Anomalous does not know.
const merged = engines.mergeGptSovitsSettings({ format: 1, keep: 1, reference: { audio: 'old.wav', language: 'ja' } }, {
main: { audio: 'ref/a.wav', text: '' },
emotions: [{ name: '开心', audio: 'ref/a.开心.wav', text: 'x' }, { name: 'main', audio: 'y.wav' }],
});
assert.deepEqual(merged, {
format: 1, keep: 1,
reference: { audio: 'ref/a.wav', language: 'ja' },
emotions: { 开心: { audio: 'ref/a.开心.wav', text: 'x' } },
});
console.log('audio_engines: ok');
+20 -1
View File
@@ -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, '阿罗娜/日配'), '阿罗娜\\日配');
+48
View File
@@ -0,0 +1,48 @@
// What the right-click menus offer, as the user set it (web/modules/canvas_menu_settings.js).
import assert from 'node:assert/strict';
import { CANVAS_ENTRIES, NODE_ENTRIES, menuSettings, moveEntry, resetMenuSettings, saveMenuSettings } from '../web/modules/canvas_menu_settings.js';
const memory = () => {
const items = new Map();
return { getItem: key => items.get(key) ?? null, setItem: (key, value) => items.set(key, String(value)), removeItem: key => items.delete(key) };
};
// Nothing saved: the defaults, in a submenu.
let store = memory();
let settings = menuSettings(store);
assert.equal(settings.placement, 'submenu');
assert.deepEqual(settings.node.map(entry => entry.id), NODE_ENTRIES.map(entry => entry.id));
assert.deepEqual(settings.canvas, CANVAS_ENTRIES.map(entry => ({ ...entry })));
// Saved and read back: order, switches and placement kept.
settings.node = moveEntry(settings.node, 'saveCombo', -1);
settings.node.find(entry => entry.id === 'open').on = false;
settings.placement = 'top';
assert.equal(saveMenuSettings(settings, store), true);
const back = menuSettings(store);
assert.equal(back.placement, 'top');
assert.equal(back.node.at(-2).id, 'saveCombo');
assert.equal(back.node.find(entry => entry.id === 'open').on, false);
// Moving past an end changes nothing.
assert.equal(moveEntry(back.node, back.node[0].id, -1), back.node);
// An old list: unknown entries and doubles dropped, new ones at their default place.
store = memory();
store.setItem('anomalous_canvas_menu', JSON.stringify({ placement: 'odd', node: [
{ id: 'saveCombo', on: true }, { id: 'gone', on: true }, { id: 'open', on: false }, { id: 'open', on: true },
] }));
settings = menuSettings(store);
assert.equal(settings.placement, 'submenu');
assert.equal(settings.node.length, NODE_ENTRIES.length);
assert.equal(settings.node[0].id, 'saveCombo');
assert.equal(settings.node[1].id, 'open');
assert.equal(settings.node[1].on, false);
assert.equal(settings.node[2].id, 'replaceModel'); // right after "open", as by default
// Bad JSON, no storage, reset.
store.setItem('anomalous_canvas_menu', '{');
assert.equal(menuSettings(store).placement, 'submenu');
assert.equal(menuSettings(null).node.length, NODE_ENTRIES.length);
assert.equal(resetMenuSettings(store).node[0].id, 'open');
console.log('canvas menu settings: ok');
+19
View File
@@ -0,0 +1,19 @@
// The name a combo kept from an output image gets (web/modules/image_keep.js comboTitle).
import assert from 'node:assert/strict';
import { comboTitle } from '../web/modules/image_keep.js';
const lora = { filename: 'Ahri_v2.safetensors', metadata: {} };
const main = { filename: 'illustriousXL.safetensors', metadata: { custom_name: 'Illustrious XL' } };
const prompt = 'masterpiece, best quality, 1girl, solo, ahri, fox ears, kimono, looking at viewer';
// The first LoRA, then two tags that are neither quality words nor ones every picture has.
assert.equal(comboTitle({ prompt, mainModel: main, loras: [lora] }), 'Ahri_v2 · ahri, fox ears');
// No LoRA: the main model's own name.
assert.equal(comboTitle({ prompt: '1girl, (red hair:1.2), beach', mainModel: main }), 'Illustrious XL · red hair, beach');
// A LoRA's name from its card wins over its file's; a path keeps only the file.
assert.equal(comboTitle({ loras: [{ filename: 'chars\\x.safetensors', metadata: { custom_name: 'Nahida' } }] }), 'Nahida');
assert.equal(comboTitle({ mainModel: { filename: 'sdxl\\base.safetensors' } }), 'base');
assert.equal(comboTitle({ mainModel: { filename: 'sdxl/base.safetensors' } }), 'base');
// Nothing to go on: the fallback.
assert.equal(comboTitle({ prompt: 'masterpiece, 1girl, solo', fallback: 'ComfyUI_00012_' }), 'ComfyUI_00012_');
console.log('combo title: ok');
+17 -3
View File
@@ -1,9 +1,11 @@
// node tests/detached_graph.mjs — a workflow read off the canvas never takes the canvas's graph id.
// node tests/detached_graph.mjs — a workflow read off the canvas never takes the canvas's graph id,
// and readDetached clears the graph after reading (its widgets' DOM elements leave the page).
import assert from 'node:assert/strict';
import { detachedGraph } from '../web/modules/detached_graph.js';
import { detachedGraph, readDetached } from '../web/modules/detached_graph.js';
const configured = [];
globalThis.LiteGraph = { LGraph: class { configure(data) { configured.push(data); } } };
let cleared = 0;
globalThis.LiteGraph = { LGraph: class { configure(data) { configured.push(data); } clear() { cleared += 1; } } };
const workflow = { id: '3675a66f-fd88-4261-ab21-5b285bd54a02', nodes: [{ id: 3, widgets_values: [20] }] };
detachedGraph(workflow);
@@ -22,4 +24,16 @@ detachedGraph(workflow);
assert.match(configured[2].id, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
Object.defineProperty(globalThis, 'crypto', { value: realCrypto, configurable: true });
// readDetached: the graph is cleared after a plain read, after an async one settles, and after a throw.
assert.equal(readDetached(workflow, graph => (assert.equal(cleared, 0), 'read')), 'read');
assert.equal(cleared, 1);
const later = readDetached(workflow, async () => 'later');
assert.equal(cleared, 1); // not before the promise settles
assert.equal(await later, 'later');
assert.equal(cleared, 2);
assert.throws(() => readDetached(workflow, () => { throw new Error('bad'); }), /bad/);
assert.equal(cleared, 3);
await assert.rejects(readDetached(workflow, async () => { throw new Error('bad'); }), /bad/);
assert.equal(cleared, 4);
console.log('detached_graph ok');
+13
View File
@@ -0,0 +1,13 @@
"""Tests never read this computer's settings: side files (model_sidecars.py) go beside the model,
whatever api/config.json on this computer says. Imported by the tests that write side files."""
import os
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import model_sidecars # noqa: E402
model_sidecars.CONFIG_PATH = os.path.join(tempfile.gettempdir(), "anomalous-tests-have-no-config.json")
model_sidecars._layout["mtime"] = None
+37
View File
@@ -0,0 +1,37 @@
// The canvas quick search's matching (web/modules/quick_search_data.js).
import assert from 'node:assert/strict';
import { comboItem, modelItem, promptItem, queryTerms, searchQuick } from '../web/modules/quick_search_data.js';
const models = [
modelItem({ type: 'loras', subfolder: 'chars', filename: 'ahri_v2.safetensors', name: '', civitai_name: 'Ahri (LoL)', base: 'Illustrious', words: ['ahri', 'fox ears'] }),
modelItem({ type: 'checkpoints', subfolder: '', filename: 'waiIllustrious.safetensors', name: 'WAI Illustrious', civitai_name: '', base: 'Illustrious', words: [] }),
modelItem({ type: 'loras', subfolder: '', filename: 'kimono.safetensors', name: '', civitai_name: '', base: 'SDXL', words: ['kimono'] }),
];
const combos = [comboItem({ filename: 'a.json', name: 'Ahri · fox ears' }, { detail: 'WAI Illustrious · 1 LoRA' }), comboItem({ filename: 'b.json', name: 'Beach' })];
const prompts = [promptItem({ id: 'p1', title: 'Fox girl', content: '1girl, fox ears,\n kimono', role: 'positive' })];
const all = [...models, ...combos, ...prompts];
assert.deepEqual(queryTerms(' Fox Ears '), ['fox', 'ears']);
// A model's name: its own, else Civitai's, else the file's.
assert.deepEqual(models.map(item => item.title), ['Ahri (LoL)', 'WAI Illustrious', 'kimono']);
assert.equal(prompts[0].detail, '1girl, fox ears, kimono');
// Every word must appear; groups in the order combos, models, prompts.
let groups = searchQuick(all, 'fox ears');
assert.deepEqual(groups.map(group => group.kind), ['combo', 'model', 'prompt']);
assert.equal(groups[1].items[0].title, 'Ahri (LoL)'); // by its trigger words
// Names starting with the words come first.
groups = searchQuick(all, 'illustrious');
assert.deepEqual(groups.find(group => group.kind === 'model').items.map(item => item.title), ['WAI Illustrious', 'Ahri (LoL)']);
// One kind only, and a cap with how many more.
groups = searchQuick(all, 'a', { kind: 'model', perKind: 1 });
assert.equal(groups.length, 1);
assert.equal(groups[0].more, 2);
// No words: the combos as they are.
groups = searchQuick(all, '');
assert.deepEqual(groups.map(group => group.kind), ['combo']);
assert.equal(groups[0].items.length, 2);
assert.equal(searchQuick(all, '', { kind: 'prompt' })[0].items.length, 1);
// Nothing matches.
assert.deepEqual(searchQuick(all, 'zzz'), []);
console.log('quick search: ok');
+43
View File
@@ -0,0 +1,43 @@
// What a combo kept from an output image holds (web/modules/sampler_inputs.js).
import assert from 'node:assert/strict';
import { isSampler, samplerSideNodes } from '../web/modules/sampler_inputs.js';
// A small graph: links [id, from, to, type]; inputs name the link feeding them.
function graph(nodes, links) {
const byId = new Map(links.map(([id, from, , type]) => [id, { id, origin_id: from, type }]));
return {
_nodes: nodes.map(([id, type, inputs = {}]) => ({ id, type, inputs: Object.entries(inputs).map(([name, link]) => ({ name, link })) })),
getLink: id => byId.get(id) ?? null,
};
}
const g = graph([
[1, 'CheckpointLoaderSimple'],
[2, 'LoraLoader', { model: 1, clip: 2 }],
[3, 'CLIPSetLastLayer', { clip: 3 }],
[4, 'CLIPTextEncode', { clip: 4 }],
[5, 'CLIPTextEncode', { clip: 5 }],
[6, 'EmptyLatentImage'],
[7, 'KSampler', { model: 6, positive: 7, negative: 8, latent_image: 9 }],
[8, 'VAEDecode', { samples: 10, vae: 11 }],
[9, 'LoadImage'],
[10, 'ControlNetApply', { conditioning: 12, image: 13 }],
[11, 'Note'],
], [
[1, 1, 2, 'MODEL'], [2, 1, 2, 'CLIP'], [3, 2, 3, 'CLIP'], [4, 3, 4, 'CLIP'], [5, 3, 5, 'CLIP'],
[6, 2, 7, 'MODEL'], [7, 10, 7, 'CONDITIONING'], [8, 5, 7, 'CONDITIONING'], [9, 6, 7, 'LATENT'],
[10, 7, 8, 'LATENT'], [11, 1, 8, 'VAE'], [12, 4, 10, 'CONDITIONING'], [13, 9, 10, 'IMAGE'],
]);
assert.equal(isSampler(g._nodes[6]), true);
assert.equal(isSampler(g._nodes[1]), false);
// Loaders, the LoRA, CLIP skip, both prompts and what sits between them and the sampler;
// not the sampler, the latent, the decode, the image loader or a note.
assert.deepEqual(samplerSideNodes(g).map(node => node.id), [1, 2, 3, 4, 5, 10]);
// A workflow without a sampler: nothing to keep.
assert.deepEqual(samplerSideNodes(graph([[1, 'CheckpointLoaderSimple']], [])), []);
// A guider (Flux): model and conditioning.
const flux = graph([[1, 'UNETLoader'], [2, 'CLIPTextEncode'], [3, 'BasicGuider', { model: 1, conditioning: 2 }]],
[[1, 1, 3, 'MODEL'], [2, 2, 3, 'CONDITIONING']]);
assert.deepEqual(samplerSideNodes(flux).map(node => node.id), [1, 2]);
console.log('sampler inputs: ok');
+21
View File
@@ -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');
+92
View File
@@ -0,0 +1,92 @@
// The voice-over drafts box without storage (web/modules/script_drafts.js): what a kept script
// may hold, the box's moves, two tabs' boxes joined, and the titles and times on its cards.
import assert from 'node:assert/strict';
import {
cleanBox, cleanScript, copyDraft, deleteDraft, deletedDrafts, draftSpeakers, draftTitle, dropOldDeleted, emptyBox,
hasLines, keepScript, liveDrafts, makeDraft, mergeBoxes, putDraft, renameDraft, restoreDraft, whenKey,
} from '../web/modules/script_drafts.js';
const DAY = 24 * 60 * 60 * 1000;
const script = (...texts) => ({ format: 1, lines: texts.map(text => ({ text, emotion: 'main', speaker: 'gpt_sovits:Aris' })), groupKey: 'gpt_sovits:Aris', script: {}, cast: {} });
// A kept script: blank lines and bad settings go, the right ones stay in a fixed order.
const cleaned = cleanScript({
format: 1,
groupKey: 'gpt_sovits:Aris',
lines: [{ text: ' ' }, { text: 'a', tune: { speed: 1.2, language: 'ja', odd: 1 } }, { text: 'b', speaker: 3 }, null],
script: { speed: 'fast', preset: 'stable' },
cast: { 'gpt_sovits:Aris': { language: 'ja' }, 'gpt_sovits:Momoi': { speed: '1' } },
});
assert.deepEqual(cleaned, {
format: 1,
lines: [{ text: 'a', emotion: 'main', speaker: null, tune: { language: 'ja', speed: 1.2 } }, { text: 'b', emotion: 'main', speaker: null }],
groupKey: 'gpt_sovits:Aris',
script: { preset: 'stable' },
cast: { 'gpt_sovits:Aris': { language: 'ja' } },
});
assert.equal(cleanScript({ format: 2, lines: [] }), null);
assert.equal(hasLines(cleanScript({ format: 1, lines: [{ text: ' ' }] })), false);
assert.deepEqual(cleanBox(null), emptyBox());
assert.deepEqual(cleanBox({ format: 1, current: 'x', drafts: [{ id: 'x', script: { format: 9 } }, { id: '', script: script('a') }] }), emptyBox());
// The box: new, renamed, copied, deleted, brought back; the last changed first.
const box = emptyBox();
const first = putDraft(box, makeDraft(script('おはよう、先生!今日もいい天気ですね'), 1000, { id: 'a' }));
const second = putDraft(box, makeDraft(script('b'), 2000, { id: 'b' }));
box.current = 'a';
assert.deepEqual(liveDrafts(box).map(draft => draft.id), ['b', 'a']);
keepScript(box, 'a', script('c'), 3000);
assert.deepEqual(liveDrafts(box).map(draft => draft.id), ['a', 'b']);
assert.equal(draftTitle(first), 'c');
renameDraft(box, 'a', ' 第 3 集开场 ', 3500);
assert.equal(draftTitle(first), '第 3 集开场');
assert.equal(draftTitle(makeDraft(script('おはよう、先生!今日もいい天気ですね。'))), 'おはよう、先生!今日もいい天気ですね…');
assert.equal(draftTitle(makeDraft(script())), '');
const copy = copyDraft(box, 'a', '第 3 集开场(副本)', 4000);
assert.equal(copy.name, '第 3 集开场(副本)');
copy.script.lines[0].text = 'changed';
assert.equal(first.script.lines[0].text, 'c', 'a copy is its own');
deleteDraft(box, 'a', 5000);
assert.equal(box.current, null, 'the open draft is let go when deleted');
assert.deepEqual(deletedDrafts(box).map(draft => draft.id), ['a']);
assert.equal(liveDrafts(box).some(draft => draft.id === 'a'), false);
restoreDraft(box, 'a', 6000);
assert.equal(first.deleted, null);
deleteDraft(box, 'b', 7000);
assert.equal(dropOldDeleted(box, 7000 + 29 * DAY), false);
assert.equal(dropOldDeleted(box, 7000 + 30 * DAY), true);
assert.equal(box.drafts.some(draft => draft.id === 'b'), false);
assert.equal(second.deleted, 7000);
// Who reads, in the order they first speak (a line without a speaker is the cast's pick).
assert.deepEqual(draftSpeakers({ groupKey: 'k:A', lines: [{ text: 'x', speaker: 'k:B' }, { text: 'y', speaker: null }, { text: 'z', speaker: 'k:B' }] }), ['k:B', 'k:A']);
// Two tabs: each draft as it was changed last; the draft open here stays this tab's.
const mine = { format: 1, current: 'm', drafts: [
makeDraft(script('mine'), 100, { id: 'm' }),
makeDraft(script('old here'), 100, { id: 's' }),
] };
const theirs = { format: 1, current: 'o', drafts: [
makeDraft(script('newer there'), 200, { id: 'm' }),
makeDraft(script('newer there'), 200, { id: 's' }),
makeDraft(script('only there'), 150, { id: 'o' }),
] };
const joined = mergeBoxes(mine, theirs);
const text = id => joined.drafts.find(draft => draft.id === id).script.lines[0].text;
assert.equal(joined.current, 'm');
assert.equal(text('m'), 'mine');
assert.equal(text('s'), 'newer there');
assert.equal(text('o'), 'only there');
const gone = { format: 1, current: null, drafts: [{ ...makeDraft(script('x'), 100, { id: 's' }), deleted: 300, updated: 300 }] };
assert.equal(mergeBoxes(gone, theirs).drafts.find(draft => draft.id === 's').deleted, 300, 'a deletion is a change');
// When: just now, minutes, today, yesterday, a date (with the year when it is another one).
const now = new Date(2026, 9, 20, 15, 30).getTime();
assert.deepEqual(whenKey(now - 20 * 1000, now), { key: 'draftsJustNow' });
assert.deepEqual(whenKey(now - 5 * 60000, now), { key: 'draftsMinutesAgo', values: { count: 5 } });
assert.deepEqual(whenKey(new Date(2026, 9, 20, 9, 5).getTime(), now), { key: 'draftsTodayAt', values: { time: '09:05' } });
assert.deepEqual(whenKey(new Date(2026, 9, 19, 23, 0).getTime(), now), { key: 'draftsYesterdayAt', values: { time: '23:00' } });
assert.deepEqual(whenKey(new Date(2026, 9, 18, 8, 0).getTime(), now), { key: 'draftsDate', values: { month: 10, day: 18, year: 2026 } });
assert.equal(whenKey(new Date(2025, 11, 31).getTime(), now).key, 'draftsDateYear');
console.log('script drafts: ok');
+82
View File
@@ -0,0 +1,82 @@
// How each voice-over line is read: its own values over its character's, and the tags for them
// (web/modules/script_tune.js), written into the script and read back (audio_script.js).
import assert from 'node:assert/strict';
import { buildScriptPackage, parseTaggedSpeech } from '../web/modules/audio_script.js';
import {
characterLayer, clearLineTune, hasOwnTune, lineLayer, lineValues, readTuneTag, scriptLayer, setLineTune, sharedValue, tuneTags,
} from '../web/modules/script_tune.js';
const character = { language: 'auto', speed: 1, preset: '' };
const stored = { language: 'auto', speed: 1, preset: '' };
// A line follows its character until it sets something itself; setting undefined follows again.
const line = { text: 'a' };
assert.deepEqual(lineValues(line, character), character);
setLineTune([line], 'speed', 1.2);
setLineTune([line], 'preset', 'lively');
assert.deepEqual(lineValues(line, character), { language: 'auto', speed: 1.2, preset: 'lively' });
assert.equal(hasOwnTune(line), true);
setLineTune([line], 'speed', undefined);
assert.deepEqual(line.tune, { preset: 'lively' });
setLineTune([line], 'preset', undefined);
assert.equal('tune' in line, false);
setLineTune([line], 'language', 'ja');
clearLineTune([line]);
assert.equal(hasOwnTune(line), false);
// Tags: what differs from what the node gives by itself; back to auto is written as auto.
assert.equal(tuneTags(character, stored), '');
assert.equal(tuneTags({ language: 'ja', speed: 1.15000001, preset: 'stable' }, stored), '[lang:ja][speed:1.15][preset:stable]');
assert.equal(tuneTags({ language: 'auto', speed: 1.1, preset: '' }, { speed: 1.1 }), '');
assert.equal(tuneTags({ language: 'auto', speed: 1, preset: '' }, { language: 'zh', speed: 1, preset: 'lively' }), '[lang:auto][preset:auto]');
// Setting a line to what its character reads it with lets it follow again.
const same = { text: 'x' };
setLineTune([same], 'speed', 1.3, () => 1.3);
assert.equal(hasOwnTune(same), false);
// Three layers, the most particular first, and where each value comes from.
const base = { language: 'auto', speed: 1, preset: '' };
assert.deepEqual(scriptLayer({ speed: 0.9 }, base), { values: { language: 'auto', speed: 0.9, preset: '' }, from: { language: 'default', speed: 'script', preset: 'default' } });
const arisLayer = characterLayer({ preset: 'lively' }, { speed: 0.9, preset: 'stable' }, { language: 'ja', speed: 1.1, preset: '' }, base);
assert.deepEqual(arisLayer.values, { language: 'ja', speed: 0.9, preset: 'lively' });
assert.deepEqual(arisLayer.from, { language: 'usual', speed: 'script', preset: 'character' });
const own = lineLayer({ text: 'a', tune: { speed: 1.2 } }, arisLayer);
assert.deepEqual([own.values.speed, own.from.speed, own.from.language], [1.2, 'line', 'usual']);
// One value over several lines: mixed, all set by the lines, or some.
const lines = [{ text: 'a', tune: { speed: 1.2 } }, { text: 'b' }];
assert.deepEqual(sharedValue(lines, 'speed', l => lineValues(l, character)), { value: 1.2, mixed: true, own: false, some: true });
assert.deepEqual(sharedValue(lines, 'language', l => lineValues(l, character)), { value: 'auto', mixed: false, own: false, some: false });
// The tag reader: settings only, a name stays a name.
assert.deepEqual(readTuneTag('speed:1.2'), { field: 'speed', value: 1.2 });
assert.deepEqual(readTuneTag('语言:中文'), { field: 'language', value: 'zh' });
assert.deepEqual(readTuneTag('lang:fr'), { field: 'language', value: undefined });
assert.deepEqual(readTuneTag('lang:JA'), { field: 'language', value: 'ja' });
assert.deepEqual(readTuneTag('预设:custom_a1'), { field: 'preset', value: 'custom_a1' });
assert.deepEqual(readTuneTag('preset:auto'), { field: 'preset', value: '' });
assert.deepEqual(readTuneTag('lang:auto'), { field: 'language', value: 'auto' });
assert.equal(readTuneTag('普拉娜'), null);
// Written into a bundle (before [take:N], after the speaker and emotion) and read back for "voice it again".
const slice = emotion => ({ emotion });
const arona = { group: 'gpt_sovits:Arona', node_value: 'Arona', has_main: true, slices: [slice('main'), slice('happy')] };
const aris = { group: 'gpt_sovits:Aris', node_value: 'Aris', has_main: true, slices: [slice('main')] };
const script = [
{ text: '早上好', emotion: 'happy', take: 2, tune: { speed: 0.9 } },
{ text: '你好', emotion: 'main', speaker: 'gpt_sovits:Aris', tune: { language: 'ja', preset: 'lively' } },
{ text: '再见', emotion: 'main', speaker: 'gpt_sovits:Aris' },
];
const tagsOf = (l, speaker) => tuneTags(lineValues(l, character), speaker === aris ? { speed: 1 } : stored);
const pkg = buildScriptPackage(script, arona, { takes: true, groups: [arona, aris], tagsOf });
assert.equal(pkg.speech, '{happy} [speed:0.9][take:2]早上好\n[Aris]{main} [lang:ja][preset:lively]你好\n{main} 再见');
assert.deepEqual(parseTaggedSpeech(pkg.speech), [
{ emotion: 'happy', text: '早上好', tune: { speed: 0.9 } },
{ emotion: 'main', text: '你好', speaker: 'Aris', tune: { language: 'ja', preset: 'lively' } },
{ emotion: 'main', text: '再见', speaker: 'Aris' },
]);
// Without tagsOf nothing changes.
assert.equal(buildScriptPackage(script, arona, { groups: [arona, aris] }).speech, '{happy} 早上好\n[Aris]{main} 你好\n{main} 再见');
console.log('script_tune ok');
+5 -2
View File
@@ -14,6 +14,7 @@ from unittest import mock
PLUGIN = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(PLUGIN), str(PLUGIN.parents[1])]
import isolated_layout # noqa: E402,F401 - side files beside the model, whatever this computer chose
from api import backup # noqa: E402
PARTS = ("recipes", "combos", "materials", "parameters", "comfy_workflows")
@@ -121,9 +122,11 @@ class BackupTests(unittest.TestCase):
base = self.b.model("plain.safetensors", sha="cd" * 32)
_summary, result = self.put_back(path)
self.assertEqual((result["model_infos"], result["model_civitai_covers"]), (1, 1))
self.assertEqual(Path(base + ".info").read_text(encoding="utf-8"), '{"id": 7, "name": "Plain"}')
restored = json.loads(Path(base + ".info").read_text(encoding="utf-8"))
self.assertEqual((restored["id"], restored["name"]), (7, "Plain"))
self.assertEqual(Path(base + ".preview.png").read_bytes(), b"civitai image")
self.assertEqual(Path(base + ".civitai_bak.png").read_bytes(), b"civitai image") # still Civitai's, not the user's
self.assertIn("anomalous_cover_sha256", restored) # still Civitai's, not the user's: noted, not kept twice
self.assertFalse(os.path.exists(base + ".civitai_bak.png"))
self.b.write(base + ".info", '{"id": 7, "name": "Newer scan"}')
_summary, kept = self.put_back(path)
self.assertEqual(kept["model_infos"], 0)
+13
View File
@@ -18,6 +18,7 @@ from aiohttp.test_utils import make_mocked_request
PLUGIN = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(PLUGIN), str(PLUGIN.parents[1])]
import isolated_layout # noqa: E402,F401 - side files beside the model, whatever this computer chose
from api import download_sources, hf_card, model_download # noqa: E402
DATA = b"model bytes " * 1000
@@ -322,6 +323,7 @@ class ManagerListTests(unittest.TestCase):
{"name": "UNet", "type": "diffusion_model", "save_path": "diffusion_models/x", "filename": "diffusion_pytorch_model.safetensors", "url": hf.format("c/unet", "diffusion_pytorch_model.safetensors")},
{"name": "Upscaler", "type": "upscale", "save_path": "default", "filename": "4x-UltraSharp.pth", "url": hf.format("k/u", "4x-UltraSharp.pth")},
{"name": "Elsewhere", "type": "VAE", "save_path": "default", "filename": "x.pth", "url": "http://example.com/x.pth"},
{"name": "Mystery", "type": "", "save_path": "default", "filename": "mystery.safetensors", "url": hf.format("m/x", "mystery.safetensors")},
]
Path(path).write_text(json.dumps({"models": models}), encoding="utf-8")
self.patch = mock.patch.object(download_sources.manager_catalog, "list_path", return_value=path)
@@ -346,6 +348,16 @@ class ManagerListTests(unittest.TestCase):
self.assertEqual([c["name"] for c in candidates("Sub/4x-ultrasharp.pth", "upscale_models")], ["Upscaler"])
self.assertEqual(candidates("x.pth"), []) # not https
self.assertEqual(candidates("ae.safetensors", "loras"), [])
# An entry whose type the list does not say fits only a node of unknown type.
self.assertEqual(candidates("mystery.safetensors", "vae"), [])
self.assertEqual([c["name"] for c in candidates("mystery.safetensors")], ["Mystery"])
def test_where_a_list_file_is(self):
site_of = download_sources.manager_catalog.site_of
self.assertEqual(site_of("https://huggingface.co/bfl/one/resolve/main/ae.safetensors"), "huggingface.co/bfl/one")
self.assertEqual(site_of("https://github.com/a/b/releases/download/v1/x.pth"), "github.com/a/b")
self.assertEqual(site_of("https://example.com/x.pth"), "example.com")
self.assertEqual(site_of(""), "")
def test_with_a_fingerprint_only_the_matching_file(self):
with mock.patch.object(download_sources, "_head", side_effect=self.head):
@@ -358,6 +370,7 @@ class ManagerListTests(unittest.TestCase):
found = download_sources.find_source(name="ae.safetensors", folder_type="vae")
self.assertTrue(found["found"] and found["by_name"])
self.assertEqual(found["base_model"], "FLUX.1")
self.assertEqual(found["list_site"], "huggingface.co/bfl/one")
# Two different files of that name: not offered.
self.assertFalse(download_sources.find_source(name="diffusion_pytorch_model.safetensors", folder_type="controlnet")["found"])
+5 -1
View File
@@ -11,6 +11,7 @@ from unittest import mock
PLUGIN = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(PLUGIN), str(PLUGIN.parents[1])]
import isolated_layout # noqa: F401 - side files beside the model, whatever this computer chose
import scraper
from aiohttp.test_utils import make_mocked_request
@@ -154,8 +155,11 @@ class LayerTests(unittest.TestCase):
Path(self.base + ".preview.png").write_bytes(b"old civitai image")
with mock.patch.object(scraper, "move_to_trash") as trash:
self.scan("--force-overwrite")
trash.assert_not_called() # same file name: overwritten in place, nothing to move
# The cover is Civitai's: the copy kept aside goes, the new picture replaces it in place.
trash.assert_called_once_with(self.base + ".civitai_bak.png")
self.assertEqual(Path(self.base + ".preview.png").read_bytes(), b"new civitai image")
info = json.loads(Path(self.base + ".info").read_text(encoding="utf-8"))
self.assertEqual(len(info["anomalous_cover_sha256"]), 64) # known as Civitai's from now on
if __name__ == "__main__":
+48
View File
@@ -0,0 +1,48 @@
"""Looking up saved model paths when the model folder is a link (api/model_catalog.py): the
subfolder is the model's place inside the folder, not a climb out of the link's path."""
from pathlib import Path
import os
import subprocess
import sys
import tempfile
import unittest
from unittest import mock
PLUGIN = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(PLUGIN), str(PLUGIN.parents[1])]
from api import model_catalog
def link_folder(link, target):
"""A junction on Windows, a symlink elsewhere; False when this computer will not make one."""
try:
if os.name == "nt":
done = subprocess.run(["cmd", "/c", "mklink", "/J", str(link), str(target)], capture_output=True)
return done.returncode == 0
os.symlink(target, link, target_is_directory=True)
return True
except OSError:
return False
class LinkedModelFolderTests(unittest.TestCase):
def test_subfolder_inside_a_linked_folder(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
real = root / "elsewhere" / "loras"
(real / "chars").mkdir(parents=True)
(real / "chars" / "ahri.safetensors").write_bytes(b"\0" * 16)
link = root / "comfy" / "loras"
link.parent.mkdir()
if not link_folder(link, real):
self.skipTest("this computer makes no folder links")
with mock.patch.object(model_catalog.folder_paths, "folder_names_and_paths", {"loras": ([str(link)], set())}), \
mock.patch.object(model_catalog.folder_paths, "get_folder_paths", lambda kind: [str(link)]):
found = model_catalog._resolve_paths_to_model_info_sync(["chars/ahri.safetensors"], ["loras"])
self.assertEqual(found["chars/ahri.safetensors"]["subfolder"], "/chars")
self.assertEqual(found["chars/ahri.safetensors"]["filename"], "ahri.safetensors")
if __name__ == "__main__":
unittest.main()
+142
View File
@@ -0,0 +1,142 @@
"""Where a model's side files are (model_sidecars.py) and Settings → Models → "Side files"
(api/sidecar_layout.py): the hidden .amb folder, reading from either place, moving there and
back, and Civitai's picture kept once instead of twice."""
import asyncio
import json
import os
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
PLUGIN = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(PLUGIN), str(PLUGIN.parents[1])]
import model_sidecars as sidecars # noqa: E402
from api import metadata as metadata_module, model_media, sidecar_layout # noqa: E402
from api.metadata import get_metadata # noqa: E402
def write(path, data):
Path(path).parent.mkdir(parents=True, exist_ok=True)
Path(path).write_text(json.dumps(data), encoding="utf-8")
class SidecarTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.dir = Path(self.temp.name)
self.config = self.dir / "config.json"
patch = mock.patch.object(sidecars, "CONFIG_PATH", str(self.config))
patch.start()
self.addCleanup(patch.stop)
sidecars._layout["mtime"] = None
self.addCleanup(sidecars._layout.update, mtime=None, value="beside")
metadata_module.clear_metadata_cache()
self.models = self.dir / "loras"
self.models.mkdir()
def model(self, name):
path = self.models / name
path.write_bytes(b"model")
return str(path.with_suffix(""))
def set_layout(self, layout):
write(self.config, {"SIDECAR_LAYOUT": layout, "OTHER": 1})
def test_new_files_go_where_the_setting_says_and_are_read_from_either_place(self):
base = self.model("a.safetensors")
self.assertEqual(sidecars.target(base, ".info"), base + ".info") # beside by default
self.set_layout("hidden")
info = sidecars.target(base, ".info")
self.assertEqual(info, os.path.join(str(self.models), ".amb", "a.info"))
self.assertTrue(os.path.isdir(self.models / ".amb"))
self.assertEqual(sidecars.target(base, ".preview.png"), base + ".preview.png") # covers stay beside
write(base + ".anomalous.json", {"custom_name": "Mine"}) # left beside from before
self.assertEqual(sidecars.target(base, ".anomalous.json"), base + ".anomalous.json") # written where it is
write(info, {"id": 3, "name": "v1", "model": {"name": "A"}})
found = get_metadata(base + ".safetensors")
self.assertEqual((found["custom_name"], found["info_source"]), ("Mine", "civitai"))
def test_a_renamed_model_takes_its_side_files_along_to_the_same_kind_of_place(self):
self.set_layout("hidden")
base = self.model("a.safetensors")
write(sidecars.target(base, ".info"), {"id": 3})
Path(base + ".preview.png").write_bytes(b"cover")
renamed = os.path.join(str(self.models), "b")
moved = {sidecars.follow(path, renamed, suffix) for path, suffix in sidecars.existing(base, (".info", ".preview.png"))}
self.assertEqual(moved, {os.path.join(str(self.models), ".amb", "b.info"), renamed + ".preview.png"})
def library(self):
return [("loras", str(self.models), str(path), path.name) for path in sorted(self.models.glob("*.safetensors"))]
def test_tidying_moves_them_and_drops_civitais_picture_kept_twice(self):
self.set_layout("beside")
a, b = self.model("a.safetensors"), self.model("b.safetensors")
write(a + ".info", {"id": 3})
Path(a + ".preview.png").write_bytes(b"civitai a")
Path(a + ".civitai_bak.png").write_bytes(b"civitai a") # the same picture twice
write(b + ".info", {"id": 4})
write(b + ".anomalous.json", {"custom_notes": "x"})
Path(b + ".preview.png").write_bytes(b"my own picture")
Path(b + ".civitai_bak.png").write_bytes(b"civitai b") # kept aside: the user's cover shows
trashed = []
def trash(*paths):
trashed.extend(paths)
for path in paths:
os.remove(path)
return list(paths)
with mock.patch.object(sidecar_layout, "_models", side_effect=self.library), \
mock.patch.object(sidecar_layout, "move_to_trash", side_effect=trash):
self.assertEqual(sidecar_layout.plan("hidden"), {"layout": "hidden", "moves": 4, "duplicates": 1, "models": 2})
done = sidecar_layout.apply("hidden")
self.assertEqual((done["moved"], done["deduped"], done["failed"]), (4, 1, []))
self.assertEqual(json.loads(self.config.read_text())["OTHER"], 1) # the rest of the settings stay
hidden = self.models / ".amb"
self.assertEqual(sorted(path.name for path in hidden.iterdir()), ["a.info", "b.anomalous.json", "b.civitai_bak.png", "b.info"])
self.assertEqual(sorted(path.name for path in self.models.iterdir()),
[".amb", "a.preview.png", "a.safetensors", "b.preview.png", "b.safetensors"])
self.assertEqual(trashed, [a + ".civitai_bak.png"])
self.assertTrue(sidecars.is_civitai_cover(a + ".preview.png", a)) # still known as Civitai's
self.assertFalse(sidecars.is_civitai_cover(b + ".preview.png", b))
self.assertEqual(sidecar_layout.apply("beside")["moved"], 4)
self.assertFalse(hidden.exists()) # nothing left in it
self.assertTrue(os.path.isfile(b + ".civitai_bak.png"))
def test_a_file_already_at_the_destination_is_left_and_named(self):
self.set_layout("beside")
base = self.model("a.safetensors")
write(base + ".info", {"id": 3})
write(self.models / ".amb" / "a.info", {"id": 99})
with mock.patch.object(sidecar_layout, "_models", side_effect=self.library):
done = sidecar_layout.apply("hidden")
self.assertEqual(done["moved"], 0)
self.assertEqual(len(done["failed"]), 1)
self.assertEqual(json.loads(Path(base + ".info").read_text())["id"], 3) # nothing overwritten
def test_a_new_cover_keeps_civitais_picture_aside_to_bring_back(self):
self.set_layout("hidden")
base = self.model("a.safetensors")
write(sidecars.target(base, ".info"), {"id": 3})
Path(base + ".preview.png").write_bytes(b"civitai a")
sidecars.note_cover_hash(base, base + ".preview.png")
async def save(path):
Path(path).write_bytes(b"my own picture")
with mock.patch.object(model_media, "move_to_trash"):
asyncio.run(model_media._handle_custom_cover(str(self.models), "a.safetensors", save))
self.assertEqual(Path(base + ".preview.png").read_bytes(), b"my own picture")
self.assertEqual((self.models / ".amb" / "a.civitai_bak.png").read_bytes(), b"civitai a")
covers = model_media._model_covers("loras", 0, str(self.models), base + ".safetensors")
self.assertTrue(covers["custom"])
self.assertIn("subfolder=.amb", covers["civitai"])
if __name__ == "__main__":
unittest.main()
+43
View File
@@ -0,0 +1,43 @@
"""The quick search's model list in a few words (api/quick_index.py)."""
from pathlib import Path
import sys
import unittest
PLUGIN = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(PLUGIN), str(PLUGIN.parents[1])]
from api import quick_index
def model(**fields):
base = {"type": "loras", "path_idx": 0, "subfolder": "chars", "filename": "a.safetensors",
"size_bytes": 10, "preview_url": "/anomalous/image?x", "metadata": {}}
base.update(fields)
return base
class CompactModelsTest(unittest.TestCase):
def test_names_base_and_words(self):
found = quick_index.compact_models([model(metadata={
"custom_name": " Ahri ", "name": "Ahri LoRA", "baseModel": "Illustrious",
"trainedWords": ["ahri", "fox ears", "", "a", "b", "c", "d", "e"], "description": "long",
})])
self.assertEqual(found, [{
"type": "loras", "path_idx": 0, "subfolder": "chars", "filename": "a.safetensors",
"name": "Ahri", "civitai_name": "Ahri LoRA", "base": "Illustrious",
"words": ["ahri", "fox ears", "a", "b", "c", "d"], "preview_url": "/anomalous/image?x",
}])
def test_one_line_per_file_and_no_node_pack_files(self):
found = quick_index.compact_models([
model(type="unet", subfolder="", filename="flux.safetensors"),
model(type="diffusion_models", subfolder="", filename="flux.safetensors"),
model(type="custom_nodes", filename="pack.pt"),
model(metadata={"trigger_words": "one word"}),
])
self.assertEqual([item["type"] for item in found], ["unet", "loras"])
self.assertEqual(found[1]["words"], ["one word"])
if __name__ == "__main__":
unittest.main()
+1
View File
@@ -15,6 +15,7 @@ from aiohttp.test_utils import make_mocked_request
PLUGIN = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(PLUGIN), str(PLUGIN.parents[1])]
import isolated_layout # noqa: F401 - side files beside the model, whatever this computer chose
import scraper
from api import activity_log, model_metadata, scan_report, scan_summary, scanner
from model_identity import computed_file_identity
+1
View File
@@ -10,6 +10,7 @@ PLUGIN_DIR = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PLUGIN_DIR.parents[1]))
sys.path.insert(0, str(PLUGIN_DIR))
import isolated_layout # noqa: F401 - side files beside the model, whatever this computer chose
import scraper
from api import metadata, scan_choice, scan_summary, scanner
from model_identity import computed_file_identity
+113
View File
@@ -0,0 +1,113 @@
// The reference audio editor's rules (web/modules/tts_voice_refs.js): voices with a main and
// auxiliary references, fixed lines, the settings written back, and the list's order.
import assert from 'node:assert/strict';
import {
clearMain, fitsMain, listClips, nameProblem, readVoices, roleOf, setMain, toggleAux, usesOf, voiceCount, voicesProblem, writeSettings,
} from '../web/modules/tts_voice_refs.js';
const settings = {
format: 1,
keep: { me: true },
reference: { language: 'ja', aux: ['b.wav', 'b.wav', 7] }, // the main reference is automatic
emotions: {
开心: { audio: 'h.wav', text: '自分の台詞', aux: ['h.wav', 'b.wav'] },
main: { audio: 'x.wav' },
},
lines: { 'c.wav': '直した', 'd.wav': ' ' },
};
const detail = {
reference: { audio: 'a.wav', source: 'auto' },
emotions: { 开心: { audio: 'h.wav', source: 'settings' }, 生气: { audio: 'a.生气.wav', source: 'filename' } },
};
// Reading: the main voice, the saved emotions, then the ones found from file names.
const { voices, fixes } = readVoices(settings, detail, { lineFixes: true });
assert.deepEqual(voices.map(voice => [voice.name, voice.main, voice.aux, voice.filename]), [
['main', null, ['b.wav'], false],
['开心', 'h.wav', ['b.wav'], false],
['生气', 'a.生气.wav', [], true],
]);
assert.deepEqual([...fixes], [['c.wav', '直した'], ['h.wav', '自分の台詞']], "a voice's own text becomes its clip's line");
assert.equal('text' in voices[1].kept, false);
// A voice's text that is the clip's line anyway is not a fix.
assert.equal(readVoices(settings, detail, { lineFixes: true, found: new Map([['h.wav', '自分の台詞']]) }).fixes.has('h.wav'), false);
// An older node: the voice keeps its own text, no lines are fixed here.
const old = readVoices(settings, detail);
assert.equal(old.fixes.size, 0);
assert.equal(old.voices[1].kept.text, '自分の台詞');
// Main and auxiliary references for one voice.
const [main, happy, angry] = voices;
assert.equal(roleOf(happy, 'h.wav'), 'main');
assert.equal(roleOf(happy, 'b.wav'), 'aux');
assert.equal(roleOf(happy, 'z.wav'), null);
toggleAux(happy, 'h.wav');
assert.deepEqual(happy.aux, ['b.wav'], 'the main reference is not also an auxiliary one');
setMain(happy, 'b.wav');
assert.deepEqual([happy.main, happy.aux], ['b.wav', []]);
toggleAux(happy, 'h.wav');
toggleAux(main, 'c.wav');
assert.deepEqual(usesOf(voices, 'b.wav', happy), [{ name: 'main', role: 'aux' }]);
assert.deepEqual(usesOf(voices, 'b.wav'), [{ name: 'main', role: 'aux' }, { name: '开心', role: 'main' }]);
setMain(main, 'a.wav');
clearMain(main);
assert.equal(main.main, null);
clearMain(happy);
assert.equal(happy.main, 'b.wav', 'only the main voice can go back to the automatic pick');
// Lengths and names.
assert.equal(fitsMain(0), true);
assert.equal(fitsMain(5), true);
assert.equal(fitsMain(12.3), false);
assert.equal(fitsMain(1), false);
assert.equal(nameProblem(' ', voices), 'ttsRefsNameMissing');
assert.equal(nameProblem('main', voices), 'ttsEditorNameInvalid');
assert.equal(nameProblem('a{b}', voices), 'ttsEditorNameInvalid');
assert.equal(nameProblem('开心', voices), 'ttsEditorNameDuplicate');
assert.equal(nameProblem('开心', voices, happy), '');
assert.equal(nameProblem('害羞', voices), '');
// What keeps them from being saved.
const files = ['a.wav', 'b.wav', 'c.wav', 'h.wav', 'a.生气.wav'];
assert.equal(voicesProblem(voices, files), null);
const shy = { key: 'e:害羞', name: '害羞', main: null, aux: [], kept: {}, filename: false, touched: true };
assert.deepEqual(voicesProblem([...voices, shy], files), { key: 'ttsRefsNoMain', values: { name: '害羞' } });
assert.deepEqual(voicesProblem(voices, ['a.wav']), { key: 'ttsEditorUnknownAudio', values: { file: 'b.wav' } });
// Writing back: unknown fields stay; an emotion from a file name only once changed; fixed lines.
fixes.set('h.wav', ' 変えた ');
const next = writeSettings(settings, voices, fixes, { aux: true, lines: true });
assert.deepEqual(next, {
format: 1,
keep: { me: true },
reference: { language: 'ja', aux: ['b.wav', 'c.wav'] },
emotions: { 开心: { audio: 'b.wav', aux: ['h.wav'] } },
lines: { 'c.wav': '直した', 'h.wav': '変えた' },
});
toggleAux(angry, 'c.wav');
assert.deepEqual(writeSettings(settings, voices, fixes, { aux: true, lines: true }).emotions['生气'], { audio: 'a.生气.wav', aux: ['c.wav'] });
// An older node: auxiliary references and lines stay as they were.
const older = writeSettings(settings, voices, new Map(), {});
assert.deepEqual(older.reference.aux, settings.reference.aux);
assert.deepEqual(older.emotions['开心'].aux, ['h.wav', 'b.wav']);
assert.deepEqual(older.lines, settings.lines);
// Nothing left: the entries go.
const bare = writeSettings({ format: 1, reference: { audio: 'a.wav' }, emotions: {}, lines: { 'a.wav': 'x' } },
[{ key: 'main', name: 'main', main: null, aux: [], kept: {}, filename: false, touched: true }], new Map(), { aux: true, lines: true });
assert.deepEqual(bare, { format: 1 });
// The list: this voice's main, then its auxiliary ones, then the rest in folder order; filters and search.
const lineOf = path => ({ 'a.wav': 'おはよう', 'c.wav': '直した' })[path] || '';
const order = listClips(files, { voice: happy, voices, lineOf });
assert.deepEqual(order, ['b.wav', 'h.wav', 'a.wav', 'c.wav', 'a.生气.wav']);
assert.deepEqual(listClips(files, { voice: happy, voices, lineOf, filter: 'here' }), ['b.wav', 'h.wav']);
assert.deepEqual(listClips(files, { voice: happy, voices, lineOf, filter: 'noline' }), ['b.wav', 'h.wav', 'a.生气.wav']);
assert.deepEqual(listClips(files, { voice: happy, voices, lineOf, query: 'おはよ' }), ['a.wav']);
assert.deepEqual(listClips(files, { voice: happy, voices, lineOf, query: '生气' }), ['a.生气.wav']);
// The counts on the voice tabs.
assert.deepEqual(voiceCount(main), { key: 'ttsRefsCountAutoAux', values: { aux: 2 } });
assert.deepEqual(voiceCount(happy), { key: 'ttsRefsCountAux', values: { aux: 1 } });
assert.deepEqual(voiceCount({ main: 'x', aux: [] }), { key: 'ttsRefsCountMain', values: { aux: 0 } });
console.log('tts_voice_refs: ok');
+1
View File
@@ -15,5 +15,6 @@ app.registerExtension({
keybindings: browserEntry.keybindings,
menuCommands: browserEntry.menuCommands,
getNodeMenuItems: browserEntry.getNodeMenuItems,
getCanvasMenuItems: browserEntry.getCanvasMenuItems,
setup: browserEntry.setup
});
+34 -28
View File
@@ -95,9 +95,16 @@ async function getJson(url, signal) {
* Each clip's line as the node finds it (a `Map` path -> line; clips without one are left
* out), or null when the node is older than interface 14 and cannot tell.
*/
export async function fetchReferenceLines(name, signal) {
/**
* Each clip's line as the node finds it and its length in seconds (`seconds`: interface 18,
* empty before): `{ lines: Map, seconds: Map }`, or null when the node cannot tell.
*/
export async function fetchReferenceClips(name, signal) {
const { ok, data } = await getJson(`/anomalous_tts/reference_lines?character=${encodeURIComponent(name)}`, signal);
return ok && data?.lines && typeof data.lines === 'object' ? new Map(Object.entries(data.lines)) : null;
if (!ok || !data?.lines || typeof data.lines !== 'object') return null;
const seconds = data.seconds && typeof data.seconds === 'object'
? Object.entries(data.seconds).filter(([, value]) => Number.isFinite(value)) : [];
return { lines: new Map(Object.entries(data.lines)), seconds: new Map(seconds) };
}
/** Installed = ComfyUI knows the node class (`/object_info/<class>` is `{}` otherwise). */
@@ -189,13 +196,37 @@ export function isTtsInstalled() {
* summary list leaves out because a folder can hold thousands of clips.
*/
export async function fetchGptSovitsCharacter(name, signal) {
return (await fetchGptSovitsDetail(name, signal)).character;
}
/** One character's detail and the node's interface version: `{ character, format }`. */
export async function fetchGptSovitsDetail(name, signal) {
const { ok, status, data } = await getJson(`/anomalous_tts/characters?name=${encodeURIComponent(name)}`, signal);
if (!ok || !data?.character) throw new Error(status === 404 ? `404 ${name}` : `HTTP ${status}`);
return data.character;
return { character: data.character, format: Number(data.format) || 0 };
}
// ---------- GPT-SoVITS settings (Anomalous writes, the node reads) ----------
/**
* Move a character's reference clips (and their own .txt / .lab) to the Recycle Bin through
* the node (interface 18); it also takes them out of the settings. Returns the node's answer
* (`removed`, `text_files`, `character`: the detail after).
*/
export async function removeGptSovitsClips(name, paths) {
const resp = await fetch('/anomalous_tts/audio/remove', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ character: name, paths }),
});
if (!resp.ok) {
const message = await resp.text().catch(() => '');
throw new Error(message || `HTTP ${resp.status}`);
}
invalidateEngineCache();
return resp.json();
}
/**
* Replace a character's anomalous_tts.json through the node's API. `settings` must be the
* full object (unknown fields preserved by the caller). Returns the refreshed group.
@@ -215,28 +246,3 @@ export async function saveGptSovitsSettings(name, settings) {
return data.character ? gptSovitsGroups({ characters: [data.character] })[0] : null;
}
/**
* Build the new settings for an emotion editor result without dropping fields
* Anomalous does not know. `main` = { audio, text } | null; `emotions` = [{ name, audio, text }].
*/
export function mergeGptSovitsSettings(previous, { main, emotions }) {
const next = { ...(previous || {}), format: 1 };
if (main?.audio) {
const kept = previous?.reference && typeof previous.reference === 'object' ? previous.reference : {};
next.reference = { ...kept, audio: main.audio };
if (main.text) next.reference.text = main.text; else delete next.reference.text;
} else {
delete next.reference;
}
const oldEmotions = previous?.emotions && typeof previous.emotions === 'object' ? previous.emotions : {};
const out = {};
for (const row of emotions || []) {
const name = String(row?.name || '').trim();
if (!name || name === 'main' || !row.audio) continue;
const kept = oldEmotions[name] && typeof oldEmotions[name] === 'object' ? oldEmotions[name] : {};
out[name] = { ...kept, audio: row.audio };
if (row.text) out[name].text = row.text; else delete out[name].text;
}
if (Object.keys(out).length) next.emotions = out; else delete next.emotions;
return next;
}
+78 -24
View File
@@ -3,9 +3,12 @@
*
* The node takes a character and a script in which `{happy}` switches to that
* emotion's reference, `{main}` back to the main one, and `[take:N]` asks for
* another take of one line. `buildTtsPrompt` runs a script without a canvas.
* another take of one line; `[lang:…]` `[speed:…]` `[preset:…]` set how one line is
* read (script_tune.js). `buildTtsPrompt` runs a script without a canvas.
*/
import { readTuneTag } from './script_tune.js';
export const MAIN_TAG = '{main}';
const normalisePath = value => String(value ?? '').replace(/\\/g, '/');
@@ -88,15 +91,17 @@ 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's `tune` (its own `[lang:…][speed:…][preset:…]`) and, with `take` > 1 (GPT-SoVITS only),
* `[take:N]` go 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);
return 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;
const text = `${line.tune || ''}${line.take > 1 ? `[take:${line.take}]` : ''}${line.text}`;
return `${line.speaker || ''}${tag ? `${tag} ${text}` : text}`;
})
.join('\n')
.trim();
@@ -107,27 +112,44 @@ 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]`; `tagsOf(line, speaker)`
* gives the tags for how a line is read (the director's run section).
* 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 = [], tagsOf = null } = {}) {
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,
tune: tagsOf?.(line, speaker) || '',
speaker: speaker === current ? '' : `[${speaker.node_value}]`,
});
current = speaker;
if (!speakers.includes(speaker.node_value)) speakers.push(speaker.node_value);
}
if (!voiced.length) return { error: 'scriptDirectorEmpty' };
return { sample: voice, speech: composeScript(voiced), lineCount: voiced.length };
return { sample: group.node_value, speech: composeScript(voiced), lineCount: voiced.length, speakers };
}
/** Save Audio prefix for a character: `output/audio/<character>/…`, keeping file names Windows accepts. */
@@ -156,16 +178,48 @@ 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.
* A line's own settings start a segment of their own and come back as its `tune` (they cover
* its first line only).
*/
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 = '';
let tune = null;
const flush = () => {
const words = text.trim();
if (words) segments.push({ emotion, text: words, ...(speaker ? { speaker } : {}), ...(tune ? { tune } : {}) });
if (words) tune = null;
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 && readTuneTag(bracket[1])) {
flush();
const { field, value } = readTuneTag(bracket[1]);
if (value !== undefined) tune = { ...(tune || {}), [field]: value };
} else if (bracket && !PAUSE_TAG.test(bracket[1])) {
flush();
speaker = bracket[1].trim();
emotion = 'main';
} else {
text += part;
}
}
flush();
return segments;
}
+35 -11
View File
@@ -9,7 +9,7 @@ import { triggerDirectModelScan } from './scan_runner.js';
import { openScanPage, renderScanPage, leaveScanPage } from './ui_scan_page.js';
import { loadGalleryImages, refreshGalleryImages, showGeneratedGallery, showGallerySelectMode, showGalleryViewer } from './ui_gallery.js';
import { showNotebooks, openCombo, openComboByFilename, refreshNotebooks, saveCurrentNotebook, deleteCurrentNotebook } from './ui_notebooks.js';
import { renderNotebookEditor, fillNotebookGalleries } from './ui_notebook_editor.js';
import { renderNotebookEditor } from './ui_notebook_editor.js';
import { sendNotebookToCanvas } from './notebook_canvas.js';
import { showRecipes, refreshRecipes } from './ui_recipe_catalog.js';
import { renderRecipeList, handleSaveRecipe, openRecipeByFilename } from './ui_recipes.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';
@@ -61,25 +62,27 @@ export class AnomalousBrowser {
/**
* Opens the browser. `followSelection`: opened by the user (button, shortcut, menu), so a
* node selected on the canvas picks the page: a text node opens the Prompts page on it, any
* other node Current node (Settings -> Opening and window can turn this off).
* other node Current node (Settings -> Opening and window can turn this off). `node`: that
* page for this node whatever the setting; `page`: this rail page (or 'assistant').
*/
show({ followSelection = false } = {}) {
show({ followSelection = false, node = null, page: wanted = null } = {}) {
followComfyLanguage();
if (this._idleReleaseTimer) {
clearTimeout(this._idleReleaseTimer);
this._idleReleaseTimer = null;
}
const page = followSelection && followsSelection() ? pageForNode(selectedCanvasNode(), NODE_KINDS) : null;
const followed = followSelection && followsSelection() ? selectedCanvasNode() : null;
const page = wanted || pageForNode(node || followed, NODE_KINDS);
this.setTriggerVisible(false);
this.modal.classList.add('visible');
if (page === 'prompts') {
this.goTo('prompts');
return;
}
if (page === 'assistant') {
this.openCurrentNode();
return;
}
if (page) {
this.goTo(page);
return;
}
if (!this.currentShellPage()) {
this.goTo(startPage());
return;
@@ -98,6 +101,28 @@ export class AnomalousBrowser {
if (this.currentShellPage() === 'scan') void renderScanPage(this, this.scanPanel);
}
/**
* The canvas menu's "Open in AMB": selects `node` and opens its page; `section:
* 'parameters'` opens Current node at the node's saved values.
*/
showForNode(node, { section = '' } = {}) {
app.canvas?.selectNode?.(node, false);
const parameters = section === 'parameters';
this.show({ node, page: parameters ? 'assistant' : null });
if (parameters) this.assistantPanel?.querySelector('.anomalous-node-params')?.scrollIntoView({ block: 'start' });
}
/** The details of `model` (GET /anomalous/find_model's: with its type, path_idx and subfolder). */
openModelDetail(model) {
this.currentType = model.type || this.currentType;
this.currentPathIdx = model.path_idx ?? 0;
this.currentSubfolder = model.subfolder || '/';
this.show({ page: 'models' });
this.historyStack = [];
this.showDetail(model);
if (this.foldersData) this.renderSidebar();
}
/** The image / audio switch: the first page of that side. */
handleDomainChange(domain) {
this.goTo(domain === 'audio' ? 'voices' : 'models');
@@ -118,8 +143,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 side panel shows the character in this script.
castScriptCharacter(filter.value);
return;
} else {
if (filter) setActiveAudioFilter(filter);
@@ -203,7 +228,6 @@ AnomalousBrowser.prototype.refreshNotebooks = refreshNotebooks;
AnomalousBrowser.prototype.saveCurrentNotebook = saveCurrentNotebook;
AnomalousBrowser.prototype.deleteCurrentNotebook = deleteCurrentNotebook;
AnomalousBrowser.prototype.renderNotebookEditor = renderNotebookEditor;
AnomalousBrowser.prototype.fillNotebookGalleries = fillNotebookGalleries;
AnomalousBrowser.prototype.sendNotebookToCanvas = sendNotebookToCanvas;
AnomalousBrowser.prototype.showRecipes = showRecipes;
+11 -15
View File
@@ -1,7 +1,8 @@
import { app } from '../../../scripts/app.js';
import { AnomalousBrowser } from './browser.js';
import { watchMcpActions } from './mcp_bridge.js';
import { hasComboNodes, menuNodes, saveSelectionAsCombo } from './ui_combo_structure.js';
import { canvasMenuItems, nodeMenuItems } from './canvas_menu.js';
import { openQuickSearch } from './ui_quick_search.js';
import {
clampFloatingTriggerPosition,
clearSavedTriggerPosition,
@@ -23,6 +24,8 @@ export const SHORTCUT_SETTING_ID = 'Anomalous.ModelBrowser.Shortcut';
export const FLOATING_TRIGGER_SIZE_SETTING_ID = 'Anomalous.ModelBrowser.FloatingTriggerSize';
export const FLOATING_TRIGGER_STYLE_SETTING_ID = 'Anomalous.ModelBrowser.FloatingTriggerStyle';
export const OPEN_BROWSER_COMMAND_ID = 'Anomalous.ModelBrowser.Open';
// The canvas's quick search bar (ui_quick_search.js); Alt+B unless rebound in ComfyUI's keybindings.
export const QUICK_SEARCH_COMMAND_ID = 'Anomalous.ModelBrowser.QuickSearch';
export const RESET_TRIGGER_POSITION_COMMAND_ID = 'Anomalous.ModelBrowser.ResetFloatingTriggerPosition';
export function createBrowserEntry({ translate, getCurrentLanguage }) {
@@ -427,28 +430,21 @@ export function createBrowserEntry({ translate, getCurrentLanguage }) {
}],
commands: [
{ id: OPEN_BROWSER_COMMAND_ID, label: t('mainOpenTitle'), function: toggle },
{ id: RESET_TRIGGER_POSITION_COMMAND_ID, label: t('mainResetFloatingTriggerPosition'), function: resetPosition }
{ id: RESET_TRIGGER_POSITION_COMMAND_ID, label: t('mainResetFloatingTriggerPosition'), function: resetPosition },
{ id: QUICK_SEARCH_COMMAND_ID, label: t('quickSearchTitle'), function: () => openQuickSearch(ensureBrowser) },
],
keybindings: [
{ combo: DEFAULT_BROWSER_SHORTCUT, commandId: OPEN_BROWSER_COMMAND_ID },
{ combo: { key: 'b', alt: true }, commandId: QUICK_SEARCH_COMMAND_ID },
],
menuCommands: [{
path: ['Extensions', 'Anomalous Model Browser'],
commands: [OPEN_BROWSER_COMMAND_ID, RESET_TRIGGER_POSITION_COMMAND_ID]
commands: [OPEN_BROWSER_COMMAND_ID, QUICK_SEARCH_COMMAND_ID, RESET_TRIGGER_POSITION_COMMAND_ID]
}],
setup,
// The canvas's node menu: the picked nodes (or this one) as a combo, when it could keep any.
getNodeMenuItems(node) {
const nodes = menuNodes(node);
if (!hasComboNodes(nodes)) return [];
return [null, {
content: nodes.length > 1 ? t('comboMenuSaveMany', { count: nodes.length }) : t('comboMenuSave'),
callback: () => {
const owner = ensureBrowser();
if (owner) void saveSelectionAsCombo(owner, nodes);
},
}];
},
// The canvas's right-click menus: AMB's entries as Settings -> Canvas sets them (canvas_menu.js).
getNodeMenuItems: node => nodeMenuItems(node, ensureBrowser),
getCanvasMenuItems: () => canvasMenuItems(ensureBrowser),
open,
ensureBrowser,
resetPosition,
+133
View File
@@ -0,0 +1,133 @@
/**
* AMB's entries in the canvas's right-click menus (the extension's getNodeMenuItems and
* getCanvasMenuItems hooks, browser_entry.js), as Settings -> Canvas sets them
* (canvas_menu_settings.js). A node's menu offers what Current node does for it — open it in
* AMB, change its model by picture, insert a LoRA before / after, its saved values, the
* model's pictures and details, save its values or the picked nodes as a combo — each only
* where it applies; the empty canvas's menu opens the quick search and AMB's pages. `browser()` gives the browser,
* made on first use.
*/
import { translate as t } from './locales.js';
import { menuSettings } from './canvas_menu_settings.js';
import { loraInsertionCapabilities } from './graph_splice.js';
import { modelComboWidgets } from './ui_node_model_picker.js';
import { hasComboNodes, menuNodes, saveSelectionAsCombo } from './ui_combo_structure.js';
import { saveNodeParameters } from './node_parameter_sets.js';
import { anomalousPrompt } from './ui_dialog.js';
import { showWorkbenchToast } from './ui_prompt_toast.js';
import { openSettingsPage } from './ui_settings_page.js';
import { RAIL_PAGES } from './ui_shell_rail.js';
import { openQuickSearch } from './ui_quick_search.js';
const item = (content, run) => ({ content, callback: () => void run() });
/** The widgets naming a model file that is set (not empty, not "None"). */
const setModels = node => modelComboWidgets(node).filter(widget => typeof widget.value === 'string' && widget.value && widget.value !== 'None');
/** The model AMB knows for `widget`'s file (with its type, path_idx and subfolder), or null. */
async function findModel(widget) {
try {
const response = await fetch(`/anomalous/find_model?search=${encodeURIComponent(widget.value.replace(/\\/g, '/'))}`);
const found = await response.json();
if (found.status === 'success' && found.model) return found.model;
} catch (error) {
console.warn('[AMB] Could not look up the model:', error);
}
showWorkbenchToast(t('canvasMenuModelUnknown'));
return null;
}
/** One label per model widget: the widget's name when the node has several. */
const perModel = (widgets, key) => widgets.map(widget => [widget, widgets.length > 1 ? `${t(key)}: ${widget.name}` : t(key)]);
// Entry id -> (node, browser) => its menu items ([] where it does not apply).
const NODE_ITEMS = {
open: (node, browser) => [item(t('canvasMenuOpen'), () => browser()?.showForNode(node))],
replaceModel: (node, browser) => perModel(modelComboWidgets(node), 'canvasMenuReplace')
.map(([widget, label]) => item(label, () => browser()?._openGalleryReplacer(node, widget))),
loraBefore: (node, browser) => loraInsertionCapabilities(node.graph, node).before.supported
? [item(t('canvasMenuLoraBefore'), () => browser()?.openLoraInsertionPicker(node, 'before'))] : [],
loraAfter: (node, browser) => loraInsertionCapabilities(node.graph, node).after.supported
? [item(t('canvasMenuLoraAfter'), () => browser()?.openLoraInsertionPicker(node, 'after'))] : [],
parameters: (node, browser) => node.widgets?.length
? [item(t('canvasMenuParameters'), () => browser()?.showForNode(node, { section: 'parameters' }))] : [],
modelOutputs: (node, browser) => perModel(setModels(node), 'canvasMenuOutputs').map(([widget, label]) => item(label, async () => {
const model = await findModel(widget);
const owner = model && browser();
if (!owner) return;
owner.show();
owner.showGeneratedGallery(model);
})),
modelDetails: (node, browser) => perModel(setModels(node), 'canvasMenuDetails').map(([widget, label]) => item(label, async () => {
const model = await findModel(widget);
if (model) browser()?.openModelDetail(model);
})),
saveParameters: node => node.widgets?.length ? [item(t('canvasMenuSaveParams'), async () => {
const name = await anomalousPrompt(t('currentNodeSaveName'), node.title || node.type, t('currentNodeSave'));
if (name === null) return;
try {
const result = await saveNodeParameters(node, name || node.title || node.type);
showWorkbenchToast(t(result.status === 'duplicate' ? 'currentNodeSavedBefore' : 'currentNodeSaved', { name: result.name }));
} catch (error) {
console.error('[AMB] Could not save the node values:', error);
showWorkbenchToast(t('currentNodeSaveFailed'));
}
})] : [],
saveCombo: (node, browser) => {
const nodes = menuNodes(node);
if (!hasComboNodes(nodes)) return [];
const label = nodes.length > 1 ? t('comboMenuSaveMany', { count: nodes.length }) : t('comboMenuSave');
return [item(label, () => {
const owner = browser();
if (owner) return saveSelectionAsCombo(owner, nodes);
})];
},
};
// Entry id -> (browser, placement) => the empty canvas's items.
const CANVAS_ITEMS = {
quickSearch: browser => [item(t('canvasMenuQuickSearch'), () => openQuickSearch(browser))],
// The pages themselves inside AMB's submenu; right in the menu, a submenu of them.
openPage: (browser, placement) => {
const pages = RAIL_PAGES.map(({ page, labelKey }) => item(t(labelKey), () => browser()?.show({ page })));
return placement === 'top' ? [{ content: t('canvasMenuOpenPage'), has_submenu: true, submenu: { options: pages } }] : pages;
},
};
/** The items of `list` that are on and apply, built by `builders`; a broken one is left out. */
function build(list, builders, ...args) {
return list.filter(entry => entry.on).flatMap((entry) => {
try {
return builders[entry.id]?.(...args) || [];
} catch (error) {
console.warn(`[AMB] Canvas menu entry ${entry.id} failed:`, error);
return [];
}
});
}
/** `items` as AMB's part of a menu: one submenu (ending in "Adjust this menu"), or right in it. */
function place(items, placement, browser) {
if (!items.length) return [];
if (placement === 'top') return [null, ...items];
const adjust = item(t('canvasMenuAdjust'), () => {
const owner = browser();
if (!owner) return;
owner.show();
openSettingsPage(owner, { tab: 'canvas' });
});
return [null, { content: t('canvasMenuTitle'), has_submenu: true, submenu: { options: [...items, null, adjust] } }];
}
/** AMB's items for the menu of `node`. */
export function nodeMenuItems(node, browser) {
const settings = menuSettings();
return place(build(settings.node, NODE_ITEMS, node, browser), settings.placement, browser);
}
/** AMB's items for the empty canvas's menu. */
export function canvasMenuItems(browser) {
const settings = menuSettings();
return place(build(settings.canvas, CANVAS_ITEMS, browser, settings.placement), settings.placement, browser);
}
+95
View File
@@ -0,0 +1,95 @@
/**
* What the canvas's right-click menus offer from AMB, as the user set it (Settings -> Canvas):
* the entries of a node's menu and of the empty canvas's menu, each on or off, in the user's
* order, and whether they sit in one "Anomalous" submenu or right in the menu. A per-viewer
* convenience in localStorage; entries added in a later version join at their default place.
* No DOM.
*/
const KEY = 'anomalous_canvas_menu';
// Each list in its default order, with whether an entry is on at first.
export const NODE_ENTRIES = Object.freeze([
{ id: 'open', on: true },
{ id: 'replaceModel', on: true },
{ id: 'loraBefore', on: true },
{ id: 'loraAfter', on: true },
{ id: 'parameters', on: true },
{ id: 'modelOutputs', on: true },
{ id: 'modelDetails', on: false },
{ id: 'saveParameters', on: false },
{ id: 'saveCombo', on: true },
]);
export const CANVAS_ENTRIES = Object.freeze([
{ id: 'quickSearch', on: true },
{ id: 'openPage', on: true },
]);
export const PLACEMENTS = Object.freeze(['submenu', 'top']);
function storage() {
try {
return globalThis.localStorage || null;
} catch {
return null;
}
}
/** The saved `list` in its order, entries it does not know dropped and new ones added at their place. */
function merged(saved, defaults) {
const known = new Map(defaults.map(entry => [entry.id, entry]));
const kept = (Array.isArray(saved) ? saved : [])
.filter(entry => known.has(entry?.id))
.filter((entry, index, all) => all.findIndex(other => other.id === entry.id) === index)
.map(entry => ({ id: entry.id, on: entry.on !== false }));
defaults.forEach((entry, index) => {
if (kept.some(other => other.id === entry.id)) return;
// After the entry that comes before it by default, else first.
const before = defaults.slice(0, index).reverse().find(other => kept.some(item => item.id === other.id));
const at = before ? kept.findIndex(item => item.id === before.id) + 1 : 0;
kept.splice(at, 0, { ...entry });
});
return kept;
}
/** { placement, node: [{ id, on }], canvas: [{ id, on }] }. */
export function menuSettings(store = storage()) {
let saved = {};
try {
saved = JSON.parse(store?.getItem(KEY) || '{}') || {};
} catch {
saved = {};
}
return {
placement: PLACEMENTS.includes(saved.placement) ? saved.placement : 'submenu',
node: merged(saved.node, NODE_ENTRIES),
canvas: merged(saved.canvas, CANVAS_ENTRIES),
};
}
/** Keeps `settings` (as menuSettings returns them); false when the browser would not keep it. */
export function saveMenuSettings(settings, store = storage()) {
try {
store.setItem(KEY, JSON.stringify({ placement: settings.placement, node: settings.node, canvas: settings.canvas }));
return true;
} catch {
return false;
}
}
/** Back to the defaults. */
export function resetMenuSettings(store = storage()) {
try {
store?.removeItem(KEY);
} catch { /* the defaults show anyway */ }
return menuSettings(store);
}
/** `list` with the entry `id` moved by `step` (-1 up, +1 down), or as it was at an end. */
export function moveEntry(list, id, step) {
const from = list.findIndex(entry => entry.id === id);
const to = from + step;
if (from < 0 || to < 0 || to >= list.length) return list;
const next = [...list];
[next[from], next[to]] = [next[to], next[from]];
return next;
}
+24 -1
View File
@@ -3,7 +3,9 @@
* workflow kept as a recipe or a combo). ComfyUI's frontend keeps widget values by the root
* graph's id, node and name, and a workflow saved from the open canvas carries the canvas's
* id: configured under that id, the detached graph would share the canvas's widget values and
* write its own onto them. So the copy gets an id of its own. No DOM.
* write its own onto them. So the copy gets an id of its own. Its nodes' widgets still make
* DOM elements on ComfyUI's page (prompt boxes that showed at its top left), which only the
* nodes' removal takes away: read it through readDetached, which clears it afterwards.
*/
/** A random version-4 UUID; `crypto.randomUUID` exists only on https or this computer. */
@@ -23,4 +25,25 @@ export function detachedGraph(workflow) {
const graph = new globalThis.LiteGraph.LGraph();
graph.configure(copy);
return graph;
}
/**
* What `read(graph)` gives for `workflow` on a detached graph, the graph cleared once it is
* read (after the promise settles when `read` returns one). What `read` gives must not need
* the graph's nodes later.
*/
export function readDetached(workflow, read) {
const graph = detachedGraph(workflow);
let result;
try {
result = read(graph);
} catch (error) {
graph.clear();
throw error;
}
if (typeof result?.then !== 'function') {
graph.clear();
return result;
}
return Promise.resolve(result).finally(() => graph.clear());
}
+12 -1
View File
@@ -1,6 +1,6 @@
/**
* What the output gallery lists: everything, pictures or videos (the switch beside its search),
* minus the formats hidden in Settings. Both are this browser's own (localStorage). A format is
* minus the formats hidden in Settings, or only the kept images (★ Kept). Both are this browser's own (localStorage). A format is
* stored when hidden, so one added later shows. The server's side is GET
* /anomalous/gallery_images?media=..&hide=.. (api/gallery_routes.py).
*/
@@ -11,6 +11,7 @@ export const GALLERY_MEDIA = ['all', 'image', 'video'];
const HIDDEN_KEY = 'anomalous_gallery_hidden_formats';
const MEDIA_KEY = 'anomalous_gallery_media';
const KEPT_KEY = 'anomalous_gallery_kept_only';
function read(key) {
try {
@@ -59,9 +60,19 @@ export function setGalleryMedia(media) {
write(MEDIA_KEY, media);
}
/** Whether the gallery shows only the images kept as a recipe, combo or prompt (★). */
export function galleryKeptOnly() {
return read(KEPT_KEY) === 'true';
}
export function setGalleryKeptOnly(on) {
write(KEPT_KEY, on ? 'true' : 'false');
}
/** Adds the gallery's filter to a listing request. */
export function appendGalleryFilter(params) {
params.set('media', galleryMedia());
if (galleryKeptOnly()) params.set('kept', '1');
const hidden = [...hiddenGalleryFormats()];
if (hidden.length) params.set('hide', hidden.join(','));
}
+70 -20
View File
@@ -1,8 +1,10 @@
/**
* Keeping an output image (the gallery's star, the image workbench) as one of three things:
* its whole workflow as a Workflow Recipe (recipe_save.js), its main model, LoRAs and
* positive prompt as a combo (搭配, a file of GET /anomalous/notebooks), or its prompts as a
* saved prompt (POST /anomalous/save_prompt_plan). The image's workflow is laid on a
* its whole workflow as a Workflow Recipe (recipe_save.js), the nodes that make its sampler's
* model and prompts as a combo (搭配, a file of GET /anomalous/notebooks; image_keep_nodes.js,
* every value as the image had it; a workflow without a sampler to read from gives its main
* model, LoRAs and positive prompt), or its prompts as a saved prompt
* (POST /anomalous/save_prompt_plan). The image's workflow is laid on a
* canvas of its own and read like a recipe saved from it (recipe_parser.js); its models are
* looked up among this computer's files. Each thing kept remembers its image, so
* GET /anomalous/kept_images can say what an image was kept as. No DOM.
@@ -11,9 +13,11 @@
import { extractRecipeMetadata } from './recipe_parser.js';
import { categorizePromptSnippet, promptTitle } from './prompt_composition.js';
import { jsonResponse } from './ui_dom.js';
import { detachedGraph } from './detached_graph.js';
import { readDetached } from './detached_graph.js';
const MAIN_TYPES = ['checkpoints', 'unet', 'diffusion_models'];
// Tags nearly every picture has: a combo's name skips them so two combos read apart.
const COMMON_TAG = /^(?:\d+\s*(?:girls?|boys?|others?)|solo|solo focus|looking at viewer|simple background|white background|smile|blush|open mouth|upper body|full body|cowboy shot)$/i;
// The combo editor lists models without a base model under this value (notebook_canvas.js).
const UNLABELED_BASE_MODEL = '__unlabeled__';
@@ -33,12 +37,27 @@ export async function loadKeptImages(signal) {
return new Map(images.map(item => [keptKey(item), item]));
}
const fileStem = name => String(name || '').split(/[\\/]/).pop().replace(/\.[^.]+$/, '');
/** What the image's workflow uses (extractRecipeMetadata) and a name: its prompt's first tags, else the file's. */
async function readImage(sourceImage) {
const info = await jsonResponse(await post('/anomalous/inspect_image_material', { source_image: sourceImage }), 'read image workflow');
const metadata = extractRecipeMetadata(detachedGraph(info.workflow));
const name = promptTitle(metadata.promptPositive[0]) || String(sourceImage?.filename || '').replace(/\.[^.]+$/, '');
return { name, metadata };
const metadata = readDetached(info.workflow, extractRecipeMetadata);
const name = promptTitle(metadata.promptPositive[0]) || fileStem(sourceImage?.filename);
return { name, metadata, workflow: info.workflow };
}
/** A model's name as its card shows it: the name the user gave it, else the file's. */
const modelLabel = model => String(model?.metadata?.custom_name || '').trim() || fileStem(model?.filename);
/**
* A combo's name that tells combos apart: its first LoRA (else its main model), then the
* prompt's first two telling tags — "Ahri · fox ears, kimono"; the file's name when neither.
*/
export function comboTitle({ prompt = '', mainModel = null, loras = [], fallback = '' } = {}) {
const model = modelLabel(loras[0] || mainModel).slice(0, 40);
const subject = promptTitle(prompt, { count: 2, skip: COMMON_TAG });
return [model, subject].filter(Boolean).join(' · ') || fallback;
}
/** { path → this computer's model } for the paths found among `types`. */
@@ -68,16 +87,41 @@ export function freeComboName(name, combos) {
}
}
/** Saves combo `data` under a free name from `wanted`. Returns { filename, name }. */
async function saveCombo(data, wanted, combos) {
const comboName = freeComboName(wanted, combos);
const filename = `${comboName}.json`;
const response = await post('/anomalous/save_notebook', { filename, name: comboName, data: { ...data, name: comboName } });
if ((await jsonResponse(response, 'save combo')).status !== 'success') throw new Error('combo save failed');
return { filename, name: comboName };
}
/**
* Keeps the image's main model, LoRAs and positive prompt as a combo; an image kept before
* gives that combo back (`existed`). Models this computer does not have are left out and
* named in `missing`. Returns { filename, name, existed, missing }.
* Keeps the image as a combo of the nodes that make its sampler's model and prompts, every
* value as it was; an image kept before gives that combo back (`existed`). Models this
* computer does not have are named in `missing` (kept by the workflow's name, `missingKept`). A workflow
* without a sampler to read from gives a combo of its main model, LoRAs and positive prompt.
* Returns { filename, name, existed, missing }.
*/
export async function keepImageAsCombo(sourceImage, { name = '' } = {}) {
const combos = await listCombos();
const kept = combos.find(note => sameImage(note.data?.source_image, sourceImage));
if (kept) return { filename: kept.filename, name: kept.name, existed: true, missing: [] };
const image = await readImage(sourceImage);
const prompt = image.metadata.promptPositive.join('\n');
// Loaded only here: it reaches the canvas modules (no DOM in this file, and tests import it).
const { keepGraphNodes } = await import('./image_keep_nodes.js');
const nodes = await readDetached(image.workflow, graph => keepGraphNodes(graph, localModels));
if (nodes) {
const title = comboTitle({ prompt, mainModel: nodes.mainModel, loras: nodes.loras, fallback: image.name });
const saved = await saveCombo({ ...nodes.data, source_image: sourceImage }, name || title, combos);
return { ...saved, existed: false, missing: nodes.missing, missingKept: true };
}
return keepSimpleCombo(sourceImage, image, prompt, name, combos);
}
/** The older kind of combo: the image's main model, LoRAs and positive prompt. */
async function keepSimpleCombo(sourceImage, image, prompt, name, combos) {
const main = image.metadata.baseModel || '';
const loraPaths = [...new Set((image.metadata.loras || []).map(lora => lora.name).filter(Boolean))];
const [mains, loras] = await Promise.all([
@@ -85,21 +129,27 @@ export async function keepImageAsCombo(sourceImage, { name = '' } = {}) {
localModels(loraPaths, ['loras']),
]);
const mainModel = mains[main] || null;
const comboName = freeComboName(name || image.name, combos);
const data = {
name: comboName,
const localLoras = loraPaths.filter(path => loras[path]).map(path => loras[path]);
const saved = await saveCombo({
baseModel: mainModel ? String(mainModel.metadata?.baseModel || '').trim() || UNLABELED_BASE_MODEL : '',
mainModel,
loras: loraPaths.filter(path => loras[path]).map(path => loras[path]),
promptEn: image.metadata.promptPositive.join('\n'),
loras: localLoras,
promptEn: prompt,
promptZh: '',
source_image: sourceImage,
};
const filename = `${comboName}.json`;
const response = await post('/anomalous/save_notebook', { filename, name: comboName, data });
if ((await jsonResponse(response, 'save combo')).status !== 'success') throw new Error('combo save failed');
}, name || comboTitle({ prompt, mainModel, loras: localLoras, fallback: image.name }), combos);
const missing = [...(main && !mainModel ? [main] : []), ...loraPaths.filter(path => !loras[path])];
return { filename, name: comboName, existed: false, missing };
return { ...saved, existed: false, missing };
}
/** Gives the combo kept in `filename` the name `name` (its file keeps its name). Returns the name. */
export async function renameCombo(filename, name) {
const wanted = String(name || '').trim().slice(0, 100);
const note = (await listCombos()).find(item => item.filename === filename);
if (!wanted || !note) throw new Error('combo rename failed');
const response = await post('/anomalous/save_notebook', { filename, data: { ...note.data, name: wanted } });
if ((await jsonResponse(response, 'rename combo')).status !== 'success') throw new Error('combo rename failed');
return wanted;
}
/**
+55
View File
@@ -0,0 +1,55 @@
/**
* An output image kept as a combo of its own nodes (image_keep.js keepImageAsCombo): the part
* of its workflow that makes the sampler's model and prompts (sampler_inputs.js), laid on a
* graph of its own, saved like nodes picked on the canvas (combo_structure.js) with every value
* as the image had it: LoRA strengths, CLIP skip, the negative prompt. A combo never changes a
* value (AGENTS.md, 功能原则). Models are looked up among this computer's files: one found
* there is named by where it is here; one not found keeps the workflow's name and is reported.
*/
import { captureStructure } from './combo_structure.js';
import { NONE, STRUCTURED } from './combo_slots.js';
import { slotLabel } from './ui_combo_structure.js';
import { samplerSideNodes } from './sampler_inputs.js';
const MAIN_FOLDERS = new Set(['checkpoints', 'unet', 'diffusion_models']);
const relOf = model => [String(model.subfolder || '').replace(/^\/+|\/+$/g, ''), model.filename].filter(Boolean).join('/');
/**
* The combo data of `graph`'s sampler side, or null when the workflow has no sampler to read
* from. `localModels(paths, folders)` gives { path → this computer's model }. Returns { data:
* { kind, structure, values, covers }, mainModel, loras, missing }.
*/
export async function keepGraphNodes(graph, localModels) {
const nodes = samplerSideNodes(graph);
if (!nodes.length) return null;
const { structure, values } = await captureStructure(nodes, { labelFor: slotLabel });
if (!structure) return null;
// An empty row of a stack node is no slot (as when saving from the canvas, unticked).
const slots = structure.slots.filter(slot => !(slot.optional && NONE.test(String(values[slot.id] ?? ''))));
const kept = Object.fromEntries(slots.map(slot => [slot.id, values[slot.id]]));
const covers = {};
const missing = [];
let mainModel = null;
const loras = [];
const models = slots.filter(slot => slot.kind === 'model' && !NONE.test(String(kept[slot.id] ?? '')));
const folders = [...new Set(models.map(slot => slot.folder).filter(Boolean))];
const found = await localModels([...new Set(models.map(slot => kept[slot.id]))], folders);
for (const slot of models) {
const model = found[kept[slot.id]];
if (!model) {
missing.push(kept[slot.id]);
continue;
}
kept[slot.id] = relOf(model);
if (model.preview_url) covers[slot.id] = model.preview_url;
if (!mainModel && MAIN_FOLDERS.has(slot.folder)) mainModel = model;
if (slot.folder === 'loras') loras.push(model);
}
return {
data: { kind: STRUCTURED, structure: { ...structure, slots }, values: kept, covers },
mainModel,
loras,
missing,
};
}
+611 -605
View File
File diff suppressed because it is too large Load Diff
-65
View File
@@ -64,71 +64,6 @@ export function lookupLocalModel(localModels, path) {
return localModels[path] || localModels[fileBaseName(path)] || null;
}
export async function parsePngMetadataFromUrl(url, options = {}) {
if (!url) return null;
try {
const response = await fetch(url, { cache: 'force-cache', signal: options.signal });
if (!response.ok) return null;
const buffer = await response.arrayBuffer();
const view = new DataView(buffer);
if (view.byteLength < 32) return null;
// PNG magic bytes: 137, 80, 78, 71, 13, 10, 26, 10
if (view.getUint32(0) !== 0x89504E47 || view.getUint32(4) !== 0x0D0A1A0A) return null;
let offset = 8;
const utf8 = new TextDecoder('utf-8');
const latin1 = new TextDecoder('iso-8859-1');
const result = {};
while (offset + 8 <= buffer.byteLength) {
const length = view.getUint32(offset);
const type = String.fromCharCode(
view.getUint8(offset + 4),
view.getUint8(offset + 5),
view.getUint8(offset + 6),
view.getUint8(offset + 7),
);
const dataOffset = offset + 8;
if (type === 'IDAT') break; // Reached image raster data, text metadata is prior
if (dataOffset + length > buffer.byteLength) break;
if (type === 'tEXt') {
const bytes = new Uint8Array(buffer, dataOffset, length);
const nullIdx = bytes.indexOf(0);
if (nullIdx > -1) {
const key = latin1.decode(bytes.subarray(0, nullIdx));
const val = latin1.decode(bytes.subarray(nullIdx + 1));
try { result[key] = JSON.parse(val); } catch { result[key] = val; }
}
} else if (type === 'iTXt') {
const bytes = new Uint8Array(buffer, dataOffset, length);
const nullIdx = bytes.indexOf(0);
if (nullIdx > -1) {
const key = latin1.decode(bytes.subarray(0, nullIdx));
let ptr = nullIdx + 1;
const compFlag = bytes[ptr++];
const compMethod = bytes[ptr++];
while (ptr < bytes.length && bytes[ptr] !== 0) ptr++;
ptr++;
while (ptr < bytes.length && bytes[ptr] !== 0) ptr++;
ptr++;
if (compFlag === 0 && ptr <= bytes.length) {
const val = utf8.decode(bytes.subarray(ptr));
try { result[key] = JSON.parse(val); } catch { result[key] = val; }
}
}
}
offset += 12 + length;
}
return result.workflow || result.prompt || null;
} catch (e) {
if (e?.name === 'AbortError') return null;
console.warn('Client-side PNG metadata read skipped:', e);
return null;
}
}
/**
* Extract generation parameters, full prompts, and categorized model references with details (e.g. LoRA strengths)
* directly from the workflow.
+10 -1
View File
@@ -69,6 +69,15 @@ export function hfMirrorOn(settings) {
return typeof settings?.hf_mirror === 'boolean' ? settings.hf_mirror : window.anomalous_browser_lang === 'zh';
}
/**
* Hugging Face's model search for a file's name (its mirror when that is on), for a foundation
* component no source was found for: the user looks for it there.
*/
export function hfSearchUrl(value, settings) {
const stem = fileName(value).replace(/\.[^.]+$/, '');
return `https://${hfMirrorOn(settings) ? 'hf-mirror.com' : 'huggingface.co'}/models?search=${encodeURIComponent(stem)}`;
}
/**
* Where each entry can be downloaded from: { found: Map entry -> { found, source, size, sha256,
* base_model, page, type, roots, reason… }, settings }. Sources are asked once per page load
@@ -93,7 +102,7 @@ export async function lookupDownloads(entries) {
info = { ...info, type: result.type, roots: data.roots?.[result.type] || [], link: item.url };
found.set(entries[Number(item.key)], info);
}
return { found, settings };
return { found, settings, managerList: data.manager_list !== false };
}
function notify() {
+70
View File
@@ -0,0 +1,70 @@
/**
* The workflow (or API prompt) a ComfyUI PNG carries, read in the browser from its text
* chunks (tEXt, uncompressed iTXt) before the image data: the gallery's detail falls back to
* it when the server could not say. Null for anything that is not such a PNG. No DOM.
*/
export async function parsePngMetadataFromUrl(url, options = {}) {
if (!url) return null;
try {
const response = await fetch(url, { cache: 'force-cache', signal: options.signal });
if (!response.ok) return null;
const buffer = await response.arrayBuffer();
const view = new DataView(buffer);
if (view.byteLength < 32) return null;
// PNG magic bytes: 137, 80, 78, 71, 13, 10, 26, 10
if (view.getUint32(0) !== 0x89504E47 || view.getUint32(4) !== 0x0D0A1A0A) return null;
let offset = 8;
const utf8 = new TextDecoder('utf-8');
const latin1 = new TextDecoder('iso-8859-1');
const result = {};
while (offset + 8 <= buffer.byteLength) {
const length = view.getUint32(offset);
const type = String.fromCharCode(
view.getUint8(offset + 4),
view.getUint8(offset + 5),
view.getUint8(offset + 6),
view.getUint8(offset + 7),
);
const dataOffset = offset + 8;
if (type === 'IDAT') break; // Reached image raster data, text metadata is prior
if (dataOffset + length > buffer.byteLength) break;
if (type === 'tEXt') {
const bytes = new Uint8Array(buffer, dataOffset, length);
const nullIdx = bytes.indexOf(0);
if (nullIdx > -1) {
const key = latin1.decode(bytes.subarray(0, nullIdx));
const val = latin1.decode(bytes.subarray(nullIdx + 1));
try { result[key] = JSON.parse(val); } catch { result[key] = val; }
}
} else if (type === 'iTXt') {
const bytes = new Uint8Array(buffer, dataOffset, length);
const nullIdx = bytes.indexOf(0);
if (nullIdx > -1) {
const key = latin1.decode(bytes.subarray(0, nullIdx));
let ptr = nullIdx + 1;
const compFlag = bytes[ptr++];
const compMethod = bytes[ptr++];
while (ptr < bytes.length && bytes[ptr] !== 0) ptr++;
ptr++;
while (ptr < bytes.length && bytes[ptr] !== 0) ptr++;
ptr++;
if (compFlag === 0 && ptr <= bytes.length) {
const val = utf8.decode(bytes.subarray(ptr));
try { result[key] = JSON.parse(val); } catch { result[key] = val; }
}
}
}
offset += 12 + length;
}
return result.workflow || result.prompt || null;
} catch (e) {
if (e?.name === 'AbortError') return null;
console.warn('Client-side PNG metadata read skipped:', e);
return null;
}
}
+2 -1
View File
@@ -46,7 +46,8 @@ function fillBox(node, box, envelope) {
});
}
function addPromptNode(graph, position, card) {
/** A new prompt node of `card`'s role and text at `position` on `graph` (also the quick search's). */
export function addPromptNode(graph, position, card) {
const node = globalThis.LiteGraph?.createNode('CLIPTextEncode');
if (!node) return;
const role = card.role === 'negative' ? 'negative' : 'positive';
+7 -4
View File
@@ -53,12 +53,15 @@ const STYLE_KEYWORDS = [
// Quality words most prompts start with: they say nothing about the picture.
const QUALITY_TAG = /^(?:masterpiece|best quality|high quality|highest quality|amazing quality|very aesthetic|aesthetic|newest|highres|absurdres|ultra[- ]?detailed|highly detailed|extremely detailed|detailed|8k|4k|hdr|uhd|score_\d+(?:_up)?|source_\w+|rating_\w+)$/i;
/** A short name from a prompt: its first few tags that are not quality words ("" when none). */
export function promptTitle(text = '') {
/**
* A short name from a prompt: its first `count` tags that are not quality words, nor ones
* `skip` matches ("" when none).
*/
export function promptTitle(text = '', { count = 3, skip = null } = {}) {
const tags = String(text).split(/[,\n]/)
.map(tag => tag.replace(/^[\s(\[{]+|[\s)\]}]+$/g, '').replace(/:[\d.]+$/, '').trim())
.filter(tag => tag && !QUALITY_TAG.test(tag));
const title = tags.slice(0, 3).join(', ');
.filter(tag => tag && !QUALITY_TAG.test(tag) && !skip?.test(tag));
const title = tags.slice(0, count).join(', ');
return title.length > 48 ? `${title.slice(0, 47)}…` : title;
}
+118
View File
@@ -0,0 +1,118 @@
/**
* What the canvas's quick search (ui_quick_search.js) looks through, and how it matches: the
* models a loader node can take (GET /anomalous/quick_index), the combos and the saved
* prompts, each read when the bar opens and kept a minute. `searchQuick` is the matching
* itself: every word typed must appear in an item's name, file, base model, trigger words or
* text; names that start with the words come first. No DOM.
*/
// The loader node each model type goes on the canvas with (ui_grid.js applyModelToCanvas).
export const MODEL_LOADERS = Object.freeze({
checkpoints: 'CheckpointLoaderSimple',
loras: 'LoraLoader',
unet: 'UNETLoader',
diffusion_models: 'UNETLoader',
vae: 'VAELoader',
clip: 'CLIPLoader',
controlnet: 'ControlNetLoader',
});
export const KINDS = Object.freeze(['combo', 'model', 'prompt']);
const KEEP_MS = 60 * 1000;
const PER_KIND = 8;
const stem = name => String(name || '').split(/[\\/]/).pop().replace(/\.[^.]+$/, '');
const lower = value => String(value || '').toLowerCase();
/** The words of `query`, lower case. */
export const queryTerms = query => lower(query).split(/\s+/).filter(Boolean);
/** A model (GET /anomalous/quick_index) as a search item. */
export function modelItem(model) {
const title = model.name || model.civitai_name || stem(model.filename);
return {
kind: 'model',
key: `model|${model.type}|${model.subfolder}|${model.filename}`,
title,
type: model.type,
detail: [model.base, model.subfolder].filter(Boolean).join(' · '),
thumb: model.preview_url || '',
haystack: lower([title, model.civitai_name, model.filename, model.subfolder, model.base, ...(model.words || [])].join(' ')),
data: model,
};
}
/** A combo (GET /anomalous/notebooks) as a search item; `detail` and `thumb` come from the caller. */
export function comboItem(note, { detail = '', thumb = '', text = '' } = {}) {
return {
kind: 'combo',
key: `combo|${note.filename}`,
title: note.name,
detail,
thumb,
haystack: lower([note.name, detail, text].join(' ')),
data: note,
};
}
/** A saved prompt card ({ id, title, content, role }) as a search item. */
export function promptItem(card) {
return {
kind: 'prompt',
key: `prompt|${card.id}`,
title: card.title,
detail: card.content.replace(/\s+/g, ' ').slice(0, 90),
role: card.role,
haystack: lower([card.title, card.content].join(' ')),
data: card,
};
}
/** 0: the title starts with the words, 1: the title holds them, 2: elsewhere. */
function rank(item, terms) {
const title = lower(item.title);
if (title.startsWith(terms.join(' '))) return 0;
return terms.every(term => title.includes(term)) ? 1 : 2;
}
/**
* The items matching `query` among `items`, grouped by kind in KINDS' order (only `kind` when
* given), at most `perKind` of each. With no words: the combos only, as they are.
*/
export function searchQuick(items, query, { kind = '', perKind = PER_KIND } = {}) {
const terms = queryTerms(query);
const wanted = kind ? [kind] : KINDS;
const groups = [];
for (const each of wanted) {
let found = items.filter(item => item.kind === each);
if (terms.length) {
found = found.filter(item => terms.every(term => item.haystack.includes(term)))
.map((item, index) => ({ item, index, rank: rank(item, terms) }))
.sort((a, b) => a.rank - b.rank || a.index - b.index)
.map(entry => entry.item);
} else if (!kind && each !== 'combo') {
found = [];
}
if (found.length) groups.push({ kind: each, items: found.slice(0, perKind), more: Math.max(0, found.length - perKind) });
}
return groups;
}
let cache = null; // { at, models, combos, prompts }
/** The kept lists when younger than a minute, else null. */
export function cachedSources(now = Date.now()) {
return cache && now - cache.at < KEEP_MS ? cache : null;
}
export function keepSources(sources, now = Date.now()) {
cache = { ...sources, at: now };
return cache;
}
/** The models a loader node can take, as search items. */
export async function loadModelItems(signal) {
const response = await fetch('/anomalous/quick_index', { signal });
if (!response.ok) throw new Error(`quick index ${response.status}`);
const { models = [] } = await response.json();
return models.filter(model => MODEL_LOADERS[model.type]).map(modelItem);
}
+2 -2
View File
@@ -9,7 +9,7 @@ import { captureRecipeDraft } from './recipe_parser.js';
import { promptTitle } from './prompt_composition.js';
import { outputImageUrl, previewIsVideo } from './ui_recipe_media.js';
import { jsonResponse } from './ui_dom.js';
import { detachedGraph } from './detached_graph.js';
import { readDetached } from './detached_graph.js';
const post = (url, body) => fetch(url, {
method: 'POST',
@@ -141,7 +141,7 @@ export async function keepImageAsRecipe(sourceImage, { name = '', tags = [], wor
workflow = info.workflow;
suggested = info.suggested_name || '';
}
const draft = captureRecipeDraft(detachedGraph(workflow));
const draft = readDetached(workflow, captureRecipeDraft);
const recipeName = String(name || promptTitle(draft.metadata.promptPositive?.[0]) || suggested || sourceImage?.filename || 'Workflow').trim().slice(0, 120);
const { filename } = await persistRecipe(draft, {
name: recipeName, tags, notes: '', sourceImage,
+46
View File
@@ -0,0 +1,46 @@
/**
* The part of a workflow that makes a sampler's model and prompts (what a combo kept from an
* output image holds): every node upstream of a sampler's model / positive / negative /
* conditioning inputs — model and CLIP loaders, LoRA chains, CLIP skip and model patches, the
* prompt nodes — but not the sampler itself, nor anything reached only through an image,
* latent or mask (image loaders, sizes, VAE decoding). A sampler is a node with a model input
* and a positive or conditioning input (KSampler, KSampler Advanced, CFG / Basic guiders).
* No DOM; `graph` is a LiteGraph graph (or anything with nodes and getLink / links).
*/
const FEEDS = new Set(['model', 'positive', 'negative', 'conditioning']);
const CUT = new Set(['IMAGE', 'LATENT', 'MASK']);
const inputNames = node => (node.inputs || []).map(input => input?.name);
/** Whether `node` is a sampler (or a guider) whose model and prompts a combo keeps. */
export function isSampler(node) {
const names = inputNames(node);
return names.includes('model') && (names.includes('positive') || names.includes('conditioning'));
}
/** The nodes upstream of the samplers' model and prompt inputs, in the graph's order. */
export function samplerSideNodes(graph) {
const nodes = graph?._nodes || graph?.nodes || [];
const byId = new Map(nodes.map(node => [node.id, node]));
const linkOf = id => graph.getLink?.(id) ?? graph.links?.[id] ?? null;
const samplers = new Set(nodes.filter(isSampler).map(node => node.id));
const kept = new Set();
const waiting = [];
const follow = (input) => {
if (input?.link == null) return;
const link = linkOf(input.link);
if (!link || CUT.has(String(link.type ?? input.type ?? '').toUpperCase())) return;
waiting.push(link.origin_id);
};
for (const id of samplers) {
for (const input of byId.get(id).inputs || []) if (FEEDS.has(input?.name)) follow(input);
}
while (waiting.length) {
const id = waiting.pop();
if (kept.has(id) || samplers.has(id) || !byId.has(id)) continue;
kept.add(id);
for (const input of byId.get(id).inputs || []) follow(input);
}
return nodes.filter(node => kept.has(node.id));
}
+210
View File
@@ -0,0 +1,210 @@
/**
* The voice-over drafts box, without DOM or storage: the shape of a kept script, the box that
* holds them (one file in ComfyUI's user folder, ui_script_drafts.js), and how two copies of
* it come together when another tab saved meanwhile.
*
* A box: `{ format: 1, current: id | null, drafts: [{ id, name, created, updated, deleted, script }] }`.
* `script` is what the director keeps of a script: `{ format: 1, lines, groupKey, script, cast }`
* (lines: `{ text, emotion, speaker, tune? }`; script and cast: the whole script's and each
* character's settings, script_tune.js). `deleted` is when it went to Recently deleted (or null);
* it comes back from there until DELETED_DAYS have passed.
*/
export const BOX_FORMAT = 1;
export const DELETED_DAYS = 30;
const DAY = 24 * 60 * 60 * 1000;
const NAME_MAX = 60;
const TITLE_FROM_LINE = 18;
const TUNE_FIELDS = { language: 'string', speed: 'number', preset: 'string' };
/** Only the line settings a draft may hold, with the right types (null when none). */
export function cleanTune(tune) {
const out = {};
for (const [field, type] of Object.entries(TUNE_FIELDS)) if (typeof tune?.[field] === type) out[field] = tune[field];
return Object.keys(out).length ? out : null;
}
/** A kept script as the director can use it: lines without text or with a bad shape are left out. */
export function cleanScript(raw) {
if (raw?.format !== 1 || !Array.isArray(raw.lines)) return null;
const lines = raw.lines.filter(line => typeof line?.text === 'string' && line.text.trim()).map((line) => {
const tune = cleanTune(line.tune);
return {
text: line.text,
emotion: typeof line.emotion === 'string' ? line.emotion : 'main',
speaker: typeof line.speaker === 'string' ? line.speaker : null,
...(tune ? { tune } : {}),
};
});
const cast = {};
for (const [character, own] of Object.entries(raw.cast && typeof raw.cast === 'object' ? raw.cast : {})) {
if (cleanTune(own)) cast[character] = cleanTune(own);
}
return {
format: 1,
lines,
groupKey: typeof raw.groupKey === 'string' ? raw.groupKey : null,
script: cleanTune(raw.script) || {},
cast,
};
}
/** A script worth keeping: at least one line with text. */
export function hasLines(script) {
return Boolean(script?.lines?.some(line => line.text.trim()));
}
export function emptyBox() {
return { format: BOX_FORMAT, current: null, drafts: [] };
}
/** The box read from its file (anything unreadable gives an empty box; bad drafts are left out). */
export function cleanBox(raw) {
if (raw?.format !== BOX_FORMAT || !Array.isArray(raw.drafts)) return emptyBox();
const seen = new Set();
const drafts = [];
for (const item of raw.drafts) {
const script = cleanScript(item?.script);
if (typeof item?.id !== 'string' || !item.id || seen.has(item.id) || !script) continue;
seen.add(item.id);
const updated = Number.isFinite(item.updated) ? item.updated : 0;
drafts.push({
id: item.id,
name: typeof item.name === 'string' ? item.name.slice(0, NAME_MAX) : '',
created: Number.isFinite(item.created) ? item.created : updated,
updated,
deleted: Number.isFinite(item.deleted) ? item.deleted : null,
script,
});
}
const current = drafts.some(draft => draft.id === raw.current && !draft.deleted) ? raw.current : null;
return { format: BOX_FORMAT, current, drafts };
}
export function newId(now = Date.now(), random = Math.random) {
return `${now.toString(36)}-${Math.floor(random() * 36 ** 6).toString(36).padStart(6, '0')}`;
}
/** A new draft holding `script`. */
export function makeDraft(script, now = Date.now(), { name = '', id = newId(now) } = {}) {
return { id, name: String(name).trim().slice(0, NAME_MAX), created: now, updated: now, deleted: null, script };
}
export const findDraft = (box, id) => box.drafts.find(draft => draft.id === id) || null;
/** Puts `draft` in the box (in place of the one with its id). */
export function putDraft(box, draft) {
const at = box.drafts.findIndex(item => item.id === draft.id);
if (at >= 0) box.drafts[at] = draft;
else box.drafts.push(draft);
return draft;
}
/** The script of draft `id` changed. */
export function keepScript(box, id, script, now = Date.now()) {
const draft = findDraft(box, id);
if (!draft) return null;
draft.script = script;
draft.updated = now;
return draft;
}
export function renameDraft(box, id, name, now = Date.now()) {
const draft = findDraft(box, id);
if (!draft) return null;
draft.name = String(name || '').trim().slice(0, NAME_MAX);
draft.updated = now;
return draft;
}
/** A copy of draft `id`, named after it. */
export function copyDraft(box, id, copyName, now = Date.now()) {
const draft = findDraft(box, id);
if (!draft) return null;
return putDraft(box, makeDraft(structuredClone(draft.script), now, { name: copyName }));
}
/** To Recently deleted (it stops being the open one). */
export function deleteDraft(box, id, now = Date.now()) {
const draft = findDraft(box, id);
if (!draft) return null;
draft.deleted = now;
draft.updated = now;
if (box.current === id) box.current = null;
return draft;
}
export function restoreDraft(box, id, now = Date.now()) {
const draft = findDraft(box, id);
if (!draft) return null;
draft.deleted = null;
draft.updated = now;
return draft;
}
/** Drops the drafts deleted more than DELETED_DAYS ago; true when any went. */
export function dropOldDeleted(box, now = Date.now()) {
const before = box.drafts.length;
box.drafts = box.drafts.filter(draft => !draft.deleted || now - draft.deleted < DELETED_DAYS * DAY);
return box.drafts.length !== before;
}
/** The drafts to show, the last changed first. */
export function liveDrafts(box) {
return box.drafts.filter(draft => !draft.deleted).sort((a, b) => b.updated - a.updated);
}
export function deletedDrafts(box) {
return box.drafts.filter(draft => draft.deleted).sort((a, b) => b.deleted - a.deleted);
}
/**
* This tab's box and the one in the file, together: each draft as it was changed last
* (a deletion is a change). The draft open here stays as it is here.
*/
export function mergeBoxes(mine, theirs) {
const byId = new Map(theirs.drafts.map(draft => [draft.id, draft]));
for (const draft of mine.drafts) {
const other = byId.get(draft.id);
if (!other || draft.updated >= other.updated || draft.id === mine.current) byId.set(draft.id, draft);
}
const drafts = [...byId.values()];
const current = drafts.some(draft => draft.id === mine.current && !draft.deleted) ? mine.current : null;
return { format: BOX_FORMAT, current, drafts };
}
/** What a draft is called: its name, else its first line (shortened). '' when it has neither. */
export function draftTitle(draft) {
if (draft?.name) return draft.name;
const first = draft?.script?.lines?.find(line => line.text.trim())?.text.trim() || '';
return first.length > TITLE_FROM_LINE ? `${first.slice(0, TITLE_FROM_LINE)}…` : first;
}
/** Who reads the draft, in the order they first speak (group keys; null lines read by the cast's pick). */
export function draftSpeakers(script) {
const keys = [];
for (const line of script?.lines || []) {
const key = line.speaker || script.groupKey;
if (key && !keys.includes(key)) keys.push(key);
}
return keys;
}
/**
* When a draft was changed, as a locale key and its values: just now, minutes ago, today at,
* yesterday at, or a date (month and day, with the year when it is not this one).
*/
export function whenKey(time, now = Date.now()) {
const minutes = Math.floor((now - time) / 60000);
if (minutes < 1) return { key: 'draftsJustNow' };
if (minutes < 60) return { key: 'draftsMinutesAgo', values: { count: minutes } };
const then = new Date(time);
const today = new Date(now);
const pad = n => String(n).padStart(2, '0');
const clock = `${pad(then.getHours())}:${pad(then.getMinutes())}`;
const dayStart = new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime();
if (time >= dayStart) return { key: 'draftsTodayAt', values: { time: clock } };
if (time >= dayStart - DAY) return { key: 'draftsYesterdayAt', values: { time: clock } };
const values = { month: then.getMonth() + 1, day: then.getDate(), year: then.getFullYear() };
return { key: then.getFullYear() === today.getFullYear() ? 'draftsDate' : 'draftsDateYear', values };
}
+148
View File
@@ -0,0 +1,148 @@
/**
* How each line of a voice-over is read (no DOM): its language, speed and tone, in three layers
* changed for this script, the most particular first: the line (`line.tune`), its character,
* the whole script; then the character's usual (saved) values, then the defaults. Each layer
* holds only the fields set on it, and a field set to what it would get anyway is dropped, so
* "where it comes from" always names the layer that decided it (`from`: 'line', 'character',
* 'script', 'usual' or 'default'). Anomalous_TTS interface 17 reads a line's own values from tags
* in the script, `[lang:ja][speed:1.2][preset:lively]` (`auto` goes back to detecting / the
* presets), over every other setting; the whole script's go through the node's inputs.
*/
export const TUNE_FIELDS = ['language', 'speed', 'preset'];
export const LINE_TAGS_FORMAT = 17; // the node interface that reads [lang:…] [speed:…] [preset:…]
export const AUTO_MODE = ''; // the mode that lets the presets decide
/** What a line set for itself: only those fields. */
export function ownTune(line) {
return line?.tune && typeof line.tune === 'object' ? line.tune : {};
}
export function hasOwnTune(line) {
const own = ownTune(line);
return TUNE_FIELDS.some(field => own[field] !== undefined);
}
/** A line's values: its own over its character's (`character`: `{ language, speed, preset }`). */
export function lineValues(line, character) {
const own = ownTune(line);
return Object.fromEntries(TUNE_FIELDS.map(field => [field, own[field] ?? character?.[field]]));
}
/** `own[field] = value`, or no entry when `value` is undefined or what the layer below gives (`inherited`). */
export function setOwn(own, field, value, inherited) {
if (value === undefined || value === inherited) delete own[field];
else own[field] = value;
}
/**
* Sets `field` on each line, or with `value` undefined (or what its character reads it with,
* `inheritedOf(line)`) lets it follow its character again.
*/
export function setLineTune(lines, field, value, inheritedOf = () => undefined) {
for (const line of lines) {
if (!line) continue;
const own = { ...ownTune(line) };
setOwn(own, field, value, inheritedOf(line));
if (Object.keys(own).length) line.tune = own;
else delete line.tune;
}
}
/** The whole script's values: what was changed for it, else the defaults (`base`). */
export function scriptLayer(script, base) {
const values = {};
const from = {};
for (const field of TUNE_FIELDS) {
const own = script?.[field] !== undefined;
values[field] = own ? script[field] : base[field];
from[field] = own ? 'script' : 'default';
}
return { values, from };
}
/**
* A character's values in this script: changed for it here (`cast`), else for the whole script,
* else its usual ones (`stored`; 'usual' only where they differ from `base`).
*/
export function characterLayer(cast, script, stored, base) {
const values = {};
const from = {};
for (const field of TUNE_FIELDS) {
if (cast?.[field] !== undefined) [values[field], from[field]] = [cast[field], 'character'];
else if (script?.[field] !== undefined) [values[field], from[field]] = [script[field], 'script'];
else [values[field], from[field]] = [stored[field], stored[field] === base[field] ? 'default' : 'usual'];
}
return { values, from };
}
/** A line's values over its character's layer (`character`: `{ values, from }`). */
export function lineLayer(line, character) {
const own = ownTune(line);
const values = {};
const from = {};
for (const field of TUNE_FIELDS) {
const mine = own[field] !== undefined;
values[field] = mine ? own[field] : character.values[field];
from[field] = mine ? 'line' : character.from[field];
}
return { values, from };
}
/** Lets the lines follow their character in everything again. */
export function clearLineTune(lines) {
for (const line of lines) if (line) delete line.tune;
}
/**
* The tags a line needs for the node to read it with `values` where it would read it with `node`
* by itself (its inputs, the character's own settings): each field that differs. A language or
* tone back to auto is `[lang:auto]` / `[preset:auto]`; missing `node` fields are auto.
*/
export function tuneTags(values, node = {}) {
const nodeLanguage = node.language ?? 'auto';
const nodePreset = node.preset ?? AUTO_MODE;
let tags = '';
if (values.language && values.language !== nodeLanguage) tags += `[lang:${values.language}]`;
if (typeof values.speed === 'number' && values.speed !== node.speed) tags += `[speed:${Math.round(values.speed * 100) / 100}]`;
if ((values.preset ?? AUTO_MODE) !== nodePreset) tags += `[preset:${values.preset || 'auto'}]`;
return tags;
}
/**
* The one value `field` has on all of `lines` (`valuesOf(line)`): `{ value, mixed }`; `own`:
* whether every line set it for itself, `some` whether any did.
*/
export function sharedValue(lines, field, valuesOf) {
const values = lines.map(line => valuesOf(line)[field]);
const owned = lines.filter(line => ownTune(line)[field] !== undefined).length;
return {
value: values[0],
mixed: values.some(value => value !== values[0]),
own: lines.length > 0 && owned === lines.length,
some: owned > 0,
};
}
const TAG = /^\s*(speed|语速|preset|预设|lang|language|语言)\s*[::]\s*(\S+)\s*$/i;
const FIELD = { speed: 'speed', 语速: 'speed', preset: 'preset', 预设: 'preset', lang: 'language', language: 'language', 语言: 'language' };
const LANGUAGE = { ja: 'ja', jp: 'ja', zh: 'zh', cn: 'zh', en: 'en', 日语: 'ja', 中文: 'zh', 英语: 'en', auto: 'auto', 自动: 'auto' };
/** `[speed:1.2]`'s inside -> `{ field: 'speed', value: 1.2 }`; null when it is no line setting (a name). */
export function readTuneTag(content) {
const match = TAG.exec(String(content || ''));
if (!match) return null;
const field = FIELD[match[1].toLowerCase()];
const raw = match[2];
if (field === 'speed') {
const value = Number(raw);
return Number.isFinite(value) ? { field, value } : { field, value: undefined };
}
if (field === 'language') return { field, value: LANGUAGE[raw.toLowerCase()] };
return { field, value: raw.toLowerCase() === 'auto' ? AUTO_MODE : raw };
}
/** "1, 3, 5" from line indexes (0-based in, 1-based out). */
export function lineNumbers(indexes) {
return indexes.map(index => index + 1).join(', ');
}
+20
View File
@@ -0,0 +1,20 @@
/**
* Settings → Models → "Side files" requests (api/sidecar_layout.py): where the files only this
* plugin uses sit beside each model ('beside', or 'hidden' in each folder's .amb), what putting
* them there would move, and doing it (Civitai pictures kept twice go too). No DOM.
*/
async function json(url, options) {
const response = await fetch(url, options);
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
return data;
}
/** { layout, moves, duplicates, models }: what putting the side files in `layout` would do. */
export const planSidecars = (layout) => json(`/anomalous/sidecars/plan?layout=${encodeURIComponent(layout)}`);
/** Saves `layout` and puts the side files there: { layout, moved, deduped, failed, more }. */
export const applySidecars = (layout) => json('/anomalous/sidecars/apply', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ layout }),
});
+199
View File
@@ -0,0 +1,199 @@
/**
* The rules of the reference audio editor (ui_audio_tts_editor.js), without DOM: a character's
* voices (the main voice and each emotion), each with one main reference (its tone and way of
* speaking) and auxiliary references (Anomalous_TTS interface 18: their timbre is averaged in,
* SoVITS v1 / v2 / v2Pro only), the reference lines fixed here, and how all of it goes back into
* the character's anomalous_tts.json, keeping every field the editor does not know.
*
* A voice: `{ key, name, main, aux, kept, filename, touched }`. `name` is 'main' for the main
* voice; `main` the clip (null on the main voice: the node picks one itself); `kept` the rest
* of its settings entry; `filename` an emotion found from a file name (`X.开心.wav`), written
* into the settings only once it is changed (`touched`).
*/
export const AUX_FORMAT = 18;
export const AUX_VERSIONS = new Set(['v1', 'v2', 'v2Pro', 'v2ProPlus']);
export const REF_MIN_SEC = 3;
export const REF_MAX_SEC = 10;
export const LINE_LIMIT = 500;
export const MAIN = 'main';
const FORBIDDEN_IN_NAME = /[{}[\]]/;
const isObject = value => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
function own(spec) {
const { audio, aux, ...rest } = isObject(spec) ? spec : {};
return rest;
}
function auxList(spec) {
return Array.isArray(spec?.aux) ? [...new Set(spec.aux.filter(path => typeof path === 'string' && path))] : [];
}
/**
* The voices and the fixed lines from the saved settings and the node's view of them (`detail`:
* the character's `reference` and `emotions` as resolved). `lineFixes` (interface 18): a line a
* voice kept in its own `text` becomes that clip's fixed line, so every clip has one line,
* unless it is the line the clip's files give anyway (`found`).
*/
export function readVoices(settings, detail, { lineFixes = false, found = new Map() } = {}) {
const fixes = new Map();
if (lineFixes && isObject(settings?.lines)) {
for (const [path, text] of Object.entries(settings.lines)) if (typeof text === 'string' && text.trim()) fixes.set(path, text);
}
const voice = (key, name, spec, extra = {}) => {
const kept = own(spec);
const main = typeof spec?.audio === 'string' && spec.audio ? spec.audio : null;
if (lineFixes && typeof kept.text === 'string') {
const text = kept.text.trim();
if (text && main && !fixes.has(main) && found.get(main)?.trim() !== text) fixes.set(main, text);
delete kept.text;
}
return { key, name, main, aux: auxList(spec).filter(path => path !== main), kept, filename: false, touched: false, ...extra };
};
const voices = [voice('main', MAIN, settings?.reference)];
const saved = isObject(settings?.emotions) ? settings.emotions : {};
for (const [name, spec] of Object.entries(saved)) {
if (name.trim() && name.trim() !== MAIN) voices.push(voice(`e:${name}`, name, spec));
}
const named = isObject(detail?.emotions) ? detail.emotions : {};
for (const name of Object.keys(named).sort()) {
const ref = named[name];
if (ref?.source !== 'filename' || voices.some(item => item.name === name)) continue;
voices.push(voice(`e:${name}`, name, { audio: ref.audio }, { filename: true }));
}
return { voices, fixes };
}
/** What `audio` is to `voice`: 'main', 'aux' or null. */
export function roleOf(voice, audio) {
if (!voice) return null;
if (voice.main === audio) return 'main';
return voice.aux.includes(audio) ? 'aux' : null;
}
/** `audio` becomes the voice's main reference (and no longer one of its auxiliary ones). */
export function setMain(voice, audio) {
voice.main = audio;
voice.aux = voice.aux.filter(path => path !== audio);
voice.touched = true;
}
/** The main voice back to the node's own pick. */
export function clearMain(voice) {
if (voice.name !== MAIN) return;
voice.main = null;
voice.touched = true;
}
export function toggleAux(voice, audio) {
if (voice.main === audio) return;
voice.aux = voice.aux.includes(audio) ? voice.aux.filter(path => path !== audio) : [...voice.aux, audio];
voice.touched = true;
}
/** Where else `audio` is used: `[{ name, role }]` for the voices other than `except`. */
export function usesOf(voices, audio, except = null) {
const out = [];
for (const voice of voices) {
if (voice === except) continue;
const role = roleOf(voice, audio);
if (role) out.push({ name: voice.name, role });
}
return out;
}
/** A clip fits as a main reference unless its known length is outside 3~10 s. */
export function fitsMain(seconds) {
return !(seconds > 0) || (seconds >= REF_MIN_SEC && seconds <= REF_MAX_SEC);
}
/** An emotion name that can be used: '' or the locale key of what is wrong with it. */
export function nameProblem(name, voices, self = null) {
const clean = String(name || '').trim();
if (!clean) return 'ttsRefsNameMissing';
if (clean === MAIN || FORBIDDEN_IN_NAME.test(clean)) return 'ttsEditorNameInvalid';
if (voices.some(voice => voice !== self && voice.name === clean)) return 'ttsEditorNameDuplicate';
return '';
}
/** The first thing that keeps the voices from being saved, as `{ key, values }`, or null. */
export function voicesProblem(voices, audioList) {
const files = new Set(audioList);
const seen = new Set();
for (const voice of voices) {
if (voice.name !== MAIN) {
if (seen.has(voice.name)) return { key: 'ttsEditorNameDuplicate', values: { name: voice.name } };
seen.add(voice.name);
if (!voice.main) return { key: 'ttsRefsNoMain', values: { name: voice.name } };
}
for (const path of [voice.main, ...voice.aux]) {
if (path && !files.has(path)) return { key: 'ttsEditorUnknownAudio', values: { file: path } };
}
}
return null;
}
/**
* The settings to save: the previous ones with the voices and the fixed lines written in.
* `aux` / `lines`: the node reads them (interface 18); without, they are left as they were.
*/
export function writeSettings(previous, voices, fixes, { aux = false, lines = false } = {}) {
const next = { ...(isObject(previous) ? previous : {}), format: 1 };
const entry = (voice) => {
const out = { ...voice.kept };
if (voice.main) out.audio = voice.main;
if (aux && voice.aux.length) out.aux = [...voice.aux];
return out;
};
const main = voices.find(voice => voice.name === MAIN);
const reference = main ? entry(main) : {};
if (!aux && Array.isArray(previous?.reference?.aux)) reference.aux = previous.reference.aux;
if (Object.keys(reference).length) next.reference = reference; else delete next.reference;
const emotions = {};
const before = isObject(previous?.emotions) ? previous.emotions : {};
for (const voice of voices) {
if (voice.name === MAIN || !voice.main || (voice.filename && !voice.touched)) continue;
emotions[voice.name] = entry(voice);
if (!aux && Array.isArray(before[voice.name]?.aux)) emotions[voice.name].aux = before[voice.name].aux;
}
if (Object.keys(emotions).length) next.emotions = emotions; else delete next.emotions;
if (lines) {
const table = {};
for (const [path, text] of fixes) if (text.trim()) table[path] = text.trim().slice(0, LINE_LIMIT);
if (Object.keys(table).length) next.lines = table; else delete next.lines;
}
return next;
}
/**
* The clips the list shows, best first for the voice picked: its main reference, its
* auxiliary ones, then the others in folder order. `filter`: 'all' | 'here' (this voice) |
* 'used' (any voice) | 'noline'; `query` matches the path or the line.
*/
export function listClips(audioList, { voice, voices, lineOf, filter = 'all', query = '' }) {
const words = String(query).trim().toLowerCase().split(/\s+/).filter(Boolean);
const matches = path => words.every(word => path.toLowerCase().includes(word) || lineOf(path).toLowerCase().includes(word));
const keep = {
all: () => true,
here: path => Boolean(roleOf(voice, path)),
used: path => voices.some(item => roleOf(item, path)),
noline: path => !lineOf(path),
}[filter] || (() => true);
const rank = (path) => {
const role = roleOf(voice, path);
return role === 'main' ? 0 : role === 'aux' ? 1 : 2;
};
return audioList.filter(path => keep(path) && matches(path))
.map((path, at) => ({ path, at, rank: rank(path) }))
.sort((a, b) => a.rank - b.rank || a.at - b.at)
.map(item => item.path);
}
/** "1 main, 2 aux" for a voice tab, as `{ key, values }`. */
export function voiceCount(voice) {
if (!voice.main) return { key: voice.aux.length ? 'ttsRefsCountAutoAux' : 'ttsRefsCountAuto', values: { aux: voice.aux.length } };
return { key: voice.aux.length ? 'ttsRefsCountAux' : 'ttsRefsCountMain', values: { aux: voice.aux.length } };
}
+32 -15
View File
@@ -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 shows the character in the side panel and makes it the one who reads new lines
* (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 shows it in the side panel (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'));
}
-7
View File
@@ -33,7 +33,6 @@ const SVG = {
GRIP: `<svg width="10" height="14" viewBox="0 0 10 14" fill="currentColor" aria-hidden="true"><circle cx="3" cy="3" r="1.2"/><circle cx="7" cy="3" r="1.2"/><circle cx="3" cy="7" r="1.2"/><circle cx="7" cy="7" r="1.2"/><circle cx="3" cy="11" r="1.2"/><circle cx="7" cy="11" r="1.2"/></svg>`,
CHECK: `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`,
SEARCH: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>`,
SCRIPT: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20"/></svg>`,
PLUS: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>`,
TUNE: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h10"/><path d="M18 6h2"/><circle cx="16" cy="6" r="2"/><path d="M4 12h4"/><path d="M12 12h8"/><circle cx="10" cy="12" r="2"/><path d="M4 18h12"/><circle cx="18" cy="18" r="2"/></svg>`,
PRONOUNCE: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7V5h10v2"/><path d="M9 5v14"/><path d="M7 19h4"/><path d="M15 13h6"/><path d="M18 10v9"/></svg>`,
@@ -246,12 +245,6 @@ function renderCharacterCard(group, owner, { onChanged, canImport = false, tunin
titleGroup.append(...(group.has_main ? [gripIcon()] : []), avatar, nameBox);
const headerRight = document.createElement('div');
headerRight.className = 'anomalous-character-voice-actions';
if (group.has_main && owner?.openScript) {
const voiceOver = createToolButton(SVG.SCRIPT, t('scriptDirectorUseCharacter'), 'is-compact is-accent');
voiceOver.title = t('scriptDirectorUseCharacterHint', { character: group.character });
voiceOver.onclick = () => owner.openScript(group.group);
headerRight.append(voiceOver);
}
if (!group.raw.error) {
const edit = createToolButton(SVG.EDIT, t('ttsEditorOpen'), 'is-compact');
edit.onclick = () => openGptSovitsEditor(group, { onSaved: () => onChanged?.() });
+387 -184
View File
@@ -1,23 +1,34 @@
import { t } from './interface_settings.js';
import { createViewScope } from './ui_lifecycle.js';
import { anomalousAlert } from './ui_dialog.js';
import { fetchGptSovitsCharacter, fetchReferenceLines, mergeGptSovitsSettings, saveGptSovitsSettings, ttsAudioUrl } from './audio_engines.js';
import { createClipSearch } from './ui_tts_clip_search.js';
import { anomalousAlert, anomalousConfirm, anomalousPrompt } from './ui_dialog.js';
import { fetchGptSovitsDetail, fetchReferenceClips, gptSovitsGroups, removeGptSovitsClips, saveGptSovitsSettings, ttsAudioUrl } from './audio_engines.js';
import {
AUX_FORMAT, AUX_VERSIONS, LINE_LIMIT, MAIN, clearMain, fitsMain, listClips, nameProblem, readVoices, roleOf,
setMain, toggleAux, usesOf, voiceCount, voicesProblem, writeSettings,
} from './tts_voice_refs.js';
/**
* GPT-SoVITS (Anomalous_TTS) emotion editor: pick one reference audio from the
* character's folder for the main voice and for each emotion, typed or found by its
* line in the clip search (ui_tts_clip_search.js). The node owns the
* file; this modal only sends the new settings through its API, keeping every
* field it does not edit.
* GPT-SoVITS (Anomalous_TTS) reference audio editor, opened from a character card: the voices
* on top (the main voice and each emotion, "+ New emotion"), and below every clip of the
* character, one row each (listen, file and length, its line, where else it is used), where the
* voice picked gets its **main reference** (its tone; one, with a line, 3~10 s) and its
* **auxiliary references** (their timbre is averaged in; any number, no line needed). A clip's
* line can be fixed right in its row. The rules are in tts_voice_refs.js; the node owns the
* file, this window only sends the new settings through its API, keeping every field it does
* not edit. A clip can be moved to the Recycle Bin from its row (the node moves it with its own
* .txt / .lab and takes it out of the settings). Auxiliary references, fixed lines and removing
* need Anomalous_TTS interface 18 (and auxiliary references a v1 / v2 / v2Pro model); otherwise
* those controls are off or hidden and say why.
*
* The studio list carries no file lists (a folder can hold thousands of clips), so
* the modal opens at once and fetches this character's files (and their lines) in the
* background; saving waits until they are here.
* The window opens at once and reads the character's clips, their lines and lengths in the
* background (a folder can hold a thousand clips: the list shows the first ones, then more).
*/
const SHOWN_FIRST = 60;
const SHOW_MORE = 120;
const FILTERS = ['all', 'here', 'used', 'noline'];
let activeScope = null;
const FORBIDDEN_IN_NAME = /[{}[\]]/;
function el(tag, className, text) {
const node = document.createElement(tag);
@@ -34,41 +45,10 @@ function button(className, label, onClick, title) {
return btn;
}
/** Audio path input with a searchable list of the character's files (folders can hold thousands). */
function audioInput(listId, value) {
const input = el('input', 'anomalous-tts-audio-input');
input.type = 'text';
input.setAttribute('list', listId);
input.placeholder = t('ttsEditorAudioPlaceholder');
input.value = value || '';
// The browser lists only the files matching the box's text, so a filled box offered one or
// two. Pressing it empties the box to list them all (the file shows greyed meanwhile);
// leaving without a pick puts the file back, unless it was deleted with a key.
let kept = '';
input.addEventListener('mousedown', () => {
if (!input.value) return;
kept = input.value;
input.placeholder = kept;
input.value = '';
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Backspace' || e.key === 'Delete') kept = '';
});
input.addEventListener('blur', () => {
if (!input.value) input.value = kept;
kept = '';
input.placeholder = t('ttsEditorAudioPlaceholder');
});
return input;
}
function textInput(value) {
const input = el('input', 'anomalous-tts-text-input');
input.type = 'text';
input.placeholder = t('ttsEditorTextPlaceholder');
input.value = value || '';
return input;
}
const say = ({ key, values }) => t(key, values);
const voiceLabel = voice => (voice.name === MAIN ? t('ttsRefsMainVoice') : voice.name);
const baseName = path => path.split('/').pop();
const folderOf = path => (path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '');
/**
* Open the editor for one GPT-SoVITS voice group (audio_engines.gptSovitsGroups shape).
@@ -80,21 +60,40 @@ export function openGptSovitsEditor(group, { onSaved } = {}) {
activeScope = scope;
const raw = group.raw || {};
const name = raw.name || group.character;
let audioList = [];
let settings = raw.settings && typeof raw.settings === 'object' ? raw.settings : {};
let loaded = false;
const state = {
loaded: false,
format: 0,
version: raw.version || null,
settings: raw.settings && typeof raw.settings === 'object' ? raw.settings : {},
audio: [],
found: new Map(), // path -> the line the node finds (a fixed one first)
seconds: new Map(), // path -> length (interface 18)
voices: [],
fixes: new Map(), // path -> the line fixed here
cleared: new Set(), // fixed lines taken back here (the file's line comes back once saved)
voiceKey: 'main',
filter: 'all',
query: '',
shown: SHOWN_FIRST,
dirty: false,
playing: null, // { path, button }
};
let player = null;
const overlay = el('div', 'anomalous-voice-modal-overlay');
const modal = el('div', 'anomalous-voice-modal anomalous-tts-editor');
const modal = el('div', 'anomalous-voice-modal anomalous-tts-refs');
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
const close = () => scope.dispose();
scope.onDispose(() => {
player?.pause();
overlay.remove();
if (activeScope === scope) activeScope = null;
});
const close = async () => {
if (state.dirty && !await anomalousConfirm(t('ttsRefsCloseUnsaved'))) return;
scope.dispose();
};
overlay.onclick = (e) => { if (e.target === overlay) close(); };
scope.listen(window, 'keydown', (e) => {
if (e.key !== 'Escape' || document.querySelector('.anomalous-dialog-overlay')) return;
@@ -102,178 +101,382 @@ export function openGptSovitsEditor(group, { onSaved } = {}) {
close();
}, true);
// A picked file gets its own line: the one it had in this dialog, the line the node uses for
// it when it is in use, else the one the node finds for it (blank with a node too old to
// tell, which then reads it when it speaks). A blank box shows the line greyed, unsaved.
const knownText = new Map([raw.reference, ...Object.values(raw.emotions || {})]
.filter(ref => ref?.audio && ref.text).map(ref => [ref.audio, ref.text]));
let lines = new Map();
const lineOf = path => knownText.get(path) || lines.get(path) || '';
const linkText = (audio, text) => {
const start = { audio: audio.value.trim(), text: text.value };
const showLine = () => { text.placeholder = lineOf(audio.value.trim()) || t('ttsEditorTextPlaceholder'); };
showLine();
audio.addEventListener('input', () => {
const path = audio.value.trim();
if (!audioList.includes(path)) return;
text.value = path === start.audio ? start.text : lineOf(path);
showLine();
});
const voice = () => state.voices.find(item => item.key === state.voiceKey) || state.voices[0];
const lineOf = path => (state.fixes.has(path) ? state.fixes.get(path) : state.cleared.has(path) ? '' : state.found.get(path) || '');
const canAux = () => state.format >= AUX_FORMAT && (!state.version || AUX_VERSIONS.has(state.version));
const canFix = () => state.format >= AUX_FORMAT;
const changed = () => {
state.dirty = true;
saveBtn.disabled = !state.loaded;
};
const playPath = (path) => {
// ---------- listening ----------
function stopPlaying() {
player?.pause();
if (!audioList.includes(path)) return;
player = null;
if (state.playing) state.playing.button.classList.remove('is-playing');
state.playing = null;
}
function play(path, btn) {
const again = state.playing?.path === path;
stopPlaying();
if (again) return;
player = new Audio(ttsAudioUrl(name, path));
player.play().catch(() => {});
};
const play = (input) => playPath(input.value.trim());
state.playing = { path, button: btn };
btn.classList.add('is-playing');
const done = () => { if (state.playing?.path === path) stopPlaying(); };
player.onended = done;
player.onerror = done;
player.play().catch(done);
}
// ---------- header ----------
// Header
const header = el('div', 'anomalous-voice-modal-header');
const heading = el('div', 'anomalous-voice-modal-heading');
heading.append(
el('div', 'anomalous-voice-modal-title', t('ttsEditorTitle', { name })),
el('div', 'anomalous-voice-modal-subtitle', t('ttsEditorSubtitle')),
el('div', 'anomalous-voice-modal-title', t('ttsRefsTitle', { name })),
el('div', 'anomalous-voice-modal-subtitle', t('ttsRefsSubtitle')),
);
header.append(heading, button('anomalous-voice-modal-close', '×', close, t('close')));
const listId = `anomalous-tts-audio-${Math.random().toString(36).slice(2)}`;
const datalist = el('datalist');
datalist.id = listId;
const status = el('div', 'anomalous-tts-hint anomalous-tts-status', t('ttsEditorLoadingFiles'));
// Main reference
const mainRow = el('div', 'anomalous-tts-row is-main');
const mainAudio = audioInput(listId, settings.reference?.audio);
const mainText = textInput(settings.reference?.text);
linkText(mainAudio, mainText);
mainRow.append(
el('span', 'anomalous-tts-row-name', '{main}'),
mainAudio,
button('anomalous-audio-play-btn', '▶', () => play(mainAudio), t('audioPlay')),
mainText,
);
const mainHint = el('div', 'anomalous-tts-hint', raw.reference?.audio && !settings.reference?.audio
? t('ttsEditorMainAuto', { file: raw.reference.audio })
: t('ttsEditorMainHint'));
// ---------- the voices ----------
// Emotion rows
const rowsBox = el('div', 'anomalous-tts-rows');
const addRow = (row = {}) => {
const line = el('div', 'anomalous-tts-row');
const nameInput = el('input', 'anomalous-tts-name-input');
nameInput.type = 'text';
nameInput.placeholder = t('ttsEditorEmotionPlaceholder');
nameInput.value = row.name || '';
const audio = audioInput(listId, row.audio);
const text = textInput(row.text);
linkText(audio, text);
line.append(
nameInput,
audio,
button('anomalous-audio-play-btn', '▶', () => play(audio), t('audioPlay')),
text,
button('anomalous-tts-remove', '×', () => line.remove(), t('ttsEditorRemove')),
);
rowsBox.appendChild(line);
return nameInput;
};
for (const [emotion, ref] of Object.entries(settings.emotions || {})) {
addRow({ name: emotion, audio: ref?.audio, text: ref?.text });
const voicesBar = el('div', 'anomalous-tts-refs-voices');
const voiceTools = el('div', 'anomalous-tts-refs-voice-tools');
function renderVoices() {
const tabs = state.voices.map((item) => {
const tab = button(`anomalous-tts-refs-voice${item.key === state.voiceKey ? ' is-current' : ''}`, '', () => {
state.voiceKey = item.key;
state.shown = SHOWN_FIRST;
renderAll();
});
tab.append(el('span', 'anomalous-tts-refs-voice-name', voiceLabel(item)), el('span', 'anomalous-tts-refs-voice-count', say(item.name !== MAIN && !item.main ? { key: 'ttsRefsCountNone' } : voiceCount(item))));
if (item.name !== MAIN && !item.main) tab.classList.add('is-missing');
return tab;
});
tabs.push(button('anomalous-tts-refs-voice is-new', `+ ${t('ttsRefsNewEmotion')}`, addEmotion));
voicesBar.replaceChildren(...tabs);
const current = voice();
const tools = [];
if (current?.name === MAIN) {
tools.push(el('span', '', current.main ? t('ttsRefsMainHint') : t('ttsRefsMainAuto', { file: raw.reference?.audio ? baseName(raw.reference.audio) : '—' })));
if (current.main) tools.push(button('anomalous-sd-link-btn', t('ttsRefsBackToAuto'), () => { clearMain(current); changed(); renderAll(); }));
} else if (current) {
tools.push(el('span', '', current.filename ? t('ttsRefsFromFilename') : t('ttsRefsEmotionHint', { name: current.name })));
if (!current.filename) {
tools.push(
button('anomalous-sd-link-btn', t('ttsRefsRename'), () => renameEmotion(current)),
button('anomalous-sd-link-btn is-danger', t('ttsRefsDelete'), () => deleteEmotion(current)),
);
}
}
voiceTools.replaceChildren(...tools);
}
const addBtn = button('anomalous-tts-add', t('ttsEditorAdd'), () => addRow().focus());
const search = createClipSearch({
play: playPath,
onMain: (path) => {
mainAudio.value = path;
mainAudio.dispatchEvent(new Event('input'));
},
onEmotion: (path) => addRow({ audio: path, text: lineOf(path) }).focus(),
async function addEmotion() {
const value = await anomalousPrompt(t('ttsRefsNewEmotionPrompt'), '', t('ttsRefsNewEmotion'));
if (value === null || value === undefined) return;
const problem = nameProblem(value, state.voices);
if (problem) return anomalousAlert(t(problem, { name: String(value).trim(), file: '' }));
const item = { key: `e:${value.trim()}:${Date.now()}`, name: value.trim(), main: null, aux: [], kept: {}, filename: false, touched: true };
state.voices.push(item);
state.voiceKey = item.key;
state.filter = 'all';
changed();
renderAll();
}
async function renameEmotion(item) {
const value = await anomalousPrompt(t('ttsRefsRenamePrompt'), item.name, t('ttsRefsRename'));
if (value === null || value === undefined || value.trim() === item.name) return;
const problem = nameProblem(value, state.voices, item);
if (problem) return anomalousAlert(t(problem, { name: String(value).trim(), file: '' }));
item.name = value.trim();
changed();
renderAll();
}
async function deleteEmotion(item) {
if (!await anomalousConfirm(t('ttsRefsDeleteConfirm', { name: item.name }))) return;
state.voices = state.voices.filter(other => other !== item);
state.voiceKey = 'main';
changed();
renderAll();
}
// ---------- the clips ----------
const toolbar = el('div', 'anomalous-tts-refs-toolbar');
const filters = el('div', 'anomalous-tts-refs-filters');
const search = el('input', 'anomalous-tts-refs-search');
search.type = 'search';
search.placeholder = t('ttsRefsSearch');
search.addEventListener('input', () => {
state.query = search.value;
state.shown = SHOWN_FIRST;
renderList();
});
toolbar.append(filters, search);
const list = el('div', 'anomalous-tts-refs-list');
const note = el('div', 'anomalous-tts-hint anomalous-tts-refs-note');
const autoEmotions = Object.entries(raw.emotions || {}).filter(([, ref]) => ref?.source === 'filename').map(([emotion]) => `{${emotion}}`);
const autoNote = el('div', 'anomalous-tts-hint', autoEmotions.length ? t('ttsEditorFilenameEmotions', { emotions: autoEmotions.join(' ') }) : '');
autoNote.hidden = !autoEmotions.length;
function renderFilters() {
const current = voice();
const count = {
all: state.audio.length,
here: current ? [current.main, ...current.aux].filter(Boolean).length : 0,
used: state.audio.filter(path => state.voices.some(item => roleOf(item, path))).length,
noline: state.audio.filter(path => !lineOf(path)).length,
};
filters.replaceChildren(...FILTERS.map(id => button(
`anomalous-tts-refs-filter${state.filter === id ? ' is-on' : ''}`,
t(`ttsRefsFilter_${id}`, { count: count[id] }),
() => { state.filter = id; state.shown = SHOWN_FIRST; renderList(); renderFilters(); },
)));
}
// Footer
const footer = el('div', 'anomalous-voice-modal-footer');
const saveBtn = button('anomalous-voice-modal-submit', t('ttsEditorSave'), async () => {
if (!loaded) return;
const rows = [...rowsBox.querySelectorAll('.anomalous-tts-row')].map(line => {
const [nameInput, audio, , text] = line.querySelectorAll('input, button');
return { name: nameInput.value.trim(), audio: audio.value.trim(), text: text.value.trim() };
}).filter(row => row.name || row.audio);
const problem = validateRows(rows, mainAudio.value.trim(), audioList);
if (problem) {
await anomalousAlert(problem);
function lineBox(path) {
const input = el('input', 'anomalous-tts-refs-line');
input.type = 'text';
input.maxLength = LINE_LIMIT;
input.value = lineOf(path);
input.placeholder = state.cleared.has(path) ? t('ttsRefsLineBack') : t('ttsRefsNoLine');
input.disabled = !canFix();
if (!canFix()) input.title = t('ttsRefsNeedsUpdate');
input.classList.toggle('is-fixed', state.fixes.has(path));
input.addEventListener('change', () => {
const text = input.value.trim();
if (!text) {
if (state.fixes.delete(path)) state.cleared.add(path);
} else if (text !== lineOf(path)) {
state.fixes.set(path, text);
state.cleared.delete(path);
} else {
return;
}
changed();
renderRow(path);
renderFilters();
});
return input;
}
const rows = new Map();
function rowFor(path) {
const current = voice();
const role = roleOf(current, path);
const seconds = state.seconds.get(path) || 0;
const row = el('div', `anomalous-tts-refs-row${role ? ` is-${role}` : ''}`);
const playBtn = button('anomalous-audio-play-btn', '▶', () => play(path, playBtn), t('audioPlay'));
if (state.playing?.path === path) {
playBtn.classList.add('is-playing');
state.playing.button = playBtn;
}
const file = el('div', 'anomalous-tts-refs-file');
const nameLine = el('div', 'anomalous-tts-refs-file-name', baseName(path));
nameLine.title = path;
if (seconds) {
const length = el('span', `anomalous-tts-refs-seconds${fitsMain(seconds) ? '' : ' is-off'}`, t('ttsRefsSeconds', { seconds: seconds.toFixed(1) }));
if (!fitsMain(seconds)) length.title = t('ttsRefsTooLongShort');
nameLine.append(length);
}
file.append(nameLine);
if (folderOf(path)) file.append(el('div', 'anomalous-tts-refs-file-folder', folderOf(path)));
const middle = el('div', 'anomalous-tts-refs-middle');
middle.append(lineBox(path));
const uses = usesOf(state.voices, path, current);
if (current?.name === MAIN && !current.main && raw.reference?.audio === path) uses.unshift({ name: MAIN, role: 'auto' });
if (uses.length) {
const tags = el('div', 'anomalous-tts-refs-uses');
tags.append(...uses.map(use => el('span', `anomalous-tts-refs-use is-${use.role}`, t(`ttsRefsUse_${use.role}`, { name: use.name === MAIN ? t('ttsRefsMainVoice') : use.name }))));
middle.append(tags);
}
const roles = el('div', 'anomalous-tts-refs-roles');
const mainBtn = button(`anomalous-tts-refs-role${role === 'main' ? ' is-on' : ''}`, role === 'main' ? `◉ ${t('ttsRefsMain')}` : `○ ${t('ttsRefsMain')}`, () => {
if (role === 'main') {
if (current.name !== MAIN) return;
clearMain(current);
} else {
setMain(current, path);
}
changed();
renderAll(false);
}, role === 'main' && current?.name === MAIN ? t('ttsRefsMainOffHint') : t('ttsRefsMainHintRow'));
mainBtn.disabled = !current || (!fitsMain(seconds) && role !== 'main');
if (!fitsMain(seconds) && role !== 'main') mainBtn.title = t('ttsRefsTooLongShort');
const auxBtn = button(`anomalous-tts-refs-role is-aux${role === 'aux' ? ' is-on' : ''}`, role === 'aux' ? `☑ ${t('ttsRefsAux')}` : `☐ ${t('ttsRefsAux')}`, () => {
toggleAux(current, path);
changed();
renderAll(false);
}, t('ttsRefsAuxHintRow'));
auxBtn.disabled = !current || !canAux() || role === 'main';
if (!canAux()) auxBtn.title = auxReason();
roles.append(mainBtn, auxBtn);
if (canFix()) {
roles.append(button('anomalous-tts-refs-remove', '🗑', () => removeClip(path), t('ttsRefsRemove')));
}
row.append(playBtn, file, middle, roles);
return row;
}
/** One clip to the Recycle Bin (after asking), then out of this window's voices too. */
async function removeClip(path) {
const uses = usesOf(state.voices, path).map(use => t(`ttsRefsUse_${use.role}`, { name: use.name === MAIN ? t('ttsRefsMainVoice') : use.name }));
const message = [
t('ttsRefsRemoveConfirm', { file: baseName(path) }),
uses.length ? t('ttsRefsRemoveUses', { uses: uses.join('、') }) : '',
].filter(Boolean).join('\n');
if (!await anomalousConfirm(message, t('ttsRefsRemove'))) return;
if (state.playing?.path === path) stopPlaying();
let answer;
try {
answer = await removeGptSovitsClips(name, [path]);
} catch (e) {
if (!scope.signal.aborted) await anomalousAlert(t('ttsRefsRemoveFailed', { error: e.message }));
return;
}
const next = mergeGptSovitsSettings(settings, {
main: mainAudio.value.trim() ? { audio: mainAudio.value.trim(), text: mainText.value.trim() } : null,
emotions: rows,
if (scope.signal.aborted) return;
state.audio = state.audio.filter(item => item !== path);
state.found.delete(path);
state.seconds.delete(path);
state.fixes.delete(path);
state.cleared.delete(path);
// The node took it out of the saved settings; an emotion it was the main reference of went
// there, and goes here too unless it was changed in this window (then it waits for another).
state.voices = state.voices.filter(item => !(item.main === path && item.name !== MAIN && !item.touched));
for (const item of state.voices) {
item.aux = item.aux.filter(other => other !== path);
if (item.main === path) item.main = null;
}
if (!state.voices.some(item => item.key === state.voiceKey)) state.voiceKey = 'main';
const after = answer?.character;
if (after?.settings && typeof after.settings === 'object') state.settings = after.settings;
if (after) raw.reference = after.reference;
status.textContent = t('ttsRefsRemoved', { file: baseName(path), count: state.audio.length });
order = order.filter(item => item !== path);
renderAll(false);
onSaved?.(after ? gptSovitsGroups({ characters: [after] })[0] : null);
}
function renderRow(path) {
const old = rows.get(path);
if (!old) return;
const next = rowFor(path);
old.replaceWith(next);
rows.set(path, next);
}
// The order is set when the voice, the filter or the search changes, not on each click,
// so a row does not move away under the pointer.
let order = [];
function renderList(reorder = true) {
if (!state.loaded) return;
if (reorder) order = listClips(state.audio, { voice: voice(), voices: state.voices, lineOf, filter: state.filter, query: state.query });
rows.clear();
const shown = order.slice(0, state.shown);
const items = shown.map((path) => {
const row = rowFor(path);
rows.set(path, row);
return row;
});
if (!order.length) items.push(el('div', 'anomalous-tts-refs-empty', t('ttsRefsNothing')));
if (order.length > shown.length) {
items.push(button('anomalous-tts-refs-more', t('ttsRefsShowMore', { count: order.length - shown.length }), () => {
state.shown += SHOW_MORE;
renderList(false);
}));
}
list.replaceChildren(...items);
}
function auxReason() {
if (state.format < AUX_FORMAT) return t('ttsRefsNeedsUpdate');
return t('ttsRefsAuxVersion', { version: state.version });
}
function renderNote() {
const parts = [t('ttsRefsNote')];
if (!canAux()) parts.push(auxReason());
note.textContent = parts.join(' ');
}
function renderAll(reorder = true) {
renderVoices();
renderFilters();
renderList(reorder);
renderNote();
}
// ---------- saving ----------
const footer = el('div', 'anomalous-voice-modal-footer');
const saveBtn = button('anomalous-voice-modal-submit', t('ttsEditorSave'), async () => {
if (!state.loaded) return;
const problem = voicesProblem(state.voices, state.audio);
if (problem) {
const missing = state.voices.find(item => item.name === problem.values?.name && !item.main);
if (missing) {
state.voiceKey = missing.key;
renderAll();
}
await anomalousAlert(say(problem));
return;
}
const next = writeSettings(state.settings, state.voices, state.fixes, { aux: state.format >= AUX_FORMAT, lines: canFix() });
saveBtn.disabled = true;
try {
const updated = await saveGptSovitsSettings(name, next);
state.dirty = false;
onSaved?.(updated);
close();
scope.dispose();
} catch (e) {
if (!scope.signal.aborted) await anomalousAlert(t('ttsEditorSaveFailed', { error: e.message }));
} finally {
if (!scope.signal.aborted) saveBtn.disabled = !loaded;
if (!scope.signal.aborted) saveBtn.disabled = !state.loaded;
}
});
footer.append(button('anomalous-voice-modal-cancel', t('dialogCancel'), close), saveBtn);
const status = el('div', 'anomalous-tts-hint anomalous-tts-status', t('ttsEditorLoadingFiles'));
saveBtn.disabled = true;
footer.append(note, button('anomalous-voice-modal-cancel', t('dialogCancel'), close), saveBtn);
Promise.all([
fetchGptSovitsCharacter(name, scope.signal),
fetchReferenceLines(name, scope.signal).catch(() => null), // the lines only help: never block the files
]).then(([detail, found]) => {
fetchGptSovitsDetail(name, scope.signal),
fetchReferenceClips(name, scope.signal).catch(() => null), // the lines only help: never block the files
]).then(([{ character, format }, clips]) => {
if (scope.signal.aborted) return;
audioList = Array.isArray(detail.audio) ? detail.audio : [];
lines = found || new Map();
search.setClips(audioList, found);
state.format = format;
state.version = character.version || state.version;
state.audio = Array.isArray(character.audio) ? character.audio : [];
// The detail is read from disk now: keep its unknown fields when saving.
if (detail.settings && typeof detail.settings === 'object') settings = detail.settings;
datalist.replaceChildren(...audioList.map(path => { const option = el('option'); option.value = path; return option; }));
status.textContent = t('ttsEditorFileCount', { count: audioList.length });
loaded = true;
if (character.settings && typeof character.settings === 'object') state.settings = character.settings;
state.found = clips?.lines || new Map();
state.seconds = clips?.seconds || new Map();
({ voices: state.voices, fixes: state.fixes } = readVoices(state.settings, character, { lineFixes: canFix(), found: state.found }));
status.textContent = t('ttsEditorFileCount', { count: state.audio.length });
state.loaded = true;
saveBtn.disabled = false;
renderAll();
}).catch(e => {
if (scope.signal.aborted) return;
status.textContent = t('ttsEditorLoadFailed', { error: e.message || String(e) });
status.classList.add('is-error');
});
const body = el('div', 'anomalous-tts-editor-body');
body.append(
status,
search.element,
datalist,
el('div', 'anomalous-tts-section', t('ttsEditorMainSection')), mainRow, mainHint,
el('div', 'anomalous-tts-section', t('ttsEditorEmotionSection')), rowsBox, addBtn, autoNote,
);
modal.append(header, body, footer);
const top = el('div', 'anomalous-tts-refs-top');
top.append(status, voicesBar, voiceTools, toolbar);
modal.append(header, top, list, footer);
overlay.appendChild(modal);
document.body.appendChild(overlay);
(mainAudio.value ? addBtn : mainAudio).focus();
return close;
}
/** First problem as a message, or '' when the rows can be saved. */
export function validateRows(rows, mainAudio, audioList) {
const files = new Set(audioList);
if (mainAudio && !files.has(mainAudio)) return t('ttsEditorUnknownAudio', { file: mainAudio });
const seen = new Set();
for (const row of rows) {
if (!row.name) return t('ttsEditorNameMissing', { file: row.audio });
if (row.name === 'main' || FORBIDDEN_IN_NAME.test(row.name)) return t('ttsEditorNameInvalid', { name: row.name });
if (seen.has(row.name)) return t('ttsEditorNameDuplicate', { name: row.name });
seen.add(row.name);
if (!row.audio) return t('ttsEditorAudioMissing', { name: row.name });
if (!files.has(row.audio)) return t('ttsEditorUnknownAudio', { file: row.audio });
}
return '';
search.focus();
return () => scope.dispose();
}
+3 -1
View File
@@ -59,7 +59,7 @@ export function closeWorkspace() {
}
// The back buttons of the pages one level down: a model's details, one combo, one recipe.
const BACK_BUTTONS = '.anomalous-detail-back, .anomalous-nb-back, .anomalous-wfd-back';
const BACK_BUTTONS = '.anomalous-detail-back, .anomalous-nb-back, .anomalous-wfd-back, .anomalous-sd-tune-back';
/**
* Esc on the browser: a page one level down (a model's details, one combo or one recipe) goes
@@ -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) {
+158
View File
@@ -0,0 +1,158 @@
/**
* Settings -> Canvas -> Right-click menu: where AMB's entries sit, as two cards each drawing a
* small menu (one "AMB" line opening them, or them listed in ComfyUI's menu), and, for a node's
* menu and the empty canvas's, each entry on or off and moved up or down, with a line showing
* what the menu will offer, and Reset. Saved at once (canvas_menu_settings.js); the menu reads
* it each time it opens.
*/
import { translate as t } from './locales.js';
import { menuSettings, moveEntry, resetMenuSettings, saveMenuSettings } from './canvas_menu_settings.js';
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, onClick) {
const node = el('button', className, label);
node.type = 'button';
node.onclick = onClick;
return node;
}
const entryName = entry => t(`canvasMenuEntry_${entry.id}`);
/** What the menu will offer, in order: the entries switched on, as chips. */
function previewLine(settings, which) {
const line = el('div', 'anomalous-menu-preview');
const draw = () => {
const on = settings[which].filter(entry => entry.on);
line.replaceChildren(el('span', 'anomalous-menu-preview-label', t('canvasMenuPreview')));
if (!on.length) {
line.append(el('span', 'anomalous-menu-preview-none', t('canvasMenuPreviewNone')));
return;
}
line.append(...on.map(entry => el('span', 'anomalous-menu-preview-chip', entryName(entry))));
if (which === 'node') line.append(el('span', 'anomalous-menu-preview-note', t('canvasMenuPreviewNote')));
};
line.redraw = draw;
draw();
return line;
}
/** A small menu drawn in a card: ComfyUI's own lines, then AMB's (`amb`: 'submenu' or 'top'). */
function sketch(settings, amb) {
const box = el('span', `anomalous-menu-sketch is-${amb}`);
const menu = el('span', 'anomalous-menu-sketch-menu');
for (const word of t('canvasMenuSketchComfy').split('|')) menu.append(el('span', 'anomalous-menu-sketch-line is-comfy', word));
const ours = settings.node.filter(entry => entry.on).slice(0, 3).map(entryName);
if (!ours.length) ours.push(t('canvasMenuEntry_open'));
if (amb === 'top') {
menu.append(el('span', 'anomalous-menu-sketch-rule'), ...ours.map(name => el('span', 'anomalous-menu-sketch-line is-ours', name)));
box.append(menu);
return box;
}
menu.append(el('span', 'anomalous-menu-sketch-line is-amb', `${t('canvasMenuTitle')} ▸`));
const sub = el('span', 'anomalous-menu-sketch-menu is-sub');
sub.append(...ours.map(name => el('span', 'anomalous-menu-sketch-line', name)));
box.append(menu, sub);
return box;
}
/** The two placement cards; a click picks one. */
function placementCards(settings, redraw) {
const cards = el('div', 'anomalous-menu-places');
cards.setAttribute('role', 'radiogroup');
cards.setAttribute('aria-label', t('canvasMenuPlacement'));
for (const value of ['submenu', 'top']) {
const picked = settings.placement === value;
const card = button(`anomalous-menu-place${picked ? ' is-picked' : ''}`, undefined, () => {
if (picked) return;
settings.placement = value;
saveMenuSettings(settings);
redraw();
});
card.setAttribute('role', 'radio');
card.setAttribute('aria-checked', String(picked));
const head = el('span', 'anomalous-menu-place-head');
head.append(el('span', 'anomalous-menu-place-mark', picked ? '✓' : ''), el('span', 'anomalous-menu-place-title', t(`canvasMenuPlace_${value}`)));
card.append(head, sketch(settings, value), el('span', 'anomalous-menu-place-help', t(`canvasMenuPlaceHelp_${value}`)));
cards.append(card);
}
return cards;
}
/** The rows of one menu's entries (`which`: 'node' or 'canvas'); `changed()` after a switch. */
function entryList(settings, which, redraw, changed) {
const list = el('div', 'anomalous-menu-entries');
list.setAttribute('aria-label', t(`canvasMenuList_${which}`));
const entries = settings[which];
entries.forEach((entry, index) => {
const line = el('div', `anomalous-menu-entry${entry.on ? '' : ' is-off'}`);
const copy = el('span', 'anomalous-menu-entry-copy');
copy.append(el('span', 'anomalous-menu-entry-name', t(`canvasMenuEntry_${entry.id}`)),
el('small', 'anomalous-menu-entry-help', t(`canvasMenuEntryHelp_${entry.id}`)));
const move = (step) => {
settings[which] = moveEntry(entries, entry.id, step);
saveMenuSettings(settings);
redraw();
};
const up = button('anomalous-menu-entry-move', '↑', () => move(-1));
up.disabled = index === 0;
up.title = t('canvasMenuUp');
up.setAttribute('aria-label', `${t('canvasMenuUp')}: ${t(`canvasMenuEntry_${entry.id}`)}`);
const down = button('anomalous-menu-entry-move', '↓', () => move(1));
down.disabled = index === entries.length - 1;
down.title = t('canvasMenuDown');
down.setAttribute('aria-label', `${t('canvasMenuDown')}: ${t(`canvasMenuEntry_${entry.id}`)}`);
const toggle = el('input', 'anomalous-scan-switch');
toggle.type = 'checkbox';
toggle.checked = entry.on;
toggle.setAttribute('aria-label', t(`canvasMenuEntry_${entry.id}`));
toggle.onchange = () => {
entry.on = toggle.checked;
line.classList.toggle('is-off', !entry.on);
saveMenuSettings(settings);
changed();
};
line.append(copy, up, down, toggle);
list.append(line);
});
return list;
}
/**
* The group's rows: `row(titleKey, helpKey, control)` and `segment(choices, current, onPick)`
* are the settings page's own helpers; `redraw()` draws the page again.
*/
export function canvasMenuRows({ row, redraw }) {
const settings = menuSettings();
let places = placementCards(settings, redraw);
const nodePreview = previewLine(settings, 'node');
const canvasPreview = previewLine(settings, 'canvas');
// A switch changes what the cards' small menus and the preview lines show.
const nodeChanged = () => {
nodePreview.redraw();
const next = placementCards(settings, redraw);
places.replaceWith(next);
places = next;
};
const reset = row('canvasMenuReset', 'canvasMenuResetHelp', button('anomalous-scan-secondary anomalous-scan-small-btn', t('settingsReset'), () => {
resetMenuSettings();
redraw();
}));
return [
el('h4', 'anomalous-menu-entries-title is-first', t('canvasMenuPlacement')),
places,
el('h4', 'anomalous-menu-entries-title', t('canvasMenuList_node')),
nodePreview,
entryList(settings, 'node', redraw, nodeChanged),
el('h4', 'anomalous-menu-entries-title', t('canvasMenuList_canvas')),
canvasPreview,
entryList(settings, 'canvas', redraw, () => canvasPreview.redraw()),
reset,
];
}
+175
View File
@@ -0,0 +1,175 @@
/**
* The head of a combo's editor (ui_notebook_editor.js, and the node-structure combos of
* ui_combo_structure.js): its picture (the output image it was kept from, with See the
* picture; else its main model's cover), its name with a Rename button (a click on the name
* renames too; saved at once), what it holds as small chips (base model, main model, LoRAs;
* a structure's models), Put on canvas and More (delete, asked twice). Every change in the
* editor saves itself, so there is no Save button.
*/
import { translate as t } from './locales.js';
import { anomalousPrompt } from './ui_dialog.js';
import { outputThumbnail } from './ui_kept_toast.js';
import { STRUCTURED } from './combo_slots.js';
import { structuredComboMeta } from './ui_combo_structure.js';
import { UNLABELED_BASE_MODEL } from './notebook_canvas.js';
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, onClick) {
const node = el('button', className, label);
node.type = 'button';
node.onclick = onClick;
return node;
}
/** A model's name as its card shows it. */
export const comboModelName = model => String(model?.metadata?.custom_name || '').trim()
|| String(model?.filename || '').split(/[\\/]/).pop().replace(/\.[^.]+$/, '');
/** A model's cover at card size ('' without one; a video's still frame). */
export function comboModelCover(model) {
const url = model?.preview_url;
if (!url) return '';
return `${url}${url.includes('?') ? '&' : '?'}${/\.(mp4|webm)(?:&|$)/i.test(url) ? 'variant=poster' : 'variant=card'}`;
}
/** The chips: what the combo holds. */
function chipsOf(data) {
if (data.kind === STRUCTURED) {
const meta = structuredComboMeta(data);
return [[meta.meta, ''], [meta.hint, 'is-warn']].filter(([text]) => text);
}
const base = data.baseModel === UNLABELED_BASE_MODEL ? t('notebookUnlabeledBase') : data.baseModel;
const loras = (data.loras || []).length;
return [
[base, 'is-base'],
[data.mainModel ? comboModelName(data.mainModel) : t('comboNoMainModel'), data.mainModel ? '' : 'is-warn'],
[loras ? t('comboLoraCount', { count: loras }) : t('comboNoLora'), ''],
].filter(([text]) => text);
}
/** More: delete, which asks once more on the button itself. */
function moreMenu(ctx, hero) {
const wrapper = el('div', 'anomalous-nb-dropdown-wrapper');
const more = button('anomalous-nb-more-btn anomalous-combo-hero-more', `${t('notebookMore')} ▾`, (event) => {
event.stopPropagation();
const open = menu.classList.toggle('show');
more.classList.toggle('active', open);
if (!open) reset();
});
const menu = el('div', 'anomalous-nb-dropdown-menu');
const remove = button('anomalous-nb-dropdown-item anomalous-nb-dropdown-item-danger', `🗑️ ${t('deleteNotebook')}`, (event) => {
event.stopPropagation();
if (!remove.classList.contains('confirming')) {
remove.classList.add('confirming');
remove.textContent = `⚠️ ${t('delSure').replace(/^⚠️\s*/, '')}`;
timer = setTimeout(reset, 4000);
return;
}
reset();
close();
ctx.deleteCurrentNotebook();
});
let timer = 0;
const reset = () => {
clearTimeout(timer);
remove.classList.remove('confirming');
remove.textContent = `🗑️ ${t('deleteNotebook')}`;
};
const close = () => {
menu.classList.remove('show');
more.classList.remove('active');
};
const onClick = (event) => {
if (!hero.isConnected) {
document.removeEventListener('click', onClick);
return;
}
if (!wrapper.contains(event.target)) {
close();
reset();
}
};
document.addEventListener('click', onClick);
menu.append(remove);
wrapper.append(more, menu);
return wrapper;
}
/** The head for `notebook` ({ filename, name, data }); `ctx` is the browser. */
export function createComboHero(ctx, notebook) {
const data = notebook.data || {};
const hero = el('header', 'anomalous-combo-hero');
const image = data.source_image?.filename ? data.source_image : null;
const pictureBox = el('div', 'anomalous-combo-hero-picture');
const frame = image ? button('anomalous-combo-hero-frame is-image', undefined, () => openImage()) : el('div', 'anomalous-combo-hero-frame');
if (image) frame.title = t('comboFromImageOpen');
const openImage = () => {
const query = new URLSearchParams({ filename: image.filename, subfolder: image.subfolder || '' });
ctx.showGalleryViewer?.(`/view?${query}&type=output`);
};
const drawPicture = () => {
const src = image ? outputThumbnail(image)
: data.kind === STRUCTURED ? structuredComboMeta(data).cover : comboModelCover(data.mainModel);
frame.replaceChildren();
if (!src) {
frame.append(el('span', 'anomalous-combo-hero-empty', '🧩'));
return;
}
const img = el('img');
img.alt = '';
img.src = src;
img.onerror = () => frame.replaceChildren(el('span', 'anomalous-combo-hero-empty', '🧩'));
frame.append(img);
};
pictureBox.append(frame);
if (image) pictureBox.append(button('anomalous-combo-hero-view', `🖼 ${t('comboSeePicture')}`, openImage));
const info = el('div', 'anomalous-combo-hero-info');
const eyebrow = el('span', 'anomalous-combo-hero-eyebrow', image ? t('comboEyebrowKept') : t('comboEyebrow'));
const nameRow = el('div', 'anomalous-combo-hero-name-row');
const name = el('h2', 'anomalous-combo-hero-name', notebook.name);
name.title = t('comboRenameHint');
const renameButton = button('anomalous-combo-hero-rename', `✎ ${t('comboRename')}`, () => void rename());
const rename = async () => {
const wanted = String(await anomalousPrompt(t('comboRenameAsk'), notebook.name, t('comboRename')) || '').trim().slice(0, 100);
if (!wanted || wanted === notebook.name) return;
const before = notebook.name;
// In place: the editor below holds this same data object.
const named = (value) => {
notebook.name = value;
data.name = value;
name.textContent = value;
};
named(wanted);
if (!await ctx.saveCurrentNotebook()) named(before);
};
name.onclick = () => void rename();
nameRow.append(name, renameButton);
const chips = el('div', 'anomalous-combo-hero-chips');
const drawChips = () => chips.replaceChildren(...chipsOf(data).map(([text, kind]) => el('span', `anomalous-combo-hero-chip ${kind}`.trim(), text)));
const actions = el('div', 'anomalous-combo-hero-actions');
const use = button('anomalous-btn-success anomalous-combo-hero-use', t('comboUse'), () => ctx.sendNotebookToCanvas());
use.title = t('comboUseHint');
actions.append(use, moreMenu(ctx, hero));
info.append(eyebrow, nameRow, chips, actions);
hero.append(pictureBox, info);
// The models part below calls this after each change.
ctx.updateNotebookModelsSummary = () => {
drawChips();
if (!image) drawPicture();
};
drawPicture();
drawChips();
return hero;
}
+208
View File
@@ -0,0 +1,208 @@
/**
* The models part of a combo's editor (ui_notebook_editor.js): the base model, the main model
* and the LoRAs as short lists of what is chosen (cover, name, base model), with Change / Add
* opening a grid of the compatible ones (GET /anomalous/compatible_models, asked only when a
* grid opens). Choosing saves at once and tells the head (ui_combo_hero.js) to redraw its chips.
*/
import { translate as t } from './locales.js';
import { isUnetModel, UNLABELED_BASE_MODEL } from './notebook_canvas.js';
import { createLoraNodeChoice } from './ui_lora_node_choice.js';
import { comboModelCover, comboModelName } from './ui_combo_hero.js';
const FALLBACK_BASES = ['SD 1.5', 'SDXL', 'Pony', 'Illustrious', 'Flux.1'];
const MAIN_TYPES = 'checkpoints,unet,diffusion_models';
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, onClick) {
const node = el('button', className, label);
node.type = 'button';
node.onclick = onClick;
return node;
}
function cover(model, className) {
const box = el('span', className);
const src = comboModelCover(model);
if (src) {
const img = el('img');
img.alt = '';
img.loading = 'lazy';
img.src = src;
img.onerror = () => img.remove();
box.append(img);
}
return box;
}
/** One chosen model: cover, name, its base model and file; `extra` controls at the end. */
function pickedRow(model, number, ...extra) {
const row = el('div', 'anomalous-combo-pick');
if (number) row.append(el('span', 'anomalous-combo-pick-number', String(number)));
const copy = el('span', 'anomalous-combo-pick-copy');
copy.append(el('span', 'anomalous-combo-pick-name', comboModelName(model)),
el('span', 'anomalous-combo-pick-detail', [model.metadata?.baseModel, model.filename].filter(Boolean).join(' · ')));
copy.title = model.filename || '';
row.append(cover(model, 'anomalous-combo-pick-cover'), copy, ...extra);
return row;
}
/** A grid of the compatible models of `types`, read once; `isPicked(model)`, `onPick(model)`. */
function pickerGrid(baseModel, types, { isPicked, onPick, number }) {
const grid = el('div', 'anomalous-nb-gallery-wrap anomalous-combo-picker');
let models = null;
const draw = () => {
if (!models) return;
grid.replaceChildren();
if (!models.length) {
grid.append(el('span', 'anomalous-combo-picker-empty', t('comboPickerNone')));
return;
}
for (const model of models) {
const picked = isPicked(model);
const card = button(`anomalous-nb-minicheck${picked ? ' selected' : ''}`, undefined, () => {
onPick(model);
draw();
});
card.title = model.filename;
const at = picked && number ? number(model) : 0;
if (at) card.append(el('span', 'anomalous-combo-picker-number', String(at)));
const thumb = cover(model, 'anomalous-combo-picker-cover');
if (!thumb.childElementCount) thumb.append(el('span', 'anomalous-nb-mini-empty', '?'));
card.append(thumb, el('span', 'anomalous-nb-minicheck-name', comboModelName(model)));
grid.append(card);
}
};
grid.load = () => {
if (models) return draw();
grid.replaceChildren(el('span', 'anomalous-combo-picker-empty', t('loading')));
fetch(`/anomalous/compatible_models?base_model=${encodeURIComponent(baseModel)}&target_type=${types}`)
.then(response => response.json())
.then((payload) => {
models = payload.models || [];
draw();
})
.catch(() => grid.replaceChildren(el('span', 'anomalous-combo-picker-empty', t('comboPickerFailed'))));
};
grid.redraw = draw;
grid.hidden = true;
return grid;
}
/** The base model drop-down: changing it empties the models (they belong to one base). */
function baseSelect(ctx, data) {
const select = el('select', 'anomalous-nb-select anomalous-combo-base-select');
const fill = (bases) => {
const list = [...new Set([...(data.baseModel ? [data.baseModel] : []), ...bases])];
select.replaceChildren(...list.map((base) => {
const option = el('option', '', base === UNLABELED_BASE_MODEL ? t('notebookUnlabeledBase') : base);
option.value = base;
option.selected = base === data.baseModel;
return option;
}));
};
fill(ctx.baseModelsCache || FALLBACK_BASES);
if (!ctx.baseModelsCache) {
fetch('/anomalous/base_models').then(response => response.json()).then((payload) => {
const bases = [...(payload.base_models || []), ...(payload.unlabeled ? [UNLABELED_BASE_MODEL] : [])];
if (!bases.length) return;
ctx.baseModelsCache = bases;
fill(bases);
}).catch(() => {});
}
select.onchange = () => {
data.baseModel = select.value;
data.mainModel = null;
data.loras = [];
ctx.saveCurrentNotebook();
ctx.renderNotebookEditor();
};
return select;
}
/** The models part for `data` (the combo's data; changed in place). */
export function createComboModels(ctx, data) {
if (!data.baseModel) data.baseModel = 'SDXL';
data.loras ||= [];
const card = el('section', 'anomalous-nb-card anomalous-combo-models');
card.id = 'amb-nb-sec-models';
const changed = () => {
ctx.saveCurrentNotebook();
ctx.updateNotebookModelsSummary?.();
};
const head = el('div', 'anomalous-combo-section-head');
const baseLabel = el('label', 'anomalous-combo-base');
baseLabel.append(el('span', '', t('comboBaseModel')), baseSelect(ctx, data));
head.append(el('h3', 'anomalous-combo-section-title', t('comboModelsTitle')), baseLabel);
// Main model: the one chosen, Change opens the grid; a pick closes it.
const mainTitle = el('h4', 'anomalous-combo-sub-title', t('mainModel'));
const mainList = el('div', 'anomalous-combo-picks');
const unetHint = el('div', 'anomalous-nb-unet-hint', t('notebookUnetNeedsTextEncoder'));
const mainGrid = pickerGrid(data.baseModel, MAIN_TYPES, {
isPicked: model => data.mainModel?.filename === model.filename,
onPick: (model) => {
data.mainModel = data.mainModel?.filename === model.filename ? null : model;
mainGrid.hidden = Boolean(data.mainModel);
changed();
drawMain();
},
});
const drawMain = () => {
const toggle = button('anomalous-combo-pick-action', '', () => {
mainGrid.hidden = !mainGrid.hidden;
if (!mainGrid.hidden) mainGrid.load();
drawMain();
});
toggle.textContent = !mainGrid.hidden ? t('comboPickerClose') : data.mainModel ? t('comboChange') : t('comboChoose');
mainList.replaceChildren(data.mainModel
? pickedRow(data.mainModel, 0, toggle)
: Object.assign(el('div', 'anomalous-combo-pick is-empty'), { textContent: t('comboNoMainModel') }));
if (!data.mainModel) mainList.firstChild.append(toggle);
unetHint.hidden = !isUnetModel(data.mainModel);
};
// LoRAs: the chosen ones in order (✕ takes one out), Add opens the grid; picks toggle.
const loraTitle = el('h4', 'anomalous-combo-sub-title', t('loras'));
const loraList = el('div', 'anomalous-combo-picks');
const loraGrid = pickerGrid(data.baseModel, 'loras', {
isPicked: model => data.loras.some(lora => lora.filename === model.filename),
number: model => data.loras.findIndex(lora => lora.filename === model.filename) + 1,
onPick: (model) => {
const at = data.loras.findIndex(lora => lora.filename === model.filename);
if (at >= 0) data.loras.splice(at, 1);
else data.loras.push(model);
changed();
drawLoras();
},
});
const drawLoras = () => {
loraTitle.textContent = data.loras.length ? `${t('loras')} · ${data.loras.length}` : t('loras');
const rows = data.loras.map((lora, index) => pickedRow(lora, index + 1, button('anomalous-combo-pick-remove', '✕', () => {
data.loras.splice(index, 1);
changed();
drawLoras();
loraGrid.redraw();
})));
rows.forEach(row => { row.lastChild.title = t('comboRemoveLora'); row.lastChild.setAttribute('aria-label', t('comboRemoveLora')); });
const add = button('anomalous-combo-add', loraGrid.hidden ? `+ ${t('comboAddLora')}` : t('comboPickerClose'), () => {
loraGrid.hidden = !loraGrid.hidden;
if (!loraGrid.hidden) loraGrid.load();
drawLoras();
});
loraList.replaceChildren(...rows, add);
};
drawMain();
drawLoras();
card.append(head, mainTitle, mainList, mainGrid, unetHint, loraTitle,
createLoraNodeChoice(data, () => ctx.saveCurrentNotebook()), loraList, loraGrid);
return card;
}
+25 -9
View File
@@ -2,6 +2,7 @@
* The Combos page (搭配): each saved combo of a main model, LoRAs and a prompt (the files
* GET /anomalous/notebooks keeps, formerly Prompt Notes), or of the model and text nodes saved
* from the canvas with their links (ui_combo_structure.js), as a card with its model's cover
* (a combo kept from an output image shows that image, its model's cover in a corner badge)
* and Put on canvas (notebook_canvas.js: a new group of nodes that follows the pointer). A card
* opens the combo in its editor (ui_notebook_editor.js), and dragged onto the canvas becomes a
* new group of nodes where it is dropped. New names one and opens it.
@@ -13,6 +14,7 @@ import { freeComboName, listCombos } from './image_keep.js';
import { bindMaterialDrag } from './material_drag.js';
import { STRUCTURED } from './combo_slots.js';
import { chooseNewCombo, structuredComboMeta } from './ui_combo_structure.js';
import { outputThumbnail } from './ui_kept_toast.js';
function el(tag, className, text) {
const node = document.createElement(tag);
@@ -31,17 +33,31 @@ function button(className, label, onClick) {
const modelName = model => String(model?.metadata?.custom_name || model?.filename || '').replace(/\.[^.]+$/, '');
const withQuery = (url, query) => `${url}${url.includes('?') ? '&' : '?'}${query}`;
/** The main model's cover, as the model cards show it (a video's still first frame). */
function cover(model) {
const box = el('div', 'anomalous-combo-cover');
const url = model?.preview_url;
if (!url) return box;
const img = el('img');
function picture(src, className = '') {
const img = el('img', className);
img.loading = 'lazy';
img.alt = '';
img.src = withQuery(url, /\.(mp4|webm)(?:&|$)/i.test(url) ? 'variant=poster' : 'variant=card');
img.src = src;
img.onerror = () => img.remove();
box.append(img);
return img;
}
/**
* The main model's cover, as the model cards show it (a video's still first frame); with
* `image` (the output it was kept from) that image, and the model's cover as a small badge.
*/
function cover(model, image = null) {
const box = el('div', 'anomalous-combo-cover');
const url = model?.preview_url;
const modelCover = url ? withQuery(url, /\.(mp4|webm)(?:&|$)/i.test(url) ? 'variant=poster' : 'variant=card') : '';
if (image?.filename) {
box.classList.add('is-kept-image');
box.title = t('comboFromImage');
box.append(picture(outputThumbnail(image)));
if (modelCover) box.append(picture(modelCover, 'anomalous-combo-cover-model'));
return box;
}
if (modelCover) box.append(picture(modelCover));
return box;
}
@@ -75,7 +91,7 @@ function comboCard(owner, note) {
});
use.title = t('comboUseHint');
body.append(use);
card.append(cover(structured ? { preview_url: structured.cover } : data.mainModel), body);
card.append(cover(structured ? { preview_url: structured.cover } : data.mainModel, data.source_image), body);
bindMaterialDrag(card, owner, {
payload: () => ({ type: 'combo', filename: note.filename, dragHint: t('comboDragHint'), dragTargetHint: t('comboDragCanvas') }),
accepts: () => false,
+6 -4
View File
@@ -11,8 +11,8 @@ import { createGalleryCard } from './ui_gallery_card.js';
import { loadKeptImages } from './image_keep.js';
import { createSearchChips } from './ui_search_chips.js';
import { newestCompareButton } from './ui_param_compare.js';
import { createGalleryMediaSwitch } from './ui_gallery_filter.js';
import { appendGalleryFilter } from './gallery_filter.js';
import { createGalleryKeptSwitch, createGalleryMediaSwitch } from './ui_gallery_filter.js';
import { appendGalleryFilter, galleryKeptOnly } from './gallery_filter.js';
const t = (key, params) => translate(key, params);
@@ -50,7 +50,8 @@ export function createGallerySearchBar(owner) {
owner.gallerySearchCount = search.countElement;
owner.galleryMediaSwitch = createGalleryMediaSwitch(() => owner.loadGalleryImages(1, true, { refresh: false }));
header.append(search.element, owner.galleryMediaSwitch, newestCompareButton('anomalous-gallery-newest'));
const keptSwitch = createGalleryKeptSwitch(() => owner.loadGalleryImages(1, true, { refresh: false }));
header.append(search.element, owner.galleryMediaSwitch, keptSwitch, newestCompareButton('anomalous-gallery-newest'));
return header;
}
@@ -128,7 +129,8 @@ export async function loadGalleryImages(page = 1, reset = false, { refresh = res
}
} else {
this.galleryHasMore = false;
this.gallerySentinel.textContent = reset ? t(query ? 'gallerySearchEmpty' : 'galleryEmpty') : t('galleryNoMore');
const empty = query ? 'gallerySearchEmpty' : galleryKeptOnly() ? 'galleryKeptEmpty' : 'galleryEmpty';
this.gallerySentinel.textContent = reset ? t(empty) : t('galleryNoMore');
}
} catch (e) {
console.error('Failed to load gallery images', e);
+2 -1
View File
@@ -153,7 +153,8 @@ function keepButton(owner, image) {
const done = Boolean(kept?.recipe || kept?.combo || kept?.prompt);
star.textContent = done ? '★' : '☆';
star.classList.toggle('is-kept', done);
star.title = t(done ? 'galleryKept' : 'galleryKeep');
const kinds = ['recipe', 'combo', 'prompt'].filter(kind => kept?.[kind]).map(kind => t(`keepNoun_${kind}`));
star.title = done ? t('galleryKeptAs', { list: kinds.join(t('listJoin')) }) : t('galleryKeep');
};
mark(null);
owner.galleryKept?.then(map => mark(map.get(keptKey(image)))).catch(() => {});
+1 -1
View File
@@ -27,10 +27,10 @@ import { renderImageInspectorContent } from './ui_image_inspector.js';
import { openParamCompare } from './ui_param_compare.js';
import {
fileBaseName,
parsePngMetadataFromUrl,
extractWorkflowDetails,
materialNodeHeading,
} from './material_inspector.js';
import { parsePngMetadataFromUrl } from './png_metadata.js';
const t = (key, params) => translate(key, params);
+20 -2
View File
@@ -1,15 +1,18 @@
/**
* The output gallery's filters (state in gallery_filter.js): All / Pictures / Videos beside its
* search, shown once the output folder has a video (or while Pictures or Videos is chosen), and
* Settings → Gallery's formats, each clicked to hide it or show it again.
* search, shown once the output folder has a video (or while Pictures or Videos is chosen), ★ Kept
* (only the images kept as a workflow, combo or prompt), and Settings → Gallery's formats, each
* clicked to hide it or show it again.
*/
import { translate as t } from './locales.js';
import {
GALLERY_IMAGE_FORMATS,
GALLERY_VIDEO_FORMATS,
galleryKeptOnly,
galleryMedia,
hiddenGalleryFormats,
setGalleryKeptOnly,
setGalleryFormatHidden,
setGalleryMedia,
} from './gallery_filter.js';
@@ -53,6 +56,21 @@ export function createGalleryMediaSwitch(onChange) {
return box;
}
/** ★ Kept: on, the gallery lists only the images kept as something; `onChange()` after a click. */
export function createGalleryKeptSwitch(onChange) {
const toggle = el('button', 'anomalous-gallery-kept-only', t('galleryKeptOnly'));
toggle.type = 'button';
toggle.title = t('galleryKeptOnlyHint');
const mark = () => toggle.setAttribute('aria-pressed', String(galleryKeptOnly()));
toggle.onclick = () => {
setGalleryKeptOnly(!galleryKeptOnly());
mark();
onChange();
};
mark();
return toggle;
}
/** Settings → Gallery: one line of pictures, one of videos; `onChange()` after a click. */
export function createGalleryFormatSettings(onChange) {
const box = el('div', 'anomalous-gallery-formats');
+2 -10
View File
@@ -12,6 +12,7 @@ import { renderSourceBadge } from './model_source.js';
import { renderModelSourcesView } from './ui_model_sources.js';
import { renderModelTidyView } from './ui_model_tidy.js';
import { recordCanvasStep } from './canvas_history.js';
import { MODEL_LOADERS } from './quick_search_data.js';
const t = (key, params) => translate(key, params);
@@ -267,16 +268,7 @@ export async function loadModels() {
export function applyModelToCanvas(type, subfolder, model) {
const nodeTypeMap = {
'checkpoints': 'CheckpointLoaderSimple',
'loras': 'LoraLoader',
'unet': 'UNETLoader',
'diffusion_models': 'UNETLoader',
'vae': 'VAELoader',
'clip': 'CLIPLoader',
'controlnet': 'ControlNetLoader'
};
const nodeType = nodeTypeMap[type];
const nodeType = MODEL_LOADERS[type];
if (!nodeType) {
alert(window.anomalous_browser_lang === 'zh' ? '当前类型暂不支持自动发布到画布' : 'Unsupported auto apply for this type');
return;
+23 -8
View File
@@ -10,6 +10,7 @@ import { translate as t } from './locales.js';
import { anomalousAlert, anomalousConfirm } from './ui_dialog.js';
import { jsonResponse } from './ui_dom.js';
import { showWorkbenchToast } from './ui_prompt_toast.js';
import { showKeptComboToast } from './ui_kept_toast.js';
import { keepImageAsRecipe } from './recipe_save.js';
import { keepImageAsCombo, keepImagePrompts, keptKey, loadKeptImages } from './image_keep.js';
@@ -40,10 +41,13 @@ function place(menu, anchor) {
menu.style.top = `${below + height > window.innerHeight - 8 ? Math.max(8, box.top - height - 6) : below}px`;
}
/** The models a new combo names that this computer lacks: kept by the picture's name (nodes), or left out. */
const missingWords = result => t(result.missingKept ? 'keepComboMissingNamed' : 'keepComboMissing', { count: result.missing.length });
function keptMessage(spec, result) {
if (result.existed) return t('keepExisted', { name: result.name });
const message = t(`keptAs_${spec.kind}`, { name: result.name });
return result.missing?.length ? `${message} ${t('keepComboMissing', { count: result.missing.length })}` : message;
return result.missing?.length ? `${message} ${missingWords(result)}` : message;
}
async function keep(owner, spec, sourceImage, kept, { name, onKept, beforeOpen }) {
@@ -55,13 +59,24 @@ async function keep(owner, spec, sourceImage, kept, { name, onKept, beforeOpen }
}
kept[spec.kind] = { filename: result.filename, name: result.name };
onKept?.(kept);
showWorkbenchToast(keptMessage(spec, result), {
label: t('keepOpen'),
run: () => {
beforeOpen?.();
spec.open(owner, kept[spec.kind]);
},
});
const openKept = () => {
beforeOpen?.();
spec.open(owner, kept[spec.kind]);
};
// A new combo's name is a guess (image_keep.js comboTitle): it can be renamed right here.
if (spec.kind === 'combo' && !result.existed) {
const missing = result.missing?.length ? missingWords(result) : '';
showKeptComboToast(sourceImage, kept.combo, {
message: [t('keptAs_comboShort'), missing].filter(Boolean).join(' '),
onRenamed: (name) => {
kept.combo = { ...kept.combo, name };
onKept?.(kept);
},
onOpen: openKept,
});
return;
}
showWorkbenchToast(keptMessage(spec, result), { label: t('keepOpen'), run: openKept });
} catch (error) {
console.error(`[AMB] Could not keep the image as ${spec.kind}:`, error);
await anomalousAlert(t(error?.message === NO_WORKFLOW ? 'keepNoWorkflow' : 'keepFailed'));
+97
View File
@@ -0,0 +1,97 @@
/**
* The toast after an output image was kept as a new combo (ui_keep_menu.js): the image's
* thumbnail, what happened, the combo's name in a box to rename it on the spot (Enter or
* leaving the box saves it, image_keep.js renameCombo) and Open. It stays while the pointer
* or the focus is on it.
*/
import { translate as t } from './locales.js';
import { renameCombo } from './image_keep.js';
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
/** The thumbnail URL of an output image ({ filename, subfolder }). */
export const outputThumbnail = image => `/anomalous/output_thumbnail?${new URLSearchParams({ filename: image.filename, subfolder: image.subfolder || '' })}`;
/**
* Shows the toast for the combo `kept` ({ filename, name }) kept from `image`. `onRenamed(name)`
* runs after a rename; `onOpen()` opens the combo.
*/
export function showKeptComboToast(image, kept, { message, onRenamed, onOpen }) {
const toast = el('div', 'anomalous-mixer-toast has-action anomalous-kept-toast');
toast.setAttribute('role', 'status');
const thumb = el('img', 'anomalous-kept-toast-thumb');
thumb.alt = '';
thumb.src = outputThumbnail(image);
thumb.onerror = () => thumb.remove();
const body = el('div', 'anomalous-kept-toast-body');
const line = el('span', 'anomalous-kept-toast-line', message);
const input = el('input', 'anomalous-kept-toast-name');
input.type = 'text';
input.value = kept.name;
input.maxLength = 100;
input.title = t('keepRenameHint');
input.setAttribute('aria-label', t('keepRenameHint'));
body.append(line, input);
const open = el('button', 'anomalous-mixer-toast-action', t('keepOpen'));
open.type = 'button';
toast.append(thumb, body, open);
let timer = null;
let saved = kept.name;
const close = () => {
clearTimeout(timer);
toast.classList.remove('is-show');
setTimeout(() => toast.remove(), 300);
};
const wait = () => {
clearTimeout(timer);
if (!toast.matches(':hover') && !toast.contains(document.activeElement)) timer = setTimeout(close, 12000);
};
const rename = async () => {
const wanted = input.value.trim();
if (!wanted || wanted === saved) {
input.value = saved;
return;
}
try {
saved = await renameCombo(kept.filename, wanted);
input.value = saved;
line.textContent = t('keepRenamed', { name: saved });
onRenamed?.(saved);
} catch (error) {
console.error('[AMB] Could not rename the kept combo:', error);
input.value = saved;
line.textContent = t('keepRenameFailed');
}
};
input.onkeydown = (event) => {
event.stopPropagation(); // the canvas and the browser keep their keys
if (event.key === 'Enter') {
event.preventDefault();
input.blur();
} else if (event.key === 'Escape') {
event.preventDefault();
input.value = saved;
input.blur();
}
};
input.onblur = () => void rename();
open.onclick = async () => {
await rename();
close();
onOpen?.();
};
toast.onpointerenter = () => clearTimeout(timer);
toast.onpointerleave = wait;
toast.addEventListener('focusin', () => clearTimeout(timer));
toast.addEventListener('focusout', () => setTimeout(wait, 0));
document.body.appendChild(toast);
setTimeout(() => toast.classList.add('is-show'), 10);
wait();
}
+15 -2
View File
@@ -11,9 +11,10 @@
import { translate as t } from './locales.js';
import { destinationFor, unsafeFormat } from './download_places.js';
import {
cancelDownload, defaultRoot, doneCount, downloadFor, lookupDownloads, rememberRoot, startDownload, syncDownloads,
cancelDownload, defaultRoot, doneCount, downloadFor, hfSearchUrl, lookupDownloads, rememberRoot, startDownload, syncDownloads,
watchDownloads,
} from './model_download.js';
import { requiresHashForModelRecovery } from './model_policies.js';
import { saveApiKey } from './ui_scan_settings.js';
import { openSettingsPage } from './ui_settings_page.js';
@@ -108,6 +109,8 @@ function dialogItem(entry, info, settings) {
if (unsafeFormat(entry.value) || unsafeFormat(info.file_name)) box.append(el('div', 'anomalous-download-note is-warn', t('downloadUnsafeFormat')));
if (info.by_name) {
box.append(el('div', 'anomalous-download-note is-warn', t('downloadByName', { name: info.list_name || '' })));
// For deciding: where the list's file is (its size and base model are in the head).
if (info.list_site) box.append(el('div', 'anomalous-download-note', t('downloadListSite', { site: info.list_site })));
} else if (info.via === 'manager_list') {
box.append(el('div', 'anomalous-download-note', t('downloadViaManager', { name: info.list_name || '' })));
}
@@ -240,8 +243,12 @@ export async function fillDownloads(owner, panel, slots, pageActions, rerender)
return;
}
if (!panel.isConnected || [...slots.values()].some(slot => !slot.isConnected)) return; // redrawn meanwhile
const { found, settings } = looked;
const { found, settings, managerList } = looked;
const ready = [];
// Without ComfyUI-Manager its model list is one source less: say so where something was not found.
if (!managerList && [...slots.keys()].some(entry => !found.get(entry)?.found)) {
pageActions.append(el('span', 'anomalous-download-text', t('downloadNoManager')));
}
const draw = () => {
for (const [entry, slot] of slots) {
const info = found.get(entry);
@@ -263,6 +270,12 @@ export async function fillDownloads(owner, panel, slots, pageActions, rerender)
}
} else {
slot.replaceChildren();
// A foundation component (VAE, text encoder…) no source knows: look for its name on Hugging Face.
if (['not_found', 'no_source'].includes(info?.reason) && requiresHashForModelRecovery(entry.node, entry.widget)) {
const search = button('anomalous-scan-row-btn', t('downloadSearchHf'), () => window.open(hfSearchUrl(entry.value, settings), '_blank', 'noopener'));
search.title = t('downloadSearchHfHint');
slot.append(search);
}
}
}
};
+21 -4
View File
@@ -35,12 +35,17 @@ async function loraTrainsTextEncoder(name) {
}
}
export function findModelComboWidget(node) {
return (node?.widgets || []).find(widget => {
/** The node's drop-downs that list model files. */
export function modelComboWidgets(node) {
return (node?.widgets || []).filter(widget => {
if (widget?.type !== 'combo') return false;
const values = getNativeWidgetValues(node, widget);
return values.some(value => /\.(safetensors|ckpt|pt|bin|pth|sft|gguf)$/i.test(value));
}) || null;
});
}
export function findModelComboWidget(node) {
return modelComboWidgets(node)[0] || null;
}
export function setWidgetValue(node, widget, value) {
@@ -86,16 +91,26 @@ export function _openGalleryReplacer(node, w, options = {}) {
let applying = false;
const modal = document.createElement('div');
modal.className = 'anomalous-picker-overlay'; // the browser's Esc leaves it to the picker (ui_browser_navigation.js)
modal.style.cssText = 'position:fixed;inset:0;background:var(--amb-lt-page,radial-gradient(circle at 15% 0%,rgba(53,73,118,0.34),transparent 35%),rgba(7,8,12,0.965));z-index:999999;display:flex;flex-direction:column;padding:22px 24px;box-sizing:border-box;color:var(--amb-lt-text,#fff);font-family:Inter,Arial,sans-serif;';
const stopMedia = container => container?.querySelectorAll?.('video,audio').forEach(media => {
media.pause();
media.removeAttribute('src');
media.load?.();
});
// Esc closes the picker wherever the focus is, and only the picker (not the browser behind).
const onEscape = (event) => {
if (event.key !== 'Escape' || !modal.isConnected) return;
event.preventDefault();
event.stopImmediatePropagation();
closeModal();
};
const closeModal = () => {
renderGeneration += 1;
window.removeEventListener('keydown', onEscape, true);
stopMedia(modal);
modal.remove();
if (!document.querySelector('.anomalous-picker-overlay')) document.documentElement.classList.remove('anomalous-picker-open');
};
const header = document.createElement('div');
@@ -197,6 +212,8 @@ export function _openGalleryReplacer(node, w, options = {}) {
confirmBtn.style.cssText = 'padding:10px 20px;background:#1976d2;color:#fff;border:none;border-radius:7px;cursor:pointer;font-weight:700;';
footer.append(selectionText, cancelBtn, confirmBtn);
modal.appendChild(footer);
// Opened from the canvas's right-click menu the browser is closed, so the floating button would sit on the picker's head.
document.documentElement.classList.add('anomalous-picker-open');
document.body.appendChild(modal);
let folderCounts = new Map();
@@ -493,7 +510,7 @@ export function _openGalleryReplacer(node, w, options = {}) {
renderCards();
};
sortSelect.onchange = renderCards;
modal.onkeydown = event => { if (event.key === 'Escape') closeModal(); };
window.addEventListener('keydown', onEscape, true);
renderFolders();
updateSelection();
renderCards();
+1 -1
View File
@@ -392,7 +392,7 @@ function keepView(view) {
/** Appends the parameters section for `node` to `container` and loads its entries. */
export function renderNodeParameters(node, container, owner) {
const section = el('section', 'anomalous-node-section');
const section = el('section', 'anomalous-node-section anomalous-node-params');
const head = el('div', 'anomalous-node-section-head');
const views = el('div', 'anomalous-node-param-views');
views.setAttribute('role', 'radiogroup');
+12 -349
View File
@@ -1,264 +1,20 @@
/**
* ui_notebook_editor.js
* Combo (搭配) editor with unfolded cards, sticky toolbar, and compatible-model galleries.
* A combo's (搭配) editor: its head (ui_combo_hero.js: picture, name with Rename, chips, Put on
* canvas), its models (ui_combo_models.js: the chosen ones, Change / Add open a grid of the
* compatible ones) and its prompt with the bilingual tag view. Every change saves itself.
* A node-structure combo gets the same head over its own editor (ui_combo_structure.js).
*/
import { translate } from './locales.js';
import { escapeHtml } from './safe_dom.js';
import { isUnetModel, UNLABELED_BASE_MODEL } from './notebook_canvas.js';
import { STRUCTURED } from './combo_slots.js';
import { renderStructuredCombo } from './ui_combo_structure.js';
import { createLoraNodeChoice } from './ui_lora_node_choice.js';
import { createComboHero } from './ui_combo_hero.js';
import { createComboModels } from './ui_combo_models.js';
const t = (key, params) => translate(key, params);
/**
* Sticky top action toolbar for the active notebook with floating dropdown menu.
*/
export function createNotebookToolbar(ctx, notebook) {
const tb = document.createElement('div');
tb.className = 'anomalous-nb-toolbar';
const titleBox = document.createElement('div');
titleBox.style.display = 'flex';
titleBox.style.alignItems = 'center';
titleBox.style.gap = '8px';
const titleIcon = document.createElement('span');
titleIcon.textContent = '📝';
titleIcon.style.fontSize = '1.2rem';
const titleArea = document.createElement('h3');
titleArea.textContent = notebook.name;
titleArea.style.margin = '0';
titleArea.style.fontSize = '1.15rem';
titleArea.style.fontWeight = '700';
titleBox.append(titleIcon, titleArea);
const rightBtns = document.createElement('div');
rightBtns.className = 'anomalous-notebook-actions';
const saveBtn = document.createElement('button');
saveBtn.type = 'button';
saveBtn.textContent = `💾 ${t('saveNotebook')}`;
saveBtn.className = 'anomalous-btn-primary';
saveBtn.onclick = async () => {
const orig = saveBtn.innerHTML;
saveBtn.innerHTML = '⏳...';
const saved = await ctx.saveCurrentNotebook();
if (!saved) { saveBtn.innerHTML = orig; return; }
saveBtn.innerHTML = '✅';
saveBtn.style.background = '#2e8b57';
setTimeout(() => {
saveBtn.innerHTML = orig;
saveBtn.style.background = '';
}, 1500);
};
// Put on the canvas: the combo as a new group of nodes.
const useBtn = Object.assign(document.createElement('button'), { type: 'button', className: 'anomalous-btn-success', textContent: t('comboUse'), title: t('comboUseHint') });
useBtn.onclick = () => ctx.sendNotebookToCanvas();
// Floating More Dropdown Menu
const moreWrapper = document.createElement('div');
moreWrapper.className = 'anomalous-nb-dropdown-wrapper';
const moreBtn = document.createElement('button');
moreBtn.type = 'button';
moreBtn.className = 'anomalous-nb-more-btn';
moreBtn.innerHTML = `<span>··· ${t('notebookMore')}</span> <span style="font-size:0.7rem;margin-left:2px;">▾</span>`;
const dropdownMenu = document.createElement('div');
dropdownMenu.className = 'anomalous-nb-dropdown-menu';
let delTimer = null;
const delBtn = document.createElement('button');
delBtn.type = 'button';
delBtn.className = 'anomalous-nb-dropdown-item anomalous-nb-dropdown-item-danger';
const normalDelHtml = `<span>🗑️</span> <span>${t('deleteNotebook')}</span>`;
delBtn.innerHTML = normalDelHtml;
const resetDel = () => {
clearTimeout(delTimer);
delBtn.classList.remove('confirming');
delBtn.innerHTML = normalDelHtml;
};
delBtn.onclick = (e) => {
e.stopPropagation();
if (!delBtn.classList.contains('confirming')) {
delBtn.classList.add('confirming');
const delSureText = (t('delSure') || (window.anomalous_browser_lang === 'zh' ? '确认删除?' : 'Confirm Delete?')).replace(/^⚠️\s*/, '');
delBtn.innerHTML = `<span>⚠️</span> <span>${delSureText}</span>`;
delTimer = setTimeout(resetDel, 4000);
} else {
resetDel();
dropdownMenu.classList.remove('show');
moreBtn.classList.remove('active');
ctx.deleteCurrentNotebook();
}
};
dropdownMenu.append(delBtn);
moreBtn.onclick = (e) => {
e.stopPropagation();
const isOpen = dropdownMenu.classList.toggle('show');
moreBtn.classList.toggle('active', isOpen);
if (!isOpen) resetDel();
};
const onDocClick = (e) => {
if (!moreWrapper.contains(e.target)) {
dropdownMenu.classList.remove('show');
moreBtn.classList.remove('active');
resetDel();
}
};
document.addEventListener('click', onDocClick);
moreWrapper.append(moreBtn, dropdownMenu);
rightBtns.append(saveBtn, useBtn, moreWrapper);
tb.append(titleBox, rightBtns);
return tb;
}
/**
* Flat companion models section (Base model + Checkpoints + LoRAs).
*/
function createCompanionModelsCard(ctx, data) {
const card = document.createElement('div');
card.id = 'amb-nb-sec-models';
card.className = 'anomalous-nb-card anomalous-nb-models-card anomalous-nb-models-fold';
card.open = true;
const header = document.createElement('div');
header.className = 'anomalous-nb-card-header';
const titleLeft = document.createElement('div');
titleLeft.style.display = 'flex';
titleLeft.style.alignItems = 'center';
titleLeft.style.gap = '8px';
const titleText = document.createElement('span');
titleText.textContent = `📦 ${t('notebookCompanionModels') || (window.anomalous_browser_lang === 'zh' ? '配套模型' : 'Companion Models')}`;
titleText.style.fontWeight = '600';
const modelsBadge = document.createElement('span');
modelsBadge.className = 'anomalous-nb-models-badge';
titleLeft.append(titleText, modelsBadge);
header.appendChild(titleLeft);
const mainSelectedBadge = document.createElement('span');
mainSelectedBadge.className = 'anomalous-nb-selected-badge';
mainSelectedBadge.style.color = 'var(--amb-lt-violet, #c084fc)';
const loraSelectedBadge = document.createElement('span');
loraSelectedBadge.className = 'anomalous-nb-selected-badge';
loraSelectedBadge.style.color = 'var(--amb-warn)';
const updateSummary = () => {
const baseInfo = data.baseModel === UNLABELED_BASE_MODEL ? t('notebookUnlabeledBase') : (data.baseModel || 'SDXL');
const mainInfo = data.mainModel?.filename ? ` · ${data.mainModel.filename}` : '';
const loraCount = data.loras?.length ? ` · ${data.loras.length} LoRA` : '';
modelsBadge.textContent = `${baseInfo}${mainInfo}${loraCount}`;
mainSelectedBadge.textContent = data.mainModel?.filename ? `✓ ${data.mainModel.filename}` : (t('recipeDiffNone') || '未选择');
loraSelectedBadge.textContent = data.loras?.length ? `✓ ${data.loras.length} LoRA` : (t('recipeDiffNone') || '未选择');
};
ctx.updateNotebookModelsSummary = updateSummary;
// Base model selection
const baseRow = document.createElement('div');
baseRow.className = 'anomalous-nb-row anomalous-nb-base-row';
baseRow.style.display = 'flex';
baseRow.style.alignItems = 'center';
baseRow.style.gap = '10px';
baseRow.style.marginBottom = '12px';
const baseTitle = document.createElement('span');
baseTitle.style.fontWeight = '600';
baseTitle.style.fontSize = '0.88rem';
baseTitle.style.color = 'var(--amb-lt-soft, #cbd5e1)';
baseTitle.textContent = `${t('baseModel') || (window.anomalous_browser_lang === 'zh' ? '基础模型' : 'Base Model')}:`;
baseRow.appendChild(baseTitle);
const baseSelect = document.createElement('select');
baseSelect.className = 'anomalous-nb-select';
const buildSelect = (bases) => {
baseSelect.innerHTML = '';
bases.forEach(b => {
const opt = document.createElement('option');
opt.value = b;
opt.text = b === UNLABELED_BASE_MODEL ? t('notebookUnlabeledBase') : b;
if (data.baseModel === b) opt.selected = true;
baseSelect.appendChild(opt);
});
if (!data.baseModel && bases.length > 0) data.baseModel = bases[0];
updateSummary();
};
if (ctx.baseModelsCache) {
buildSelect(ctx.baseModelsCache);
} else {
const tempBases = ['SD 1.5', 'SD 2.1', 'SDXL', 'SD 3.0', 'SD 3.5', 'Flux.1', 'Pony', 'HunyuanVideo', 'LTX-Video', 'OmniGen'];
buildSelect(tempBases);
fetch('/anomalous/base_models').then(r => r.json()).then(d => {
const bases = [...(d.base_models || []), ...(d.unlabeled ? [UNLABELED_BASE_MODEL] : [])];
if (bases.length > 0) {
ctx.baseModelsCache = bases;
buildSelect(ctx.baseModelsCache);
}
}).catch(() => { });
}
if (!data.baseModel) data.baseModel = 'SDXL';
baseSelect.onchange = () => {
data.baseModel = baseSelect.value;
data.mainModel = null;
data.loras = [];
ctx.saveCurrentNotebook();
ctx.renderNotebookEditor();
};
baseRow.appendChild(baseSelect);
// Main Model Gallery
const mainBox = document.createElement('div');
mainBox.className = 'anomalous-nb-gallery-box';
const mainRow = document.createElement('div');
mainRow.className = 'anomalous-nb-row anomalous-nb-gallery-head';
const mainLabel = document.createElement('strong');
mainLabel.textContent = `🎨 ${t('mainModel') || (window.anomalous_browser_lang === 'zh' ? '主模型' : 'Main Model')}`;
mainRow.append(mainLabel, mainSelectedBadge);
const mainGallery = document.createElement('div');
mainGallery.className = 'anomalous-nb-gallery-wrap';
mainBox.append(mainRow, mainGallery);
// LoRA Gallery
const loraBox = document.createElement('div');
loraBox.className = 'anomalous-nb-gallery-box';
const loraRow = document.createElement('div');
loraRow.className = 'anomalous-nb-row anomalous-nb-gallery-head';
const loraLabel = document.createElement('strong');
loraLabel.textContent = `⚡ ${t('loras') || (window.anomalous_browser_lang === 'zh' ? 'LoRA 模型' : 'LoRA Models')}`;
loraRow.append(loraLabel, loraSelectedBadge);
const loraGallery = document.createElement('div');
loraGallery.className = 'anomalous-nb-gallery-wrap';
loraBox.append(loraRow, createLoraNodeChoice(data, () => ctx.saveCurrentNotebook()), loraGallery);
const unetHint = document.createElement('div');
unetHint.className = 'anomalous-nb-unet-hint';
unetHint.textContent = t('notebookUnetNeedsTextEncoder');
mainBox.appendChild(unetHint);
const showUnetHint = () => { unetHint.hidden = !isUnetModel(data.mainModel); };
ctx.updateNotebookUnetHint = showUnetHint;
showUnetHint();
card.append(header, baseRow, mainBox, loraBox);
updateSummary();
ctx.fillNotebookGalleries(data.baseModel, mainGallery, loraGallery, data);
return card;
}
/**
* Prompt composer section with bilingual dual pane and compact inline tools.
*/
@@ -351,7 +107,9 @@ function createPromptSection(ctx, data) {
tagL.innerHTML = ''; tagL.appendChild(inp); inp.focus();
const finish = () => {
tags[idx] = inp.value.trim();
rawArea.value = tags.join(', ');
rawArea.value = tags.filter(Boolean).join(', ');
data.promptEn = rawArea.value; // a tag changed in the bilingual view is the prompt's too
saveSoon();
updateVisualTags();
};
inp.onblur = finish;
@@ -484,13 +242,9 @@ export function renderNotebookEditor() {
if (!this.currentNotebook) return;
const data = this.currentNotebook.data || (this.currentNotebook.data = {});
if (data.kind === STRUCTURED) return renderStructuredCombo(this, this.nbEditor, this.currentNotebook, createNotebookToolbar(this, this.currentNotebook));
if (!data.loras) data.loras = [];
const tb = createNotebookToolbar(this, this.currentNotebook);
const modelsCard = createCompanionModelsCard(this, data);
const promptSec = createPromptSection(this, data);
this.nbEditor.replaceChildren(tb, modelsCard, promptSec);
const hero = createComboHero(this, this.currentNotebook);
if (data.kind === STRUCTURED) return renderStructuredCombo(this, this.nbEditor, this.currentNotebook, hero);
this.nbEditor.replaceChildren(hero, createComboModels(this, data), createPromptSection(this, data));
} catch (err) {
console.error('[AMB] Error rendering notebook editor:', err);
if (this.nbEditor) {
@@ -498,94 +252,3 @@ export function renderNotebookEditor() {
}
}
}
/**
* Fill compatible main models and Loras into horizontal galleries.
*/
export function fillNotebookGalleries(baseModel, mainGallery, loraGallery, data) {
if (!baseModel) return;
const buildThumbHtml = (m) => {
if (m.preview_url) {
const isVid = m.preview_url.match(/\.mp4(?:&|$)/i) || m.preview_url.match(/\.webm(?:&|$)/i);
if (isVid) return `<video src="${m.preview_url}" muted loop playsinline></video>`;
return `<img src="${m.preview_url}" />`;
}
return '<div class="anomalous-nb-mini-empty">?</div>';
};
fetch(`/anomalous/compatible_models?base_model=${encodeURIComponent(baseModel)}&target_type=checkpoints,unet,diffusion_models`)
.then(r => r.json()).then(d => {
const buildMainDOM = (models) => {
mainGallery.innerHTML = '';
if (!models || !models.length) {
mainGallery.innerHTML = `<span style="color:var(--amb-lt-muted,#666);">${window.anomalous_browser_lang === 'zh' ? '未找到兼容的主模型。' : 'No compatible main models found.'}</span>`;
} else {
models.forEach(m => {
const isSelected = (data.mainModel && data.mainModel.filename === m.filename);
const card = document.createElement('div');
card.className = 'anomalous-nb-minicheck ' + (isSelected ? 'selected' : '');
card.innerHTML = `${buildThumbHtml(m)}<div class="anomalous-nb-minicheck-name" title="${escapeHtml(m.filename)}">${escapeHtml(m.filename)}</div>`;
if (m.preview_url && (m.preview_url.match(/\.mp4(?:&|$)/i) || m.preview_url.match(/\.webm(?:&|$)/i))) {
card.onmouseenter = () => { const v = card.querySelector('video'); if (v) v.play().catch(() => { }); };
card.onmouseleave = () => { const v = card.querySelector('video'); if (v) { v.pause(); v.currentTime = 0; } };
}
card.onclick = () => {
data.mainModel = (data.mainModel && data.mainModel.filename === m.filename) ? null : m;
this.saveCurrentNotebook();
this.updateNotebookModelsSummary?.();
this.updateNotebookUnetHint?.();
buildMainDOM(models);
};
mainGallery.appendChild(card);
});
}
};
buildMainDOM(d.models || []);
}).catch(() => { });
fetch(`/anomalous/compatible_models?base_model=${encodeURIComponent(baseModel)}&target_type=loras`)
.then(r => r.json()).then(d => {
const buildLoraDOM = (models) => {
loraGallery.innerHTML = '';
if (!models || !models.length) {
loraGallery.innerHTML = `<span style="color:var(--amb-lt-muted,#666);">${window.anomalous_browser_lang === 'zh' ? '未找到兼容的 LoRA。' : 'No compatible Loras found.'}</span>`;
} else {
models.forEach(m => {
const loraIndex = data.loras.findIndex(l => l.filename === m.filename);
const isSelected = loraIndex !== -1;
const card = document.createElement('div');
card.className = 'anomalous-nb-minilora ' + (isSelected ? 'selected' : '');
card.style.position = 'relative';
let badgeHtml = '';
if (isSelected) {
badgeHtml = `<div style="position:absolute; top:-5px; right:-5px; background:linear-gradient(135deg, #f59e0b, #d97706); color:#180808; border-radius:50%; width:20px; height:20px; font-size:12px; display:flex; align-items:center; justify-content:center; font-weight:bold; z-index:10; box-shadow: 0 2px 8px rgba(0,0,0,0.7), 0 0 6px rgba(245,158,11,0.4); border: 1px solid var(--amb-border-strong);">${loraIndex + 1}</div>`;
}
card.innerHTML = `${badgeHtml}${buildThumbHtml(m)}<div class="anomalous-nb-minilora-name" title="${escapeHtml(m.filename)}">${escapeHtml(m.filename)}</div>`;
if (m.preview_url && (m.preview_url.match(/\.mp4(?:&|$)/i) || m.preview_url.match(/\.webm(?:&|$)/i))) {
card.onmouseenter = () => { const v = card.querySelector('video'); if (v) v.play().catch(() => { }); };
card.onmouseleave = () => { const v = card.querySelector('video'); if (v) { v.pause(); v.currentTime = 0; } };
}
card.onclick = () => {
if (isSelected) {
data.loras = data.loras.filter(l => l.filename !== m.filename);
} else {
data.loras.push(m);
}
this.saveCurrentNotebook();
this.updateNotebookModelsSummary?.();
buildLoraDOM(models);
};
loraGallery.appendChild(card);
});
}
};
buildLoraDOM(d.models || []);
}).catch(() => { });
}
+3 -3
View File
@@ -9,7 +9,7 @@
import { app } from '../../../scripts/app.js';
import { translate as t } from './locales.js';
import { recordCanvasStep } from './canvas_history.js';
import { detachedGraph } from './detached_graph.js';
import { readDetached } from './detached_graph.js';
import { applyNodeMaterialValues } from './node_material_actions.js';
import { showWorkbenchToast } from './ui_prompt_toast.js';
import { keyParamTest, onKeyParamsChange, toggleKeyParam } from './key_params.js';
@@ -42,7 +42,7 @@ const sideTitle = side => (side.image?.filename && side.label ? `${side.label}
/** One side's parameters: the canvas as it is now, a saved workflow, or the workflow kept in an output image. */
async function readSide(side, signal) {
if (side.canvas) return readParams(canvasGraph());
if (side.workflow) return readParams(detachedGraph(side.workflow));
if (side.workflow) return readDetached(side.workflow, readParams);
const response = await fetch('/anomalous/inspect_image_material', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -53,7 +53,7 @@ async function readSide(side, signal) {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const { workflow } = await response.json();
if (!workflow) throw new Error(t('compareNoWorkflow'));
return readParams(detachedGraph(workflow));
return readDetached(workflow, readParams);
}
function sideCard(side, letter) {
+2 -2
View File
@@ -8,7 +8,7 @@
*/
import { translate as t } from './locales.js';
import { detachedGraph } from './detached_graph.js';
import { readDetached } from './detached_graph.js';
import { keyParamTest, onKeyParamsChange } from './key_params.js';
import { focusTest } from './recipe_focus.js';
import { compareParams, readParams, shownValue } from './param_compare.js';
@@ -32,7 +32,7 @@ export function redrawParameterSets() {
/** A workflow's values as readParams reads them, read once per workflow object. */
export function workflowParams(workflow) {
if (!read.has(workflow)) read.set(workflow, readParams(detachedGraph(workflow)));
if (!read.has(workflow)) read.set(workflow, readDetached(workflow, readParams));
return read.get(workflow);
}
+5 -2
View File
@@ -43,9 +43,10 @@ function hintOnce() {
/**
* Fills `grid` (between the cards and the boxes). `target` is the prompt side ({ addCard(card,
* text), valueFor(card) }); `prefs` the studio's { glossLang }. Returns { open(card), close(), render() }.
* text), valueFor(card) }); `prefs` the studio's { glossLang }; `onShow(card)` hears which card is
* open (null once closed). Returns { open(card), close(), render() }.
*/
export function createPromptReference(grid, { drawer, scope, prefs, target }) {
export function createPromptReference(grid, { drawer, scope, prefs, target, onShow }) {
const panel = el('section', 'anomalous-ps-ref');
panel.hidden = true;
const head = el('header', 'anomalous-ps-target-head');
@@ -150,6 +151,7 @@ export function createPromptReference(grid, { drawer, scope, prefs, target }) {
grid.classList.add('has-ref');
widen(true);
render();
onShow(card);
}
function close() {
@@ -157,6 +159,7 @@ export function createPromptReference(grid, { drawer, scope, prefs, target }) {
panel.hidden = true;
grid.classList.remove('has-ref');
widen(false);
onShow(null);
}
return { open, close, render };
+29 -5
View File
@@ -5,11 +5,14 @@
* (ui_prompt_card_popover.js) into the prompt box of its role, dragged onto a box, or
* dragged out onto the canvas (prompt_card_drag.js); a saved one is renamed or deleted from its
* preview. The saved list is checked again every 30 seconds and when the window comes back.
* A card is drawn as a notebook page: its role on the bound spine, its title, its first tags;
* the one open as the reference wears a ribbon (`setOpen`).
*/
import { translate as t } from './locales.js';
import { bindPromptCardDrag } from './prompt_card_drag.js';
import { PROMPT_PRESETS, promptTitle } from './prompt_composition.js';
import { promptTagsOf, tagWeight } from './prompt_tags.js';
import { loadPromptSourceCards, savePromptCard } from './prompt_material_source.js';
import { anomalousAlert, anomalousConfirm, anomalousPrompt } from './ui_dialog.js';
import { jsonResponse } from './ui_dom.js';
@@ -33,11 +36,15 @@ function button(className, label, title, onClick) {
return node;
}
/** Fills `parent`; `onPick(card)` takes a clicked card, `onAdd(card)` one added whole from its preview. Returns { sync() }. */
const SHOWN_TAGS = 8; // first tags on a card; two ruled lines show what fits, the head counts them all
/** Fills `parent`; `onPick(card)` takes a clicked card, `onAdd(card)` one added whole from its preview.
* Returns { sync(), setOpen(id) } (the card open as the reference, or null). */
export function createPromptSourceDeck(parent, drawer, scope, { onPick, onAdd }) {
const presets = PROMPT_PRESETS.map(card => ({ ...card, title: t(card.titleKey) }));
let saved = [];
let keyword = '';
let openId = null;
const popover = createCardPopover({
drawer,
scope,
@@ -153,8 +160,18 @@ export function createPromptSourceDeck(parent, drawer, scope, { onPick, onAdd })
}
function cardElement(card) {
const item = el('div', `anomalous-ps-card is-${card.role}`);
item.append(el('span', 'anomalous-ps-card-title', card.title), el('span', 'anomalous-ps-card-text', card.content));
const role = card.role === 'negative' ? 'negative' : 'positive';
const item = el('div', `anomalous-ps-card is-${role}`);
item.classList.toggle('is-open', card.id === openId);
item.dataset.cardId = card.id;
const tags = promptTagsOf(card.content).map(tag => tagWeight(tag).core.trim()).filter(Boolean);
const head = el('div', 'anomalous-ps-card-head');
head.append(el('span', 'anomalous-ps-card-title', card.title),
el('span', 'anomalous-ps-card-count', t('promptDeckTagCount', { count: tags.length })),
el('span', 'anomalous-ps-card-role', t(role === 'negative' ? 'promptDeckRoleNegative' : 'promptDeckRolePositive')));
const words = el('div', 'anomalous-ps-card-words');
words.append(...tags.slice(0, SHOWN_TAGS).map(tag => el('span', 'anomalous-ps-card-word', tag)));
item.append(head, words);
item.onmouseenter = event => popover.hover(card, item, event);
item.onmouseleave = event => popover.leave(event);
// Onto a prompt box in the studio (the card itself), or out onto the canvas (prompt_card_drag.js).
@@ -173,7 +190,9 @@ export function createPromptSourceDeck(parent, drawer, scope, { onPick, onAdd })
function group(title, cards, empty) {
if (!cards.length && !empty) return;
list.append(el('div', 'anomalous-ps-cards-head', title));
const head = el('div', 'anomalous-ps-cards-head');
head.append(el('span', 'anomalous-ps-cards-tab', title), el('span', 'anomalous-ps-cards-count', String(cards.length)));
list.append(head);
if (cards.length) list.append(...cards.map(cardElement));
else list.append(el('div', 'anomalous-ps-cards-empty', empty));
}
@@ -224,7 +243,12 @@ export function createPromptSourceDeck(parent, drawer, scope, { onPick, onAdd })
if (!document.hidden) void sync();
});
function setOpen(id) {
openId = id;
for (const item of list.querySelectorAll('.anomalous-ps-card')) item.classList.toggle('is-open', item.dataset.cardId === id);
}
renderList();
void sync();
return { sync };
return { sync, setOpen };
}
+1 -1
View File
@@ -144,5 +144,5 @@ export function createPromptWorkbench(owner, drawer, scope, { mode = 'docked', o
});
const target = createPromptTarget(grid, { owner, drawer, scope, prefs, onSave: save });
// Between the cards and the boxes, whichever side the drawer is docked on.
const reference = createPromptReference(grid, { drawer, scope, prefs, target });
const reference = createPromptReference(grid, { drawer, scope, prefs, target, onShow: card => deck.setOpen(card?.id ?? null) });
}

Some files were not shown because too many files have changed in this diff Show More