Compare commits

...
787 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
DemonGatanjieuandClaude Opus 5.5 0956f7fc02 v1.60.2: the browser opens whatever the plugin folder is called
The stylesheet and the no-cache rule used the fixed path
/extensions/Anomalous_Model_Browser/. A plugin unpacked from a GitHub ZIP
sits in Anomalous_Model_Browser-main: its look never loaded, the browser
opened unstyled under the canvas (a click seemed to do nothing) and the
top-bar entry showed although the floating button was chosen. Both now
follow the folder's real name. Reported in a Bilibili comment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 15:01:18 +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 a6115df306 v1.60.1 is a stable release, not a beta
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 21:46:18 +00:00
Claude 6634f17bdd Merge branch 'feature/remote-access' into feature/1.61
# Conflicts:
#	CHANGELOG.md
2026-10-22 21:42:45 +00:00
Claude 44ac79a218 v1.60.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 21:42:25 +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 95147fda5e Merge feature/model-import: the browser no longer opens blank after Put on canvas
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:01:43 +08:00
DemonGatanjieuandClaude Opus 5.5 a679b82af5 Combos: the browser no longer opens blank after Put on canvas
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 combo list or the combo put down.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:00:22 +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 2fa934574e Merge branch 'feature/model-import' into feature/remote-access
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:52:46 +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 ea6b29d783 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 02:02:04 +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 653386ad6e Prompt Studio: a clicked card opens as a reference to take single tags
A user asked to pick tags from a saved prompt instead of pouring the
whole card in. A clicked card now opens between the cards and the prompt
being edited (ui_prompt_reference.js): a click on one of its tags adds
that tag, Add all the whole card, ticks show what the box has. The
preview's Add and dragging a card work as before; beside the canvas the
drawer widens by the column while it is open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:31:20 +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
DemonGatanjieuandClaude Opus 5.5 998f232307 Voice-over: listen from the ▶ on each line card
Each card has a ▶ (on hover, and on the card you are on) that reads its
line in the mode picked on the side; the side names the line and keeps
the modes heard on it as buttons to compare, without a listen button of
its own. The cards are drawn again once the node's modes are known.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:31:12 +08:00
DemonGatanjieuandClaude Opus 5.5 17a2c2f03d Full window for the browser; more room on Voice-over
A full-window button beside Dock (and a double-click on the title bar)
makes the window fill the ComfyUI page, remembered; it is neither dragged
nor resized then, and docking turns it off. Voice-over: one row per line
card, a divider between the lines and the generating side (dragged and
remembered, ui_split_bar.js; about half each by default), "Into your own
workflow" folded, who reads and their voices on one row, the how-to line
only until there are lines. On a wide side the modes' scale and numbers
are shown open unless the fold was closed before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:20:17 +08:00
DemonGatanjieuandClaude Opus 5.5 8e923c5bbe Voice-over: listen to the line you click on the left
The line card you click or edit becomes the one the modes are heard on:
it is marked "heard on the right", and the run section names it beside
Listen ("line 3: ..."), replacing the dropdown. An empty line falls back
to a sample in the character's language.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:56:41 +08:00
DemonGatanjieuandClaude Opus 5.5 156cede0c6 Voice-over: a shorter mode area, and listening names the line it reads
On the narrow side the scale joins the numbers in one fold and the long
notes became one sentence with the mode's description, so the side fits
without scrolling at a mid-size window; the side is a little wider. A
dropdown beside Listen shows which line of the script is read (#1 at
first) and picks another; the takes heard follow that line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:52:36 +08:00
DemonGatanjieuandClaude Opus 5.5 7341ab52d1 Make the modes' editable numbers read as values, the box only on hover
A bordered input pulled the eye from "15 -> 10". The number now looks like
plain bold text with a dashed underline; the box shows on hover or while
typing, without spin arrows (they covered the digits). The old value is a
little brighter so the change reads as one line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:38:37 +08:00
DemonGatanjieuandClaude Opus 5.5 5e23c4a52d Measure modes against the default, steady to lively, and listen to them
A mode is shown against one fixed mode (the default preset in Settings,
Auto on Voice-over): a steady-to-lively scale with both marked and a
sentence ("a little livelier than your default"); the numbers move into a
fold. Listening reads one line with one seed through Preview Audio, only
the mode changing; takes are kept, and the modes heard stay as buttons to
compare. Settings picks the character and the line; Voice-over uses the
script's first sentence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 20:51:38 +08:00
DemonGatanjieuandClaude Opus 5.5 9f7f58cb67 Generation modes: pick a preset to see its numbers against the last one
GPT-SoVITS Settings and the Script Director share one switch: the modes
as choices, the picked one's numbers drawn like the Current node card
(two bars, before -> now, an arrow), each with its plain and its node
name, editable in place and saved into the preset. Settings gets Make
default / Reset / + New / Rename / Delete around it. The Script Director
replaces its folded fine-tuning numbers (which showed the node's defaults,
not what the presets used) with Auto or one preset for the whole script,
sent as the node's preset input, plus links to the presets and to the
character's generation settings; Save as default keeps the language and
speed and leaves the rest of the character's defaults alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 19:33:12 +08:00
DemonGatanjieuandClaude Opus 5.5 39ca8ab25c Show every preset's numbers side by side; make, rename and delete presets
The numbers table marks values that differ between presets and changed
built-in values (with the built-in one), like the compare window; built-in
presets reset one by one or together. New presets start as a copy, are
named by the user and can be picked everywhere. A character's generation
settings show each row's numbers in effect next to the general ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 18:23:12 +08:00
DemonGatanjieuandClaude Opus 5.5 3159249eb2 Generation presets in GPT-SoVITS Settings; a character's generation settings
For Anomalous_TTS interface 16: the default preset (Steady / Standard /
Lively), a preset for each pair of languages the characters can meet and the
presets' numbers, saved at once to the node's presets file; a Generation
settings button on character cards for the character's own preset, speed and
numbers, as a whole and per language. Older nodes get a pointer to the update
line instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 16:44:12 +08:00
DemonGatanjieuandClaude Opus 5.5 2e62703923 Update Anomalous_TTS from GPT-SoVITS Settings
The version manager takes an optional Checkout, so the companion node pack is
handled like the plugin itself: api/tts_version.py finds it among the
custom_nodes folders by its node class and accepts only a git clone of the
official repository; releases are fetched only on "Check for updates", an
update checks out the newest stable tag and can be undone. A checkout without
the release tags (cloned before it, then pulled) is matched by ls-remote.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:08:10 +08:00
DemonGatanjieu 469e199542 Merge branch 'main' into feature/model-import
# Conflicts:
#	CHANGELOG.md
#	README.md
2026-10-17 12:55:33 +08:00
DemonGatanjieu d857322c96 Merge branch 'feature/model-import' into feature/remote-access 2026-10-17 12:55:33 +08:00
DemonGatanjieuandClaude Opus 5.5 8697663026 v1.59.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:55:01 +08:00
DemonGatanjieuandClaude Opus 5.5 5b3c365026 CHANGELOG: the v3/v4 voice entries ship in 1.59.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:50:50 +08:00
DemonGatanjieu 33177a8994 Merge branch 'feature/model-import' into feature/remote-access 2026-10-17 12:49:55 +08:00
DemonGatanjieu 964f7e0195 Merge branch 'main' into feature/model-import
# Conflicts:
#	CHANGELOG.md
#	web/styles.css
2026-10-17 12:49:48 +08:00
DemonGatanjieuandClaude Opus 5.5 07a989fbe9 Show the GPT-SoVITS model version on character cards; v3/v4 imports (1.59.1)
From 1.60.1 (6996f07): character cards show the version Anomalous_TTS reports
(interface 15); v3/v4 base models and vocoders are listed and reminded of only
when a character or a dropped SoVITS file needs them, with their own labels;
the import tells an unreadable SoVITS file from a version an older node cannot
use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:48:37 +08:00
DemonGatanjieuandClaude Opus 5.5 4997edecb0 CHANGELOG: v3 and v4 voices; v5 left out until tried with a real model
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:40:09 +08:00
DemonGatanjieuandClaude Opus 5.5 6996f07063 Show the GPT-SoVITS model version on character cards; v3/v4/v5 imports
Character cards show the version Anomalous_TTS reports (interface 15). The
v3/v4/v5 base models and vocoders are listed, counted and reminded of only
when a character or a dropped SoVITS file needs them; the import tells an
unreadable SoVITS file from a version an older node cannot use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:29:25 +08:00
DemonGatanjieu cdc8ad2f57 Merge branch 'feature/model-import' into feature/remote-access 2026-10-17 11:34:29 +08:00
DemonGatanjieu 3cbe5046d2 Merge branch 'main' into feature/model-import 2026-10-17 11:34:28 +08:00
DemonGatanjieuandClaude Opus 5.5 f35d68671f README: Chinese pictures from the 1.59 interface
The Chinese part's seven pictures are re-shot in the 1.59 interface on the
isolated demo ComfyUI (demo models only); the combo picture shows picked
nodes saved as a combo and put back, and its text says so. The old
Chinese title picture, no longer used, is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:34:21 +08:00
DemonGatanjieu 08b0c8b613 Merge branch 'feature/model-import' into feature/remote-access 2026-10-17 11:25:59 +08:00
DemonGatanjieu 4dcbd00692 Merge branch 'main' into feature/model-import 2026-10-17 11:25:58 +08:00
DemonGatanjieuandClaude Opus 5.5 169d455e1e README: Model Check finds renamed models by the fingerprints in the workflow
Without them it can only offer a file of the same name; the setting that
records them is named.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:25:52 +08:00
DemonGatanjieu c6190d4974 Merge branch 'feature/model-import' into feature/remote-access 2026-10-17 11:20:33 +08:00
DemonGatanjieu 5d1243511f Merge remote-tracking branch 'origin/feature/model-import' into feature/model-import 2026-10-17 11:20:30 +08:00
DemonGatanjieu 765d3aff9e Merge branch 'feature/model-import' into feature/remote-access 2026-10-17 11:20:02 +08:00
DemonGatanjieu d084c2d74f Merge remote-tracking branch 'origin/main' into feature/model-import
# Conflicts:
#	CHANGELOG.md
2026-10-17 11:20:02 +08:00
Claude 0ebd2f0cb1 Merge branch 'feature/next' into feature/model-import 2026-10-17 02:03:48 +00:00
Claude 241f702655 v1.59.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 02:03:09 +00:00
DemonGatanjieuandClaude Opus 5.5 e05e4e24fa Combo models as cover cards, not rows as wide as the window
A combo saved from picked nodes shows each model as a card like the model
library's: the cover with its slot written on it, the name and node below,
a click on the card (or Change on the cover) picks another model. The
default combo's main models and LoRAs to pick from are cover cards side by
side instead of wide rows; its empty cover is a class, not inline styles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 03:21:44 +08:00
DemonGatanjieu db0067daea Merge branch 'feature/model-import' into feature/remote-access 2026-10-17 03:13:48 +08:00
DemonGatanjieu 1cb8c32312 Merge branch 'feature/next' into feature/model-import 2026-10-17 03:13:47 +08:00
DemonGatanjieuandClaude Opus 5.5 8681be0402 README: the combo picture shows saving picked nodes, then putting them back
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 03:13:47 +08:00
DemonGatanjieuandClaude Opus 5.5 348dd1dbe5 Call this line v1.60.1, not v1.61.0
Better scanning and cloud servers improve 1.60 without being a big release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 03:05:48 +08:00
DemonGatanjieu 9a0f319162 Merge branch 'feature/model-import' into feature/remote-access
# Conflicts:
#	README.md
2026-10-17 02:40:37 +08:00
DemonGatanjieu e38ae06386 Merge branch 'feature/next' into feature/model-import
# Conflicts:
#	README.md
2026-10-17 02:40:29 +08:00
DemonGatanjieuandClaude Opus 5.5 bf115de8b0 README: start from what it helps with; English pictures for the English part
Each language now opens with six everyday problems (unknown model files, a
shared workflow with red nodes, finding how a picture was made, the same
combo again, long prompts, talking characters) plus Activity's Undo, each
with a picture; install is three steps. The page table, tools, MCP, opening,
updating, network and backup text moved unchanged into folded sections.
The English part and the title picture use new English-interface pictures
from the isolated demo ComfyUI (1.59, demo models only); the Chinese part
keeps its pictures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 02:39:06 +08:00
DemonGatanjieu dd53fe3775 Merge branch 'feature/model-import' into feature/remote-access 2026-10-17 02:14:19 +08:00
DemonGatanjieu a64f28fa7d Merge branch 'feature/next' into feature/model-import
# Conflicts:
#	README.md
2026-10-17 02:14:18 +08:00
DemonGatanjieu f9ed678e4c Merge branch 'main' into feature/next
# Conflicts:
#	README.md
2026-10-17 02:14:08 +08:00
DemonGatanjieuandClaude Opus 5.5 2327a6df6e README: the new Bilibili videos and the Reddit post; no YouTube
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 02:13:40 +08:00
DemonGatanjieuandClaude Opus 5.5 6cba4c9e14 CHANGELOG: 1.61 without a headline, scanning and cloud servers apart
The owner closes the cloud-server work here (no more server tests) and
keeps adding the next changes to 1.61, so its heading names no theme yet.
The scan page and Civitai items get a Scanning section of their own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:53:39 +08:00
DemonGatanjieuandClaude Opus 5.5 886c49f104 Cloud guide: not fully tested, and a section for AI assistants
The guide, both README notes and the 1.61 changelog say it was tested on
one AutoDL server only, so users watch out. The guide ends with "For AI
assistants": what view only means, the two ways (SSH tunnel, the switch
file via the chip's Copy), how to check (the yellow chip, the start log
line, /anomalous/access), what to do when it did not work, and what to
tell the user about safety (no proxy or VPN software on the server, no
keys in the chat). A user can hand the link to their AI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:50:25 +08:00
DemonGatanjieuandClaude Opus 5.5 e7cd835635 Cloud servers: say how far support goes
Overseas cloud servers (RunPod...) are fully supported. Servers in
mainland China (AutoDL...) only in part: browsing, offline scans and
tidying work, but Civitai is mostly out of reach there, so lookups are
unreliable and covers rarely download. Said at the top of the cloud
guide, in both README notes and in the 1.61 changelog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:45:19 +08:00
DemonGatanjieuandClaude Opus 5.5 6374c914b5 Scan page: last counts at once, headers kept on disk, found models listed
With many models, or models on a network drive, every visit to the scan
page read every folder and, after a restart, every new model's header
again (79 s for 1041 models on an AutoDL model disk).

api/model_heads.py keeps what each file's header says by real path, size
and time in user/anomalous/model_heads.json (at most 50,000 files, the
longest unused dropped first, written a few seconds after the last
change, only once setup_routes called keep_on_disk); get_metadata reads
headers through it, so a header is read once per file version.

scan_summary keeps the last summary (memory, and scan_summary.json when
it changed); ?quick=1 hands it out at once and the page shows it with
"showing the last counts while they are updated", then the fresh ones.
Requests meanwhile await one count (_fresh). The models found on Civitai
(components apart, at most 5000 kept) are listed by GET
/anomalous/scan_matched; the page's "Found on Civitai" count opens them,
with Civitai's name, base model, a box to find one by name and Open.

Checked on the isolated ComfyUI reading the real loras and checkpoints
read-only (scans stubbed; no file there changed): the last counts showed
at once also after a restart, the fresh ones replaced them, the found list
had 68 models with names, the box filtered, Open and Back returned to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 22:43:44 +08:00
DemonGatanjieuandClaude Opus 5.5 142280e4ec Scans ask when Civitai cannot be reached; linked models keep outside info
Before an online scan (the scan page, a card's radar, after a download or
an import), scan_connection.js asks GET /anomalous/civitai_check, which
tries both of Civitai's addresses. When neither answers, a dialog says
why and offers Scan from the files (the offline scan, so nothing waits on
every model), Set a proxy (Settings → Models, Network) or Cancel. A check
that reached Civitai is trusted for five minutes. anomalousConfirm takes
okClass, so this choice is not drawn as a delete.

A model that is a link with no info beside it shows the info beside the
file it links to (metadata._info_base): A1111's Civitai Helper leaves
.civitai.info there. Only read there; covers, edits and scans stay beside
the link.

Checked on the isolated ComfyUI with the check and the scan request
stubbed in the page: the dialog's three choices (from files posts
offline_only, set a proxy opens the Models tab, cancel posts nothing),
an answering Civitai scans online without asking, and a second scan
skips the check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 22:21:57 +08:00
DemonGatanjieu c157928e53 Merge remote-tracking branch 'origin/feature/model-import' into feature/remote-access 2026-10-16 22:10:06 +08:00
DemonGatanjieu 5605ca010a Merge remote-tracking branch 'origin/feature/next' into feature/model-import 2026-10-16 22:09:56 +08:00
DemonGatanjieuandClaude Opus 5.5 e0ea150abe Chinese UI says 代理软件 instead of the slang 梯子
The proxy help, the SOCKS warning, the refused-connection reason and the
Civitai scan hint used the slang word 梯子. Plain 代理软件 says the same and
is safe to show in videos and screenshots on Chinese platforms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:46:58 +08:00
DemonGatanjieuandClaude Opus 5.5 07686cf026 CHANGELOG: linked models under 1.61, where the fix now lives
1.59 and 1.60 no longer carry it, so its line moves from the 1.59
Fixes into the 1.61 cloud-server section, with Tidy moving the link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:38:19 +08:00
DemonGatanjieuandClaude Opus 5.5 9a6643a03e Merge feature/model-import, keeping linked models on 1.61
feature/model-import's only new commits are the reverts that took the
linked-models fix out of 1.59 and 1.60 (0e81e2f, 80b42fd, 7eadc55). The
owner keeps that work in 1.61, so this merge records them and keeps this
branch's files as they were (2366d18): later merges from 1.59 and 1.60 do
not take the fix out here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:36:39 +08:00
DemonGatanjieu 7eadc553e5 Merge branch 'feature/next' into feature/model-import
# Conflicts:
#	api/model_folder.py
2026-10-16 21:34:44 +08:00
DemonGatanjieuandClaude Opus 5.5 80b42fd834 Revert the merge of "Models that are links: taken as named"
This reverts merge 25f1f6d (feature/next's 851a8cb and this branch's
adaptations of the scan choice, the editor's covers, Tidy's move and
move_into). The owner keeps the cloud-server work in 1.61
(feature/remote-access); 1.60 is back to 4bc559e.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:34:05 +08:00
DemonGatanjieuandClaude Opus 5.5 0e81e2f456 Revert "Models that are links: taken as named, covers and info beside the link"
This reverts commit 851a8cb. The owner keeps the cloud-server work in
1.61 (feature/remote-access), so 1.59 ships as it was; the change lives
on there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:32:29 +08:00
DemonGatanjieu 2366d1880f Merge branch 'feature/model-import' into feature/remote-access
# Conflicts:
#	AGENTS.md
2026-10-16 21:20:15 +08:00
DemonGatanjieuandClaude Opus 5.5 25f1f6d7b7 Merge feature/next: models that are links, taken as named
1.60's own places that took a model file by name use the new helpers
too: the scan choice (model_file_within), the editor's covers, Tidy's
move (model_reference_within). Opening a folder keeps 1.60's "no file
name opens the folder". move_into tells a rename from a copy by where the
link is (lstat), so Tidy moving a linked model renames the link instead
of copying the model disk's file onto the server. The open-folder test
stubs through path_utils and also refuses "..".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:18:31 +08:00
DemonGatanjieuandClaude Opus 5.5 851a8cb2d5 Models that are links: taken as named, covers and info beside the link
On an AutoDL server every model in the models folder is a link into the
platform's shared, read-only model disk, named like 6b4679... without an
extension. Scans wrote the .info beside the link, but get_metadata
resolved the path and read beside the disk's file: a scanned model
showed as not scanned, under the disk's file name. Delete, edit, open
folder, MCP and saved-workflow lookups resolved the file too, so they
refused it as outside the models folder (or, for a link inside it, would
have acted on the file behind it).

path_utils.model_file_within joins a resolved folder with a bare name
(require_filename, which now refuses "." and ".." too);
model_reference_within resolves the subfolders of a relative path inside
the root, through links as before, and takes the file as named.
get_metadata reads beside the path it is given (abspath). Edits, renames
and deletes act on the link; nothing writes into a model file.

Checked with a link stood in by patching realpath (this computer cannot
make links): the info beside the link is read and the name is the
link's; "..", ".", "a/b" and a relative path leaving the root are refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:12:15 +08:00
DemonGatanjieuandClaude Opus 5.5 6fb69294f2 Civitai: ask civitai.red when civitai.com does not answer
On an AutoDL server civitai.com timed out while civitai.red, Civitai's
other address with the same API, answered in about a second, so scans
there fell back to guessing from the files.

civitai_client.api_hosts gives both addresses, the one that answered
last first; the scan's lookups, import's and Model Check's
(download_sources._civitai_json) and the network test ask the other one
when the first does not answer at all. An HTTP error is Civitai's own
answer and is not asked again. The test answers which host answered,
and Settings says so when it was civitai.red.

The pictures stay on image.civitai.com, which such networks often cannot
reach either: after COVERS_DOWN_AFTER (2) covers in a row that would not
download, a scan fetches no more covers instead of waiting on each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:49:29 +08:00
DemonGatanjieu 0ed6ab2543 Merge branch 'feature/model-import' into feature/remote-access 2026-10-16 19:39:35 +08:00
DemonGatanjieuandClaude Opus 5.5 4bc559eaa4 Scan page: quick with many models or a network drive; says it is counting
On a cloud server with 1041 models not scanned yet, on the platform's
network model disk, GET /anomalous/scan_summary took 79 s, each time the
page opened, and the page stayed blank meanwhile: it read each new
model's header (for "looks like no drawing model") one after another.

The headers are now read 16 at a time (HEAD_READERS) and through
get_metadata, whose cache keeps each answer until the file changes, so
the page opened again reads none of them and the picker after it finds
them there. Opened afresh, a count slower than 400 ms shows "Counting
your models…" instead of a blank page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:39:15 +08:00
DemonGatanjieuandClaude Opus 5.5 df2c5dd6c3 Remote chip: the line to paste into the server's terminal, with Copy
Cloud users had to find the plugin folder and make allow_remote_changes
themselves. GET /anomalous/access now also answers another device with
"command": the line that makes the file (touch, or echo off > on Windows,
which works in cmd and PowerShell), or removes it when it is there, with
this server's real path, quoted. The chip's dialog shows it with a Copy
button; when the browser refuses the clipboard the line is selected for
Ctrl+C. The line only runs where someone pastes it, on the server.

anomalousAlert takes options.copy; copyText moved from ui_share.js to
ui_dialog.js so both use it.

Checked behind the stand-in gateway: without the file the dialog offers
the line that makes it, with the file the line that removes it; both
Windows lines tried in cmd and PowerShell.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:29:51 +08:00
DemonGatanjieuandClaude Opus 5.5 4a070729b1 Remote chip: a span with a button's role; the start warning flushes
The header's button rules are !important, so a <button> chip lost its own
colours and size; it is a span again, with role="button", a tab stop and
Enter / Space opening its explanation. The start-up line about
allow_remote_changes is flushed at once, so it shows even when ComfyUI's
output goes to a file.

Checked on an isolated ComfyUI behind a stand-in gateway that adds
X-Forwarded-For (as a cloud platform's proxy does): without the file the
page is view only and changes answer 403; with it the chip reads "remote
changes on" in yellow, a scan choice is saved and reset, and a picked
offline scan runs to the end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:07:39 +08:00
DemonGatanjieuandClaude Opus 5.5 5b97d8a5ab Cloud servers: an allow_remote_changes file lets other devices change things
ComfyUI on a rented cloud server (AutoDL, RunPod) is only ever opened
from another device, so since v1.58.1 the plugin was view only there.
An empty allow_remote_changes file in the plugin folder, made on the
server and read once at start, now makes local_guard.may_change true for
every request; the middleware and the routes that check for themselves
(import, placement, scan choice, recipe focus) use it. is_local stays for
import's search of this computer's Downloads and for MCP. The page cannot
make or remove the file, and the file is git-ignored.

GET /anomalous/access answers {local, changes}. The header chip is now a
button: "view only" says what is refused and the two ways for cloud users
(SSH tunnel, the file); with the file it reads "remote changes on", in
yellow, and says that anyone with the address may change things.

docs/guides/cloud-server.md explains both ways and the risk; README,
AGENTS.md rule 4, backend.md and the CHANGELOG (v1.61.0) say so too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:00:30 +08:00
DemonGatanjieu 797a7218d8 Merge branch 'main' into feature/next
# Conflicts:
#	CHANGELOG.md
#	README.md
2026-10-15 13:03:53 +08:00
DemonGatanjieu 5e9ca3e3ac Merge branch 'feature/next' into feature/model-import 2026-10-15 13:03:53 +08:00
DemonGatanjieuandClaude Opus 5.5 17026b3645 v1.58.3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:03:07 +08:00
DemonGatanjieu 613034f3ec Merge branch 'feature/next' into feature/model-import
# Conflicts:
#	web/styles.css
2026-10-15 12:55:22 +08:00
DemonGatanjieuandClaude Opus 5.5 b2389de45b Structure check rejects :has() that matches the style attribute
The pattern behind issue #22 came back once already (removed in v1.57.2,
back with the icon rail in v1.58.0). tools/check_structure.mjs now fails
on any :has() in the web stylesheets whose argument matches [style...],
pointing at the line; AGENTS.md, docs/guides/testing.md and lessons.md
say why and what to do instead (a class on an ancestor).

Prepared in a cloud session (checkpoint cbfc782), committed now that the
issue #22 fix is on this branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:53:51 +08:00
DemonGatanjieu 785b48bb1b Merge branch 'main' into feature/next
# Conflicts:
#	CHANGELOG.md
#	web/styles.css
2026-10-15 12:53:20 +08:00
DemonGatanjieuandClaude Opus 5.5 7ceb591cbf Fix the list column's :has() rule slowing every style change (#22)
The rule that hides the list column while a workspace is open matched
the workspace panel's style attribute inside :has(). Chrome then treats
every inline-style write on the page as a possible change and restyles
the whole page, so extensions that move their buttons every frame took
a large workflow's canvas down to 2-3 fps. The container now gets an
anomalous-workspace-open class from a MutationObserver on the panel's
style (every module opening a workspace sets its display), and the CSS
keys off that class.

Found, measured and fix proposed by Lindemellis in issue #22.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:52:49 +08:00
DemonGatanjieuandClaude Opus 5.5 396b2a1489 Move the node packs not used lately to Settings → Node packs
Node packs are ComfyUI's extensions, not material, so looking for them
under the models page's Tidy tab felt odd. Tidy keeps the models and a
line leading to the new Settings tab; both pages share the check and
its head (usage_view.js), ui_pack_usage.js draws the packs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:25:04 +08:00
DemonGatanjieuandClaude Opus 5.5 afd45520d4 Tidy: "I still use it" for node packs no record can judge
A pack used only through its panels or buttons leaves no record, so the
user can mark it as still in use (POST /anomalous/usage/keep, kept in
user/anomalous/usage_kept.json, only packs ComfyUI loaded). Marked packs
leave the groups for a list of their own; Unmark undoes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:59:50 +08:00
DemonGatanjieuandClaude Opus 5.5 55e9611b76 Tidy: say that only a pack's panel being used leaves no record
"Never opened" read as "this pack was never opened", while it meant
"in no opened workflow". The page-part group now says using only a
panel or a button leaves no record.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:54:52 +08:00
DemonGatanjieuandClaude Opus 5.5 48e273f5c7 Tidy: opened workflows count as use; packs with a page part set apart
A node put on the canvas or a workflow opened and never run read as
"never used". The page now notes what each opened workflow holds
(usage_notes.js, POST /anomalous/usage/opened, kept apart from runs in
the run record) and the open tabs just before a check.

Packs whose nodes went unused but which also add to the page (buttons,
menus, monitors) get their own group: unused nodes do not say the pack
is. The packs count covers only packs that work through nodes alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:49:32 +08:00
DemonGatanjieu 8de2926a53 Merge branch 'feature/next' into feature/model-import 2026-10-14 21:34:34 +08:00
DemonGatanjieuandClaude Opus 5.5 40fc88cb2d Tidy: say what counts as use
"No sign of use" read as "never made an image". The node pack page now
says only what a run uses counts (a node put on the canvas, a panel
opened do not) and that "installed" is the folder's creation date.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:34:34 +08:00
DemonGatanjieuandClaude Opus 5.5 f53a3dd743 Remove the Claude and Gemini entry files
AGENTS.md is the one set of rules; CLAUDE.md and GEMINI.md only pointed
to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:34:13 +08:00
DemonGatanjieuandClaude Opus 5.5 bf9c46d728 Tidy: a run record, careful judging and a node pack page
ComfyUI keeps no lasting note of what a run used, so AMB now notes each
queued run's node types and model files (on_prompt hook, user/anomalous/
usage_ledger.json). "Not used lately" reads it with the outputs, now
sounds too, and the saved workflows, and judges nothing that came in the
last 30 days; while the record is younger than the group shown, the page
says so.

Node packs get their own page: installed for how long, whether they add
to the page, which packs take what only they make, and a button opening
ComfyUI Manager's node list searched for the pack. Packs without nodes
are listed apart, as nothing can tell whether they are used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:18:37 +08:00
DemonGatanjieuandClaude Opus 5.5 f70d890e61 Tidy: models and node packs not used lately
A Bilibili user asked for a way to clear out models and node packs they
tried once and no longer use, without breaking other workflows.

api/model_usage.py (GET /anomalous/usage/check, this computer only, run
on a press): when each model and node pack was last used, from the PNG
outputs' prompts (image_search's cached records now keep node types) and
the saved ComfyUI workflows, recipes, combos and parameter sets; models
match by path, file name or scanned SHA-256; reading is capped and
nothing is deleted there.

ui_model_usage.js under Tidy's two steps: not used for 30 / 90 days or
never, biggest first, the last output's date, a warning when a saved
workflow still names a model; picked models go to the Recycle Bin. Node
packs are only listed, with a button that opens the installed ComfyUI
Manager's node list (Manager 4 or 3, probed by its own routes; without
one it says so).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:28:44 +08:00
DemonGatanjieu b6681ed0b9 Merge branch 'feature/next' into feature/model-import
# Conflicts:
#	web/modules/ui_settings_page.js
2026-10-14 20:14:06 +08:00
DemonGatanjieuandClaude Opus 5.5 d69527968a Settings: the downloads card's comment back above its own function
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:13:54 +08:00
DemonGatanjieu a0f7fa3f9c Merge branch 'feature/next' into feature/model-import
# Conflicts:
#	ARCHITECTURE.md
#	api/__init__.py
#	api/network_check.py
#	web/modules/locales.js
#	web/modules/ui_scan_lists.js
#	web/modules/ui_settings_page.js
2026-10-14 20:13:35 +08:00
DemonGatanjieuandClaude Opus 5.5 6f4347d88d Settings -> Network: a proxy for the plugin, and why Civitai cannot be reached
A Bilibili user's scan said Civitai could not be reached while their
browser reached it through a VPN app: the plugin's urllib requests only
use HTTPS_PROXY or the Windows proxy, not a PAC script or a browser-only
proxy.

net_proxy.py: the proxy address saved in api/config.json (NETWORK_PROXY)
that every request of the plugin goes through (Civitai, Hugging Face,
GitHub, translators), handed to the scan process as HTTP(S)_PROXY; empty
keeps the old behaviour. It also names why a request failed (timeout,
refused, dns, ssl, reset, proxy, http:<code>).

Settings -> Network has the address (SOCKS and malformed ones refused
with what to write) and Test connection, which calls 1.60's
/anomalous/civitai_check (brought here unchanged, now through the proxy
and with the failure's name). A scan that loses Civitai records why
(civitai_error) and the scan page and activity log say it with what to
try.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:10:34 +08:00
DemonGatanjieuandClaude Opus 5.5 103ee6a25d First-run setup on Home: check Civitai, optional key, scan everything, then the tour
New users did not know to scan first. Until anything is scanned, Home shows a card with four
steps and "Set up for me": GET /anomalous/civitai_check (api/network_check.py, local requests
only) says whether Civitai answers and whether a set key is refused; when it does not, the card
offers Retry (with a proxy) or an offline scan. The scan never renames files or refetches,
whatever the scan page was set to; the card follows its progress. Never a popup; "Later" hides it
per browser and Home's "New here?" line brings it back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:26:13 +08:00
DemonGatanjieuandClaude Opus 5.5 c59927e701 Read the LoRAs of popular third-party nodes wherever AMB reads a workflow's models
AMB read models only from ordinary drop-downs, so rgthree's Power Lora Loader (rows are
{on, lora, strength} objects) was invisible: Model Check never reported its missing LoRAs.
Now its switched-on rows are Model Check boxes (lora_nodes.js powerLoraRowBoxes: ComfyUI's LoRA
list as choices, a fix changes only the row's LoRA; downloads put files in the same way), saved
fingerprints and a picture's model search read them, the workflow summary lists them, and saved
model references include them and Easy-Use LoRA stack rows (nodeModelValues /
_model_reference_values, mirrored). Only these widely used nodes; ComfyUI's loaders stay the
reference.

recipe_parser.js was over 600 lines: which LoRAs a node loads moves to recipe_loras.js and the
box readers to recipe_widgets.js.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:57:36 +08:00
DemonGatanjieuandClaude Opus 5.5 06461c4f9a Combos put LoRAs in rgthree's Power Lora Loader or Easy-Use's LoRA stack
Stacking many LoRAs made a long chain of Load LoRA nodes. A default combo now says which LoRA
node it is laid out with (data.loraNode: ComfyUI's own chain, rgthree's Power Lora Loader, or
Easy-Use's LoRA stack + Apply LoraStack), chosen under New or in its editor; the third-party ones
are offered only when installed, and a missing one falls back to ComfyUI's own and says so.

Save as combo lost a Power Lora Loader's rows (object values were not stored): they are kept as
the node's rows, made again before the slots are filled, each row's LoRA a slot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:36:08 +08:00
DemonGatanjieuandClaude Opus 5.5 38236d70f6 1.60 takes in param-compare: its What's-new step and the release title
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:29:01 +08:00
DemonGatanjieu 34f57b5c64 Merge branch 'feature/model-import' into feature/param-compare 2026-10-13 21:05:44 +08:00
DemonGatanjieuandClaude Opus 5.5 7d8cf3427f Description code blocks wrap instead of widening the details
A prompt quoted on one line in a Civitai description made the whole details column scroll sideways.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:05:43 +08:00
DemonGatanjieu 1031c77569 Merge branch 'feature/model-import' into feature/param-compare
# Conflicts:
#	web/styles.css
2026-10-13 21:03:51 +08:00
DemonGatanjieuandClaude Opus 5.5 6d0f833b07 Details foot: a gap above its rule on long pages too
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:02:49 +08:00
DemonGatanjieu 284ddc782a Merge branch 'feature/model-import' into feature/param-compare
# Conflicts:
#	ARCHITECTURE.md
#	web/styles.css
#	web/styles/03-gallery-recipes.css
2026-10-13 20:59:37 +08:00
DemonGatanjieuandClaude Opus 5.5 fba4de0f11 Details: path, Copy and Delete at the foot, no ⋯; the folder drawer no longer squeezes the page
The ⋯ menu opened over the path line and the description, and two of its three entries
(copy path, copy file name) were rarely needed. The path bar sat between the buttons and the
description. Now the file's path, Copy and Delete model are the quiet foot of the details, and
Folder's tooltip shows the path.

In a narrow window the folder drawer went back into the page's layout the moment it closed and
shrank from 270px, squeezing the page as it slid; it now stays positioned over the page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:58:07 +08:00
DemonGatanjieu ca5f2fdb8d Merge feature/model-import: Combos page like the others, description headings
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	ARCHITECTURE.md
2026-10-13 20:34:37 +08:00
DemonGatanjieuandClaude Opus 5.5 7c8206b36a Combos page like the others; descriptions' headings kept to size; CHANGELOG
- The Combos workspace sat in a lighter, inset box with a shadow, a second "Combos" heading
  and a red close: it now fills the page in the page's colour like Workflows; its bar shows
  only in the editor, with "← All combos" (Esc presses it, the rail and Esc close the page).
  The explanation folds behind ? and shows by itself while there are no combos.
- A model's description (Civitai's HTML) keeps h1-h6 near the text size.
- CHANGELOG for these and the folder column.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:33:15 +08:00
DemonGatanjieu 37d1feebe3 Merge feature/model-import: folder column follows the type, opens folders
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	web/styles.css
2026-10-13 20:28:15 +08:00
DemonGatanjieuandClaude Opus 5.5 e0abbd277a Folder column: the current type's folders, with 📂 to open one in Explorer
The list column showed every type's tree (raw folder names, [Root] rows with 0, nothing marking
where you are), repeating the type chips above it. Now (ui_folder_list.js) it follows the chips:
the type's heading with "📂 Open", All, its subfolders as a tree whose numbers count their
subfolders (empty ones dimmed, folding per session in owner.collapsedFolders), and "In the top
folder" for models directly in the type's folder; the listed one is marked.

A Bilibili comment asked for quick ways to open model folders: POST /anomalous/open_model_folder
without a file name now opens the folder itself (type root or subfolder; still by type, index
and subfolder, never a path, local only). Tests in tests/test_model_folder.py.

Gone: the every-type tree, Expand / Collapse all, expandedFolders and their locale keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:28:03 +08:00
DemonGatanjieuandClaude Opus 5.5 b08e4b02e1 CHANGELOG: Esc in a model's Outputs window
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:51:17 +08:00
DemonGatanjieuandClaude Opus 5.5 8841efb785 Merge feature/model-import: CHANGELOG for the Outputs Esc fix
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:51:17 +08:00
DemonGatanjieuandClaude Opus 5.5 b2f4855422 Merge feature/next: CHANGELOG for the Outputs Esc fix
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:51:17 +08:00
DemonGatanjieuandClaude Opus 5.5 acb8214588 Merge feature/model-import: Esc closes a model's Outputs window
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:50:59 +08:00
DemonGatanjieuandClaude Opus 5.5 681b8ec7d2 Merge feature/next: Esc closes a model's Outputs window
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:50:59 +08:00
DemonGatanjieuandClaude Opus 5.5 4a0e75ae39 Esc closes a model's Outputs window, not the details under it
The Outputs window (showGeneratedGallery) sits inside the browser with only an id, so the Esc
rule did not see it as a layer: Esc pressed the details' back button and left the window open
over the list. Esc now hides it, as its close button does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:50:58 +08:00
DemonGatanjieu 3e4c5910eb Merge feature/model-import: model details, layout B
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	ARCHITECTURE.md
#	web/styles.css
#	web/styles/03-gallery-recipes.css
2026-10-13 15:46:07 +08:00
DemonGatanjieuandClaude Opus 5.5 983e9b1166 Model details, layout B: name and actions beside the cover
From a Bilibili comment (buttons small and in five places, the file name twice, a title bar
of little use), layout B chosen by the user from three mockups:
- The title bar goes. The head (new model_detail_head.js) tops the right column: a crumb
  back (Models / LoRA, or where history leads; still .anomalous-detail-back, so Esc presses
  it), the name, the source line with the source links, and one action row: Add node (the
  one accent button), Edit, Folder, Outputs, and ... with Copy path, Copy file name, Delete.
- Delete asks in AMB's dialog instead of window.confirm, and is no longer beside Add node.
- model_location.js: one path line with Copy; Show in folder is exported for Folder.
- action_menu.js: the small menu the gallery card's right-click had, shared with the ...
- Narrow (no cover column), a 64 px cover sits beside the name.
- Gone: the jump-to-bottom button, the full-width Outputs button, the location card's file
  name row, and their locale keys (back, backToPrev, detailJumpToBottom,
  detailPhysicalFilename, detailFullPath).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:45:44 +08:00
DemonGatanjieuandClaude Opus 5.5 c7b5048f60 Merge feature/model-import: credit the PR #21 authors by account
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:28:02 +08:00
DemonGatanjieuandClaude Opus 5.5 1d2f3d034a Merge feature/next: credit the PR #21 authors by account
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:28:02 +08:00
DemonGatanjieuandClaude Opus 5.5 5b8ea4792a CHANGELOG: credit the PR #21 authors by their GitHub accounts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:28:01 +08:00
DemonGatanjieu 986d002385 Merge feature/model-import: no focus ring around the viewer's video
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	web/styles.css
2026-10-13 15:18:45 +08:00
DemonGatanjieuandClaude Opus 5.5 89b849159e Merge feature/next: no focus ring around the viewer's video
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:16:09 +08:00
DemonGatanjieuandClaude Opus 5.5 95eea6c4ce Gallery viewer: no focus ring around a video
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:16:09 +08:00
DemonGatanjieu 770d161bdf Merge feature/model-import: gallery videos play in their card, fill the viewer
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	web/modules/ui_gallery_card.js
#	web/styles.css
2026-10-13 15:14:14 +08:00
DemonGatanjieuandClaude Opus 5.5 b82e18e26f Merge feature/next: gallery videos play in their card, fill the viewer
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:14:00 +08:00
DemonGatanjieuandClaude Opus 5.5 338bc0a260 Gallery videos play muted in their card; the viewer fits a video to the window
- A video card plays its video muted while the pointer is on it, or while in view when
  Settings -> Video covers says always play (the model covers' rule). The <video> exists only
  while playing, so a page of videos decodes nothing at rest; it lets clicks and drags through.
- The viewer sizes a video to the window (keeping its shape): 480p outputs looked small.
- Double-click in the viewer toggles full screen (checked afterwards so a browser that does it
  natively does not toggle twice).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:14:00 +08:00
DemonGatanjieuandClaude Opus 5.5 4de23834c2 Merge feature/model-import: CHANGELOG wording for gallery videos
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:34:56 +08:00
DemonGatanjieuandClaude Opus 5.5 0ce69d4a15 Merge feature/next: CHANGELOG wording for gallery videos
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:34:56 +08:00
DemonGatanjieuandClaude Opus 5.5 47ed42104d CHANGELOG: a dragged video becomes a Load Video node; the switch's English labels
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:34:56 +08:00
DemonGatanjieuandClaude Opus 5.5 8e0e28bbe1 Merge feature/model-import: the gallery viewer stops a video at once
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:33:18 +08:00
DemonGatanjieuandClaude Opus 5.5 8806c30ef9 Merge feature/next: the gallery viewer stops a video at once
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:33:18 +08:00
DemonGatanjieuandClaude Opus 5.5 aa742b7a69 The gallery viewer stops a video at once when it closes
The dialog's close event came half a second late in testing: the sound went on, and a video
opened again in that time was stopped. The x, a click beside it and Esc (cancel) stop it now;
close only catches a close nothing else saw.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:33:17 +08:00
DemonGatanjieu 3b157d22e5 Merge feature/model-import: videos in the gallery (PR #21)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	ARCHITECTURE.md
#	web/modules/ui_gallery.js
#	web/modules/ui_gallery_card.js
#	web/styles.css
2026-10-13 14:30:50 +08:00
DemonGatanjieu 208bcfdfdd Merge feature/next: videos in the gallery (PR #21)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	ARCHITECTURE.md
#	docs/architecture/frontend.md
#	web/modules/ui_settings_page.js
2026-10-13 14:29:19 +08:00
DemonGatanjieuandClaude Opus 5.5 4f0fd38dd2 Videos in the gallery: All / Pictures / Videos, formats in Settings, fixes
Follow-up to PR #21:
- The header shows All / Pictures / Videos beside the search (only once the output folder has
  a video) instead of twelve chips; the formats move to Settings -> Gallery, stored as hidden
  so a format added later shows.
- GET /anomalous/gallery_images lists pictures only unless media= says otherwise, so the recipe
  cover picker and the MCP image search are unchanged; hide= drops formats (jpg covers .jpeg);
  has_videos tells the gallery whether to offer the switch. .avi is left out (Chrome cannot
  play it), .avif is a picture format in the filter too.
- The viewer stops the video however it closes (Esc included).
- Video cards have no View parameters, star or right-click menu, and the workbench steps
  through pictures only.
- 图库, not 画廊; the 1.59 CHANGELOG entry credits cocorang and wkdtjs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:27:48 +08:00
DemonGatanjieuandClaude Opus 5.5 fb0ae67e52 Merge PR #21: videos in the gallery and a file-format filter
From cocorang and wkdtjs. The PR's own "v1.58.2" CHANGELOG section is left out (1.58.2 shipped
other changes); the 1.59 entry and the follow-up fixes come in the next commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:20:07 +08:00
DemonGatanjieuandClaude Opus 5.5 0c3797d61f Merge feature/model-import: the default shortcut is Shift+B
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:08:07 +08:00
DemonGatanjieuandClaude Opus 5.5 2ad8cb87a2 Merge feature/next: the default shortcut is Shift+B
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:08:06 +08:00
DemonGatanjieu bcf2bb04d1 Merge main: the default shortcut is Shift+B
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	docs/architecture/frontend.md
2026-10-13 14:08:05 +08:00
DemonGatanjieuandClaude Opus 5.5 fcad1bf133 The default shortcut is Shift+B, not B
comfyui-browser toggles its own window on a plain b (hard-coded, no
setting), so with both installed one press opened one window and closed the
other. Shift+B keeps the B and leaves comfyui-browser alone (it checks for a
lowercase b). Tested with both plugins loaded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:07:53 +08:00
DemonGatanjieuandClaude Opus 5.5 01e6c2b4c9 Esc presses the rebuilt workflow details' back button too
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:56:42 +08:00
DemonGatanjieuandClaude Opus 5.5 3406aeaaa6 Merge feature/model-import: Esc goes back from a detail and closes from a list; B opens the browser
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:54:39 +08:00
DemonGatanjieuandClaude Opus 5.5 33915e584a Merge feature/next: Esc goes back from a detail and closes from a list; B opens the browser
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:54:31 +08:00
DemonGatanjieu c4ffaa15f1 Merge main: Esc goes back from a detail and closes from a list; B opens the browser
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	docs/architecture/frontend.md
2026-10-13 13:54:23 +08:00
DemonGatanjieuandClaude Opus 5.5 942733fc22 Esc goes back from a detail and closes from a list; B opens the browser
The user's two wishes for v1.58.2:
- Esc on a page one level down (a model's details, one combo, one recipe)
  presses that page's own back button; on any other page, Workflows and
  Combos included (they are layers, but look like the other rail pages),
  it closes the browser. The model details' back button gets a class on
  the line that makes it, so ui_detail.js does not grow.
- The default shortcut is B, like ComfyUI's single keys for its side panels.
  Ctrl+Shift+M is taken by Chrome and Edge (switch profile) before the page
  sees it. ComfyUI keeps shortcuts the user set; typing in a text box is
  left alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:54:03 +08:00
DemonGatanjieuandClaude Opus 5.5 d8e7002a54 Merge feature/model-import: the browser steps aside while a picture is dragged; inserted node follows the pointer
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:42:59 +08:00
DemonGatanjieuandClaude Opus 5.5 09814f3171 Merge feature/next: the browser steps aside while a picture is dragged; inserted node follows the pointer
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:42:58 +08:00
DemonGatanjieuandClaude Opus 5.5 a2ca3593df Merge main: the browser steps aside while a picture is dragged; inserted node follows the pointer
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:42:48 +08:00
DemonGatanjieuandClaude Opus 5.5 0b212cc1b4 Gallery: the browser steps aside while a picture is dragged; inserted node follows the pointer
The user's two wishes for v1.58.2:
- Dragging a gallery picture fades the browser out, as for the other cards
  (anomalous-material-dragging), so the canvas and the node under the
  pointer take the drop; it comes back on dragend.
- Insert as a Load Image node closes the browser and the node follows the
  pointer until a click puts it down, Esc takes it off: a combo's Put on
  canvas (notebook_canvas.js's followPointer, now exported).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:42:47 +08:00
DemonGatanjieuandClaude Opus 5.5 f8d7906e48 Merge feature/model-import: v1.58.2, Insert as a Load Image node copies into input
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:34:47 +08:00
DemonGatanjieu 59391dc55c Merge feature/next: v1.58.2, Insert as a Load Image node copies into input
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	web/modules/locales.js
2026-10-13 13:34:46 +08:00
DemonGatanjieu 7f6812ee23 Merge main: v1.58.2, Insert as a Load Image node copies into input
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	CHANGELOG.md
#	README.md
2026-10-13 13:34:26 +08:00
DemonGatanjieuandClaude Opus 5.5 f902f60b4d Insert as a Load Image node copies the picture into the input folder
A "tut01/name.png [output]" value ran, but ComfyUI's check before a run
only finds output pictures in this session's history, so an older picture
was flagged "a required media input has no file selected" (seen on the
user's library). The picture is now uploaded through /upload/image like a
file dropped on the node; ComfyUI keeps one copy of the same picture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:34:04 +08:00
DemonGatanjieuandClaude Opus 5.5 fe27ee3ba7 v1.58.2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:26:51 +08:00
DemonGatanjieuandClaude Opus 5.5 a1a2690dd4 Merge feature/model-import: Show in folder brings Explorer to the front
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:25:37 +08:00
DemonGatanjieuandClaude Opus 5.5 5f1703ec59 Merge feature/next: Show in folder brings Explorer to the front
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:25:36 +08:00
DemonGatanjieuandClaude Opus 5.5 cb63cb9828 Merge main: Show in folder brings Explorer to the front
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:25:34 +08:00
DemonGatanjieuandClaude Opus 5.5 4e21b21a72 Show in folder brings Explorer to the front
Windows keeps a background program (ComfyUI) from raising a window, so the
folder opened behind the browser and was easy to miss. After starting
Explorer, the route waits up to 3 s for its window (a new one, or the open
one Explorer reused, matched by the folder's name in the title) and raises
it, borrowing the foreground window's input queue for the moment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:25:33 +08:00
wkdtjs 8e4d184078 Update CHANGELOG.md 2026-10-13 13:25:53 +09:00
wkdtjs 72afbfa23a Update print statement to say 'Goodbye World' 2026-10-13 13:25:11 +09:00
cocorang 8a7a07d064 Gallery extension selection feature 2026-10-13 13:20:37 +09:00
DemonGatanjieu 3585088e42 Merge feature/model-import: Esc closes, Show in folder, insert a picture as a node
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	ARCHITECTURE.md
#	web/modules/ui_gallery_card.js
#	web/styles.css
2026-10-13 01:14:05 +08:00
DemonGatanjieu 244af6a1e0 Merge feature/next: Esc closes, Show in folder, insert a picture as a node
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	ARCHITECTURE.md
#	api/__init__.py
#	web/modules/locales.js
#	web/modules/ui_detail.js
2026-10-13 01:12:43 +08:00
DemonGatanjieu 1af47f255c Merge main: Esc closes, Show in folder, insert a picture as a node
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	CHANGELOG.md
#	api/__init__.py
#	web/modules/browser_entry.js
#	web/styles.css
2026-10-13 01:11:13 +08:00
DemonGatanjieuandClaude Opus 5.5 e0129eaa2f Esc and the shortcut close the browser; Show in folder; insert a picture as a node
From a Bilibili comment (2026-10-13):
- Esc closes the top layer: a dialog or menu (the plugin's alert and confirm
  now close on Esc, as OK / Cancel), else the Workflows / Combos workspace,
  else the browser. One capture listener decides before the layers act, so a
  dialog closing on the same Esc still counts as above. The shortcut and the
  Extensions entry toggle the browser.
- A model's file card has Show in folder: POST /anomalous/open_model_folder
  (api/model_folder.py) takes type, folder index, subfolder and file name,
  never a path, and opens Explorer / Finder with the file selected; a POST,
  so local_guard keeps it to this computer. The card moves out of
  ui_detail.js into model_location.js.
- A gallery card's right-click menu (gallery_card_menu.js): Insert as a Load
  Image node reads the output file in place ("name.png [output]"), and View
  Parameters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 01:09:29 +08:00
DemonGatanjieu aa21a866fb Merge feature/model-import: English UI fixes (picture details, one / many)
param-compare's own English counts get one / many too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	web/modules/locales.js
2026-10-12 19:08:25 +08:00
DemonGatanjieu 064847e031 Merge feature/next: English UI fixes (picture details, one / many)
model-import's own English counts get one / many too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	web/modules/locales.js
2026-10-12 19:06:19 +08:00
DemonGatanjieu 4e0e49241f Merge main: English UI fixes (picture details, one / many)
next's own English counts get one / many too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	web/modules/locales.js
2026-10-12 19:03:42 +08:00
DemonGatanjieuandClaude Opus 5.5 a23c3ec69d English UI: no Chinese in picture details, one / many in counts
Found while recording in English: picture details showed "20 步" and
"定位模型", and the activity log said "Changed 1 things". Picture
details' last fixed Chinese strings (steps, Show model, copied JSON, two
empty notes) move to locales. formatTranslation learns {count|one|other},
and every English "{count} <plural noun>" uses it (49 strings; names like {engine} or {node} are left alone); two
sentences whose verb followed the count are reworded. The voice guides
name the TTS node by its English title, Character Speech (GPT-SoVITS).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:01:12 +08:00
DemonGatanjieuandClaude Opus 5.5 82ef6c578b Merge feature/model-import: README network note
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:07:04 +08:00
DemonGatanjieuandClaude Opus 5.5 6e2bcc9a14 README: the new scan page's Offline mode, and online falls back by itself
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:06:54 +08:00
DemonGatanjieuandClaude Opus 5.5 627aaca01d Merge feature/next: README network note
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:06:53 +08:00
DemonGatanjieuandClaude Opus 5.5 8b8e9c855e README: downloads need the network too; Hugging Face has a China mirror
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:06:38 +08:00
DemonGatanjieuandClaude Opus 5.5 bb49b79dd9 Merge main: README network note
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:06:37 +08:00
DemonGatanjieuandClaude Opus 5.5 d2b6c40921 Merge the README network note into main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:06:30 +08:00
DemonGatanjieuandClaude Opus 5.5 594cb70dee README: a word on the network (Civitai, Google Translate, offline scan)
Users in mainland China often have no proxy: say which parts need the
network and that an offline scan works without it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:06:29 +08:00
DemonGatanjieuandClaude Opus 5.5 68448642d4 Merge feature/model-import: arrows in picture details after ticking a checkbox
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:57:06 +08:00
DemonGatanjieuandClaude Opus 5.5 f77793cbeb Merge feature/next: arrows in picture details after ticking a checkbox
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:56:58 +08:00
DemonGatanjieu aee8fb32da Merge main: arrows in picture details after ticking a checkbox
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	CHANGELOG.md
2026-10-12 11:56:57 +08:00
DemonGatanjieuandClaude Opus 5.5 3f5774a724 Picture details: arrows switch pictures again after ticking a checkbox
The key handler ignored every <input> as a text field, so once a node's
checkbox under All parameters was ticked it kept the focus and the arrows
and A / D did nothing until a click elsewhere. Only text entry (text inputs,
textareas, contenteditable) keeps the keys now. In since 2026-09-04.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:56:44 +08:00
DemonGatanjieu 3cfb0cd2d9 Merge feature/model-import: a node combo's back button gets its own click
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	web/styles.css
2026-10-12 02:18:44 +08:00
DemonGatanjieuandClaude Opus 5.5 0278f6a68f Merge feature/next: a node combo's back button gets its own click
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 02:18:20 +08:00
DemonGatanjieuandClaude Opus 5.5 cd7e496d29 docs: lessons.md says the local check landed in v1.58.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 02:18:11 +08:00
DemonGatanjieuandClaude Opus 5.5 1dc64da9f8 A node combo's "‹ All combos" gets its own click on a scrolled page
On a combo saved from canvas nodes, once the page was scrolled a little,
"‹ All combos" opened the model picker: Chrome hit-tested the model covers
(overflow: hidden) that had scrolled under the header as still there. The
combo editor's scroll box gets clip-path: inset(0), like the recipe details
on 2026-10-09. lessons.md records it as the second time, with the sweep that
finds it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 02:18:01 +08:00
DemonGatanjieuandClaude Opus 5.5 6010713493 Merge feature/model-import: README says Anomalous_TTS installs from ComfyUI Manager
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 01:52:39 +08:00
DemonGatanjieuandClaude Opus 5.5 4078fa5b69 Merge feature/next: README says Anomalous_TTS installs from ComfyUI Manager
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 01:52:30 +08:00
DemonGatanjieuandClaude Opus 5.5 0b54251808 README: Anomalous_TTS installs from ComfyUI Manager too
It has been listed there since 2026-10-11.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 01:52:11 +08:00
DemonGatanjieuandClaude Opus 5.5 826928bb42 Focus values take the local check from local_guard
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 01:43:10 +08:00
DemonGatanjieu c1b7408456 Merge branch 'feature/model-import' into feature/param-compare 2026-10-12 01:42:11 +08:00
DemonGatanjieuandClaude Opus 5.5 a3a580b449 Docs: the import route names local_guard.is_local
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 01:42:05 +08:00
DemonGatanjieuandClaude Opus 5.5 4a0ea14b40 Merge feature/next: only this computer can change things (v1.58.1)
Import, Tidy and scan choices take the local check from local_guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 01:41:56 +08:00
DemonGatanjieuandClaude Opus 5.5 a44c93bf44 Merge main: only this computer can change things (v1.58.1)
MCP now uses local_guard.is_local (one local check). A backup upload
request that is no upload answers 400 instead of 500. tests/test_local_guard.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 01:40:28 +08:00
DemonGatanjieuandClaude Opus 5.5 aa9c237f98 Only this computer can change things; other devices can look (v1.58.1)
With ComfyUI started with --listen, other devices could use most of AMB's
actions: delete and rename models, save and delete workflows and prompts,
start downloads and scans, export a backup of all data, translate with the
user's key, change settings, switch the plugin version. A probe of all
routes as a LAN device found 9 of 112 refusing.

api/local_guard.py is one middleware in front of every /anomalous/ route:
anything but GET answers only this computer unless it is a read sent as POST
(OPEN_POSTS); a GET is open unless it is more than a read (LOCAL_GETS:
backup file, GitHub releases, local folders). Local = loopback peer, no
proxy headers, loopback Host (DNS rebinding is refused too). Refusals are
403 local_only with a bilingual message; GET /anomalous/access lets the page
show a "view only" chip (remote_view.js). AGENTS.md rule 4 now names it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 01:38:20 +08:00
DemonGatanjieuandClaude Opus 5.5 f326018d8d Current node says which set was last put into the node
A line on top of the parameters: the set last put into this node and when,
what changed since (with Put them back), and a "last put in" mark on its
card and column. Remembered in this browser per workflow and node (the
newest 100), forgotten when the receipt undoes it, and dropped from view
once none of the values it changed is left. Without a record, the line says
which saved set the values match or come closest to (at most 3 apart).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:04:35 +08:00
DemonGatanjieuandClaude Opus 5.5 2777d86831 Current node: where saved values came from, their picture, side by side
Each saved set for the selected node is a card with the picture made with
its values (or the kept picture it came from) and a line per source: a
workflow's parameter set (with how many pictures it made, Open shows that
set), a kept picture (See it), or saved here, with the day. Changes are
drawn: numbers as two bars with an arrow, choices as old -> new; what stays
says why; Show all lists every value. Compare opens the whole workflow
against the canvas. With two sets or more, a side-by-side table (picture on
top of each column, changed values marked, Use N under each).

Backend: materials/by_node_type tells each record's origin, kept picture and
preview; parameters/by_node_type gives each set's signature; new GET
/anomalous/parameter_pictures counts the pictures of many sets in one pass,
reading each picture's signature once per file version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:41:23 +08:00
DemonGatanjieu 20ac73a46f Merge branch 'feature/model-import' into feature/param-compare 2026-10-11 16:26:20 +08:00
DemonGatanjieuandClaude Opus 5.5 4f46676452 Model editor redone, with a source link; inferred information says so
The editor is rebuilt (it was inline styles everywhere): the cover large
with its choices under it (the current one, Civitai's own when the user
replaced it, the newest pictures, upload, the whole gallery), Name, Source
link and Notes with a word each, Reset / Cancel / Save at the foot. Nothing
is written before Save; fields left as they were are not sent. The source
link (asked for in the "not found" list's hint, but nowhere to type it) is
kept in the user layer; update_metadata refuses one that is no http(s)
address. GET /anomalous/model_covers tells the editor Civitai's kept cover.

A model Civitai does not know: its card reads "推断 <base>", its details
open with a note on what was read from the file and a button to add a name
and link (instead of the English placeholder description), and the scan
page says an online scan infers what Civitai does not find.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:26:14 +08:00
DemonGatanjieu 1ab5d75ebe Merge branch 'feature/model-import' into feature/param-compare 2026-10-11 15:53:39 +08:00
DemonGatanjieuandClaude Opus 5.5 58d5432014 Scan page: covers instead of the ring
The scan card opens on two rows of the models' covers, newest first, with an
empty dashed frame for each model not scanned yet (they pulse while a scan
runs): what a scan does, in a picture. The ring, which said the same as the
line under it and the state bar, is gone; narrow, nothing sits alone above
the card any more. The scan summary carries up to 48 covers of scanned models
(components left out) for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:53:33 +08:00
DemonGatanjieu 57bb52a092 Merge branch 'feature/model-import' into feature/param-compare 2026-10-11 15:33:03 +08:00
DemonGatanjieuandClaude Opus 5.5 949645bc9d Scan page: a scan icon, quiet links for what is left out, narrow by width
The scan button gets a scan-frame icon (the radar read as a clock). The
models set to never be scanned and the files in other formats are quiet
links under the state chips, so "worth a look" only appears for damaged
files and voice-like models. The scan card stacks the ring above the
rest by the page's own width (docked in the sidebar too), and the More
arrow turns when unfolded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:32:53 +08:00
DemonGatanjieu 5de3691b81 Merge branch 'feature/model-import' into feature/param-compare 2026-10-11 15:26:38 +08:00
DemonGatanjieuandClaude Opus 5.5 97ae88bc0b Scan page: one card on top, quieter below, settings as tiles
The scan card answers "what now": a ring of the models found on Civitai,
what is left in one line, the scan button, "pick models to scan" and an
online / offline switch beside them, and what the scan does in one
sentence (Details unfolds the counts); a yellow line when a switch that
changes files is on. Below it the models' state as a bar with a chip per
state and the components' chip, then, only when there is something, what
needs a look (one row each, with its icon), the settings as tiles (the
two that change files folded under More) and the last scan in one line.
Icons are scan_icons.js. The cards, chips and rules the page no longer
draws are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:26:38 +08:00
DemonGatanjieu 326a96c4d3 Merge branch 'feature/model-import' into feature/param-compare
# Conflicts:
#	web/styles.css
2026-10-11 13:27:46 +08:00
DemonGatanjieuandClaude Opus 5.5 22edc20d0f Scan page: pick beside the button, settings in place, components apart
"Pick models to scan" sits beside the scan button; the picks are named
under it and the button scans them, "clear" goes back to everything, so
the all/picked switch is gone. Under the button the page says what this
scan will do, each line with how many of its models are components.

The settings card (ui_scan_settings.js, holding the scan options and the
Civitai key prompt) unfolds in place instead of opening its own page.

The summary counts and lists models in component folders (VAE, text
encoders, CLIP, CLIP Vision, upscalers) apart. The counts at the top and
the not found / not scanned lists leave them out; a card and a list of
their own say that many are not on Civitai and that this is no fault.
The not found list looks up again just the models it shows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:27:24 +08:00
DemonGatanjieu 6ca0ed1a0a Merge branch 'feature/model-import' into feature/param-compare
# Conflicts:
#	web/styles.css
2026-10-11 13:01:08 +08:00
DemonGatanjieuandClaude Opus 5.5 899a1d9ac1 Model picker: folders in order, aligned columns, offline wording
The picker's models folders are sorted by type, each named with where it
is when the folder is not named after its type (Diffusion · unet) or the
type has two; models follow their folder. A row's state, base model and
size sit in fixed columns. Offline, the settings page no longer counts
models last scanned without a connection as "not on Civitai": they wait
for the next online scan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:00:44 +08:00
DemonGatanjieu 75780601be Merge branch 'feature/model-import' into feature/param-compare
# Conflicts:
#	web/styles.css
2026-10-11 12:50:35 +08:00
DemonGatanjieuandClaude Opus 5.5 2af526c6b9 Scan settings say what a scan will do; a new model picker
"All models" now lists, from the scan summary, how many models a scan
looks up, keeps, leaves for "look up again" and leaves out, following
the offline and fetch-again switches. "Only ones I pick" names the picks
with their size and says when found ones would only be read again.

The picker (ui_scan_picker.js, GET /anomalous/scan_pickable) lists the
.safetensors files a scan reads with the scan page's states, folder,
base model and size, as a list or covers, narrowed by folder, state,
search and picked-only; "pick these" picks what is shown, shift-click a
run. Picks go to the scan as targets. The old inline-styled selector and
its batch_select route are gone; setWidgetValuePath keeps its own file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:50:20 +08:00
DemonGatanjieuandClaude Opus 5.5 20416bc409 Merge feature/model-import: question card layout
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:40:48 +08:00
DemonGatanjieuandClaude Opus 5.5 5e34186445 The scan page's question about voice-like models sits in one row
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:40:48 +08:00
DemonGatanjieuandClaude Opus 5.5 d0880b9fc2 Merge feature/model-import: models you never want scanned
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:39:34 +08:00
DemonGatanjieuandClaude Opus 5.5 908ad72ec2 Models you never want scanned: asked, marked, left out; not on Civitai no longer warns
- The scan summary marks new models whose safetensors header reads but names no drawing model
  kind (a voice model such as F5-TTS) as not_drawing; the scan page asks: don't scan them,
  scan them, or see each. The choice lives in <model>.anomalous.json ("scan": "skip" / "scan",
  model_identity.scan_choice), saved by POST /anomalous/scan_choice (api/scan_choice.py, this
  computer only, at most 2000 models, only readable .safetensors layers).
- "skip": a scan of a whole folder leaves the model out (picked by hand it is still scanned),
  the summary counts it apart (excluded) with its own list to change it back, the card and the
  details say "不扫描"; the details have the same button.
- The unmatched count warns only for damaged files or lookups still to do; not-found rows say
  the fingerprint is kept for repairing workflows.
- model_kind.inspect_file now gives format None when the header cannot be read, as its text said.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:38:29 +08:00
DemonGatanjieuandClaude Opus 5.5 1f14953b4d Merge feature/model-import: damaged rows show their advice whole
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 03:41:11 +08:00
DemonGatanjieuandClaude Opus 5.5 3706d9db83 Merge feature/next: damaged rows show their advice whole
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 03:41:11 +08:00
DemonGatanjieuandClaude Opus 5.5 0ad2bbd0cf Damaged model rows show their advice whole
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 03:41:10 +08:00
DemonGatanjieuandClaude Opus 5.5 2b62f68eec Merge feature/model-import: header check fix
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 03:39:01 +08:00
DemonGatanjieuandClaude Opus 5.5 5607668ce4 Header check uses model_kind's header limit (and imports struct) after the merge
On this line the header reading moved to model_kind.py, which took model_identity's struct import with it; the damaged-header check from feature/next needed both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 03:39:01 +08:00
DemonGatanjieuandClaude Opus 5.5 4c83b14657 Merge feature/model-import: damaged model files are named as such
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 03:36:18 +08:00
DemonGatanjieuandClaude Opus 5.5 45353ffea1 Scans name damaged model files instead of calling them unknown
A model Civitai has no record of now has its safetensors header checked (model_identity.
safetensors_damage: too short, header longer than the file, not JSON, or a tensor ending past
the file's end; only the header is read). Such files get the unmatched reason "damaged": a red
badge on the card, a line in the details, a warning on the scan page (the summary counts them)
and first place in the unmatched list, with advice to download the model again.

Tests: header cases, a scan telling a damaged file from a whole one; the scan report fixtures
are whole safetensors files now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 03:35:23 +08:00
DemonGatanjieuandClaude Opus 5.5 ca02bdb385 Merge feature/next: damaged model files are named as such
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 03:35:23 +08:00
DemonGatanjieuandClaude Opus 5.5 9fdbaa847b Focus stars answer at once and save light
A star turns at once with a little bounce, the value fades in (one unmarked fades out), a failed save turns it back; quick presses queue instead of being refused. Focus values save alone through POST /anomalous/recipe_focus (this computer only, size-capped read, only focusParams written, no version), instead of a full recipe update that kept a version and re-read every model each time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 02:11:31 +08:00
DemonGatanjieuandClaude Opus 5.5 227063cce3 Merge feature/model-import: offline-found models keep their name
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 02:05:19 +08:00
DemonGatanjieuandClaude Opus 5.5 b60756c06c Test: models found offline keep their name across scans
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 02:04:23 +08:00
DemonGatanjieuandClaude Opus 5.5 ff82d489a5 Merge feature/next: offline-found models keep their name
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 02:04:23 +08:00
DemonGatanjieuandClaude Opus 5.5 9481729c14 Models found offline no longer get their name doubled on each scan
A record inferred from the file alone names model and version after the file. With renaming on, the scan renamed it to name_name, and every later scan doubled it again; the details showed name - name. Such records are no longer renamed (physically or virtually, an earlier doubled virtual name is dropped), and a model and version of one name read once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 02:03:47 +08:00
DemonGatanjieuandClaude Opus 5.5 5b19d85a5a Merge main: offline-found models keep their name
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 02:03:47 +08:00
DemonGatanjieuandClaude Opus 5.5 b323d82220 Focus values: mark what matters most in a workflow
- Every node value under the details' "every node's values" has a star; marked values show at
  the top of the workflow's own values and of each set as tiles (node, value, a note, and on a
  set the default when it differs). Set rows list them first; the comparison window leads its
  key values with them and their note.
- Kept in the recipe's params as focusParams [{node, type, widget, note}]; the server refuses
  any other shape (at most 12, short strings, notes up to 120 characters). Kept while the node
  of that id and type is there, also when the workflow is updated from the canvas.
- compareParams' isKey now also receives the node ids, so a mark holds for one node only.
- Tests: tests/recipe_focus.mjs, focus values in test_recipe_roundtrip.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:03:40 +08:00
DemonGatanjieuandClaude Opus 5.5 1f25de145e Merge feature/model-import: the Models page no longer comes back empty
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:36:32 +08:00
DemonGatanjieuandClaude Opus 5.5 2360e09256 Merge feature/next: the Models page no longer comes back empty
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:35:36 +08:00
DemonGatanjieuandClaude Opus 5.5 c51d25696b Models page no longer empty after the browser sat closed
After 90 seconds closed the model grid is released. Reopened onto Current node or Prompts (a node selected on the canvas), nothing redrew it, so pressing Models showed an empty page. Showing the models now redraws an empty grid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:35:36 +08:00
DemonGatanjieuandClaude Opus 5.5 16ebb90cdb Merge feature/model-import: the blank Workflows page fix, now listed under v1.58.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:55:15 +08:00
DemonGatanjieuandClaude Opus 5.5 392406de1d Merge feature/next: the Workflows page no longer goes blank after opening a workflow
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:53:21 +08:00
DemonGatanjieuandClaude Opus 5.5 0f0c98270f Merge main: the Workflows page no longer goes blank after opening a workflow
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:53:20 +08:00
DemonGatanjieuandClaude Opus 5.5 f0dca38ffb Workflows page no longer blank after opening a workflow on the canvas
Opening a workflow on the canvas hid the workspace without restoring the page under it, so
opening the browser again showed an empty page. It now only hides the browser; the Workflows
list stays in place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:53:07 +08:00
DemonGatanjieuandClaude Opus 5.5 2ef6555fe8 Workflows: no blank page after opening, safer card buttons, tips, narrow details, new save dialog
- Opening a workflow on the canvas only hides the browser; the Workflows list stays, so opening
  the browser again no longer shows an empty page (the old close hid the workspace without
  restoring the page under it).
- Card Open and more are two small round buttons in the cover's corner, so a press meant for the
  details does not open the canvas.
- Workflow details: the models' fold says whether all are here; folds open as the viewer last
  left them; a tip when a workflow has no set (with Save the canvas) and one pointing at compare
  on a set until used. Narrow (docked) details turn the left column into a row of pills.
- Save dialog rebuilt in ui_recipe_save_dialog.js: cover on the left from the canvas, the current
  cover or recent pictures as thumbnails; fields on the right; Cancel and Save always in view; the
  name starts as the canvas's saved workflow name; Esc and Enter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:48:50 +08:00
DemonGatanjieuandClaude Opus 5.5 2901a6664c Workflow details built around the values; compare pictures inside a workflow
The details open on the workflow's values instead of an overview. A bar (Back, the name
edited in place, the models' state, Open, ⋯ edit and export); a left column
(ui_recipe_sets.js): the workflow's own values, the saved sets unlike them (each with its
key values and difference count), Save the canvas, New, then all pictures and versions;
the chosen view on the right (ui_recipe_set_view.js): Apply to canvas, ⇄ with the default
values or the canvas, ⋯ copy / rename / delete, the main values, prompts (role menu,
folded when long), the pictures made with these values, and folded models and nodes.
ui_recipe_overview.js gives way to ui_recipe_info.js (name, notes, tags, cover, models'
state); ui_recipe_parameters.js keeps prompt roles, node values and the set editor, and
loses its 600-line tab renderer.

The pictures (ui_recipe_gallery.js) load as thumbnails and each has ⇄: the pick bar from
ui_param_compare.js now goes wherever the pictures are and can offer the workflow's own
values as a side. Also: applyRecipeToCanvas used anomalousAlert without importing it.

The old tab, hero, bento, console and gallery-dialog rules are pruned (about 1,000 lines).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:23:16 +08:00
DemonGatanjieuandClaude Opus 5.5 c11e8eb334 Workflow details to match the new Workflows page
The details' bar (Back and the tabs) loses its inline styles and heavy band. The overview
is rebuilt: cover, name, the models' state as one line (no banner), Open and ⋯ through
ui_popup_menu.js (the old More dropdown added a document click listener on every open
and never removed it), the numbers as small tiles, notes and tags, prompts with their
role and Show all for long ones (styles/29-workflow-detail.css).

Parameter sets: buttons on hover, names on two lines, the sidebar's hint and the console's
explanation removed; a new set is named after the recipe with the next number
(nextSetName) instead of a long suffix; raw values saved without names show their node
type's widget labels (comfyWidgetLabels) instead of 控件 N.

The rules for the old hero, banner, More dropdown, metric tiles and prompt boxes are
pruned from the older stylesheets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:05:03 +08:00
DemonGatanjieuandClaude Opus 5.5 617a7a3bc4 Workflows page: one bar, quieter cards
The page's three rows (kind pills, search, tag select, count and close; view, share and
save; the drag tip strip) become one bar: search, a Filter menu (kind and tag), the count,
grid or list, share and import, save the canvas. Cards keep the picture, the name, a line
of steps, sampler, LoRAs and size, and the model; the models' state is a dot (words only
when some are missing), 功能模块 marks a partial workflow, and Open and ⋯ (edit, export,
delete) show on hover. ui_popup_menu.js is the small menu both use.

The page's styles move to styles/28-workflows.css; the card and bar rules spread over four
stylesheets and the ~240 lines of CSS injected from ui_recipe_cards.js are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:50:53 +08:00
DemonGatanjieuandClaude Opus 5.5 a0aee2ec38 Recipe card: the parameter set count no longer overlaps the specs
The card's height is fixed, so the chip's own line pushed into the specs line. It is now
a compact ▤ N first in that line (the full wording in its tooltip).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:38:32 +08:00
DemonGatanjieuandClaude Opus 5.5 53c9b3f777 Compare the canvas with the newest picture
The Gallery and Activity pages get "Canvas vs. newest picture": the comparison window on
the newest output PNG and the canvas, so hand edits since the last run show too (the
activity log only knows Anomalous's own canvas writes) and the picture's values can go
back onto the canvas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:59:23 +08:00
DemonGatanjieuandClaude Opus 5.5 5c0448f009 Key parameters and parameter sets in view
Star a value in the comparison (key_params.js: node type + widget name, a hidden ComfyUI
setting) and it stays at the top of every comparison; equal values can be listed to be
starred. Recipe cards show 参数方案 N (sets unlike the recipe, from the new read-only
GET /anomalous/parameters/signatures) and open the Parameters tab; each set there shows
its key values, how many values differ from the recipe, and ⇄ to compare
(ui_param_set_keys.js). The window also compares saved workflows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:56:14 +08:00
DemonGatanjieuandClaude Opus 5.5 ffe0cb4f40 Compare: say which side is which
A and B each get their own colour, shown on the side cards, the column heads and every
text line. Prompts list the tags only A has and only B has (each once) instead of
removed/added; long texts take one line per side with the differing part marked; values
wrap whole instead of ending in an ellipsis.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:37:01 +08:00
DemonGatanjieuandClaude Opus 5.5 b920a711d1 Compare parameters: two pictures, or a picture and the canvas
The gallery card's ⇄ picks a picture, a second picture's ⇄ or Compare with the canvas
completes the pair; the picture details' ⇄ compares with the canvas. The window says
whether both use the same nodes and lists only the values that differ (prompts as tags
removed and added with their role, seeds folded), pairing nodes by id, then by a type
each side has once. Ticked picture values go onto the canvas as one Ctrl+Z step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:26:09 +08:00
DemonGatanjieuandClaude Opus 5.5 cf117381a4 Merge feature/next: keeping a picture no longer changes the canvas
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:20:43 +08:00
DemonGatanjieuandClaude Opus 5.5 a60c33fbf4 Merge main: keeping a picture no longer changes the canvas
Adds tests/detached_graph.mjs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:20:39 +08:00
DemonGatanjieuandClaude Opus 5.5 9c7fe63f60 Keep a picture without changing the canvas's values
ComfyUI keeps widget values by the root graph's id; a picture's workflow laid on a
detached graph under the open canvas's id shared and overwrote the canvas's values.
detached_graph.js gives the copy an id of its own; image_keep and recipe_save use it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:18:57 +08:00
DemonGatanjieuandClaude Opus 5.5 7538afce2e Merge feature/next: v1.58.0 fixes and docs cleanup from main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:05:22 +08:00
DemonGatanjieuandClaude Opus 5.5 7be055af9f Merge main: v1.58.0 fixes and docs cleanup
The picker, combo-save and activity fixes now ship in v1.58.0, so their entries
move from v1.59.0's Fixes to v1.58.0's (the code here already had them; the
picker keeps this branch's theme-aware border colour). Outdated docs/plans and
docs/audits are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:04:00 +08:00
DemonGatanjieuandClaude Opus 5.5 32c7fbd95a Fix picker jumping to the top, combo saved per tag, unreadable long-text activity
Same fixes as feature/next 99576e6, brought into v1.58.0 (the user fixes the
released line directly while it has few users):
- Current node's model picker repaints the chosen card in place instead of
  redrawing the grid, which scrolled back to the top.
- The classic combo editor no longer saves when its bilingual tags are shown nor
  once per translated tag; one save follows the last translation.
- A long activity before/after pair is cut around its first difference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:02:21 +08:00
DemonGatanjieuandClaude Opus 5.5 d1c820d931 docs: remove outdated plans and the September audit
docs/plans held task briefs written for other agents, most of them done or
abandoned (the toolbox shortcuts, the Material Library node capture), and
docs/audits a 2026-09-19 report on the old Model Doctor and Material Library.
Read publicly they looked like current plans. GEMINI.md now points front-end work
to AGENTS.md section 4 and docs/architecture/frontend.md; the audits row and the
audits ignore rules are gone. History keeps every file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:50:20 +08:00
DemonGatanjieuandClaude Opus 5.5 81fcc6ebc4 Merge feature/next: picker scroll, combo save burst, activity long-text fixes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:36:15 +08:00
DemonGatanjieuandClaude Opus 5.5 99576e60bd Fix picker jumping to the top, combo saved per tag, unreadable long-text activity
- Current node's model picker redrew the whole grid to mark the chosen card, so
  choosing one further down scrolled back to the top. The cards are repainted in
  place (paintCard) instead.
- The classic combo editor saved when its bilingual tags were shown and again for
  each tag's translation as it arrived: a long prompt wrote the combo dozens of
  times in a second (34 note_save entries in one user's log). Rendering no longer
  saves and the translations share one save half a second after the last. Its
  prompt placeholder moves into locales.js (no inline language branch).
- Activity values are capped at 400 characters from the start, so an edit late in
  a long prompt logged identical before and after. A long before/after pair is now
  cut around its first difference (test included).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:36:15 +08:00
DemonGatanjieuandClaude Opus 5.5 cfcb6b3183 Merge feature/next: Star on GitHub entry
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:48:00 +08:00
DemonGatanjieuandClaude Opus 5.5 a7c39df7a5 Add a "Star on GitHub" entry to Home and Settings
Home's feedback band ends with "⭐ Star on GitHub" and Settings -> Help and feedback
has a "Star it on GitHub" row; both open the project page in a new tab
(feedback.js openRepoPage). No pop-up, no reminder and no request is made: the
page opens only when pressed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:47:56 +08:00
DemonGatanjieuandClaude Opus 5.5 354b8e16a4 Merge feature/ui-shell: security and resource rules in AGENTS.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:17:51 +08:00
DemonGatanjieuandClaude Opus 5.5 2cce43e1e2 Merge feature/next: security and resource rules in AGENTS.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:12:13 +08:00
DemonGatanjieuandClaude Opus 5.5 172ae79bc5 Merge feature/ui-shell: security and resource rules in AGENTS.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:12:03 +08:00
DemonGatanjieuandClaude Opus 5.5 26a39f781d docs(agents): read-only routes, read limits and a resource section
Two more security-floor rules from the Edit emotions search: read-only routes follow
rule 3 too (by name or id where possible), and reading user files is capped, with
unreadable or oversized files skipped. A new "资源占用" part: act only on user action
(timers only while their view is open), heavy I/O off the event loop (moved here),
data lives with its view, thumbnails rather than originals. The delivery checklist
asks about paths, read limits and idle cost.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:11:59 +08:00
DemonGatanjieuandClaude Opus 5.5 4a109c4df8 Edit emotions: search a character's clips by line or file name
A search box above the clips (ui_tts_clip_search.js) finds clips by part of their
line or file name and lists the first 30 with their lines, to hear, use as the main
voice or add as an emotion (the new row gets the clip and its line). The lines come
once per dialog from Anomalous_TTS's GET /anomalous_tts/reference_lines (interface
14, by character name only); with an older node the request fails quietly and the
search covers file names. Picking a clip now fills its line from the same lines,
so the per-clip request is gone. Lines reach the page as text only (textContent).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:03:46 +08:00
DemonGatanjieuandClaude Opus 5.5 769e46a5eb Edit emotions: fill in a picked clip's line, and show the line in use
With Anomalous_TTS interface 14 (GET /anomalous_tts/reference_text), picking a clip
that is not yet in use fills in the line the node finds for it (.txt / .lab /
annotation list); an older node gives no answer and the box stays blank as before.
The answer is used only while the box still holds that clip and is still blank, and
is dropped with the dialog. A blank line box shows the line the node uses for its
clip as greyed placeholder, so the settings are not changed by opening the dialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:50:48 +08:00
DemonGatanjieuandClaude Opus 5.5 a3ee55dfd2 Edit emotions: list every clip, and let the line follow the clip
The audio boxes are a native datalist, which lists only files whose names contain
the box's text, so a filled box showed one or two clips. Pressing a box now empties
it to list all of them (the file shows as the placeholder) and puts the file back
when nothing is picked; Backspace or Delete really clears it.

Picking another clip kept the old clip's line next to it. The line box now follows:
the line it had for the first clip, the line the node already reads for a clip in
use, or blank, which makes the node read the clip's own .txt / .lab / list line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:39:15 +08:00
DemonGatanjieuandClaude Opus 5.5 9d4fe22030 Merge feature/ui-shell: v1.58.0 Beta
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:52:05 +08:00
DemonGatanjieuandClaude Opus 5.5 bd9afedb68 Merge feature/next: clicks on a scrolled combo or recipe page reach the header again
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:28:57 +08:00
DemonGatanjieuandClaude Opus 5.5 0f44d3fb2f Merge feature/ui-shell: clicks on a scrolled combo or recipe page reach the header again
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:27:54 +08:00
DemonGatanjieuandClaude Opus 5.5 314dc1cea4 Clicks on a scrolled combo or recipe page reach the header again
Once the combo editor or a recipe's details was scrolled, Chrome hit-tested content
that had scrolled out of view as still sitting under the header: "‹ All combos" hit
a LoRA tile, and the recipe's Back and tabs hit its cover. The combo tiles lose
content-visibility: auto (their off-screen boxes kept taking clicks); the recipe
detail's scroll box gets clip-path: inset(0), which Chrome also applies to hit tests.
Found with a sweep that scrolls every scroll box of each page in steps and checks
each visible button still gets its own click.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:26:44 +08:00
DemonGatanjieuandClaude Opus 5.5 ebbe82bda4 v1.60.0 is not a beta
CHANGELOG heading and README badge drop "Beta". The recipes architecture note and
AD-013 said recipes stay beta until a stability decision; that decision is this
release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:54:54 +08:00
DemonGatanjieuandClaude Opus 5.5 0298fd4f0e Merge feature/next: v1.58.0 and v1.59.0 notes; model-import's notes as v1.60.0 Beta
- CHANGELOG: v1.59.0 Beta and older taken as they are on next; model-import's own
  sections under a new v1.60.0 Beta heading (model import, Tidy, base-model filter,
  version labels, settings tabs; the scanner's wider offline base-model guess moves
  under the base-model filter). Stale copies of entries the v1.58.0 release reworded
  are dropped.
- README badge v1.60.0 Beta.
- What's new guide (new id 2026-10-model-import-and-tidy): import, Tidy, base-model
  filter with version labels, settings tabs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:36:41 +08:00
DemonGatanjieuandClaude Opus 5.5 d2b4b03e32 Merge feature/ui-shell: v1.58.0 Beta release; next's notes as v1.59.0 Beta
- CHANGELOG: ui-shell's v1.58.0 Beta block kept as it is there; next's own sections
  move to a new v1.59.0 Beta heading (model downloads, backup, share codes, recipe
  packages, node combos, MCP, light theme, opening by node, header search, LoRA
  insertion, DeepL key, tests; "Ctrl+Z after a scan" under Fixes).
- README badge v1.59.0 Beta.
- What's new guide (new id 2026-10-downloads-and-backup): downloads from Model Check,
  backup and share codes, node combos, light theme, MCP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:33:54 +08:00
DemonGatanjieuandClaude Opus 5.5 59b46f07f9 chore(release): v1.58.0 Beta
- CHANGELOG: Unreleased becomes v1.58.0 Beta (2026-10-09), its entries grouped by
  subject (layout, keeping results, prompts and Current node, Model Check, scanning,
  deleting, memory, activity log, voices, fixes); no entry dropped. Stale wording
  fixed: the rail's real order (no toolbox), no material workspace, no help page,
  Activity canvas entries do have Undo, combos instead of prompt notes.
- README: changelog badge v1.58.0 Beta.
- What's new guide: the five steps are the release's main changes (rail and Home,
  ☆ keep and combos, Prompt Studio, Model Check, voices); the model-types,
  activity and memory steps are gone (Activity is named in the layout step).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:25:59 +08:00
DemonGatanjieu 4cc7d1aa46 Merge feature/next: security floor and the review lesson 2026-10-09 15:51:51 +08:00
DemonGatanjieuandClaude Opus 5.5 a2622188aa docs(lessons): the ComfyUI-Manager review of Anomalous_TTS and where AMB stands
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:51:51 +08:00
DemonGatanjieu 2379b52088 Merge feature/ui-shell: security floor in AGENTS.md 2026-10-09 15:51:50 +08:00
DemonGatanjieuandClaude Opus 5.5 1e1555ff27 docs(agents): security floor from the ComfyUI-Manager review
No code from downloads or user files runs; downloads are checked by
SHA-256; routes take no arbitrary local paths; routes that write, move,
delete or go online on the user's behalf answer only this computer;
deletions go to the Recycle Bin and nothing is overwritten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:51:30 +08:00
DemonGatanjieu bb0587ccdb Merge feature/next: folder rules comment 2026-10-09 15:18:37 +08:00
DemonGatanjieu b8f9efa177 Merge feature/ui-shell: folder rules comment 2026-10-09 15:18:36 +08:00
DemonGatanjieuandClaude Opus 5.5 2eb35f113c docs(tts): the folder rules no longer mirror the node's removed browse.py
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:18:36 +08:00
DemonGatanjieu 3717503537 Merge feature/next: ask GPT-SoVITS again before refusing an import
# Conflicts:
#	CHANGELOG.md
2026-10-09 15:13:41 +08:00
DemonGatanjieu 0394bcdcb7 Merge feature/ui-shell: ask GPT-SoVITS again before refusing an import
# Conflicts:
#	CHANGELOG.md
2026-10-09 15:12:35 +08:00
DemonGatanjieuandClaude Opus 5.5 e4d351a44e fix(tts): ask GPT-SoVITS again before refusing an import
The voices page keeps the node's status for a minute. On a fresh install
the first status said the storage place (models/gpt_sovits) was missing,
so "Import character" refused with "存放位置不能写入" until the cached
answer expired, although the folder existed by then. Before refusing, the
import asks for the status once more. (Anomalous_TTS now also creates the
folder when it starts.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:12:21 +08:00
Claude 9c6831c1f0 docs: point to the node's INTERFACE.md only
The Claude project mirror anomalous-tts-interface.md is no longer kept.
2026-10-09 06:35:38 +00:00
Claude 2d339eb770 docs: point to the node's INTERFACE.md only
The Claude project mirror anomalous-tts-interface.md is no longer kept.
2026-10-09 06:35:38 +00:00
Claude 888e7c8dc4 docs: point to the node's INTERFACE.md only
The Claude project mirror anomalous-tts-interface.md is no longer kept.
2026-10-09 06:32:59 +00:00
Claude ca2cdecf7e fix(models): import upload and Tidy moves only from this computer
PUT /anomalous/import/upload and POST /anomalous/placement/move wrote
into the model folders for any caller, while place and identify already
answered only the computer ComfyUI runs on. With ComfyUI started with
--listen, another device could upload model files or move models
between folders. Both now check mcp_server._is_local first and answer
403 not_local, which the import and Tidy screens already show.

Tests drive both routes through a proxy header and check nothing was
written or moved; they fail without the check. Full suite: structure
check, every tests/*.mjs and 234 Python tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018P2T2R7TFbJsc3C9maCD8B
2026-10-08 20:35:57 +00:00
Claude 1b8839d2c6 Merge feature/next: Anomalous_TTS interface 13, no local paths
Brings stage 2 into stage 3: the gallery drag fix and the change that
follows Anomalous_TTS interface 13. No conflicts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018P2T2R7TFbJsc3C9maCD8B
2026-10-08 20:23:55 +00:00
Claude def279f434 Merge feature/ui-shell: Anomalous_TTS interface 13, no local paths
Brings stage 1 into stage 2: the gallery drag fix and the change that
follows Anomalous_TTS interface 13 (no server path picker, no storage
change or move, folders shown with the node's settings file).

Conflicts: the picker CSS, the storage-change activity label and its
locale keys are dropped; stage 2's model download and backup entries stay.
tests/tts_setup_api.mjs now checks the pretrained download route instead
of the removed storage and library routes, and that no export sends a
path on the computer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018P2T2R7TFbJsc3C9maCD8B
2026-10-08 20:23:29 +00:00
Claude fe8e08d8cf fix(tts): follow Anomalous_TTS interface 13, no local paths
Anomalous_TTS no longer takes a folder or file path on this computer from
a request (ComfyUI-Manager review of the node): the storage place, other
character folders and pretrained sources are set only in its settings
file, and imported files are always uploaded.

- GPT-SoVITS settings shows the storage place, other places and package
  sources without change, move or remove buttons, and names the node's
  settings file (status.settings_file) with a copy button and how to edit it.
- The import window drops "from this computer": no server path picker
  (ui_tts_path_picker.js is gone), no local folder scan or local clip
  preview; a very large upload is confirmed instead.
- tts_setup_api.js loses the removed routes; formatSize moves there.
- The storage-change activity entry, unused locale keys and picker CSS go.

Verified: tools/check_structure.mjs; tts_setup_api in Node with fetch
stubbed; the settings card, batch hero, add menu, file sources and player
rendered with jsdom and stubbed ComfyUI scripts. Not run in ComfyUI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018P2T2R7TFbJsc3C9maCD8B
2026-10-08 20:21:50 +00:00
DemonGatanjieuandClaude Opus 5.5 98148481bc Models page tabs, base model filter, Tidy tab redone with covers
- The models page has tabs: All models, Tidy (with its count) and Sources;
  the type bar keeps the type chips and Import.
- Base model chips under the type chips (All, Illustrious, SDXL, Unknown...),
  one per family the listed models have (model_bases.js; SDXL 1.0 = SDXL);
  models not scanned are listed by their header's guess (metadata base_guess).
- Tidy is cards with the model's cover and name in two steps: models in a
  folder whose loader cannot read them (from -> to, one plain reason, Move
  there, Change place), and identical files side by side with the copy to
  keep marked (the one in the right folder; click another to change) and one
  button for the extra copies. Models that load anyway and extra copies of a
  well-placed file are not in step 1. Same-size files up to 256 MB are
  compared at once; larger ones on Compare.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 23:02:00 +08:00
DemonGatanjieuandClaude Opus 5.5 552809c541 Tidy view: models in the wrong folder and identical copies
The models page's Tidy chip (with a count) reads every model's header
(model_kind) and lists files whose type does not match their folder - such
as an Anima/Flux/Wan diffusion model in checkpoints, which Civitai labels
"Checkpoint" - with why it matters, where it belongs (same subfolder, same
drive) and Move there, taking its cover, info and notes along; never over a
file. A whole checkpoint in diffusion models is marked as loading anyway.
Identical files are grouped by SHA-256 (scan hashes; same-size unscanned
files on Compare); each copy can go to the Recycle Bin. Moves are logged
(model_move). Also: opening the Tidy or Sources view stops cards of an
earlier load from being drawn into it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 22:02:14 +08:00
DemonGatanjieuandClaude Opus 5.5 118f2f5178 Tell Anima/Cosmos models and merged checkpoints apart
Anima (Cosmos-Predict2 with a Qwen3 adapter, with or without "net." names) was
taken for Wan or not recognized; it is a diffusion model with base "Anima". A
merged checkpoint that kept a LoRA's keys was taken for a LoRA: whole models are
now told before LoRAs. Found by reading the headers of a real library.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:50:39 +08:00
DemonGatanjieuandClaude Opus 5.5 f7447b2222 Import dropped model files into the right folders; version labels; settings tabs
- Models page: "+ Import" chip and model files dropped on the grid open an import
  window. model_kind.py tells a file's type (LoRA, checkpoint, VAE, text encoder,
  diffusion model, ControlNet, embedding, CLIP Vision, upscaler) and base model from
  its safetensors / GGUF header only; .ckpt/.pt are never opened. api/model_import.py
  finds the dropped file in Downloads/Desktop (name, size, time) and moves it, or
  takes an upload; checks it against Civitai and the scanned models; never replaces
  a file (" (2)"). New files are scanned after.
- Model cards show the Civitai version name (metadata version_name).
- Settings page in tabs (Look, Models, Workflows, Prompts, Backup, Help); the
  download folder is a choice of ready-made folders or typed by hand; default is now
  by base model ({base}).
- The scanner's offline base model uses model_kind (LoRAs, Qwen, Wan, Chroma...).
- AD-019: no Civitai browser inside the plugin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:07:43 +08:00
DemonGatanjieuandClaude Opus 5.5 4ab08a36e4 Combos keep every picked node, not only model and text nodes
Save as combo now keeps all picked nodes but subgraphs, with the links
between them. Model drop-downs and text boxes stay the slots; nodes
without slots are listed in the editor under "Nodes put back as saved"
with their values and go back exactly as saved. Inputs fed by links from
unpicked nodes are stored in structure.open, and the save dialog, the
editor and the card say which ones to wire after putting it down. Cards
of combos without models name their nodes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 02:39:44 +08:00
DemonGatanjieuandClaude Opus 5.5 d8e12b8a59 Fix gallery drags to the canvas and the thumbnail rail jumping
Chrome adds a dragged <img> as a file, and ComfyUI prefers that file to
the link: since the gallery shows WebP thumbnails, a drop on the canvas
loaded the thumbnail (no workflow) as a Load Image node. The card is the
drag source now, so ComfyUI fetches the original PNG.

Picking a picture near the end of the details rail loaded the next page
and rebuilt the rail, which scrolled down again from the top. The new
thumbnails are appended instead, and clicks read the current index.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 02:23:01 +08:00
DemonGatanjieuandClaude Opus 5.5 c7fb34a8b3 Fix gallery drags to the canvas and the thumbnail rail jumping
Chrome adds a dragged <img> as a file, and ComfyUI prefers that file to
the link: since the gallery shows WebP thumbnails, a drop on the canvas
loaded the thumbnail (no workflow) as a Load Image node. The card is the
drag source now, so ComfyUI fetches the original PNG.

Picking a picture near the end of the details rail loaded the next page
and rebuilt the rail, which scrolled down again from the top. The new
thumbnails are appended instead, and clicks read the current index.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 01:49:15 +08:00
DemonGatanjieuandClaude Opus 5.5 430a83af00 Make light theme text clearer
Grey secondary and hint text is darker in the light theme. Labels and
buttons laid over pictures (model card names, recipe cover labels, the
gallery's View parameters button) keep the dark tokens, including
ComfyUI's palette variables, so their text no longer turns dark on a dark
backing. Prompt role badges, the red close crosses and the voice import
button get colors that read in every theme.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 18:25:01 +08:00
DemonGatanjieuandClaude Opus 5.5 7b363bdac8 Light theme: follow ComfyUI, dark, light or Abyssal Scarlet
Settings -> Appearance -> Theme (and the ComfyUI setting
Anomalous.ModelBrowser.Theme, which replaces the Abyssal Scarlet switch)
picks Follow ComfyUI (default), Dark, Light or Abyssal Scarlet. Following
ComfyUI watches the dark-theme class ComfyUI sets on <html> for a dark
palette and repaints at once; an older Abyssal switch reads as Abyssal.

The light theme is html.amb-theme-light with its own --amb-* values. Hard
coded colors were turned into tokens whose dark values are the colors they
replace (checked: every stylesheet resolves to the same dark values):
rgba(var(--amb-ink-rgb), a) for tints, status shades, tooltip, float,
dialog, sunken, scrim and note tokens. Older rules and inline styles that
wrote a dark color now write var(--amb-lt-<role>, <that color>); the
--amb-lt-* tokens exist only in the light theme. Inside the plugin's
windows ComfyUI's palette variables (--bg-color, --fg-color ...) follow the
plugin's theme when the two differ. Surfaces over images stay dark, and the
image viewer gets the dark tokens back. The ready banner on a recipe reads
green instead of pink. Stylesheet cache versions are bumped.

Checked on a test ComfyUI in light and dark: home, models, gallery and its
viewer, workflows and a recipe's tabs, combos and a combo, prompts and the
docked drawer, voices, audio library, settings, scan, Model Check, current
node and its model picker, a model's detail with notes, share, save
recipe, update guide, versions, GPT-SoVITS settings and the prompt dialog;
light with a dark ComfyUI, and Abyssal Scarlet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:27:02 +08:00
DemonGatanjieuandClaude Opus 5.5 b78747c9a8 Prompts is a page like the others; "Beside the canvas" docks it
The rail's Prompts entry used to fold the browser away and open Prompt
Studio in a drawer by the canvas, the one page that left the window. It is
now the browser's Prompts page (owner.promptPanel): the rail, the 7 key,
opening the browser with a text node selected, Current node's "Edit in
Prompt Studio" and a kept prompt all go there, and reopening the browser
returns to it. "Beside the canvas" on the page moves the studio into the
drawer as before (side and width remembered, browser folded away); the
drawer has "Back to the window" and Close.

Only one studio is shown at a time. The page's studio stops when another
page is shown, a workspace opens or the browser closes (its 400 ms poll
with it) and is made again when the page is shown; closing a workspace
opened from the Prompts page returns to it.

Checked live on a test ComfyUI: a CLIP Text Encode opens the page aimed at
it; docking, Back to the window and Close; 3 then 7; Workflows opened and
closed from the page; reopening the browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:06:27 +08:00
DemonGatanjieuandClaude Opus 5.5 ef0e21ef94 Remove Prompt Studio's own shortcut
The "Open Prompt Studio shortcut" (Ctrl+Shift+L by default) goes: its row
in ComfyUI's settings, its command and Extensions menu entry, its default
keybinding and the keydown fallback that backed it. The command goes too,
so a binding a user recorded for it no longer opens anything. Prompt
Studio opens from the rail, with 7 while the browser is open, or by
opening the browser with a text node selected. The browser's own shortcut
(Ctrl+Shift+M) stays. Unused key-matching helpers are removed with it.

Checked on a test ComfyUI: the settings page lists only the browser's
shortcut, the command list has Open and Reset position, Ctrl+Shift+L does
nothing, and no console errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:50:19 +08:00
DemonGatanjieuandClaude Opus 5.5 80805b88d3 Open where the selected node is; number keys for the rail's pages
Opening the browser (the trigger, the action-bar button, the menu or its
shortcut) now follows the node selected on the canvas: a node with prompt
boxes and no model drop-down opens Prompt Studio, which targets that node;
any other node (a KSampler, a model loader) opens Current node; nothing
selected reopens the page used last. Settings -> Opening and window can turn
it off. Prompt Studio closing, the doctor banner and MCP actions still open
the browser as it was.

While the browser is open, the plain keys 1-9 go to the rail's pages from
the top. They are ignored in text fields, while an input method is
composing and while a dialog is open; ComfyUI binds no plain digits (W is
its Workflows key, so W/S were not used). The rail's tooltips name each
page with its number.

The rules are in shell_open_rules.js with tests/shell_open_rules.mjs.
Checked live on a test ComfyUI: a CLIP Text Encode opens Prompt Studio on
it, a KSampler opens Current node, 1/3/4/5/7/8 switch pages, digits typed
into the search box stay there, and the setting turns the rule off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:43:53 +08:00
DemonGatanjieuandClaude Opus 5.5 7c140f59ac Settings: a DeepL key for prompt translation
Settings -> Translation -> DeepL key saves the user's own DeepL API key;
translation then asks DeepL first, else Google's free endpoint, else
MyMemory. Saving a key translates one sentence and says whether it works or
why not (a refused key, a used-up month, busy). Only the key's tag changes,
so the page keeps its scroll place. The key stays on this computer: backups
leave it out like the Civitai key.

DeepL stopped taking the key as an auth_key parameter in 2025, so a key put
in config.json by hand no longer worked. It now goes in the Authorization
header with a JSON body; targets are ZH-HANS and EN-US. When DeepL fails,
the answer carries deepl_error beside the other provider's translation.

Checked live: a made-up key is refused by DeepL's server and the dialog
says so; removing it switches back to Google.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 03:19:25 +08:00
DemonGatanjieuandClaude Opus 5.5 93cf8f122a Recipe packages: tell the receiver about the cover; unstretched cover preview
The import dialog said "with 1 picture" for a package with a cover and one
model thumbnail: the cover lives inside recipe.json as a small image, not as
an asset file. Inspect now returns has_cover and model_pictures, and the
dialog says "a cover, N model thumbnails".

The save dialog's cover preview stretched the chosen image to the dialog's
width; it now keeps its proportions (object-fit: contain).

Checked live: a recipe with an output-image cover and a LoRA thumbnail,
exported and imported with the output image moved away, shows both on the
new card and its detail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 01:14:29 +08:00
DemonGatanjieuandClaude Opus 5.5 da38e2d6ad feat(recipes): reopen recipe packages, imported through share and import
By the user's decision, recipe packages are open again: the card's
export button and the detail's More menu make a .zip named after the
recipe (workflow, notes, tags, parameters, cover, model thumbnails;
versions only when ticked). Dropping one into the Workflows page's
share and import dialog inspects it and keeps it as a new recipe card,
optionally opened on the canvas.

An import never replaces a recipe any more: the "replace" path, which
erased the old recipe for good, is gone; a taken name gets " (2)".
Assets and versions move in before the recipe file, and a failure
removes only this import's new folders. The release gates and their
503 answers are removed. Tests: new card and renaming, nothing left
after a failed import, tampered and escaping packages refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 00:32:05 +08:00
DemonGatanjieuandClaude Opus 5.5 875715e4a0 feat(share): short AMB2 share codes, one dialog to share and import
share_code.js: a workflow's lean form (node id, type, widget values,
position to 10 px, only what differs from a fresh node; node-pack nodes
keep their ports), links by slot, groups and the anomalous_* extras,
deflated and spelled in Chinese characters (14 bits each) or base64url.
Encoding rebuilds and compares; subgraphs, native reroutes or any
difference share the whole workflow. AMB0/AMB1 still decode. About 4x
shorter than AMB0 on ComfyUI's default workflow (657 vs 2521), 13x on
an Easy-Use/rgthree graph.

ui_share.js replaces the share code dialogs that lived in main.js: the
code is made on opening with both copy buttons and the fingerprint
notes, and pasting a code or JSON, or dropping/choosing a workflow file,
an image or a backup .zip, imports it. tests/share_code.mjs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:22:11 +08:00
DemonGatanjieuandClaude Opus 5.5 d77655fb76 feat(backup): models' scan results, and Civitai covers on request
A backup now keeps each model's scan file (.info, small; on by default)
so names, trigger words and base models survive Civitai removing a
model, and on request Civitai's covers (stored once as the civitai_bak
copy; they can be hundreds of MB). Putting back writes a scan file only
where the model has none (replaced only with "replace") and Civitai's
cover only where it has no cover, with its civitai_bak copy so it stays
Civitai's and a later user cover still wins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:11:51 +08:00
DemonGatanjieuandClaude Opus 5.5 1efa20fd91 feat(backup): download the models a new computer lacks
A backup also keeps a model list (each model's folder, path, size,
SHA-256 and link). Inspecting it names the models this computer lacks
(two copies of one file are one download); "Find downloads..." looks
them up like Model Check (Civitai by hash, the saved link, Hugging
Face), shows size, source and free space, and downloads the ticked ones
one after another into the path they had, each checked against its hash
and scanned after. A file found by name only starts unticked. Verified
on the isolated ComfyUI: an 18 MB Civitai LoRA came back to its folder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 18:23:37 +08:00
DemonGatanjieuandClaude Opus 5.5 797a52105e feat: Settings -> Backup, export and import the user's data
Export packs the stores (recipes, combos, materials, parameters, and
optionally ComfyUI's saved workflows), the plugin settings without the
Civitai key, and each model's user layer and own cover into one .zip to
keep on a cloud drive. Import lists what is new, the same or different
and the models it finds again (by SHA-256, else folder, path and size),
adds only what is missing by default, replaces differing files only on
request with this computer's going to the Recycle Bin first, merges a
model's name and notes field by field and puts the user's cover in
place of a Civitai one. Unsafe or foreign zips are refused. Both are
activity entries. tests/test_backup.py covers the rules; testing.md
names a single test file the way unittest finds it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 18:12:45 +08:00
DemonGatanjieuandClaude Opus 5.5 7bb46ba82f docs: lessons move from the private .agents notes into the repository
The recurring traps (a :has() rule slowing every canvas frame, fixes
left on an unpushed local main, release checks, imports lost in a module
split) are now docs/architecture/lessons.md, so the links from
ARCHITECTURE.md, AGENTS.md and the docs indexes open for everyone.
AGENTS.md: tests live in tests/, .agents stays a local folder for
screenshots and logs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 17:54:20 +08:00
DemonGatanjieuandClaude Opus 5.5 7b09909e5b test: publish the plugin's tests, one command to run them
tests/ (Python backend tests, .mjs frontend rule tests) is no longer
ignored. tools/run_tests.mjs runs the structure check, every
tests/*.mjs and the Python tests with $PYTHON, the portable
python_embeded or python. docs/guides/testing.md says how to run and
write them; README links it. Developer notes no longer point at 18 test
files that were removed earlier (AGENTS.md table, backend, frontend and
update-guide notes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 17:47:35 +08:00
DemonGatanjieuandClaude Opus 5.5 211f8b9981 Merge feature/ui-shell: README pictures
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:43:35 +08:00
DemonGatanjieuandClaude Opus 5.5 afe73258d5 docs(readme): pictures of each feature and how it is used
Seven short "see it in action" steps in English and Chinese, each with a
still or a few-second animation (WebP, about 500 KB in all) from the
isolated demo ComfyUI: scan results, Model Check putting models back,
gallery search and drag to canvas, a combo dragged onto the canvas,
Prompt Studio tags, the activity log with Undo, character voices; the
model cover wall under the title. .gitignore keeps docs/images/*.webp.
The Activity row no longer says it only records: canvas changes have Undo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:42:40 +08:00
DemonGatanjieuandClaude Opus 5.5 0abfd0a775 feat(combos): node names, prompt translation peek, multi-model nodes
Each slot names its node (and node pack when not ComfyUI's own). A small
"译" button beside a prompt shows its translation in a read-only panel
(ui_translation_peek.js). On nodes with several models, saved values go
to the row with the same number (strength_02 under lora_02) and more
than four fold behind "+N"; empty optional rows (None) start unticked in
the save dialog so a ten-row LoRA stack does not make ten empty slots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 15:50:58 +08:00
DemonGatanjieuandClaude Opus 5.5 3995e34eb4 feat(combos): node combo page grouped in flow order, covers and saved values
Models, LoRAs and prompts in their own groups, in the order they flow
(LoRA 1 next to the model, positive before negative); made-up names
renumbered along the chain. Covers looked up for every model, a whole
row opens the picker, names wrap to two lines, each model node's saved
values (strength, CLIP strength, type) shown read-only, prompt boxes
grow with their text. New captures keep nodes in flow order. The combo
editor's sticky top bar sat 20 px down with the page showing above it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 15:32:37 +08:00
DemonGatanjieuandClaude Opus 5.5 4c6a4806d2 feat(combos): right-click "Save as combo" on the canvas
The canvas's node menu (the frontend's getNodeMenuItems hook) offers
"Save as combo" for the picked nodes, or the right-clicked node when it
is not among them, when any of them is a model or text node; it opens
the same save dialog without opening the browser first, and the browser
shows the new combo afterwards.

Values other than the slots stay as they were saved (agreed): a combo
always puts down exactly what was saved; other values mean another combo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 15:04:03 +08:00
DemonGatanjieuandClaude Opus 5.5 0dcc67f394 feat(combos): combos from the nodes picked on the canvas
A combo can now hold its own node structure: pick nodes on the canvas and
New -> From the nodes picked on the canvas keeps the model nodes (a
drop-down of model files) and the text nodes (a multiline text box by the
node's definition) with the links between them; other picked nodes and
links to them are left out, their ports stay open on the canvas.

Slots are per box, so a node with several model drop-downs has several; a
box fed by a link is no slot. Each model slot's folder is the one whose
files hold all its drop-down's model-file options (ComfyUI's /models
lists), never guessed from names. Every other box is stored by name and
put back untouched, in order (a count box adds its rows first).

The save dialog shows what is kept and dropped and lets slots be renamed
or unticked. The editor lists the slots: a picker of that folder's models
with covers, filtered by base model, and text boxes. Put on canvas makes
the nodes (missing node types and their packs are named before anything
is placed), fills the slots by name, links by port name and type, as one
Ctrl+Z step; a box or link that cannot be restored is reported, never
guessed. New -> Same structure as ... starts another combo from a saved
structure. The default combo is unchanged. MCP list_combos lists node
combos by slot and place_combo places them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 04:19:09 +08:00
DemonGatanjieuandClaude Opus 5.5 ca003d20d2 feat: Hugging Face first with the mirror, model card covers, text encoders shown, header search
- Downloads: with the China mirror on, a Civitai match whose very file (same
  SHA-256) is on Hugging Face too, by the workflow's link or ComfyUI-Manager's
  list, is fetched from Hugging Face; Civitai's name, base model and page stay.
- A file downloaded from Hugging Face gets its model card's first example
  image as cover and its base model, trigger words and example prompts as
  notes, with the repository as link (api/hf_card.py), never over what the
  user or a scan set.
- Text encoders, CLIP vision and upscalers are shown (and scanned) by
  default, so shared workflows carry their fingerprints; older configs get
  them turned on once (folder_defaults_version 2).
- The models page's search sits in the header (ui_model_search.js) instead
  of inside the folder list; it searches the whole type by name, file name,
  folder, trigger words, base model and notes, every word; Esc clears it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 03:37:17 +08:00
DemonGatanjieuandClaude Opus 5.5 9624ae1356 feat(model-download): find base models in ComfyUI-Manager's model list
Models not on Civitai (Flux's VAE, T5 / CLIP text encoders, upscalers,
ControlNets...) are looked up by file name in the model-list.json of the
ComfyUI-Manager installed here (api/manager_catalog.py), keeping entries
that fit the node's folder type. With the workflow's fingerprint only the
listed file with that SHA-256 counts (same-name entries are each asked);
without one, a single entry is offered as "Download by name" with a
warning, never in Download all or the MCP's download_missing_models.

A site that stops answering is skipped for the rest of a lookup while the
others are still asked, so Civitai failing in mainland China no longer
stops Hugging Face through its mirror. GitHub raw file links count as
sources; a Hugging Face file behind a login offers its repository page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 03:07:17 +08:00
DemonGatanjieuandClaude Opus 5.5 224d339afe feat(mcp): AI apps can download models
Three MCP tools: download_missing_models (Model Check's Download all for
the open workflow, through the page so the placement rules and the put-in
afterwards are the UI's own), download_model (one model from a Civitai
version link, a Hugging Face / GitHub file link or a Civitai hash, into a
folder of its type) and download_status. The default models folder of a
type is shared by the dialog and the tools.

A Civitai version link without a matching file name now takes the
version's primary model file (people rename their files); a hash, when
there is one, still decides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 02:53:49 +08:00
DemonGatanjieuandClaude Opus 5.5 a04f1525d4 feat(model-download): Hugging Face through the China mirror
Settings > Model downloads can send Hugging Face downloads through
hf-mirror.com, on by default for the Chinese interface. The lookup and the
download use the chosen host whichever one the link names; when the mirror
sends a network outside China back to huggingface.co, the lookup follows
once for the size and SHA-256. A failed download from Hugging Face itself
suggests the mirror.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 01:46:09 +08:00
DemonGatanjieuandClaude Opus 5.5 698a256a16 feat(model-check): download missing models, to the folder you choose
Model Check offers Download (and Download all) for a missing model whose
source names one exact file: Civitai by the workflow's fingerprint, or a
Civitai version / Hugging Face / GitHub file link the workflow carries.
A dialog shows each file's size, source and full destination, with the
models folder (when there are several) and subfolder changeable; Settings
> Model downloads sets the default: where the workflow says (the node
needs no change) or a set folder, {base} sorting by base model and
matching an existing folder in any spelling.

Downloads run one at a time into <file>.part, continue after an
interruption, are checked against the SHA-256 before they get their name,
never replace a file, and send the Civitai key to Civitai only. Finished
files are put into their nodes (one Ctrl+Z step each), scanned for info
and covers, and recorded in the activity log.

A change of fingerprints alone (after any scan) no longer takes a Ctrl+Z
step that seemed to do nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 01:28:51 +08:00
DemonGatanjieuandClaude Opus 5.5 fa11c55293 feat(lora): one way to insert a LoRA, for Current node and MCP alike
Current node's "Insert LoRA before / after" refused the most common workflows: a
checkpoint whose CLIP feeds both prompts (one link per output only) and UNet workflows
with their own CLIP loader (MODEL and CLIP had to come from one node).

- graph_splice.js: planLoraInsertion decides the links (after a node: every link of its
  MODEL output; before one: only its own MODEL input) and, for LoRAs that train the text
  encoder, the CLIP line (same node, a UNet workflow's one CLIP source, or before a
  sampler the source its prompts share); spliceLora wires it, all or nothing. The old
  analyzeModelChainInsertion / spliceModelChainNode / spliceAfterOutputs are gone.
- api/lora_info.py: GET /anomalous/lora_info reads the LoRA's safetensors header; the
  picker asks it on confirm and inserts a model-only loader when there are no
  text-encoder weights. The MCP add_lora uses the same helper.
- Insertion messages speak of the MODEL line; the fan-out ones are removed.
- tests/graph_splice.mjs (local) covers checkpoint, UNet + CLIP loader, before a
  sampler, and the rollback.

Verified on 8199 through the Current node picker: after a checkpoint feeding two prompts
(full LoRA, both prompts moved) and before a sampler (model-only Flux LoRA).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:00:32 +08:00
DemonGatanjieuandClaude Opus 5.5 0529502421 fix(mcp): add_lora wires CLIP too when the LoRA trains the text encoder
A model-only LoRA loader was used for every UNet workflow, so a LoRA with text-encoder
weights lost them; and inserting after a checkpoint whose CLIP feeds both prompts was
refused (one link per output only).

- api/mcp_actions.py reads the LoRA's safetensors header: lora_te / te1 / te2 /
  text_encoder keys mean it trains the text encoder.
- Such a LoRA becomes a LoraLoader taking MODEL from the model line's end and CLIP from
  the same node (checkpoint, full LoRA loader) or a UNet workflow's one CLIP source;
  others stay model-only.
- graph_splice.js: spliceAfterOutputs puts a node after outputs on different nodes and
  moves every link each output had. The picker's spliceModelChainNode is back to its
  committed behavior (the `types` option is gone).

Verified on 8199: UNet + CLIP loader with a text-encoder LoRA, a model-only one after it
and another text-encoder one; a checkpoint feeding two prompts; Ctrl+Z after each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 22:47:14 +08:00
DemonGatanjieuandClaude Opus 5.5 80f41f98e9 feat(mcp): AI apps can act on the open workflow, scan and speak
The second batch of MCP tools: they change things, through the same code the UI uses.

- Canvas, done by the open ComfyUI page: describe_canvas, set_prompt, set_model,
  add_lora, place_combo, check_workflow_models, fix_workflow_models, run_workflow,
  open_in_anomalous. api/mcp_bridge.py sends the action over ComfyUI's websocket and
  waits; web/modules/mcp_bridge.js claims it (visible tab first), does it with
  Anomalous's canvas code and answers. Each change is one Ctrl+Z step, shows a toast and
  is logged as the AI's (activity entries carry via: "mcp", with their undo).
- This server: scan_models (new / look up again / picked models, via /anomalous/scan_all),
  list_voices and speak (Anomalous TTS through /prompt and /history).
- graph_splice.js takes the chain's slot types, so a LoRA can go into a model-only (UNet,
  Flux) chain as LoraLoaderModelOnly; the default (MODEL and CLIP) is unchanged.
- Nothing deletes or renames files; acting tools are not marked read-only, so AI apps
  ask before calling them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 22:13:28 +08:00
DemonGatanjieuandClaude Opus 5.5 358c2bc4d3 feat(mcp): AI apps can read the library through /anomalous/mcp
An MCP endpoint (Streamable HTTP) inside ComfyUI's server, so Claude, Cursor, Cherry
Studio and other MCP apps can ask about the user's library without anything extra
installed and without memory or VRAM while nobody is connected.

- api/mcp_server.py: one JSON-RPC message per POST, answered with JSON. Serves the
  2026-07-28 stateless protocol (headers must match the body) and the earlier
  initialize-based versions; no sessions. This computer only: loopback peer, no proxy
  headers, loopback Host and Origin (DNS rebinding).
- api/mcp_tools.py: 11 read-only tools: library overview, model search and details
  (optional cover), scan status, output-image search and generation settings
  (optional thumbnail), combos, workflow recipes, saved prompts, generated audio,
  activity log. Ids resolve inside their roots; tool failures come back as isError.
- docs/guides/mcp.md: setup for each app, what can be asked, privacy note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 21:26:10 +08:00
DemonGatanjieuandClaude Opus 5.5 0c528cc281 Every drop onto the canvas becomes a ComfyUI history step
Audit of canvas writes for the Model Check gap: every path records a step except
a character voice dropped on a TTS node (audio_voice_drag.js), where a reload
right after the drop brought the old voice back. material_drag.js now records
after any drop handler finishes, so no drop target can miss it; handlers that
already record add no extra step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:42:31 +08:00
DemonGatanjieuandClaude Opus 5.5 517e7efaf2 Model Check put-ins become a ComfyUI history step
ComfyUI was not told about a model put in by Model Check, so reloading the page
right after Put in (or Ctrl+Z) brought back the old, missing name and its red
mark. A single put-in now records one step, and putting in several records one
step for all of them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:33:55 +08:00
DemonGatanjieuandClaude Opus 5.5 6c8d3e8eb4 Model Check bar: "Don't show again" and a check-on-open switch
- The bar starts with "Model Check:" so it is clear it comes from the browser.
- "Don't show again" turns the check on opening off and says where to turn it
  back on; Settings → Workflows has the same switch (on by default), and turning
  it on checks the open workflow right away. While off, the bar never shows.
- The tour's Model Check step mentions the button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:27:24 +08:00
DemonGatanjieuandClaude Opus 5.5 01dcdf37a9 Combo group on the pointer: Esc cancels, prompt nodes always, common negative
- While a combo group follows the pointer, Esc removes it again; the activity log
  holds back until it is put down, so a cancelled group leaves no entry.
- The positive and negative prompt nodes are always laid out (positive empty when
  the combo has no prompt); the negative takes Prompt Studio's common negative card
  (starter cards moved to prompt_composition.js as PROMPT_PRESETS).
- The UNet "connect clip yourself" message shows once the group is down; it used to
  pop up while the group followed the pointer, and closing it put the group down.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:09:25 +08:00
DemonGatanjieuandClaude Opus 5.5 7f96afe459 Combos only lay out a new group; replacing into the workflow is gone
"Use" tried to put a combo into the open workflow (main loader, LoRA
chain rebuilt, positive prompt) with a list of what changed and what was
skipped and why, which read as guesswork. A combo is a shortcut: its one
button, Put on canvas (放到画布), and dragging its card both lay out the
main model loader, LoRAs and prompt nodes as a new wired group, and never
change nodes already on the canvas. An existing node's values are changed
in Current node.

notebook_apply.js, the Use confirmation and its strings are removed; the
editor's More menu keeps only Delete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:51:26 +08:00
DemonGatanjieuandClaude Opus 5.5 800e51a6b4 Combo Use: a replacing LoRA keeps the canvas's strengths
Rebuilding the LoRA chain kept strengths only for a LoRA the combo
kept; a different LoRA went back to the node's 1.00, silently changing
values the user had set (0.80 became 1.00). Now the new LoRAs take over,
in order, the strengths of the LoRAs they replace. LoRAs added beyond
the old chain start at the node's default, and the confirmation says
which ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:38:43 +08:00
DemonGatanjieuandClaude Opus 5.5 c7a3ce5b1b Combo Use: say only Ctrl+Z undoes a rebuilt LoRA chain
The confirmation always said the activity log could undo Use too, but
the log only undoes changed values; a LoRA chain rebuild removes and
rewires nodes, so the log offers no Undo. When the LoRAs change, the
confirmation now names Ctrl+Z alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:28:48 +08:00
DemonGatanjieuandClaude Opus 5.5 8e05c82cbf Drag combo cards onto the canvas; un-keep from the gallery star
A combo card dropped on a main model loader does Use for that loader
(same confirmation), and on empty canvas builds its new group of nodes
where it was dropped; other nodes refuse it with a hint.

The star's keep menu has Remove (取消收藏) beside each kept row: after a
confirmation the recipe, combo or saved prompt goes to the Recycle Bin
through its own delete route, and the star (and the gallery's cached
list) goes back to empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 00:31:45 +08:00
DemonGatanjieuandClaude Opus 5.5 7245037315 Tour: go through the pages in the order they are used
Ten steps: the rail as one line, Gallery and what its star keeps,
Workflows, Combos, Prompts, Current node, scanning, Model Check, the
"!" guide and Settings. Steps that still described translation on
Current node and the old scan wizard are rewritten, and the tour's text
moved into locales.js instead of inline Chinese/English pairs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:57:28 +08:00
DemonGatanjieuandClaude Opus 5.5 41c62d8667 Prompt Studio: a ✕ beside the node's name switches to the draft
"Use draft" (改草稿) read as "edit the draft". The node's name now has a ✕
that stops editing it, and the draft is labelled as not in any node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:35:01 +08:00
DemonGatanjieuandClaude Opus 5.5 d00882925c Prompt Studio: meanings in a chosen language, any language to English
Tag meanings are brighter and slightly larger, and the top bar picks
their language (Simplified/Traditional Chinese, Japanese, Korean,
Russian, Spanish, French, German, Portuguese, or none) for every
interface language; the old Chinese switch carries over. Typed text in
any language that is not plain English is translated to English, and
translating a box's foreign tags drops one whose English is already
there. The backend skips DeepL for targets it does not take instead of
falling back to English, and lets MyMemory detect other sources.
Unused prompt helpers in translation_service.js are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:24:04 +08:00
DemonGatanjieuandClaude Opus 5.5 e6de76502a Prompt Studio edits the selected node's prompt boxes as tags
The assembly board, its write-to-node step and the positive/negative tabs
are gone. The studio now shows the prompt boxes of the prompt node last
selected on the canvas (or a draft) as tags that are edited in place:
weight, edit, remove, reorder, type to add with Chinese translated to
English, and an optional Chinese meaning under each tag. Every change is
one node write with Ctrl+Z, and the studio has its own Undo.

Cards: three common ones instead of six, plus saved prompts; a saved
prompt's text is read once instead of every 30 seconds. Current node
lists the prompt boxes with Edit in Prompt Studio instead of its own
translation. The inspector modal, starter-card data, plan loading and
about 2,000 lines of CSS for the old board are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 03:05:08 +08:00
DemonGatanjieuandClaude Opus 5.5 3a55eb08a9 Show a prompt's Chinese meaning in full in Current node
The note used a line clamp with padding, which painted the next line into
the bottom padding. It now shows the whole translation and scrolls when long.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:21:42 +08:00
DemonGatanjieuandClaude Opus 5.5 1d02d5b74d Remove the Material Library page; Prompts opens Prompt Studio
The page held four unrelated things, so each now lives where it is used:
the rail's Prompts entry (and the Ctrl+Shift+L shortcut) opens Prompt
Studio beside the canvas, where saved prompts are managed; the browser
comes back when the studio closes. Saved node values stay in Current
node, which can now delete them; moved whole workflows are no longer
offered there twice. Recent results, combos and whole workflows already
have their own homes.

Deleted ui_materials.js, ui_material_cards.js, ui_material_detail.js,
ui_material_application.js (its receipt is ui_apply_receipt.js) and
material_feedback.js, the recipe page's copy-to-library buttons and the
save_parameter_material route, with about 2,000 lines of CSS and 200 locale
keys. Words that said "material library" now say where things go.
Material files are untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:59:35 +08:00
DemonGatanjieuandClaude Opus 5.5 fc4e1a7fd1 Rename, delete and drag saved prompts in Prompt Studio
Library cards in the studio were read-only. Their preview now renames a
saved prompt (tags kept) or deletes it to the Recycle Bin, and any card
can be dragged out of the drawer onto a canvas prompt box (outlined,
previewed, one undo step) or onto empty canvas for a prompt node; over
the drawer the drag passes through to the assembly board.

The preview moved to ui_prompt_card_popover.js (the deck goes from 751 to
480 lines) and the canvas drag is prompt_card_drag.js; material_drag.js
takes passThrough and effectAllowed, prompt_drop.js a known-text drag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:39:44 +08:00
DemonGatanjieuandClaude Opus 5.5 302fe7e6e8 Keep gallery results as a workflow, a combo or prompts
Every PNG in the gallery has a star that asks what to keep: the whole
workflow as a Workflow Recipe, its main model, LoRAs and positive prompt
as a combo (models looked up on this computer, missing ones left out), or
its prompts as a saved prompt. What is kept remembers its image, so
GET /anomalous/kept_images fills the star and the menu opens what was
kept. The image workbench's Keep opens the same menu instead of its name
and tags form. Things are named after the prompt's first tags.

The Material Library's Recent shelf and its route are gone: the gallery
lists the same images. Gallery cards moved to ui_gallery_card.js, and
deleting asks in the usual dialog. Two missing locale keys are fixed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:32:51 +08:00
DemonGatanjieuandClaude Opus 5.5 074e9e43a0 Make Prompt Notes a Combos page with one Use button
A note was always a main model, LoRAs and a prompt kept together, so it is
now a combo (搭配) with its own rail page and Home card: a card per combo
with its model's cover, search and New; a card opens the editor, Back
returns to the list. Use puts the combo into the open workflow when the
canvas has a main model loader, builds a new group of nodes when it has
none, and asks when several loaders leave it unclear. The notes column,
quick-jump list and the Material Library's notes shelf are gone, with
their CSS and words; note files are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:19:27 +08:00
DemonGatanjieuandClaude Opus 5.5 0f7d6317ad Put Prompt Notes on the Material Library's Prompts page
ui_material_notes.js lists the notes above the saved prompts, each with
its model, LoRAs and prompt, Put into this workflow and New group of
nodes; a card opens the note's editor, which now has a way back to the
library, and New note starts one. The separate Prompt Notes button goes,
and so does the editor's "save to library" card with the route only it
used (save_prompt_note_material): a note is in the library already.
Notes keep their own files; prompt materials saved from notes before
still list and open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 17:39:38 +08:00
DemonGatanjieuandClaude Opus 5.5 7bea74dd09 Keep whole workflows as recipes, not materials
Recent's star and the image workbench's whole-workflow save now make a
Workflow Recipe with the image as cover (recipe_save.js lays the image's
workflow on a canvas of its own and saves it through the same path as the
canvas save, now shared). The Material Library's "all" lists the pieces
only and loses its Workflow pill. Whole workflows kept as materials
before can be moved from the Workflows page: each becomes a recipe and
its material gains moved_to_recipe, which keeps it out of every listing
while the file stays. Recent marks images kept as recipes and opens them;
partial recipes are "Modules" everywhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 17:20:28 +08:00
DemonGatanjieuandClaude Opus 5.5 dbf2022b06 Put a Prompt Note into the open workflow
notebook_apply.js plans and makes a note's changes to the canvas: the main
loader takes the note's model (same kind only), the LoRA chain right after
it is rebuilt with the note's LoRAs, rewiring MODEL and, for LoraLoader,
CLIP to what the old chain fed (a kept LoRA keeps its strengths), and the
positive prompt box of the sampler that model reaches takes the prompt. A
selected loader or prompt node decides when there are several. The note
editor's "Put into this workflow" shows each change and what is left as
is before writing, as one Ctrl+Z step; "New group of nodes" (formerly
Send to canvas) now wires a UNet note's prompts to the canvas's only CLIP
loader, and says so when there is none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 16:50:50 +08:00
DemonGatanjieuandClaude Opus 5.5 a93ed042d2 Slim the recipe detail and leave model recovery to Model Check
The overview's models are one compact list (preview, name, node, here
under the saved name or missing, size, note, download page). The recipe's
own match / apply-match flow, identity badges, advanced path and hash
blocks, origin editor and recipe fingerprint are gone; Model Check finds
renamed or moved models once the workflow is on the canvas, and saving
the recipe again keeps it. ui_recipe_model_matching.js becomes
ui_recipe_models.js; ui_recipe_detail.js drops from 776 to 292 lines.
Plain wording for the save dialog, empty page and status; no per-node
save-to-library buttons on the parameters page. Recipe CSS rules for
classes no script uses (107 rules) are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 16:41:51 +08:00
DemonGatanjieuandClaude Opus 5.5 3593e50e9f Carry model download links in saved and shared workflows
When a workflow is serialized (save, export, image metadata, share code),
a model present here without a link kept in the workflow gets its own
information's link (the editor's, else Civitai's), marked auto so a newer
one replaces it, and links for names no node holds any more are dropped;
the live graph is untouched. So after Model Check swaps in a local model,
the export names that file, its fingerprint and link. Fingerprints now
cover GGUF, .pth and .sft too. The share-code export shows how many models
carry a fingerprint, warns about those without, and can leave links out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:21:43 +08:00
DemonGatanjieuandClaude Opus 5.5 0b13f3b431 Mark fingerprint matches in Model Check as sure
Every match waits for a press, so the fingerprint ones get the top of the
same scale: ●●● sure (green), and ●●○ likely moves to the accent colour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 14:56:39 +08:00
DemonGatanjieuandClaude Opus 5.5 012c5ea8b3 Show how sure each Model Check match is
Next to the model's name: ●●○ likely (same name and size), ●○○ maybe
(same name or same size), ○○○ unclear / does not match / not found.
Fingerprint matches carry none; their button already says it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 14:54:36 +08:00
DemonGatanjieuandClaude Opus 5.5 e04fa66b0d Fix Model Check hashing same-size files inside ComfyUI
model_resolution imported calculate_sha256 and the header inference from
scraper.py, which only works when the plugin folder is on sys.path (the
tests); inside ComfyUI the import failed, so a renamed model the scan had
not hashed was reported missing and the route answered 500. Both
functions now live in model_identity.py, which the API and the scanner
already share; scraper.py keeps its printing wrapper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 14:46:57 +08:00
DemonGatanjieuandClaude Opus 5.5 4cfff5382e Make Model Check offer same-name files as candidates, not as the same file
For a workflow without fingerprints, a missing ordinary model took the
local same-name file's hash as its evidence, so it showed as "the same
file under another name" and counted among those put in at once. Only the
workflow's own record is evidence now; the one same-name file the node can
load is a candidate, and a size candidate with the same name is shown as
likely, first. Foundation components still go by fingerprint only. Model
Check also reads GGUF values and resolves upscale models.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 14:23:05 +08:00
DemonGatanjieuandClaude Opus 5.5 2104bb659f List GGUF, .pth, .sft and .bin models, in any case
The Models page, the folder counts, search and lookups only matched
.safetensors, .ckpt and .pt in lower case, so quantized GGUF models and
most upscalers never showed. One list, MODEL_EXTENSIONS, and
is_model_file() now decide what a model file is everywhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 13:51:46 +08:00
DemonGatanjieuandClaude Opus 5.5 81787cf139 Make model folder settings a readable view of the settings page
Folder management was a pop-up listing ComfyUI's raw folder names with
eye/cross emoji, "Category mode / Physical mode" radios and a Save and
reload button. It is now a view of the settings page with Back: grouping as
two plain choices (by model type, by folders on disk), the shown types
first with their usual names, folder names, a switch and up/down, then the
ones that are off, the rarely used ComfyUI folders folded away. Each change
is saved at once and the folder list reloads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 12:50:08 +08:00
DemonGatanjieuandClaude Opus 5.5 915feff820 Drop the help manual pop-up for a tour, what's new and the full guide
The manual was a long block of text that had fallen behind the window (it
still called recipes and materials "in testing") and was reachable only
from Settings. Pages explain themselves, Home lists what you can do and !
shows what changed, so Settings -> Help and feedback now offers the
interface tour, what's new, the full guide (the README on GitHub, at its
Chinese half in Chinese) and feedback. Removes ui_help.js, its strings and
styles, and the recipe "beta" notice code that no longer had an element.
The README's backup note no longer calls the features a test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 12:43:31 +08:00
DemonGatanjieuandClaude Opus 5.5 c86d48faa2 Write feedback in a small window first, then send it to GitHub
The prefilled issue looked like a form to fear: raw headings in two
languages, an HTML comment and a block of technical lines. Report a problem
and Suggest something now open a window with one text box (a sentence or
two, any language); Submit on GitHub opens the issue with that text, titled
from its first words, in the browser's language, and the environment folded
in <details> at the end when ticked (it can be looked at first). The issue
forms ask just what went wrong, with optional screenshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 12:32:33 +08:00
DemonGatanjieuandClaude Opus 5.5 5b82452514 Make feedback easy: report a problem or an idea on GitHub
Home ends with "Found a problem, or have an idea?" and Settings has the
same under Help and feedback. Report a problem / Suggest something open a
new GitHub issue with headings and the environment written in (plugin and
ComfyUI versions, Python/PyTorch, GPU and memory, OS, browser, page and
language; never a path or ComfyUI's command line); Copy diagnostics copies
it for other channels (feedback.js). Bilingual issue forms for problems and
suggestions are added under .github/ISSUE_TEMPLATE.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 12:21:32 +08:00
DemonGatanjieuandClaude Opus 5.5 00db5c6fc5 Follow a ComfyUI language change when the browser opens
With the language on "Follow ComfyUI" (the default), the browser read
ComfyUI's language only when the page loaded, and ComfyUI switches its
language without a reload. Opening the browser now takes up the current
ComfyUI language.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 12:13:57 +08:00
DemonGatanjieuandClaude Opus 5.5 a8b75d33a6 Faster model cards, video posters, and a settings page
Card thumbnails lived in ComfyUI's temp folder, which ComfyUI empties on
every start, so each first visit after a restart remade them all. They now
live in <user>/anomalous/cache/card_thumbnails (still capped at 256 MB).
Listing a model type queues the missing ones for one background thread;
the first screen of cards loads before the rest. A video cover gets a
first-frame poster (PyAV) and fetches nothing until it plays; hover-play
is now the default, as the setting already recommended.

The gear opens a settings page instead of a pop-up with a dialog inside:
look and language, model cards and memory with the thumbnail cache (size,
empty), folders, workflow fingerprints, opening mode and window, help.
Display preferences live behind owner.displayPrefs. Also removes a dead
overlay rule that shared the new page's id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 12:00:47 +08:00
DemonGatanjieuandClaude Opus 5.5 87aa9a1ac2 Put Voice-over back on the Voices page, with calmer settings and cards
Voices has two views switched at the top, Characters and Voice-over, sharing
the character list (clicking a character on Voice-over makes it speak); the
rail's Voices entry reopens the view used last, and the separate Voice-over
rail entry is gone. Generation settings: language as choices and speed as a
slider in view, the sampling numbers folded under fine-tuning with plain
names, no sideways scroll. A finished take says how long it took and links
to the audio gallery. Line cards show editing buttons on hover, the emotion's
reference clip sits beside its chips, and a character with only its main
voice shows no chips. The audio gallery shows each file's lines once and
can open them on Voice-over for another take; the list no longer repeats
the gallery. Also fixes the audio list staying empty after a reload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:06:45 +08:00
DemonGatanjieuandClaude Opus 5.5 65ffa602e2 Give voice-over its own page
The Script Director was a drawer behind one of four equal buttons on
Voices, over the character cards, and its name did not say what it does.
It is now Voice-over (配音), a rail page between Voices and Audio
(ui_script_page.js mounts the director); each character card has a
Voice-over button that opens it with that character chosen, and Home's
"Give a character a voice" goes there. Generate is the main button;
pushing, dragging and copying sit under "Into your own workflow", and a
push or drop is one Ctrl+Z step. Plainer words throughout; the Voices
page keeps import, refresh and search.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:19:08 +08:00
DemonGatanjieuandClaude Opus 5.5 cab02179e8 Turn the scan page's Advanced fold into a Scan settings page
The fold-out section did not match the rest of the page, which now opens
everything as a page of its own. The scan page shows a card saying how the
next scan goes (all or picked models, online or offline, renaming files and
so on, the ones that change files in yellow); it opens Scan settings, a page
with Back and one card per question.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:07:39 +08:00
DemonGatanjieuandClaude Opus 5.5 c863dacf36 Lay out the scan page's Advanced section like settings
Which models is two choices side by side; every option is a row with a
switch on the right; renaming files and fetching again carry a short tag
instead of whole paragraphs in yellow; the online-only options sit under
their own heading and fade for an offline scan; the API key row keeps its
button on one line. The folded title lists the choices that differ from
the usual.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 18:47:20 +08:00
DemonGatanjieuandClaude Opus 5.5 718cc0be70 Give the scan page's lists pages of their own
The scan page keeps only the counts, the scan button, the last scan in one
card and Advanced. Not found, not scanned yet, other formats and the last
scan's models each open a page with Back, why those models are there, one
button for the whole list and the rows; a model opened from a list returns
to it where it was scrolled. "Show the result" after a scan opens the last
scan's page. Also fixes "formerly Model Check" in the changelog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 18:38:15 +08:00
DemonGatanjieuandClaude Opus 5.5 20424e4428 Add a Recent shelf to the Material Library: one star keeps a result
Saving parameters asked people to decide, then save, while busy
generating. The library's first category, Recent, now lists the newest
output PNGs that carry their workflow, each with the start of its positive
prompt, the main model and steps / CFG / sampler / size (GET
/anomalous/recent_generations, read from PNG text chunks and cached). A star
keeps one as a whole-workflow material through the existing
save_image_material route, named after the prompt's first tags; a kept
image shows a filled star (matched by the image's SHA256, as the save
route's duplicate check does) and opens its material. The picture opens the
image workbench for keeping single nodes; the search box searches Recent
too. The empty-library guide points here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:53:13 +08:00
DemonGatanjieuandClaude Opus 5.5 4dd183ead4 Make Model Sources a view of the Models page
The Sources chip at the end of the model types now shows, in place of the
cards, where each model is downloaded: for this workflow or all models,
filtered by with / without a link, searchable. Each row's link can be
typed or pasted (any site), found, opened and saved with one button; a
link goes to the model's own information (the editor's download link) and,
for the workflow's models, into the workflow too, as one Ctrl+Z step. The
canvas note and the clipboard list stay. Model Check's "Where to download"
opens the view on this workflow.

The pop-up goes, with its lock-then-edit links and four ways of saving;
ui_model_sources.js (815 lines) splits into the view and
model_source_links.js (data and actions). Old hub CSS and locale keys go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:41:42 +08:00
DemonGatanjieuandClaude Opus 5.5 d379581853 Rename Model Doctor to Model Check
"Doctor" said little about what it does: check the opened workflow's
models and put back the ones this computer has. The rail, page, home card,
help, tour, README, the unreleased changelog and the architecture docs now
say Model Check (模型检查); the help paragraph now describes the check as
it works today. Code, CSS and locale keys keep "doctor"; released changelog
entries, audits and decisions keep the old name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:32:33 +08:00
DemonGatanjieuandClaude Opus 5.5 054cb91b9b style: one palette for every page, from the theme tokens
Pages made at different times used their own greys (slate here, neutral
there) and accents (sky, violet, indigo), and most ignored the Abyssal
Scarlet theme. About 1,850 literal colours in web/styles and in CSS and
inline styles written from JS now use the --amb-* tokens by what they are
for: surfaces, text (new --amb-text-soft and --amb-text-dim), borders (new
--amb-border-strong), the primary button, and one accent (new --amb-link,
tinted with color-mix). Both themes define the new tokens.

Literal colours stay where the colour is the meaning: status, prompt
roles, voice emotions, canvas node colours, shadows and overlays; theme
override rules are untouched. The share-code dialogs move from inline
styles to .anomalous-share-* classes. Stylesheet versions are bumped.

Checked: text contrast on every rail page, Prompt Studio, Prompt Notes,
Model Sources, settings, model detail and editor, share codes, in both
themes; structure check and JS tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:19:55 +08:00
DemonGatanjieuandClaude Opus 5.5 51b7113f5c feat(drag): preview a drop before release
While a prompt is over a prompt box, the text a release writes is laid
over that box and the opposite-role box on the same sampler; leaving the
box, the canvas or the drag removes it. The overlay never touches the
widget's value.

Over a node of a type the material holds values for, the hint lists what
would change ("steps 12 -> 28"), as applyMaterialBlock does it: seeds stay,
and a choice this computer lacks is named. Every dragged material is now
fetched when its drag starts (prepareMaterialDrag).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:05:43 +08:00
DemonGatanjieuandClaude Opus 5.5 a2a11ca2cf feat(scan): the scan page says in plain words why a model is not there
The lead says what a scan does (the file's fingerprint looked up on
Civitai). "Not found" lists the usual reasons (not published on Civitai,
as voice and upscale models mostly are; trained or merged; changed after
download) and that the model works as usual. A not-found model that is no
known image model says it may be a voice or upscale model.

The summary also lists model files in other formats (.pth, .ckpt, .bin...),
which scans never read, outside hidden folders; the page shows them under
"Other formats" with why, only when there are some.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:00:08 +08:00
DemonGatanjieuandClaude Opus 5.5 4146872765 fix(materials): the top bar wraps instead of cutting off its buttons
Docked to the side (660px) the Material Library's top bar was 153px too
wide: Prompt Studio was half hidden, Prompt Notes and refresh not shown.
The bar now wraps; the buttons go to a second row, right-aligned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:52:07 +08:00
DemonGatanjieuandClaude Opus 5.5 5492d4a5e1 feat(undo): every canvas write is a Ctrl+Z step; undo from the toast and the activity log
ComfyUI takes undo steps on mouse-up and key-up; a drag-and-drop ends in
neither, and writes made in click handlers or after a fetch landed in no
step, so Ctrl+Z could undo the step before instead. canvas_history.js asks
for a step after each write (applied values, drops, created or inserted
nodes, recipes, prompt notes).

A drop's receipt also pops up at the top right with Undo (shared with the
receipt's own button); prompt nodes made by a drop on empty canvas can be
undone there. canvas_undo.js keeps what each canvas activity entry did, in
full, while the page is open; the log offers Undo while nothing changed it
since. Entries that removed nodes or rewired existing ones, seeds, and
choices this computer no longer has are never offered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:48:14 +08:00
DemonGatanjieuandClaude Opus 5.5 264b098b34 docs(tour): describe the doctor, Current node and material drags as they work now
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 06:22:10 +08:00
DemonGatanjieuandClaude Opus 5.5 307381c439 feat(shell): every tool opens from its page; the toolbox goes
The rail keeps scan, Model Doctor and Current node (ui_rail_tools.js,
which replaces ui_toolbox.js). The tools the toolbox held now sit where
their things are:

- Workflow share codes (AMB0/AMB1 import / export): the Workflows page's
  top bar, in place of the disabled "import" button.
- Prompt Studio and Prompt Notes: the Material Library's top bar.
- Model Sources: the end of the type chips on Models (library) and
  "where to download" in Model Doctor (the open workflow).

Removed with it: the toolbox popover, its tool catalog and custom-item
hook (no plugin used it), the generic tool dispatch (Home opens the doctor
directly), the tour step, its styles and the toolbox and old "planned
tools" strings. The scan button's tooltip no longer says "wizard".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 06:20:28 +08:00
DemonGatanjieuandClaude Opus 5.5 96736560af feat(node): translate prompts on the node; the separate translator goes
Current node lists the selected node's prompt boxes (prompt_boxes.js, so
each is marked positive or negative by its wiring). Chinese text gets
"translate to English", written back only if the box still holds the same
text when the translation arrives (one undo); English text can show its
Chinese meaning without writing.

The standalone Prompt Translator (ui_prompt_translator.js, 838 lines, its
four fallback ways of guessing a text box, its styles and its toolbox
entry) is removed, with Prompt Studio's appendPromptToStudio that only it
used. Prompt Studio keeps its own translate buttons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 06:14:05 +08:00
DemonGatanjieuandClaude Opus 5.5 a173e4e561 feat(node): Current node panel with one list of saved parameters
The Node Assistant becomes "Current node" (当前节点): one page instead of the
"actions" / "parameter sets" tabs, model actions on top, Parameters below.

- node_parameter_sets.js: the node type's saved values from the material
  library and from recipes' parameter sets as one list; the same values
  saved in both are one entry naming both sources. For the selected node,
  exactly what an entry would change; seeds (and their after-run control),
  model files and choices this computer lacks are kept.
- ui_node_parameters.js: the list, "apply these N" (one undo in the
  receipt) and "save these values".
- api/node_material.py: POST /anomalous/save_node_material keeps one
  node's values as a node_parameter_selection material, deduplicated by
  type and values; the library lists it under parameters.
- Removed: ui_node_presets.js with its hover popovers, the beta notice and
  their styles and strings. The panel and help texts use the new name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 06:09:49 +08:00
DemonGatanjieuandClaude Opus 5.5 86d165b891 feat(prompts): a dragged prompt shows where it goes before release
While a prompt material is dragged, every prompt box on the canvas is
outlined by role (green positive, red negative, grey unwired) and the box
under the pointer is the target. The hint says what release writes where,
including the opposite-role box on the same sampler, which is filled with
the material's other side. A node with several boxes needs the pointer on
one; a node without a box says so instead of falling through to "new node".

- prompt_drop.js: outlines, target box, hint; the material is fetched when
  the drag starts so the hint knows which sides it has.
- material_drag.js: accepts/targetHint/rejectHint/drop get the event;
  onStart/onMove hooks; a node's refusal beats the blank-canvas action.
- Dropping a prompt plan on empty canvas created an empty node (summaries
  carry no text); it now fetches the plan and creates one node per side.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 05:57:12 +08:00
DemonGatanjieuandClaude Opus 5.5 4abb0100a7 feat(prompts): find prompt boxes by node definition and wiring
One module, prompt_boxes.js, now decides which boxes of a live node take a
prompt and whether each is positive or negative. A prompt box is a
multiline STRING input by the node's ComfyUI definition, not a widget
whose name looks right; its role is its own name (positive / negative) or
the wiring down to a sampler's positive / negative input, not the title.

- Material drops, Prompt Studio, the translator and recipe capture use it;
  their name regexes and fallbacks ("any string widget") are gone.
- Prompt Studio's role detection read graph.links[id], which is a Map in
  current ComfyUI, so it never followed a link; it uses the module now.
- Material writes call onWidgetChanged(name, ...) as ComfyUI expects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 05:46:21 +08:00
DemonGatanjieuandClaude Opus 5.5 715b8b7271 docs: call the scanning section what it is now, not the removed deep scan
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 04:38:20 +08:00
DemonGatanjieuandClaude Opus 5.5 2358649f03 feat(doctor): Model Doctor checks each opened workflow and says what to do
A bar over the canvas appears when an opened workflow uses models this
computer does not have: how many, and how many are here under another
name or folder (same hash), with one press to put those in. The doctor
page is redone on the scan page's layout: what needs you first, why in
plain words, and a button per row (put in, use a same-size file, pick
one, look it up on Civitai); models that are fine fold away.

- model_check.js: the check (no DOM, changes nothing) and the fixes,
  which call the node's widget hooks so ComfyUI drops its red mark;
  the node picker does the same now.
- Opening the doctor no longer rewrites the workflow by itself.
- Removed: the auto-detect switch (nothing read it), the doctor's own
  deep hash scan and its routes (the resolver hashes same-size files on
  demand; the page points to the scan page), the hash dialog, the old
  resolver in hash_resolver.js, the unused pick-for-node path in
  ui_detail.js, 45 old strings.
- Node Assistant's diagnoseNode and LoRA insertion moved to
  ui_node_assistant.js.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 03:36:41 +08:00
DemonGatanjieuandClaude Opus 5.5 e466e9dfcf feat(scan): list unmatched and unscanned models, show each scan's result, explain why
The scan page now says which models are unmatched or not scanned yet (type, folder,
why Civitai had nothing) and what each scan did, model by model; rows open the model,
scan it, look it up again or search Civitai. The progress box shows the counts and,
when floating, a way to the result. The activity log records a scan when it ends with
its result, and model edits (from -> to) and deletions (files moved) in detail.

- scraper.py reports one JSON line per changed model (--report-file); a failing model
  no longer ends the scan. Unmatched info keeps why (not_found / network / offline);
  online scans look network/offline ones up again. After two models in a row without
  an answer the rest of the scan infers from the files (--civitai-down for the next
  folders) instead of waiting out every timeout.
- civitai_client.py: the network side moved out of scraper.py; tells a 404 from
  Civitai being unreachable (CivitaiUnreachable).
- api/scan_report.py: ScanJob turns a scan's reports into one result, kept as the last
  scan (/anomalous/last_scan) and logged; /anomalous/scan_model opens a listed model.
- /anomalous/scan_all takes picked or listed models as `targets`, so one scan has one
  result; scan_summary lists unmatched and new models with their reason.
- Activity middleware takes details a handler attaches (request[ACTIVITY_DETAIL]).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 02:52:17 +08:00
DemonGatanjieuandClaude Opus 5.5 e689cc3dac chore: restore consistent CRLF line endings in six files
Edits in the last commits left some LF-only lines in files the repository
keeps with CRLF. Line endings only; no content change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 01:57:06 +08:00
DemonGatanjieuandClaude Opus 5.5 e647ad03b1 feat(models): mark where a model's information came from; fix the locale file; check syntax
Cards look as before for models matched on Civitai. Only the exceptions are
marked: a base model inferred from the file shows "≈ <base>" with a dashed
outline, and a .safetensors model not scanned yet says so; both explain
themselves in a tooltip (model_source.js). The detail header's line adds
the source and the fields the user edited; the green title that meant
"renamed" gives way to it (ui_detail.js 755 -> 752 lines).

Fix: the previous commit's locales.js had raw line breaks inside the
"fetch again" confirmation strings, so the plugin did not load. Neither
`node --check` (it passes such .js files on Node 24) nor the structure
check noticed. check_structure.mjs now compiles every web file as the ES
module the browser loads and fails on a parse error; AGENTS.md says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 01:56:40 +08:00
DemonGatanjieuandClaude Opus 5.5 00ea3f78c4 feat(models): the user's edits are a layer of their own; scans keep the user's covers
A model's information now has three layers, the user's first:
<model>.anomalous.json (written only by the model editor), the scan result
(<model>.info, or another tool's .civitai.info with Civitai ids; id -1 =
inferred from the file) and the file itself. get_metadata reads the user
layer last, so it wins (an emptied field stays empty), and reports
info_source (civitai / local / "") and user_fields. Edits saved by earlier
versions in .civitai.info keep working.

- The editor writes .anomalous.json (atomically) instead of mixing edits
  into .civitai.info, which other tools rewrite.
- scan_info_path: a .civitai.info holding only old edits is not a scan
  result. Editing a never-scanned model used to make scans skip it for good.
- Covers: a cover is Civitai's only while byte-identical to
  <model>.civitai_bak.*; any other cover is the user's and is never
  replaced. "Fetch again" updates only Civitai's own cover, keeps names,
  notes and covers, and asks before starting.
- A scan no longer downloads covers and model pages again for models whose
  information is already on disk (it did for every model, every scan).
- The earlier "keep the editor's name" change in the scraper is reverted:
  editor names never lived in .info; the CHANGELOG entry is corrected.
- .anomalous.json moves and goes to the Recycle Bin with its model.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 01:49:10 +08:00
DemonGatanjieuandClaude Opus 5.5 a2e3077726 feat(files): deleting the user's files goes to the Recycle Bin
recycle_bin.move_to_trash is now the only way the plugin deletes the
user's files: SHFileOperationW with FOF_ALLOWUNDO on fixed Windows drives
(send2trash or gio elsewhere). Where there is no Recycle Bin (USB stick,
network share) it raises TrashUnavailable and nothing is deleted; the API
says so (api/trash.trash_failure), including "file in use".

Routed through it: model delete (model + sidecars in one operation, or
nothing), cover replacement and cover reset, output images, generated
audio, recipes with history and images, materials with images, prompt
notes, parameter notes, the duplicate copy a physical rename finds and the
covers a forced rescan replaces. Temp files, caches and the bounded recipe
history are still removed directly.

The unused POST /anomalous/clean_civitai_info, which erased every
.civitai.info in the library (with the names users set), is removed with
its activity-log label. Delete confirmations say where things go. Tests use
a fake Recycle Bin (none of their files reach the real one).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 01:42:49 +08:00
DemonGatanjieuandClaude Opus 5.5 fb21e6c6e9 feat(scan): the scan page, scan runner and scanner changes (completes 5af9b6d)
5af9b6d was committed with only the removal of ui_scan_wizard.js staged;
this adds everything its message describes, so the branch works again:

- ui_scan_page.js / scan_runner.js / 18-scan-page.css: the scan page with
  matched / unmatched / not-scanned counts, one scan button, "look up the
  unmatched models again", progress in the page, Advanced options.
- api/scan_summary.py: GET /anomalous/scan_summary.
- scraper.py: --retry-unmatched (saved hash when the file is unchanged,
  covers kept); display names set in the editor survive scans.
- api/scanner.py: one request-to-switch mapping (_scraper_flags) and one
  cache-clearing helper for both scan routes.
- model_identity.py: sidecar_info / is_unmatched shared by scraper and API.
- Shell wiring (rail, Home, panel lists, close), the provenance switch in
  Settings -> Model settings, locale keys, docs and CHANGELOG.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 01:14:54 +08:00
DemonGatanjieuandClaude Opus 5.5 5af9b6d260 feat(scan): a scan page replaces the scan wizard; unmatched models can be looked up again
The rail's scan button and Home's "Scan model folders" open a tool page
instead of the seven-switch dialog: counts of models matched on Civitai,
not matched and not scanned yet (GET /anomalous/scan_summary), one button
to scan, progress inside the page (scan_progress.js hosts its panel there;
it floats again when the page or the browser is closed). Offline, renaming
files, fetching everything again, picking models and the API key (with its
set / not set state) are under Advanced; the two risky switches reset after
each scan. The provenance switch moved to Settings -> Model settings.

Scanner:
- --retry-unmatched: only models whose info was written from the file alone
  (Civitai had no match or could not be reached) are looked up again, with
  the saved hash when the file is unchanged and without replacing covers.
  Before, such models stayed local-only unless every model was refetched,
  which also deleted custom covers.
- A display name set in the model editor is no longer replaced by Civitai's
  name on each scan or when the info is fetched again.
- Both scan routes map request fields through _scraper_flags; folder cache
  clearing is one helper (scanner.py 602 -> 579 lines).
- sidecar_info / is_unmatched live in model_identity.py for scraper and API.

ui_scan_wizard.js is gone: scan_runner.js starts and follows scans,
ui_scan_page.js renders the page; 36 wizard-only locale keys removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 01:14:32 +08:00
DemonGatanjieuandClaude Opus 5.5 f13b725d97 feat(audio): open a character from the TTS node; install card links the published pack
- window.anomalous_open_voice(character) opens the browser on that
  character's voice card (browser.openVoice); the Anomalous_TTS node's
  character menu calls it.
- voiceGroupKey() is the one place that builds a GPT-SoVITS group key.
- The install card always says how to install (ComfyUI Manager or the
  project page) and links the Anomalous_TTS repository, which is going
  public; the "not published yet" text is gone.
- The voice card drag hint no longer promises a tag button on every row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 21:52:49 +08:00
DemonGatanjieuandClaude Opus 5.5 8c1ad49e25 docs(copy): README, help and the "!" guide describe the rail layout
The README, the help page and the image-side "!" guide still described the
old top tabs and bottom bar and an earlier release's features. They now
cover the rail, Home, model type chips, the activity log, character voices
(with the optional Anomalous_TTS node pack) and the lighter memory use.

- New update guide id 2026-10-rail-and-voices with five steps; the old
  guide's locale keys (and the never-shown navigation/transfer/library
  ones) are removed.
- Help: the Civitai API key goes in with the key button in the scan wizard,
  not in Settings.
- The audio guide says the tag copy button only exists for characters
  with emotions.
- update-guide.md loses its section on the removed bottom bar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 17:58:10 +08:00
DemonGatanjieuandClaude Opus 5.5 3a221b71d3 feat(models): type chips head the model grid; voice tag buttons say what they copy
The models page starts with a chip per models folder (Checkpoint, LoRA, ...)
and its count. A chip lists the whole folder with its subfolders
(GET /anomalous/type_models); a folder picked in the list narrows the grid
to that folder, shown as a crumb whose x goes back to the whole type. The
chosen type is remembered.

owner.modelScope now says what the grid lists; currentType/PathIdx/Subfolder
stay the folder of the model at hand, set from each card (focusModel), so
the editor, the scanner and "add to canvas" get the right subfolder.

Voice clips: the copy button reads "Copy {tag}" with a tooltip on what
pasting it does, and is gone for characters with only a main voice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 01:09:40 +08:00
DemonGatanjieuandClaude Opus 5.5 230566b864 perf(gallery): show thumbnails in the output gallery; release its images when closed
The gallery grid loaded every output at full size: a 1024x1536 PNG decodes to
about 6 MB in the browser, so a few pages of outputs held hundreds of MB to
GBs of browser memory and graphics memory. Cards now use a cached 512 px WebP
from /anomalous/output_thumbnail (the model cards' thumbnail cache; about
0.7 MB decoded); dragging to the canvas, the viewer and the workbench still use
the original. When the browser has been closed for 90 s its gallery images are
released like the model grid, and reopening on the gallery reloads them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 00:39:48 +08:00
DemonGatanjieuandClaude Opus 5.5 9b0b95ff22 fix(shell): the narrow-window list is a drawer; cap the activity log at 2 MB
In a narrow or docked window the open list now sits against the rail over a
dimmed page, and closes when a folder or character is picked, when the dimmed
page is pressed (that press does not reach the cards) or on Esc. Wide windows
are unchanged.

The activity log also drops its oldest entries past 2 MB, so long canvas
entries cannot outgrow the JSON writer's limit and stop the log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 00:14:28 +08:00
DemonGatanjieuandClaude Opus 5.5 376284e647 feat(activity): log what Anomalous changed on the canvas and in files
A new Activity page on the rail (and the home page's recent list) shows, by day,
what the plugin changed. Record only; undo comes later (AD-018).

- api/activity_log.py keeps the newest 500 entries in the ComfyUI user folder.
  A middleware records this plugin's and Anomalous_TTS's write requests after
  they succeed (including the 200 + {"status": "error"} style as failure), from
  one route table; the log never fails the request it records.
- activity_canvas.js: a press inside Anomalous snapshots the canvas; the next
  press or key outside it (or the next press inside) logs the difference, so
  every button, drag and dialog that writes nodes is covered and the user's own
  edits are not. Opening another workflow is logged as that. <html> carries an
  Anomalous class and does not count as the plugin's interface.
- activity_diff.js: pure snapshot and difference; activity_log.js: client and
  wording; ui_activity.js: the page (filters, details, find on canvas, clear)
  and the home list.
- Tests: tests/activity_diff.mjs, tests/test_activity_log.py (local).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 00:00:28 +08:00
DemonGatanjieuandClaude Opus 5.5 4ade73550c chore(rules): files over 600 lines may only shrink
New files stay under 600 lines; files already over it may only shrink or stay
the same when touched, so they get smaller with everyday work instead of a
dedicated refactor. tools/check_structure.mjs now compares with the last commit
and fails on a new oversized file or a grown one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 23:48:05 +08:00
DemonGatanjieuandClaude Opus 5.5 46facb9115 feat(shell): icon rail, per-page list column and a home page
The browser now navigates from one narrow icon rail on the left: Home, Models,
Gallery, Workflows, Materials, then Voices and Audio, then scan, Model Doctor,
Node Assistant and the toolbox, with settings at the bottom. It replaces the
header tabs, the image/audio toggle and the tool bar under the folder list, so
the list column holds only a list and can close without hiding a command.

- ui_shell_rail.js builds the rail; ui_shell_nav.js owns goTo(page), which page
  has a list and whether it is open (remembered per page; below 760 px it starts
  closed and overlays), the page title in the header and the page reopened next
  time. Clicking the current page's icon toggles its list (from a model's detail
  it returns to the grid).
- ui_home.js: task cards and first steps; the first open lands there.
- Window drag/resize moved to ui_shell_frame.js and scan polling to
  ui_scan_watch.js; ui_sidebar.js goes from 864 to 464 lines.
- Workspaces (recipes, notes, materials) keep the rail visible; the list steps
  aside while one is open.
- Removed what the rail replaced: sidebar_actions.js (bottom-bar hover labels and
  tooltip bubble), the domain toggle button, their CSS and unused text keys.
- Spotlight tour: the rail step replaces the header-tabs step, cards open to the
  right of rail items, and the audio tour points at the rail's Audio entry.
- Decision record AD-017; architecture map, frontend and update-guide docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 23:08:16 +08:00
DemonGatanjieuandClaude Opus 5.5 7da4e1d91e fix(audio): the audio guide's tour walks the audio page; suggested folder layout
The "!" guide on the audio page hid its tour banner with the hidden attribute,
but the banner's display:flex overrode it, so the banner showed and started the
image-side tour, whose buttons are not on the audio page. The audio guide now
has its own spotlight steps, and tour steps whose target is not on screen are
skipped. The batch import screen shows a folded example of a folder layout that
sorts cleanly: one folder per character, named after it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 16:32:46 +08:00
DemonGatanjieuandClaude Opus 5.5 805b297a80 Merge main (v1.57.3 hotfix) into feature/audio-engines
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 16:01:36 +08:00
DemonGatanjieuandClaude Opus 5.5 6331b25241 chore(release): v1.57.3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 15:59:03 +08:00
DemonGatanjieuandClaude Opus 5.5 c8689d56e4 fix: offline scans stay offline, workshop snap dock, 400 on malformed requests
From an outside audit (same change as 32a3719 on feature/audio-engines):
- The scan wizard's offline mode still sent the greyed-out rename and
  overwrite options, and scraper.py fetched Civitai model pages and preview
  media for models whose .info held a Civitai id. Offline now implies no
  media and no model page; the folder scan and scan_all refuse a missing or
  non-object body instead of defaulting to an online scan.
- The Prompt Workshop's snap dock referenced leftSearch from the source deck
  module (ReferenceError on click); the deck now exposes focusSearch().
- translate, delete_model, delete_gallery_image and the image-material
  routes answer malformed bodies with 400 instead of 500.
- Removed six unused imports in ui_doctor.js and 16 duplicate locale keys
  per language (the later, effective entry is kept, so nothing changes on
  screen).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 15:44:04 +08:00
DemonGatanjieuandClaude Opus 5.5 32a3719d97 fix: offline scans stay offline, workshop snap dock, 400 on malformed requests
From an outside audit:
- The scan wizard's offline mode still sent the greyed-out rename and
  overwrite options, and scraper.py fetched Civitai model pages and preview
  media for models whose .info held a Civitai id. Offline now implies no
  media and no model page; the folder scan and scan_all refuse a missing or
  non-object body instead of defaulting to an online scan.
- The Prompt Workshop's snap dock referenced leftSearch from the source deck
  module (ReferenceError on click); the deck now exposes focusSearch().
- translate, delete_model, delete_gallery_image and the image-material
  routes answer malformed bodies with 400 instead of 500.
- Removed six unused imports in ui_doctor.js and 16 duplicate locale keys
  per language (the later, effective entry is kept, so nothing changes on
  screen).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 15:36:07 +08:00
DemonGatanjieuandClaude Opus 5.5 a083cfdc54 feat(audio): wider Script Director with a generate column, full cover when docked
The drawer grows to 1000px. From 760px wide the line cards take the full
height and the generation settings, generate bar, result and canvas buttons
sit in a 320px column beside them. When narrow (docked browser) the drawer
covers the studio column and keeps generating below the cards; only the
cards scroll in either layout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 14:17:42 +08:00
DemonGatanjieuandClaude Opus 5.5 21d8ae5800 feat(audio): Script Director keeps one scroll area and a short bottom bar
Long scripts squeezed the line cards between the list and a footer that
scrolled on its own. Generation settings (language, speed, sampling) are
now one folded line above the cards that scrolls with them, and the footer
is a short bar: generate / retake / cancel, the result and the canvas
buttons. The fold state is remembered per browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 14:09:53 +08:00
DemonGatanjieuandClaude Opus 5.5 721a006b16 refactor(audio): drop F5-TTS, the audio page manages GPT-SoVITS only
The F5-TTS voice library (input/F5-TTS scanning, voice upload with ffmpeg
conversion, romanization, Load Audio clip drags) and the engine switch are
removed. The audio page now works with one engine, Anomalous_TTS
(GPT-SoVITS): characters, drags onto its node, the Script Director with
direct generation, pronunciation and settings. The audio gallery and its
stream route stay (output and temp folders only). The root PATH hack that
existed for F5-TTS's ffmpeg lookup is gone. Recorded as AD-016.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:53:07 +08:00
DemonGatanjieuandClaude Opus 5.5 6cb9ea39be feat(audio): pronunciation table for GPT-SoVITS characters
"Pronunciation" on a character card edits the node's `replace` setting
(script text -> what the character reads). Each row can be heard as the
character reads it now (the saved table applies) and as replaced, through
a Preview Audio run that stays out of the audio gallery; one preview at a
time, cancelled when the dialog closes. The file is re-read before editing
and every other settings field is kept. buildTtsPrompt gains `preview`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 02:41:44 +08:00
DemonGatanjieuandClaude Opus 5.5 e341b8a26d feat(audio): sampling parameters in the Script Director, roomier drawer, no audio workflow tab
- Generate section: top_k / top_p / temperature / repetition_penalty folded
  under "Advanced", ranges and defaults read from the node's /object_info;
  "save as this character's default" keeps language, speed and these (only
  values that differ from the node's defaults). The seed is not saved.
- Drawer 560px wide, line cards keep 180px, footer scrolls; for GPT-SoVITS
  the bundle preview is hidden and the canvas buttons are compact.
- The audio domain no longer shows the "Audio Workflows" header tab (the
  recipe library filtered to "audio"); a `hidden` header button now really
  hides despite the header's display !important rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 02:16:07 +08:00
DemonGatanjieuandClaude Opus 5.5 ff726db2f6 feat(audio): generate GPT-SoVITS scripts in the Script Director, drop the Arona template
The Script Director gets a Generate section for GPT-SoVITS characters
(ui_script_run.js): it queues the script as an API prompt with this
page's client id (audio_tts_run.js), shows queued/running/progress,
plays the result and saves it to output/audio/<character>/. The canvas
is never touched. A line card's retake button adds [take:N] to that
line so only it is regenerated; "New take of all" changes the seed.
Cancel deletes a queued job or interrupts it by prompt id when running
(checked against /queue, since the start event can arrive first).
Events that arrive before /prompt answers are buffered and replayed.
Language/speed start from the character's `defaults` and can be saved
back (read-modify-write, unknown fields kept). Needs Anomalous_TTS
interface 11; older versions get a hint and keep push-to-node.

The Arona multi-voice template read a file only one machine has; the
button, route, helper (alignedVoiceValue) and strings are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 01:25:05 +08:00
DemonGatanjieuandClaude Opus 5.5 a4a49d9d0c Merge main (v1.57.2 hotfix) into feature/audio-engines
The save and Prompt Note fixes were cherry-picked onto main for the
replaced v1.57.2; their changelog entries move from Unreleased into the
v1.57.2 section. Code is unchanged here, feature already had both fixes;
feature keeps its own cache parameters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 18:03:51 +08:00
DemonGatanjieuandClaude Opus 5.5 3ba16e874c fix(notebooks): make UNet notes runnable and list unlabeled models
Sending a Prompt Note with a UNet main model chained its LoRAs through
LoraLoader, whose clip input nothing could feed. UNet LoRAs now use
LoraLoaderModelOnly and the editor says to connect a text encoder.

Models without a baseModel in their metadata matched no base model, so
they never appeared; base_models now reports them and
compatible_models serves them for "__unlabeled__". Both routes share
one walker and run it off the event loop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 17:57:47 +08:00
DemonGatanjieuandClaude Opus 5.5 42d8c7bbfc fix(backend): restore imports lost in the module split
Saving a workflow recipe failed for any workflow with a model loader:
recipe_images.py called _resolve_exact_model_reference and hashlib, which
stayed behind in recipe_schema.py when 6ad975c split the module. The
resolver moves to path_utils.py so both recipe modules can import it
without a cycle.

The same split left media_routes.py without time/tempfile (card
thumbnails silently fell back to full-size covers) and model_metadata.py
without _resolve_paths_to_model_info_sync (editing a moved model failed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 17:57:24 +08:00
DemonGatanjieuandClaude Opus 5.5 7c97b73779 fix(notebooks): make UNet notes runnable and list unlabeled models
Sending a Prompt Note with a UNet main model chained its LoRAs through
LoraLoader, whose clip input nothing could feed. UNet LoRAs now use
LoraLoaderModelOnly and the editor says to connect a text encoder.

Models without a baseModel in their metadata matched no base model, so
they never appeared; base_models now reports them and
compatible_models serves them for "__unlabeled__". Both routes share
one walker and run it off the event loop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 17:33:35 +08:00
DemonGatanjieuandClaude Opus 5.5 c19e3bb711 fix(backend): restore imports lost in the module split
Saving a workflow recipe failed for any workflow with a model loader:
recipe_images.py called _resolve_exact_model_reference and hashlib, which
stayed behind in recipe_schema.py when 6ad975c split the module. The
resolver moves to path_utils.py so both recipe modules can import it
without a cycle.

The same split left media_routes.py without time/tempfile (card
thumbnails silently fell back to full-size covers) and model_metadata.py
without _resolve_paths_to_model_info_sync (editing a moved model failed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 17:28:18 +08:00
DemonGatanjieuandClaude Opus 5.5 685de2fa15 fix(workspace): keep the header tabs usable, close with Esc, restore after the translator
- The recipe / notes / material workspace (nbPanel) starts below the 48px
  header instead of covering it, so Models / Gallery / Workflows stay
  clickable while it is open.
- Esc closes that workspace like a click beside it, unless one of this
  plugin's dialogs or a ComfyUI dialog is above it or a text field has focus.
- Closing the Prompt Translator shows the browser again when opening it had
  folded the browser away (not after sending text on to the studio).

Found in a browser audit; checked in an isolated ComfyUI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:24:52 +08:00
DemonGatanjieuandClaude Opus 5.5 5fd9e8c872 chore(repo): keep test screenshots and local paths out of the repository
The doc folders now track only their Markdown; screenshots, logs and raw
test data belong under the ignored .agents/. AGENTS.md section 0 says so,
and forbids local paths and file:/// links in committed files. Fixes the
existing audit index link that pointed at a local path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:17:36 +08:00
DemonGatanjieu 713e06550c Merge pull request #20 from DemonGatanjieu/claude/awesome-wright-48lzun
Sync main (v1.57.2 changelog and material card cache version)
2026-09-27 03:35:05 +08:00
Claude 660f1839b0 Merge remote-tracking branch 'origin/main' into claude/awesome-wright-48lzun
# Conflicts:
#	web/styles.css
2026-09-26 19:34:54 +00:00
Claude a4cbe72086 docs(changelog): v1.57.2 also fixes Material Library cards with a node selected
The two apply-mode card fixes (cherry-picked from feature/audio-engines)
ship in v1.57.2; the stylesheet cache version is bumped so browsers load
the new card CSS. Checked in ComfyUI with a selected KSampler: before, two
"⚡ ⚡ Apply Scheme" buttons per card and four overlapping text pairs; after,
one button, no overlaps, and applying still writes the scheme's values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012RMuwbsWZHnx2eCWpxYqAB
2026-09-26 19:34:38 +00:00
DemonGatanjieu 4e8603b4cc fix(materials): stop apply-mode card text from overlapping
The grid used one fixed row height, so the extra apply button squeezed the
badges, date and node types on top of each other. Cards in apply mode now get
a taller row (the list is marked is-apply-mode when it renders), the apply
button sits at the bottom, and only the tag row may be clipped when space
runs out. The normal row is a little taller so a tag row fits as well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeQEub5
(cherry picked from commit 9e761d0705)
2026-09-26 19:32:04 +00:00
DemonGatanjieu e1ac84f6d8 fix(materials): one apply button per card, without a doubled icon
In apply mode each card rendered a second apply button (a clone meant for
the list layout's hover actions) that showed as an extra row in the grid,
and both labels prefixed another icon to a locale string that already has
one. Cards now render a single button whose text comes straight from the
locale (textContent), in grid and list layouts alike.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeQEub5
(cherry picked from commit 01fc925bd6)
2026-09-26 19:32:03 +00:00
DemonGatanjieu 120f0f7665 Merge pull request #19 from DemonGatanjieu/claude/awesome-wright-48lzun
Merge v1.57.2 canvas performance hotfix from main
2026-09-27 03:22:32 +08:00
Claude a7749beed4 Merge remote-tracking branch 'origin/main' into claude/awesome-wright-48lzun
# Conflicts:
#	CHANGELOG.md
#	web/main.js
#	web/modules/browser_entry.js
#	web/styles.css
2026-09-26 19:21:26 +00:00
Claude af3bb60981 fix(perf): stop a :has() rule from slowing canvas dragging (v1.57.2)
`body:has(#…-modal[style*="display: flex"]) #…-tooltip-bubble` made the
browser re-check the whole page on every inline-style change anywhere.
ComfyUI moves every DOM widget (text boxes) on each pan/zoom frame, so every
frame paid for it, with the browser closed too, and much more with plugins
that add DOM per widget (ComfyUI Prompt Assistant).

Measured in ComfyUI (frontend 1.53.6, Chromium) panning a 300-node
workflow for 120 frames, style recalculation in total:
- no plugins 60 ms; AMB 690 ms -> 63 ms
- AMB + Prompt Assistant 2300 ms (~19 ms/frame) -> 86 ms (PA alone 80 ms)

The rule was redundant: both popups hide the tooltip when they open and
showSharedTooltip refuses while either is open (checked in the browser).
Cache-busting versions bumped so browsers load the new stylesheet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012RMuwbsWZHnx2eCWpxYqAB
2026-09-26 19:20:03 +00:00
DemonGatanjieu 4fe746af2c Merge pull request #18 from DemonGatanjieu/claude/awesome-wright-48lzun
feat(tts-import): folder-first grouping, visible skips, batches, preview
2026-09-26 23:27:22 +08:00
Claude d4603f8e8a feat(tts-import): folder-first grouping, visible skips, batches, preview
- A folder holding one character's GPT + SoVITS weights and its clips is that
  character, named after the folder: two characters whose weights are both
  "model" no longer merge, pinyin weights in a Chinese-named folder take the
  folder's name, version folders read "派蒙 v2". Package weight folders keep
  the name-by-stem rule.
- Skip rules match the node (interface v9): Python environments and base
  models always; GPT_SoVITS / logs / output / TEMP / tools only inside a
  package; the chosen or dropped folder never. The drop walker and the folder
  dialog apply them where the listing is known (the old re-check on each
  file's path skipped a chosen folder called "output"), local scans prefix
  the chosen folder so every source sorts alike, and the window names the
  skipped and too-deep folders instead of losing files quietly. Drops only
  open importable files and stop at 5000.
- Same-name files inside a character are renamed with their folder
  (难过_01.wav, sent as the file's "name") instead of dropped; an unclaimed
  .list goes to every character.
- Big adds open 20 characters (about 800 files) at a time; "next batch" opens
  the rest, and it opens by itself once a batch is imported, replacing the
  imported cards. Unassigned files upload only once put into a character.
- Every clip can be previewed while importing, local paths included (through
  the node's preview route): one at a time, ▶ turns into stop, another clip
  switches.
- Upload queue and preview player move to their own modules, the "add more"
  menu to the screens module (ui_tts_import.js 647 → 652 lines with batches and renames added).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012RMuwbsWZHnx2eCWpxYqAB
2026-09-26 15:20:24 +00:00
DemonGatanjieuandClaude Opus 5.5 4396ca375c feat(tts-import): big batches stay responsive; unusable clips left out
- A folder per character: clips and line files follow the weights in their
  nearest folder, so pinyin weights, Chinese folders and English clips still
  land together (a real 34-character folder: 3065 unassigned files -> 0).
- Clips outside 3-10 s and their same-name line files are left out of the
  import and folded under "left out" instead of one warning per clip.
- GPT-SoVITS program and training folders are skipped in drops (not even
  walked) and picked folders; a very large upload (>1500 files or 2 GB)
  first offers the local folder picker.
- Uploads run three at a time, status repaints are coalesced, a card shows
  its first 60 clips, move menus are filled when opened, and the unassigned
  card folds into one line per folder that can be assigned at once.
- New ui_tts_import_sources.js brings files in (dialogs, drops, local picker).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 20:17:47 +08:00
DemonGatanjieuandClaude Opus 5.5 9fc38dc69f docs(agents): hard per-commit rules and a structure check
AGENTS.md section 0 lists what every commit must do, with a table of which
document each kind of change updates. tools/check_structure.mjs (tracked)
fails when a source file has no line in the architecture map or a web module
is unreachable from an entry, and lists files over the size threshold.
ARCHITECTURE.md now points to section 0 instead of repeating it; CLAUDE.md
joins GEMINI.md as a reading entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 17:57:41 +08:00
DemonGatanjieuandClaude Opus 5.5 bd7fb8beaa chore(shortcuts): remove the never-wired shortcut organizer
ui_shortcut_organizer.js and shortcut_layout.js only referenced each other;
the bottom bar is the fixed one built in ui_toolbox.js. Also drops their
eleven locale keys and marks the customizable-shortcuts plan as abandoned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 17:57:41 +08:00
DemonGatanjieuandClaude Opus 5.5 55208061a8 docs(architecture): the ownership map doubles as the file directory
Adding, deleting, renaming or splitting a source file now always counts as a
module-owner change, checked by the local tests/architecture_map.mjs. Lists
the eight modules the map had missed: model_constants.py,
recipe_constants.py, model_policies.js, scan_progress.js,
shortcut_controls.js, ui_dialog.js and ui_prompt_toast.js.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 17:49:15 +08:00
DemonGatanjieuandClaude Opus 5.5 3274636720 feat(audio): guided GPT-SoVITS import (choose, checklist, tour)
The import window now asks what the user has first: one character, many
characters or a package, or files for an existing character (picked from
a list, or opened straight from a card's "Add files"). Dropped files skip
the question.

Each character card leads with a checklist (GPT model, SoVITS model,
clips, main voice, name) that lights up the next step and offers its
button in that line; the chosen weights sit in their lines, and the
card header says how many steps are left. A main voice (3-10 s) is now
required for a new character, as GPT-SoVITS needs one.

A spotlight tour explains the first question and the card the first time
each is shown; "?" in the header replays it. ui_spotlight_tour.js takes
its steps and an onClose from the caller now; the browser tour is
unchanged. The fixed screens and tour steps live in
ui_tts_import_screens.js.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 17:30:25 +08:00
DemonGatanjieuandClaude Opus 5.5 f220c63315 feat(audio): redesign the GPT-SoVITS import window
The two-column workbench was hard to read on first open. The window now
opens as one drop area that says what to bring (GPT model, SoVITS model,
3-10 s clips) with two buttons and a quiet "files on this PC" line, and
three steps at the top show where the user is.

With files, each character is a card: folded, one line with the name
(edited in place), a short status and what it holds; unfolded, two model
tiles, one line per clip (main-voice star, emotion, line) and the line
files and language at the bottom. One card is open at a time; files that
match no character get a card of their own at the top. "Add more" is a
single button with a menu, the owner picker on each clip only shows on
hover, and the footer says what still needs a look and where it saves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 16:55:24 +08:00
DemonGatanjieuandClaude Opus 5.5 56882fd1e4 feat(audio): batch import workbench for GPT-SoVITS characters
The import form becomes a two-column workbench for one or many
characters. Files and whole folders can be dropped, or a folder on this
PC scanned (Anomalous_TTS browse?recursive=1, interface 6). Weights pair
up by name with the latest epoch kept; clips and line files follow their
folder or file-name prefix; anything unclear waits in an unassigned
tray. The left column lists the characters with a status dot, the right
shows the selected one with one compact line per clip and a picker (or
drag handle) to move files between characters. Ready characters are
imported one after another, each on its own, so a failure leaves the
others alone.

Grouping rules live in tts_import_groups.js, the draft pane in
ui_tts_import_draft.js and OS drop reading in ui_tts_file_drop.js.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 16:26:16 +08:00
DemonGatanjieuandClaude Opus 5.5 a8d02f135b feat(audio): import form shows at a glance what is done
Each section title gets a mark (green check with counts, amber
"required" for what a new character still needs, red on problems) and
each row a coloured bar: green ready or merged, blue uploading, grey
skipped, red failed or refused. Clips scroll in their own box and the
Import button stays in view.

When adding to a character, inspect now sends the target (Anomalous_TTS
interface 5): files it already has show as skipped, annotation files as
merged, and a different file under the same name blocks the import.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 15:55:34 +08:00
DemonGatanjieuandClaude Opus 5.5 248e137f23 feat(audio): richer audio sidebar, GPT-SoVITS settings as a dialog
The sidebar groups characters by language (GPT-SoVITS) or folder
(F5-TTS) in foldable groups; a character unfolds into its clips and a
click plays one through the studio row. Characters drag onto nodes from
the sidebar, a red dot marks characters that need a look, a search box
shows from six characters on, and files dropped on a GPT-SoVITS
character open "Add files" for it.

GPT-SoVITS settings move to a dialog opened from the sidebar footer,
which carries the reminder dot; "Import character" moves into the
studio toolbar. Canvas drag helpers move to audio_voice_drag.js so the
sidebar and the cards share them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 13:00:50 +08:00
DemonGatanjieuandClaude Opus 5.5 144c32929e feat(audio): quieter GPT-SoVITS setup, empty studio as a drop area
The setup card is one folded line; missing pretrained files (fetched on
first use anyway) only get a small dismissable dot once the studio's
characters need them, and the import form notes what the chosen
language will download. An empty GPT-SoVITS studio is a drop area with
the import button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 12:37:41 +08:00
DemonGatanjieuandClaude Opus 5.5 5dc570d880 feat(audio): one storage place for GPT-SoVITS characters
Uses Anomalous_TTS interface v4.

- Setup card: "where characters are kept" replaces the library list.
  "Change..." picks a folder on the ComfyUI machine and asks whether the
  current characters move along (move / don't move / cancel). A move shows
  its progress (characters, and bytes when copying across disks) and the
  studio redraws when it ends; an error says where it stopped.
- "Other places" lists earlier storage places that still hold characters
  and yaml folders; x on an earlier place stops listing it (files kept).
- Import form: no location picker; new characters go to the storage place
  and the form shows the exact path.
- The "add library" action is gone (no longer offered by the node).

Tested: node tests/tts_setup_api.mjs (storage calls, moving summary).
Isolated ComfyUI in the browser: change storage with move (both
characters moved, a non-character folder left, config updated), change
back without moving (old place listed under other places, stale move
message not shown), remove the earlier place (characters unlisted, files
kept), import form shows the storage path; a .list dropped on a clip's
text box fills it without being added as a file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 20:28:20 +08:00
DemonGatanjieuandClaude Opus 5.5 c0a6beff04 feat(audio): more ways to find a clip's reference line on import
- Each clip shows where its line came from (same-name .txt / .lab,
  annotation file, or the file name, marked as a guess to check).
- Dropping a .txt / .lab / .list on a clip's line box fills it: a plain
  file is the line, an annotation file is searched for that clip (same
  rule as the node, including the name without the emotion part).
- .lab is an importable line file.

Tested: node tests/tts_setup_api.mjs (textFromFile added).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 20:16:26 +08:00
DemonGatanjieuandClaude Opus 5.5 fd966a7ee3 feat(audio): simpler GPT-SoVITS import form
- Fixed sections for GPT weight, SoVITS weight, reference clips and line
  files; files dropped anywhere on the form go to their section. One GPT
  and one SoVITS: a new one replaces the old, and from a batch the latest
  epoch is kept (the others are named in a note).
- The save location is one line ("Saved to …/gpt_sovits/<name>") with
  "Change location"; the library list only shows when asked for.
- Name clashes are shown while typing; an exact match offers "add to that
  character instead", which switches the form to add mode in place.
- Unsupported SoVITS versions (v3/v4) are marked and block the import;
  clips outside 3-10 s cannot be the main voice.
- After an import the studio scrolls to the new card and highlights it.

Tested: node tests/tts_setup_api.mjs (epoch pick, name clash, version
block, clip range added). Isolated ComfyUI in the browser: name clash and
switch to add mode, three GPT epochs -> e15 kept, SoVITS replaced by a v4
file -> blocked, short clips not selectable as main, add-mode import of a
clip as {平静} with the card highlighted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 20:10:07 +08:00
DemonGatanjieuandClaude Opus 5.5 ed86a92aeb feat(audio): GPT-SoVITS setup card and character import
Uses Anomalous_TTS interface v3 so a new user can get going without
editing extra_model_paths.yaml or sorting model folders by hand.

- Setup card on the GPT-SoVITS studio: character libraries (add/remove),
  pretrained files (per-item or all download with progress, or use an
  existing GPT-SoVITS package) and missing Python packages with the
  install command. Collapses to one line when ready.
- Import form: drop files, pick them in the browser, or pick them on the
  ComfyUI machine (server-side path picker, no upload). Name, SoVITS
  version, clip length, lines, main voice and language are filled in from
  the node's inspect; emotions per clip. Uploads go in 8 MB chunks.
- "Add files" on a card, or a drop onto it, adds clips and emotions to
  that character without replacing its main voice.
- Hidden for nodes without v3; disabled when the page is not opened on
  the ComfyUI machine.
- New modules: tts_setup_api.js (calls + pure form rules),
  ui_tts_setup.js, ui_tts_import.js, ui_tts_path_picker.js and
  styles/12-tts-setup.css. ui_audio_studio.js only wires them in.

Tested: node tests/tts_setup_api.mjs (new, local) plus the audio engine,
node target and script rule tests. Isolated ComfyUI with both plugins in
the browser: setup card for a fresh user, package as pretrained source,
library add, import from local paths (lines found in all.txt), file drop
onto a card (chunked upload, add mode), cancel discards the upload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 19:37:42 +08:00
DemonGatanjieuandClaude Opus 5.5 ad8596ad48 perf(audio): stop the GPT-SoVITS page from refetching on every render
- Cache engine presence and voice groups (60 s) so domain switches, sidebar
  clicks and the studio + sidebar pair cost no requests; saves and added
  voices invalidate, Refresh also asks Anomalous_TTS to rescan (?refresh=1).
- Follow Anomalous_TTS interface v2: the list is a summary; the emotion
  editor opens at once and loads the character's audio list in the
  background (save waits for it, keeps unknown settings fields).
- Cards: show "角色/版本" as name + quiet suffix, one-line drag hint,
  header wraps instead of squeezing.
- Docs: ARCHITECTURE audio_engines paragraph, audio-studio guide.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q6FStZ5hiKELvNH3WKgBnx
2026-09-24 18:15:49 +00:00
DemonGatanjieu ccb2beb490 Merge main (material card layout fix) into feature/audio-engines 2026-09-24 16:00:18 +00:00
DemonGatanjieuandClaude Opus 5.5 9e761d0705 fix(materials): stop apply-mode card text from overlapping
The grid used one fixed row height, so the extra apply button squeezed the
badges, date and node types on top of each other. Cards in apply mode now get
a taller row (the list is marked is-apply-mode when it renders), the apply
button sits at the bottom, and only the tag row may be clipped when space
runs out. The normal row is a little taller so a tag row fits as well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeQEub5
2026-09-24 16:00:18 +00:00
DemonGatanjieu cb26b763b8 Merge main (material apply button fix) into feature/audio-engines 2026-09-24 15:55:24 +00:00
DemonGatanjieuandClaude Opus 5.5 01fc925bd6 fix(materials): one apply button per card, without a doubled icon
In apply mode each card rendered a second apply button (a clone meant for
the list layout's hover actions) that showed as an extra row in the grid,
and both labels prefixed another icon to a locale string that already has
one. Cards now render a single button whose text comes straight from the
locale (textContent), in grid and list layouts alike.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeQEub5
2026-09-24 15:55:24 +00:00
DemonGatanjieuandClaude Opus 5.5 634b2384cf feat(audio): engine switch with runtime detection; GPT-SoVITS via Anomalous_TTS
- audio_engines.js: F5-TTS and GPT-SoVITS (the separate Anomalous_TTS node
  pack), detected through /object_info/<node class>. Both engines' voices are
  normalised into one voice-group shape so cards, sidebar and Script Director
  share one implementation.
- The audio page shows an engine switch; a missing engine stays listed as
  "not installed" with install steps (no popups). The F5-TTS library still
  works without the F5-TTS node; GPT-SoVITS is never scanned when missing.
- GPT-SoVITS cards come only from the node's HTTP contract: preview reference
  clips, drag a character onto AnomalousTTS_CharacterSpeech, and edit the
  emotion references ("Edit emotions"), saved through /anomalous_tts/settings
  with unknown fields preserved and the node's validation messages shown.
- Drop targets and the Script Director are per engine: voices of one engine
  are refused on the other engine's node with the reason.
- Audio gallery reads the character of GPT-SoVITS generations.
- Usage guide (in-app and docs/guides/audio-studio.md) covers both engines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeQEub5
2026-09-24 13:13:49 +00:00
DemonGatanjieuandClaude Opus 5.5 9f45fef85b feat(audio): explicit drop targets, drag hints and an in-app usage guide
- audio_node_targets.js is the only list of nodes the audio studio writes
  into: F5-TTS (Audio / Advanced / FromModel) takes a whole character, so the
  card header sets its main voice; ComfyUI's Load Audio takes one exact clip,
  so a row sets that file. Unlisted nodes are refused instead of matched by
  widget name, and a single emotion clip is never written into F5-TTS's
  sample (it would break {emotion} tags).
- While dragging, the hint says what releasing will do, or why a hovered node
  is refused and what to do instead (shared bindMaterialDrag targetHint /
  rejectHint).
- Script Director uses the same table and refuses nodes whose sample_audio is
  linked.
- In the audio domain the header "!" opens a five-step usage guide;
  docs/guides/audio-studio.md is the full guide (docs/guides is now tracked).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeQEub5
2026-09-24 11:43:31 +00:00
DemonGatanjieuandClaude Opus 5.5 2737c1b5b0 docs(changelog): Audio & Voice Studio under Unreleased
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeQEub5
2026-09-23 19:23:09 +00:00
DemonGatanjieuandClaude Opus 5.5 98992063be Merge feature/audio-voice-studio into main
Audio & Voice Studio: F5-TTS voice library and uploader, Script Director,
audio gallery, plus the recipe/material regression fixes made on the branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeQEub5
2026-09-23 19:22:53 +00:00
DemonGatanjieu f7873ce106 Merge branch 'main' into feature/audio-voice-studio 2026-09-23 22:58:55 +08:00
DemonGatanjieu e788306291 docs(changelog): clean up version entry description in update guide 2026-09-23 22:58:07 +08:00
DemonGatanjieu ca32fe09ed Merge main (version in update guide) into feature/audio-voice-studio 2026-09-23 14:53:50 +00:00
DemonGatanjieuandClaude Opus 5.5 eabff31c1c fix(ui): show the version in the update guide instead of the sidebar footer
The footer version sat right under the tool dock and invited misclicks.
The "!" update guide now opens with the current version and a
"Check for updates / switch version" button that closes the guide and
opens the version panel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeQEub5
2026-09-23 14:53:49 +00:00
DemonGatanjieu 41e2e8805b Merge branch 'main' into feature/audio-voice-studio 2026-09-23 22:35:50 +08:00
DemonGatanjieu c79b88d464 docs(changelog): fold sidebar footer version chip into v1.57.1 Beta 2026-09-23 22:34:28 +08:00
DemonGatanjieu 7b79e2b7d9 Merge main (version number in sidebar footer) into feature/audio-voice-studio 2026-09-23 14:28:16 +00:00
DemonGatanjieuandClaude Opus 5.5 61b828c716 fix(ui): move the version number to the sidebar footer
In the header it pushed the dock and close buttons out of view when the
browser is docked or the display is scaled. It now sits under the tool
dock and hides with the collapsed sidebar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeQEub5
2026-09-23 14:28:16 +00:00
DemonGatanjieu 2a874be1bd Merge branch 'main' into feature/audio-voice-studio 2026-09-23 22:04:22 +08:00
DemonGatanjieu 58e49cc635 docs: align version references to v1.57.1-beta 2026-09-23 22:04:10 +08:00
DemonGatanjieu efae0b84df Merge branch 'main' into feature/audio-voice-studio 2026-09-23 21:58:20 +08:00
DemonGatanjieu 585a745683 docs: update Changelog badge to v1.58_Beta 2026-09-23 21:58:14 +08:00
DemonGatanjieuandClaude Opus 5.5 49514025c2 Merge main (v1.58 version panel, docs) into feature/audio-voice-studio
Conflicts:
- api/__init__.py, web/styles.css: keep audio routes/styles and add the
  version manager.
- README.md, CHANGELOG.md: take main's released text; this branch's
  0922b27 was an earlier alignment of the same v1.57 notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeQEub5
2026-09-23 13:50:55 +00:00
DemonGatanjieuandClaude Opus 5.5 0fd50b1a18 docs: changelog and README for the v1.58 version panel
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeQEub5
2026-09-23 13:49:27 +00:00
DemonGatanjieuandClaude Opus 5.5 e622850349 feat: version panel with manual update check, switching and rollback
- api/version_manager.py: installed version (local only), published
  releases on request (GitHub API, git tag fallback), switch to a
  published tag, return to the default branch (fast-forward only), undo
  the last switch; refuses to overwrite modified tracked files
- header version chip and "Version & updates" panel; nothing goes online
  until "Check for updates" is clicked; restart via ComfyUI Manager
- zh/en strings, styles, architecture notes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeQEub5
2026-09-23 13:42:58 +00:00
DemonGatanjieu 39e99db53d docs: align README and CHANGELOG with live fixed toolbox dock layout 2026-09-23 20:55:03 +08:00
DemonGatanjieu 3768c8ec44 docs: align README with verified live browser UI structure 2026-09-23 20:48:14 +08:00
DemonGatanjieu dbb645feb5 docs: document Toolbox architecture, vector iconography, and gallery search in README and CHANGELOG 2026-09-23 19:39:16 +08:00
DemonGatanjieu 0922b27b56 docs: align README and CHANGELOG with cloud release v1.57.0 Beta 2026-09-23 19:30:20 +08:00
DemonGatanjieuandClaude Opus 5.5 c5eaca1dc4 Merge main (gallery search, model hash index fix) into feature/audio-voice-studio
Conflicts resolved:
- entry trigger files, web/main.js, api/__init__.py: main carries the squashed
  entry fix (09b06d1), identical to this branch before its later cleanup, so
  this branch's versions are kept
- ARCHITECTURE.md, docs/architecture/backend.md, web/modules/ui_sidebar.js:
  both sides' additions kept

Verified on the merged tree: 107 Python tests, all JS tests except the orphaned
material_parameter_cross_node.mjs, jsdom walkthroughs of the audio studio,
domain switching and the gallery search blocks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeqEub5
2026-09-23 10:08:10 +00:00
DemonGatanjieuandClaude Opus 5.5 e0776891c8 feat(gallery): turn search terms into visible, removable search blocks
Splitting the query on spaces hid how it was read and made phrases like
"cyberpunk city" impossible to search.

- ui_search_chips.js: Enter or a comma (also "," / "、" inserted by an IME)
  turns the typed text into a block; blocks keep their spaces and match as a
  phrase; hash-like and numeric blocks are labelled; × removes a block,
  Backspace in an empty box removes the last one, one button clears all; text
  still being typed joins the search after a short pause; IME composition
  never commits a block
- gallery_images accepts repeated `term` params (q still works)

Verified: tests/test_image_search.py (phrase blocks vs split query, 107 Python
tests in total) and a jsdom walkthrough of blocks, IME commas, backspace,
remove, clear and the request parameters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeqEub5
2026-09-23 10:00:05 +00:00
DemonGatanjieuandClaude Opus 5.5 1476fafd8a feat(gallery): search output images by prompt, model, seed and model hash
- api/image_search.py reads only the PNG text chunks before IDAT (ComfyUI
  prompt/workflow, A1111 parameters), builds one small record per image and
  caches it by mtime; all space-separated terms must match
- hex terms of 8+ characters also match SHA256 values recorded in the
  workflow (extra.anomalous_hashes) and local model files with that hash, so
  renamed models are still found; A1111 "Model hash" text matches as text
- gallery_images accepts q; the search box above the gallery debounces input,
  shows the match count, keeps paging/infinite scroll and the detail viewer on
  the filtered list, and lets a newer query supersede one still loading

On the local output (774 PNG) the first search reads ~10 MB of text chunks;
later searches use the cache.

Verified: tests/test_image_search.py (prompt, seed, AND terms, A1111, file
names, recorded and local-index hashes, cache, chunk reader), full Python suite
(106), jsdom walkthrough of the gallery search box.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeqEub5
2026-09-23 10:00:00 +00:00
DemonGatanjieuandClaude Opus 5.5 83bce45af6 fix(models): import MODEL_EXTENSIONS in model_resolution
Since 9cebdbd9 model_resolution.py used MODEL_EXTENSIONS without importing it.
/anomalous/all_hashes swallowed the NameError and always returned {}, and
resolve_hash / resolve_hash_batch (recipe model matching) failed while
collecting candidates. The all-hashes walk is now collect_model_hash_index()
so the output gallery search can reuse it; the endpoint behaves as before.

Verified: tests/test_image_search.py::ModelResolutionImportTests; on the local
library the index now returns 191 keys (176 with SHA256) instead of 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeqEub5
2026-09-23 09:59:44 +00:00
DemonGatanjieuandClaude Opus 5.5 b319fa1027 feat(audio): show each file's speech in the audio gallery and rescue unsaved previews
The gallery listed file names only, and audio from Preview Audio nodes (the
template used one) lives in ComfyUI's temp folder, so the gallery stayed empty.

- api/audio_metadata.py reads ComfyUI's `prompt` comment from FLAC and
  Ogg/Opus files (read-only) and extracts the TTS node's speech, sample, seed
- gallery rows show the speech (emotion chips, expandable per-segment script),
  voice and seed, with copy-speech; results are cached by mtime/size
- search (?q=) matches speech text and file names
- "unsaved previews" section lists temp audio with "save to gallery", which
  copies into output/audio/ (O_EXCL, never overwrites; the preview is untouched)
- audio_stream serves input/output/temp only; .opus is recognized
- parseTaggedSpeech in audio_script.js reads speech back the way F5-TTS splits it

Also outside the repo: ComfyUI/arona_multivoice_workflow.json now ends in a
Save Audio node (prefix audio/F5-TTS/Arona) instead of Preview Audio.

Verified: 100 Python tests (5 new gallery tests with synthetic FLAC/Opus),
tests/audio_script_rules.mjs, jsdom walkthrough (speech rows, preview save,
search, delete, paging), and a read-only run on the two real temp previews.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeqEub5
2026-09-23 09:24:05 +00:00
DemonGatanjieuandClaude Opus 5.5 f04c5d8f8c fix(audio): stop the Script Director from splitting off punctuation-only cards
Sentence splitting cut after every '…', so "嗯……" produced a card holding only
"…"; stacked marks ("?!") and closing quotes ("!」") were split off the same way.

- split only at 。!?!? and a lone '.' before whitespace; ellipses never split
- stacked end marks and trailing quotes/brackets stay with their sentence
- punctuation-only pieces (either split mode) are attached to a neighbour
- new ✂ button splits a card at the text cursor, complementing merge

Verified: tests/audio_script_rules.mjs (ellipsis, quotes, stacked marks,
punctuation-only lines, caret split) and a jsdom walkthrough of split/merge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeqEub5
2026-09-23 09:11:51 +00:00
DemonGatanjieuandClaude Opus 5.5 a8e81beeb0 feat(audio): let Script Director users control segments (split mode, merge, add)
One card is one segment spoken with one emotion, so users need to decide where
segments start and end instead of relying only on punctuation splitting.

- split mode: by sentence (default) or one card per line
- merge a card with the next one (keeps this card's emotion); Latin words get a
  space, CJK text is joined directly
- "+ add segment" appends an empty card with the previous card's emotion and
  focuses it
- counts now read "segments"

Verified: tests/audio_script_rules.mjs (line mode, joinSegments), jsdom
walkthrough of merge/add/delete/line-mode re-parse/drag; not yet checked in a
live ComfyUI session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeqEub5
2026-09-23 09:05:54 +00:00
DemonGatanjieuandClaude Opus 5.5 d69507724e feat(audio): rework Script Director around per-line emotion cards and a draggable bundle
Assigning a voice used to mean selecting a line card and then clicking a voice
row in the studio behind the drawer, which was not discoverable.

- pick one character at the top (F5-TTS needs one main voice per script); the
  hint lists the emotions that character can voice
- after parsing, each line is a card with editable text, a preview button,
  delete, and a row of emotion chips (main / happy / ...); a choice the current
  character cannot voice stays visible and blocks the bundle instead of being
  silently reset
- the footer shows the bundled speech and offers copy, push to the selected or
  only F5TTSAudio node, and a handle to drag the bundle onto a node
- voice rows and the bundle both use the shared bindMaterialDrag (the browser
  hides while dragging, the target node is highlighted); the studio's own
  window drop listener and the row-click assignment path are removed
- bundling is pure (buildScriptPackage/usableEmotions in audio_script.js)

Verified: tests/audio_script_rules.mjs, jsdom walkthrough of parse -> chips ->
push -> character switch -> drag onto a node, domain navigation walkthrough;
other JS tests unchanged. Not yet checked in a live ComfyUI session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeqEub5
2026-09-23 08:55:37 +00:00
DemonGatanjieuandClaude Opus 5.5 fa0482393f chore(entry,docs): tidy trigger module and bring architecture docs back to contracts
- entry: one explicit legacy-coordinate cleanup instead of three module-load
  side effects, no ?v= import, shared size estimate and DEFAULT_MIN_SAFE_X
  instead of literals, drop unused exports; drag behavior unchanged
- explain the !important topbar-entry rules as AGENTS.md requires
- docs: restore the model_identity.py entry, describe the F5-TTS voice layout,
  ingestion guarantees, the no-cache/no-?v= module rule, the shared all-in-one
  adapter table and the no-cross-role prompt rule; remove pixel values and
  promotional wording, fix the contradictory trigger position text

Verified: tests/floating_trigger_position.mjs (9 pass), jsdom setup of the real
browser entry plus an audio-domain navigation walkthrough (outside the repo).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeqEub5
2026-09-23 08:11:34 +00:00
DemonGatanjieuandClaude Opus 5.5 40195aa842 fix(recipes,materials): keep all-in-one support without guessing or crossing roles
Keeps the planned All-in-One loader and cross-node prompt features but removes
the regressions they introduced:

- model references: one adapter table for verified layouts (easy a1111Loader,
  easy fullLoader, Efficient Loader) shared by recipe_identity.js and
  recipe_schema.py; native loaders keep their stored labels; no more guessing
  categories from file names (a "...vae.safetensors" checkpoint was a VAE);
  "None"/"Baked VAE" are never references
- recipe summary sync follows the extraction rules: only checkpoint/UNet
  loaders or ckpt_name/unet_name set the base model (not an upscaler's
  model_name), only sampler-like nodes set seed/steps/cfg, and LoRA edits
  update the entry of the edited loader instead of falling back to loras[0]
- one LoRA entry per loader again (deduplication shifted entries)
- prompt injection never writes negative text into a positive slot or the
  reverse; the block chooser follows the same rule
- material cards only accept cross-node drops that carry prompts; parameter
  blocks stay same-type
- drop unused imports and the unused modelSpecs alias

Verified: regression cases added to tests/recipe_all_in_one_compatibility.mjs
and tests/material_prompt_cross_node.mjs (both fail on the previous commit),
new tests/test_recipe_model_references.py; all JS tests except the orphaned
material_parameter_cross_node.mjs and 95 Python tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeqEub5
2026-09-23 08:07:14 +00:00
DemonGatanjieuandClaude Opus 5.5 66979f698a fix(audio): align voices with ComfyUI-F5-TTS multi-voice layout and repair studio UI
ComfyUI-F5-TTS resolves {happy} to <sample stem>.happy.wav beside the selected
sample, so `Arona_happy.wav` + `{Arona_happy}` could never run. Voices now use
Character.wav (main, {main}) and Character.<emotion>.wav ({emotion}); the
catalog groups a main voice with its variants and marks tags that F5-TTS
cannot resolve (missing main, missing .txt, different format).

- script director: one character per script, untagged lines pinned to {main},
  pushes sample (main voice, node's own combo spelling) and speech together,
  confirms before overwriting and re-checks the node after the dialog
- pure rules (splitting, composing, planning, combo spelling) in audio_script.js
- one module instance per file: drop ?v= imports that duplicated
  ui_audio_gallery/ui_audio_studio state, so studio playback stops the gallery
- all user/file-derived text goes through textContent (no innerHTML)
- studio/sidebar/gallery renders ignore stale responses; errors are shown
- gallery: count badge no longer overwrites the title, paging via "load more",
  deletion errors surfaced, timeupdate instead of a 100ms timer
- sidebar: filter selection updates in place instead of refetching; the header
  tabs, sidebar and domain switch share browser.switchAudioTab
- uploader: createViewScope lifecycle (Esc/overlay/close release listeners and
  object URLs, Esc no longer reaches the parent), 409 -> confirm -> overwrite,
  sends the pre-romanization text as original_text, no hard-coded character
- workflow template: confirm before replacing the canvas, clear message when
  the template file is missing, align sample paths to the node's combo values
- static styles moved from inline JS into 11-audio-studio.css; strings localized

Verified: tests/audio_script_rules.mjs, tests/test_audio_catalog.py (15), a
jsdom walkthrough of studio/gallery/director/uploader with a stubbed app
(outside the repo), JS syntax and import/export link check. Not yet checked in
a live ComfyUI session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeqEub5
2026-09-23 08:02:33 +00:00
DemonGatanjieuandClaude Opus 5.5 9038637967 fix(audio): harden voice ingestion, streaming and romanization backend
- upload: validate target folder with resolve_within, refuse to overwrite an
  existing voice unless overwrite=1, stage audio/transcripts and swap them in
  together so conversion or disk failures keep the previous voice, drop stale
  .txt/.orig.txt and other-format siblings, write .orig.txt for the native script
- ffmpeg is resolved explicitly (PATH, then portable python dir) with a timeout
- voice/gallery scans run in a worker thread; stems are unique across scan
  folders; audio URLs are percent-encoded and versioned by mtime
- stream and delete only accept audio extensions; paging and template names
  are validated; errors are JSON
- romanizer no longer reads Han-only (Chinese) text as Japanese and reports
  missing pykakasi / hangul-romanize instead of silently succeeding
- extension static files use Cache-Control: no-cache (revalidate) instead of
  no-store; document the deliberate PATH exception in __init__.py

Verified: new local tests/test_audio_catalog.py (14 tests) plus the existing
Python suite (78 tests) pass in a Linux venv; not yet exercised in ComfyUI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeqEub5
2026-09-23 07:45:49 +00:00
DemonGatanjieuandClaude Opus 5.5 3ec4b1de61 chore(checkpoint): save uncommitted Script Director work from previous session
未完成/验证待完成:剧本台(ui_script_director.js、13-script-director.css)、
音频面板返回状态与 locales 文案来自上一轮会话,尚未验证。仅作为恢复点,
后续修复另行提交。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeqEub5
2026-09-23 07:42:41 +00:00
DemonGatanjieu e6758759f2 fix(audio): eliminate browser media caching with timestamp cache-buster and HTTP no-cache headers 2026-09-23 00:10:40 +08:00
DemonGatanjieu 68ef786450 feat(audio): support companion .orig.txt for native script display in audio cards 2026-09-22 23:56:00 +08:00
DemonGatanjieu e486c8c94f fix(audio): inject PATH for ffmpeg/ffprobe, deduplicate audio stems, and convert uploads to PCM WAV 2026-09-22 23:37:28 +08:00
DemonGatanjieu 2f70ef71b6 feat(audio): add intelligent multi-lingual romanization tool (Japanese & Korean -> Latin) to voice asset uploader 2026-09-22 21:27:45 +08:00
DemonGatanjieu 28bf192b6b feat(audio): implement voice asset ingestion modal with live audio preview and automatic F5-TTS companion transcript persistence 2026-09-22 11:49:26 +08:00
DemonGatanjieu da7439af8e docs: update ARCHITECTURE.md with audio workflow loading and canvas injection 2026-09-21 11:05:52 +08:00
DemonGatanjieu c0f7320977 feat(audio): add 1-click Arona workflow loader and canvas node drop injection 2026-09-21 11:05:34 +08:00
DemonGatanjieu 09b06d1dc7 fix(entry): resolve floating trigger position, boundary clamping, and refresh flicker 2026-09-21 02:02:50 +08:00
DemonGatanjieu 53c9f1b17c fix(entry): eliminate trigger button FOUC flicker on page refresh via pre-mount positioning and smooth opacity reveal 2026-09-21 01:51:46 +08:00
DemonGatanjieu 4573e1295d fix(entry): set default trigger position to top-left safe zone (80, 80) 2026-09-21 01:39:00 +08:00
DemonGatanjieu 9c2db0f1e9 fix(entry): resolve drag persistence collapse, dock entrapment, and regular refresh cache desync 2026-09-21 01:03:15 +08:00
DemonGatanjieu 364c5aff97 fix(entry): resolve ES module caching, mount-first lifecycle, and versioned coordinate storage 2026-09-21 00:47:07 +08:00
DemonGatanjieu 5b2e59c855 fix(entry): restore missing normalization imports and flush legacy dock coords to top-right 2026-09-20 23:52:49 +08:00
DemonGatanjieu a9aa88b439 fix(entry): restore unadulterated drag persistence and position trigger in top-right corner 2026-09-20 23:47:11 +08:00
DemonGatanjieu 91ec1eb4ce fix(entry): pull trigger outside sidebar frame to safe canvas and sanitize dirty coords 2026-09-20 23:04:34 +08:00
DemonGatanjieu 6e0988d01e fix(entry): relocate floating trigger to top-right safe canvas area and harden PointerEvents persistence 2026-09-20 22:58:54 +08:00
DemonGatanjieu fecd5e6ba7 Revert "feat(materials): implement Cross-Node Parameter Protocol (CNPP)"
This reverts commit fd34bbf246.
2026-09-20 22:46:09 +08:00
DemonGatanjieu 6f8fd99e11 fix(entry): relocate floating trigger to top-left safe canvas area and auto-heal sidebar occlusion 2026-09-20 21:01:29 +08:00
DemonGatanjieu 6c66e67742 fix(entry): fix floating trigger position collapse to top-left and harden boundary clamping 2026-09-20 20:53:04 +08:00
DemonGatanjieu fd34bbf246 feat(materials): implement Cross-Node Parameter Protocol (CNPP) 2026-09-20 20:33:24 +08:00
DemonGatanjieu a9640b9e79 refactor(materials): unify prompt extraction pipeline and enforce SSOT 2026-09-20 18:17:11 +08:00
DemonGatanjieu 5381b1326c fix(materials): strictly filter out model file paths and loader blocks from prompt envelope 2026-09-20 17:25:16 +08:00
DemonGatanjieu e4112083e3 feat(materials): implement cross-node prompt injection protocol (CNPIP) for EasyUse and third-party loaders 2026-09-20 17:15:19 +08:00
DemonGatanjieu e4dcbfab28 feat(recipe): implement universal semantic compatibility for All-in-One and third-party loaders 2026-09-20 15:46:23 +08:00
DemonGatanjieu 28a24da0c1 feat(audio): complete audio sidebar, audio vault gallery, and domain navigation 2026-09-20 12:35:13 +08:00
DemonGatanjieu 531a25d578 style(audio): refine domain switcher to sleek 26px SVG button and polish Audio Studio dark aesthetic 2026-09-20 12:25:28 +08:00
DemonGatanjieu 3bb144655a feat(audio): add dual-domain mode switcher and Audio Voice Studio workspace 2026-09-20 11:54:38 +08:00
DemonGatanjieu dd1bb8639c build: remove .agents and tests directories from git tracking and ignore them 2026-09-19 20:58:23 +08:00
DemonGatanjieu 4e2110ad4b test: normalize line endings in css_bundle_order for cross-platform deterministic hashing 2026-09-19 20:52:21 +08:00
DemonGatanjieu 154c159bc0 Merge pull request #15 from DemonGatanjieu/feature/recipe-model-binding
feat(recipes, materials, studio): Recipe Studio, Material Library, and Polymorphic Drag
2026-09-19 20:50:05 +08:00
DemonGatanjieu 882db0a81c docs: update ARCHITECTURE.md reading map to include docs/audits/ 2026-09-19 20:36:06 +08:00
DemonGatanjieu 5c0a207f06 docs: track docs/audits directory and add index README 2026-09-19 20:35:45 +08:00
DemonGatanjieu d88c067183 docs: organize audit reports into docs/audits directory 2026-09-19 20:35:07 +08:00
DemonGatanjieu 7584c794e2 docs: update audit report with verification sign-off section 2026-09-19 20:32:46 +08:00
DemonGatanjieu 04b2eeff8f fix(materials, doctor, scan): polymorphic material drag, third-party prompt nodes, model resolution, and scan wizard improvements 2026-09-19 20:22:45 +08:00
DemonGatanjieu 2ff5e28ac0 docs: add E2E browser audit and bug report 2026-09-19 19:34:39 +08:00
DemonGatanjieu 0d1e25ade3 fix(recipes): harmonize and synchronize model readiness between cards and detail view 2026-09-19 19:04:07 +08:00
DemonGatanjieu 6411e591ca fix(recipes): fix duplicate hint icon, make hint dismissible, and enhance readiness pill contrast 2026-09-19 18:31:35 +08:00
DemonGatanjieu 23cb24c6fd feat(recipes): add canvas drag affordance, hint strip, and empty state onboarding blueprint 2026-09-19 18:20:57 +08:00
DemonGatanjieu 596750bd1a docs(arch): document material library UI and empty state onboarding in ARCHITECTURE.md 2026-09-19 16:12:26 +08:00
DemonGatanjieu 3e6b6e09a5 feat(materials): add empty state onboarding blueprint and canvas drag affordance 2026-09-19 16:11:43 +08:00
DemonGatanjieu bea49f08d3 feat(ui): adopt low-profile callout capsule for spotlight tour in update guide 2026-09-19 15:59:23 +08:00
DemonGatanjieu 63769026fb feat(ui): elevate spotlight tour button visibility with accent styling and breathing glow 2026-09-19 15:52:31 +08:00
DemonGatanjieu 579a8be4d3 docs(tour): refine spotlight tour wording to be concise and instructional 2026-09-19 15:41:23 +08:00
DemonGatanjieu 950990a19c feat(tour): implement interactive spotlight mask tour for key buttons and studios 2026-09-19 15:33:28 +08:00
DemonGatanjieu e4372720f8 feat(locales): emphasize material drag-to-canvas and prompt workbench assembly in update guide 2026-09-19 15:11:37 +08:00
DemonGatanjieu 8cdff4833a feat(guide): rewrite update guide to highlight milestone features over origin/main 2026-09-19 14:59:13 +08:00
DemonGatanjieu fc62523193 fix(scan): correctly unpack backend model wrapper to display factual base-model feedback 2026-09-19 14:39:43 +08:00
DemonGatanjieu 3018c17fc7 fix(scan): remove blocking alert and route model scan feedback to progress panel 2026-09-19 14:21:08 +08:00
DemonGatanjieu 25f7a10ba3 fix(scan): simplify completion feedback to be purely factual without speculative instructions 2026-09-19 14:11:13 +08:00
DemonGatanjieu f460ae916f fix(ui): correct toast text to refer to card edit button 2026-09-19 14:07:52 +08:00
DemonGatanjieu 024d0a0abd fix(ui,scan): remove misleading clickScan text and provide transparent scan feedback for non-Civitai models 2026-09-19 13:45:11 +08:00
DemonGatanjieu 14014a952c fix(sources,scan): resolve scope toggle deadlock and streamline direct model scanning 2026-09-19 13:31:22 +08:00
DemonGatanjieu 96fba93e80 fix(grid): restore card hover action buttons and one-click canvas publishing 2026-09-19 13:16:52 +08:00
DemonGatanjieu af162ef953 fix(translator): streamline compact button layout, elastic textarea flex and 460px sidebar width 2026-09-19 03:48:29 +08:00
DemonGatanjieu 79646b6d94 fix(translator): add live canvas selection sync, multi-tab graph support and Chinese widget extraction 2026-09-19 03:43:13 +08:00
DemonGatanjieu 0553f920be feat(translator): auto-load selected node prompt on open and remove read node button 2026-09-19 03:33:30 +08:00
DemonGatanjieu 35714084be fix(materials): optimize detail layout ratio and header wrapping for sidebar docked mode 2026-09-19 03:24:34 +08:00
DemonGatanjieu 1bf4946260 fix(materials): restore browse mode and shortcut 2026-09-19 03:05:23 +08:00
DemonGatanjieu b5d534df6b fix(assistant): restore model picker rendering 2026-09-19 02:50:14 +08:00
DemonGatanjieu f05c592c46 fix(recipes): optimize parameter preset layout ratio for sidebar docked mode 2026-09-19 02:42:02 +08:00
DemonGatanjieu b6acd09a1f fix(recipes): decouple modal close button to top-right and replace details with floating dropdown 2026-09-19 02:35:47 +08:00
DemonGatanjieu acbf0e39ae fix(ui): restore gallery zoom and source filtering 2026-09-19 02:19:24 +08:00
DemonGatanjieu 66fa0ae4a8 fix(sources): ignore offline Civitai sentinel links 2026-09-18 21:01:09 +08:00
DemonGatanjieu 2c5b883187 docs(plan): record gallery AI creative assistant concept 2026-09-18 19:03:39 +08:00
DemonGatanjieu 5c99c90053 docs(plan): record selected node material capture idea 2026-09-18 17:30:01 +08:00
DemonGatanjieu 2c992c6ca5 feat(sources): align library scope and fold components 2026-09-18 17:17:21 +08:00
DemonGatanjieu 2f1fd07f55 docs(plan): scope source hub folder alignment and component disclosure 2026-09-18 16:54:20 +08:00
DemonGatanjieu 1b2013fe69 fix(sources): include foundation components and distinguish missing links 2026-09-18 11:04:33 +08:00
DemonGatanjieu 200e3823d1 docs: bound source hub to links and defer multi-source enrichment 2026-09-18 10:50:34 +08:00
DemonGatanjieu e5cba40374 fix(notebooks): remove duplicated warning triangle in delete confirmation 2026-09-18 10:19:34 +08:00
DemonGatanjieu 24aea49f3b fix(notebooks): eliminate nested gallery scrollbars and replace details with floating more popover 2026-09-18 10:14:28 +08:00
DemonGatanjieu ad71dfdf71 fix(notebooks): adapt sidebar quick jump to docked mode with icon-only view and clean duplicate labels 2026-09-18 10:05:46 +08:00
DemonGatanjieu 44d2c824bc refactor(notebooks): optimize prompt notes UX with flat layout, sticky toolbar, quick jump navigation, and complete localization 2026-09-18 09:58:50 +08:00
DemonGatanjieu 4463f2406b docs: record completed structure refactor 2026-09-18 01:59:20 +08:00
DemonGatanjieu a63a615514 refactor(backend): split shared utility route families 2026-09-18 01:56:10 +08:00
DemonGatanjieu a0c6849878 refactor(materials): separate schema assets and storage 2026-09-18 01:51:00 +08:00
DemonGatanjieu 9cebdbd994 refactor(models): separate catalog recovery metadata and media 2026-09-18 01:43:09 +08:00
DemonGatanjieu 6ad975c1cd refactor(recipes): separate schema images and storage 2026-09-18 01:40:10 +08:00
DemonGatanjieu e8b50ecaf5 refactor(styles): split ordered stylesheet bundle 2026-09-18 01:36:13 +08:00
DemonGatanjieu 7c167444db docs(architecture): record remaining frontend splits 2026-09-18 01:33:57 +08:00
DemonGatanjieu 7afa7518bc refactor(notebooks): split editor and canvas creation 2026-09-18 01:32:38 +08:00
DemonGatanjieu 2951d610c5 refactor(materials): split catalog cards and detail 2026-09-18 01:30:59 +08:00
DemonGatanjieu 3e3e26d2b2 refactor(gallery): split image stage and inspector 2026-09-18 01:28:18 +08:00
DemonGatanjieu 12875f9053 refactor(detail): separate editor and model selector 2026-09-18 01:24:35 +08:00
DemonGatanjieu 3ad1eaacc5 docs(architecture): record doctor module ownership 2026-09-18 01:22:28 +08:00
DemonGatanjieu e6217bb557 refactor(doctor): separate assistant picker and presets 2026-09-18 01:20:42 +08:00
DemonGatanjieu e75beafd70 refactor(recipes): separate cards and dialogs 2026-09-17 16:59:30 +08:00
DemonGatanjieu 7f7e3cef71 docs(refactor): record recipe module split 2026-09-17 16:55:58 +08:00
DemonGatanjieu e5f3301971 refactor(recipes): extract parameter workspace 2026-09-17 16:54:53 +08:00
DemonGatanjieu 881570e887 refactor(recipes): extract overview view 2026-09-17 16:51:19 +08:00
DemonGatanjieu 56774a2d99 refactor(recipes): extract model matching and parameter utilities 2026-09-17 16:48:50 +08:00
DemonGatanjieu 413b876dfe refactor(recipes): extract catalog and media views 2026-09-17 16:45:36 +08:00
DemonGatanjieu 2978025cbe docs(refactor): record main module boundaries 2026-09-17 02:06:55 +08:00
DemonGatanjieu a984ee4a31 refactor(main): separate entry and interface settings 2026-09-17 02:06:13 +08:00
DemonGatanjieu 67d5be6277 refactor(main): extract browser composition 2026-09-17 01:59:39 +08:00
DemonGatanjieu d9ce967de7 docs: record recipe detail split progress 2026-09-17 00:59:09 +08:00
DemonGatanjieu d8691d8811 refactor(recipes): extract detail history and gallery 2026-09-17 00:58:33 +08:00
DemonGatanjieu aed9501b34 docs: record structure refactor progress 2026-09-17 00:55:08 +08:00
DemonGatanjieu 1e42033ac0 refactor(sidebar): separate settings toolbox and navigation 2026-09-17 00:53:52 +08:00
DemonGatanjieu 2a53e3edf5 refactor(sidebar): extract scan and support dialogs 2026-09-17 00:45:48 +08:00
DemonGatanjieu c39701773a docs: plan staged code structure refactor for Sol 2026-09-17 00:36:05 +08:00
DemonGatanjieu 29b14d2c71 refactor(sources): lock links by default with explicit edit toggle and dynamic save-local button 2026-09-17 00:13:14 +08:00
DemonGatanjieu 52259acd09 refactor(recipes): remove unused recipe package import button from topbar 2026-09-16 23:44:01 +08:00
DemonGatanjieu e588392e14 fix(sources): auto-resolve workflow model URLs from local metadata and add cross-folder update fallback 2026-09-16 21:58:36 +08:00
DemonGatanjieu 6d25e4aa74 style(toolbox): add 3-row grid height and enhance popover prominence 2026-09-16 21:27:14 +08:00
DemonGatanjieu d596af293c fix(notebooks): resolve TDZ ReferenceError on updateModelsSummary and empty state rendering 2026-09-16 21:18:23 +08:00
DemonGatanjieu f84379c0ae feat(notebooks): move companion models to top & reorganize editor layout 2026-09-16 21:10:25 +08:00
DemonGatanjieu be96077b3c feat(recipe-detail): default expand parameter nodes & integrate prompt notes into toolbox 2026-09-16 21:01:46 +08:00
DemonGatanjieu 174c82dcc5 fix(recipe-detail): overhaul parameter presets layout, resolve occlusion and typography bugs 2026-09-16 20:28:11 +08:00
DemonGatanjieu 143f076751 fix(ui): remove beta footer text and add popover anchor caret with smooth spring transition 2026-09-16 19:53:30 +08:00
DemonGatanjieu a165921889 style(ui): streamline toolbox modal with frameless compact 40px tiles and clean 216px footprint 2026-09-16 19:46:32 +08:00
DemonGatanjieu 9e5cd77148 fix(ui): suppress bottom action tooltips and labels completely while toolbox modal is open 2026-09-16 19:42:30 +08:00
DemonGatanjieu 05c6bd95ec style(ui): rebalance icon hierarchy with 20px bottom toolbar and 14.5px compact toolbox cards 2026-09-16 19:03:45 +08:00
DemonGatanjieu 35f02a1122 feat(ui): optimize toolbox proportions with balanced 200px height, expansion placeholders, and click text suppression 2026-09-16 18:55:11 +08:00
DemonGatanjieu 72212f1e6a style(ui): scale down toolbox modal, tiles and icons for compact visual harmony with bottom bar 2026-09-16 18:48:42 +08:00
DemonGatanjieu cd1ea0ee2e feat(ui): redesign toolbox into spacious 3-column grid with moderate icons and 9-item capacity 2026-09-16 18:44:24 +08:00
DemonGatanjieu 0d8d163081 feat(ui): restore colorful emoji icons and expand toolbox layout into spacious 2x2 grid 2026-09-16 18:39:08 +08:00
DemonGatanjieu e78b46ba36 fix(ui): revert toolbox to native icons, disable drag-and-drop, and exclude outside tools 2026-09-16 18:26:56 +08:00
DemonGatanjieu 9b4b87a879 fix(ui): eliminate sidebar action button hover flicker and stabilize pointer hit-testing 2026-09-16 14:44:32 +08:00
DemonGatanjieu e370bedf02 feat: implement customizable shortcut bar and toolbox drag-and-drop reorganization 2026-09-16 13:02:47 +08:00
DemonGatanjieu 0fe1827f3f docs: consolidate frontend design brief and Gemini handoff 2026-09-16 12:46:52 +08:00
DemonGatanjieu 3f71ddfcb7 docs(plan): define customizable toolbox shortcuts and hover timing
Specify pinning and removal semantics, fixed anchors, drag cancellation, accessible alternatives, tooltip timing and bounds, state ownership, persistence and staged validation. Planning only; runtime behavior is unchanged.
2026-09-16 11:52:27 +08:00
DemonGatanjieu 77ae7ce888 fix(ui): keep update guide manual and clean sidebar styles
Keep a persistent header guide button, restore upward label transitions, remove duplicate CSS and obsolete notice events, and move replay styling to CSS.

Document proportional UX checks and interruption recovery checkpoints. Validated update_guide.mjs, 48-module parse/link check, diff check, and live ComfyUI manual open, Escape, replay, Help and sidebar keyboard labels. No backend behavior changed; mobile and alternate themes were not exercised.
2026-09-16 11:27:19 +08:00
DemonGatanjieu 705cdf0a7f fix(ui): widen sidebar tooltips to prevent vertical wrapping and switch update guide to clean monochrome 2026-09-16 11:03:43 +08:00
DemonGatanjieu 476c34e79d fix(ui): wire sidebar action hover labels, unclip tooltips, and polish update guide modal 2026-09-16 10:54:27 +08:00
DemonGatanjieu fe27e7f2e3 feat(guide): replace automatic popup with restrained top-right amber notice badge 2026-09-16 10:36:20 +08:00
DemonGatanjieu 276ad766a6 fix(transfers): keep toolbox share center and pause recipe imports 2026-09-16 10:03:54 +08:00
DemonGatanjieu 0f592dc77f feat(toolbox): lock panel to original dimensions while removing unused buttons 2026-09-15 21:59:14 +08:00
DemonGatanjieu e052a7ae39 revert(toolbox): restore toolbox tools layout and original panel dimensions 2026-09-15 21:47:30 +08:00
DemonGatanjieu bf2764239f feat(toolbox): clean up unlit placeholder tools and refine source hub icon 2026-09-15 21:39:17 +08:00
DemonGatanjieu b37204fd68 fix(exports): pause workflow share codes across library entry points 2026-09-15 20:57:55 +08:00
DemonGatanjieu 8bd3130fe7 fix(exports): remove prompt JSON export and pause recipe packages 2026-09-15 20:50:58 +08:00
DemonGatanjieu 45958356f1 feat(toolbox): add unified model source hub with workflow sync and note node generation 2026-09-15 16:17:52 +08:00
DemonGatanjieu 02f0230c08 fix(prompt-studio): refine popover safe-zone corridor and blank-space dismiss guards 2026-09-15 14:27:04 +08:00
DemonGatanjieu d664826093 style(prompt-studio): prevent role and category badges from wrapping 2026-09-15 11:09:20 +08:00
DemonGatanjieu a4b34100de feat(prompt-studio): unify positive/negative indicators and auto-tail cross-role blocks with tags 2026-09-15 11:03:50 +08:00
DemonGatanjieu 43c994cc34 fix(prompt-studio): implement global pointer safe zone to prevent popover closing during transit to buttons 2026-09-15 10:44:08 +08:00
DemonGatanjieu fd16be87ce fix(prompt-studio): resolve intermittent popover disappearance with 100ms open-debounce, 380ms buffer, 28px bridge and pin support 2026-09-15 10:13:12 +08:00
DemonGatanjieu 109565fa00 fix(prompt-studio): enable interactive card preview popover with 220ms debounce bridge and copy/add actions 2026-09-15 10:06:13 +08:00
DemonGatanjieu 5ecdef15e2 fix(prompt-studio): ensure more dropdown is hidden by default and correctly toggles via .is-open class 2026-09-15 09:52:23 +08:00
DemonGatanjieu 8c0b65385c fix(prompt-studio): resolve stacking context z-index bug causing workbench buttons to pierce and obscure more dropdown menu 2026-09-15 09:49:48 +08:00
DemonGatanjieu 04182578dc Add development rules and Gemini reading entry 2026-09-15 09:38:49 +08:00
DemonGatanjieu de02f5dd81 refactor: split prompt studio views and unify UI lifecycle 2026-09-15 09:32:18 +08:00
DemonGatanjieu 6dd7f94c0e feat(doctor,materials): overhaul parameter schemes, hover popover, explicit apply buttons, gallery naming, and drag text previews 2026-09-14 22:42:14 +08:00
DemonGatanjieu 5ce15dfe30 style(assistant): auto-collapse folder sidebar on node assistant to maximize workspace 2026-09-14 22:13:13 +08:00
DemonGatanjieu 9f653a30c3 style(prompt-studio): replace native browser title tooltip with eye-catching obsidian glass card preview popover and category borders 2026-09-14 21:53:01 +08:00
DemonGatanjieu a9c4e3d961 fix(prompt-studio): suppress global trigger button overlaying studio drawer 2026-09-14 21:46:50 +08:00
DemonGatanjieu 96c340b79b style(prompt-studio): default to left dock, auto-orient track facing canvas, widen cards column to 190px and eliminate topbar wrapping 2026-09-14 21:44:05 +08:00
DemonGatanjieu e1f8bf39d9 feat(prompt-studio): redesign Prompt Studio as minimalist sidebar drawer with expanded assembly track, interactive weight wheel, zero-height floating dock, and popout inspector modal 2026-09-14 21:34:52 +08:00
DemonGatanjieu 3df69ba7f4 fix(translator): streamline header layout to guarantee always-visible close button and move language selector to mid-bar 2026-09-14 14:29:19 +08:00
DemonGatanjieu fafad3de75 fix(translator): add canvas focus mode auto-collapse, dock-side toggle, and fix header layout compression 2026-09-14 14:18:12 +08:00
DemonGatanjieu 4e6e1050b4 feat(translator): add dedicated sidebar drawer mode with canvas passthrough and resizable width 2026-09-14 11:23:59 +08:00
DemonGatanjieu 46a67c7760 feat(translator): add direct source write-back, swap button, and translate-to-EN-and-write node integration 2026-09-14 11:12:28 +08:00
DemonGatanjieu abc34f3f8b fix(translator): support Chinese enumeration marks in tag splitting and add tag normalization 2026-09-14 10:37:27 +08:00
DemonGatanjieu 1294e30e3c fix(translate): resolve Google Translate 429 rate limit with dict-chrome-ex/mymemory fallback and fix false-positive error caching 2026-09-14 10:24:39 +08:00
DemonGatanjieu 2b0393723d feat(recipes-translation): decouple workflow recipes from dual-tabs and integrate atomic prompt translation 2026-09-14 10:15:26 +08:00
DemonGatanjieu db09ef5dcc feat(prompt-studio): enhance card tactile affordance and auto-snap drop dock 2026-09-12 23:03:40 +08:00
DemonGatanjieu dfa208b44c refactor(prompt-studio): redesign sidebar mode with unified dual-zone flow and streamlined controls 2026-09-12 17:25:51 +08:00
DemonGatanjieu ad14a596e5 refactor(materials): completely remove prompt studio from material library 2026-09-11 23:45:15 +08:00
DemonGatanjieu f7e3348e3d refactor(prompt-studio): reclaim vertical workspace and display final assembled text on-demand 2026-09-11 22:50:36 +08:00
DemonGatanjieu 0a0f2f6bcf style(prompt-studio): strip gaudy effects and adapt to docked sidebar mode 2026-09-11 22:31:46 +08:00
DemonGatanjieu e568e94fac feat(toolbox): integrate Prompt Studio into toolbox palette with ready status and direct invocation 2026-09-11 22:12:43 +08:00
DemonGatanjieu fcb1d84e7f fix(ui): eliminate ugly scrollbars and prevent overflow in toolbox modal 2026-09-11 21:57:22 +08:00
DemonGatanjieu c2f04e3cc9 feat(ui): rename workflows tab, restore card action buttons, and redesign toolbox as bottom-left icon grid 2026-09-11 21:54:03 +08:00
DemonGatanjieu f1edb2163e fix(ui): keep workspace overlay above model sidebar 2026-09-11 21:32:48 +08:00
DemonGatanjieu 3142dace09 feat(ui): implement extensible toolbox hub panel and sidebar action entry 2026-09-11 21:21:30 +08:00
DemonGatanjieu 770822a5b6 fix(ui): normalize settings hub help icon to 13px inline glyph and replace confusing ruler with reset arrow 2026-09-11 21:05:55 +08:00
DemonGatanjieu 8ffa222128 style(ui): harmonize header navigation tabs with 28px height, 13px icons, and persistent text labels 2026-09-11 17:59:48 +08:00
DemonGatanjieu 44aa09e67c fix(ui): replace cryptic nested-hexagon icon with clean 3D isometric model asset cube 2026-09-11 17:46:27 +08:00
DemonGatanjieu 3798d9f9b4 feat(ui): elevate compact layout to primary default with 48px slim industrial header 2026-09-11 17:39:39 +08:00
DemonGatanjieu bd4abead45 style(ui): eliminate cool-blue cast and switch to neutral dark charcoal obsidian palette 2026-09-11 01:01:43 +08:00
DemonGatanjieu 31d8b3737c feat(ui): implement restrained design language with standard, compact, and aesthetic modes 2026-09-11 00:45:57 +08:00
DemonGatanjieu 6eaf4fce05 fix(ui): mount settings hub to container to break stacking context trap 2026-09-11 00:28:23 +08:00
DemonGatanjieu 8464b7c983 fix(ui): resolve settings hub clip, isolate doctor/assistant backdrops, and refine restrained iconography 2026-09-11 00:24:02 +08:00
DemonGatanjieu f3985005a4 style(ui): redesign bottom sidebar action icons to be vivid and intuitive 2026-09-11 00:00:32 +08:00
DemonGatanjieu 1b985baf29 style(ui): redesign floating entry trigger icon with Anomalous Hypercube Core and frosted glass orb 2026-09-10 23:52:24 +08:00
DemonGatanjieu 852d9e19ed style(ui): unify sidebar and header with full-window anime archive frosted glass blend 2026-09-10 23:35:18 +08:00
DemonGatanjieu 93bb46cef9 style(ui): make anime archive background vivid and clear with runic blueprints and transparent grid 2026-09-10 23:27:46 +08:00
DemonGatanjieu 896cd3d20d fix(ui): restore recipe cards and remove decorative control glows 2026-09-10 23:14:22 +08:00
DemonGatanjieu 731cf89101 style(ui): unify design tokens, overhaul 3-tier button system, add anime archive library backdrop, and streamline workbench layout 2026-09-10 23:05:21 +08:00
DemonGatanjieu ffa6f56d08 feat(theme): rework dark matter watermark in normal mode and velvet inner shadows in abyssal mode 2026-09-10 22:31:51 +08:00
DemonGatanjieu a36c8dfc50 fix(theme): isolate abyssal scarlet easter egg and restore clean neutral dark normal mode 2026-09-10 21:39:37 +08:00
DemonGatanjieu 4b283565f6 fix(ui): resolve header click swallow, eliminate cyan badges for gothic palette, and add ambient illumination 2026-09-10 21:16:44 +08:00
DemonGatanjieu ea64877bb9 fix(ui): emergency remediation - restore dock button, tighten glow to 2-4px, add backdrop fallbacks, and purge cold slate tones 2026-09-10 20:56:00 +08:00
DemonGatanjieu 79ac441585 style(ui): refine tooltips, condense button glows, upgrade concept backdrops, and unify button geometry 2026-09-10 20:36:55 +08:00
DemonGatanjieu 38c75f3fc0 fix(core): repair syntax error in ui_doctor.js and restore floating trigger button presentation 2026-09-10 19:50:31 +08:00
DemonGatanjieu fc40dead3d fix(ui): visual correction audit - return to gothic vampire aesthetics, sweep emojis, center header, and fix tooltip overlap 2026-09-10 19:06:44 +08:00
DemonGatanjieu b72a7a5a5f feat(ui): implement global design system with semantic colors, island sidebar, and hover reveal 2026-09-10 18:06:03 +08:00
DemonGatanjieu aa3b49a3ee feat(ui): implement Task 9 master container geometric chamfers, accent energy dividers, empty space watermarks, and micro-floating tooltips 2026-09-10 17:50:10 +08:00
DemonGatanjieu 0628183c74 feat(ui): overhaul iconography with pure-code vector SVGs and break boxy layouts with asymmetric chamfers and depth shadows 2026-09-10 17:15:45 +08:00
DemonGatanjieu 8a9a91b3d2 fix(ui): distinguish scan wizard from reset layout with vector radar scanner and distinct layout icon 2026-09-10 16:50:14 +08:00
DemonGatanjieu 2370ad86b4 style(ui): overhaul button morphology with asymmetric chamfers, micro-rune glyphs, dynamic inflow hover bars, and satin glow strokes 2026-09-10 16:35:32 +08:00
DemonGatanjieu 2c7a79885d feat(ui): introduce Abyssal Scarlet anime gothic theme with domain expansion easter egg and corruption animation 2026-09-10 16:20:49 +08:00
DemonGatanjieu 1c2df86a90 style(ui): elevate core action buttons and active pills with AI micro-texture underlays 2026-09-10 03:14:56 +08:00
DemonGatanjieu 1213a5e825 feat: integrate AI concept art, cyber atmospheric backgrounds, and default blind-box thumbnails 2026-09-10 02:51:05 +08:00
DemonGatanjieu ae7a5bd1c8 style: overhaul frontend presentation and DOM rendering across prompt studio, recipes and notebooks 2026-09-10 02:31:30 +08:00
DemonGatanjieu 462f8287d3 fix(prompts): resolve 9 workbench defects, ensure lossless roundtrip, card persistence and role isolation 2026-09-10 01:44:27 +08:00
DemonGatanjieu e2d4387de7 feat(prompt-workbench): expand right mixer stage, compact lego cards, and clarify import label 2026-09-10 00:41:06 +08:00
DemonGatanjieu 9f307b06d4 feat(prompt-workbench): add one-click node prompt extraction and material library sync 2026-09-10 00:28:04 +08:00
DemonGatanjieu 828c4d5488 feat(prompt-workbench): implement dual-column prompt studio with source cards library and drag-to-assemble stage 2026-09-10 00:23:01 +08:00
DemonGatanjieu 888330de6a fix(ui-prompt-composer): resolve multiline regex syntax error restoring extension entry icon 2026-09-10 00:15:19 +08:00
DemonGatanjieu 200e6e4d6b feat(prompt-mixer): deliver Prompt Mixer Deck with Lego block reordering, smart sort and canvas drag 2026-09-09 23:43:26 +08:00
DemonGatanjieu 07c1bbe89c fix(studio): connect filters and prompt import contracts 2026-09-09 23:25:08 +08:00
DemonGatanjieu af2e223dc1 feat(recipe-detail): transform prompt cards into full-width streamlined decks, remove awkward buttons, and add active pulse dot for presets 2026-09-09 23:08:51 +08:00
DemonGatanjieu d86032ff58 feat(recipe-detail): overhaul internal studio layout, hide redundant topbar, replace bulky copy button with micro-copy, and redesign parameters into modern Bento matrix 2026-09-09 22:58:24 +08:00
DemonGatanjieu 00c884b517 fix(recipes): add card-level error isolation and document lesson 65 2026-09-09 22:38:43 +08:00
DemonGatanjieu dac700337a fix(recipes): import bindMaterialDrag in ui_recipes.js to fix card rendering error 2026-09-09 22:36:51 +08:00
DemonGatanjieu b4b86eb572 feat(recipes): overhaul studio topbar, uniform bento cards with readiness indicator and canvas drag-drop 2026-09-09 22:33:19 +08:00
DemonGatanjieu 0f24498704 feat(materials): studio topbar with split prompt studio and uniform grid/list views 2026-09-09 22:10:36 +08:00
DemonGatanjieu ea8284b482 fix(ui): replace ugly pink toolbox emoji with elegant vector asset cube SVG across sidebar and materials 2026-09-09 21:58:46 +08:00
DemonGatanjieu 39a7d7094a feat(ui): redesign prompt composer with paper-like note cards, active drag, canvas reveal, and material import drawer 2026-09-09 21:53:28 +08:00
DemonGatanjieu 2c51befe66 fix(materials): separate library entry and simplify canvas drops 2026-09-09 21:40:04 +08:00
DemonGatanjieu 975863bf0f feat(materials): integrate node application and prompt combinations 2026-09-09 11:13:24 +08:00
DemonGatanjieu f0079ad457 fix(ui): relocate node selection checkbox to row-start with dark-mode styling 2026-09-09 00:22:35 +08:00
DemonGatanjieu ed3f1774b3 perf(frontend): improve card differentiation, add inline title editing, and optimize CSS transitions/blurs 2026-09-09 00:16:55 +08:00
DemonGatanjieu 9e2bdbe8cb Use ComfyUI node titles on material cards 2026-09-09 00:00:58 +08:00
DemonGatanjieu 8f8063615d style(gallery): redesign parameter inspect button, workbench actions and seed copy interaction 2026-09-08 23:51:54 +08:00
DemonGatanjieu 393c4e9ade Connect prompt notes to materials and simplify capture pages 2026-09-08 21:36:40 +08:00
DemonGatanjieu 01dfa18d12 Reduce material detail rendering and payloads 2026-09-07 23:20:48 +08:00
DemonGatanjieu f8d159dc11 Improve material library browsing and reuse 2026-09-07 22:47:27 +08:00
DemonGatanjieu a7104fa507 Cache material summaries and output gallery listings 2026-09-07 15:17:15 +08:00
DemonGatanjieu 346860ff00 Make notebook and material persistence recoverable 2026-09-07 15:11:53 +08:00
DemonGatanjieu ef9c8b4519 Fix full-file model identity evidence 2026-09-07 15:07:17 +08:00
DemonGatanjieu a6d2705b9d feat(materials): refactor material library for card-click navigation and default-expanded prompt cards 2026-09-04 18:49:17 +08:00
DemonGatanjieu b6adb0a0bc refactor(materials): add focused library inspector 2026-09-04 15:57:49 +08:00
DemonGatanjieu 585b751c57 feat(materials): expand saved parameter details 2026-09-04 15:37:13 +08:00
DemonGatanjieu 6bdb1e072f refactor(materials): save nodes from their cards 2026-09-04 15:11:35 +08:00
DemonGatanjieu f6311ff8f1 refactor(workbench): standardize on windowed modal design and remove fullscreen mode 2026-09-04 14:59:14 +08:00
DemonGatanjieu 809c6124b7 feat(workbench): expand prompt cards by default with collapsible toggle 2026-09-04 14:50:28 +08:00
DemonGatanjieu 3c29cc0142 style(workbench): move filmstrip to left rail and enlarge thumbnail cards 2026-09-04 14:44:11 +08:00
DemonGatanjieu 57b480da79 fix(ui_gallery_detail): remove duplicate clipboard emoji from copy prompt button 2026-09-04 14:31:39 +08:00
DemonGatanjieu 43d7901d0d feat(materials): save selected gallery nodes efficiently 2026-09-04 14:15:33 +08:00
DemonGatanjieu 9b124333c0 feat(gallery): refactor image detail into fullscreen studio workbench with multi-dimensional navigation and bento inspector 2026-09-04 13:58:17 +08:00
DemonGatanjieu 628cacfbe9 feat(materials): expose detailed gallery node parameters 2026-09-03 23:57:41 +08:00
DemonGatanjieu 35d6abfa48 feat(materials): uncap expansion limits, wrap resolution metrics, and categorize models by role with LoRA strength details 2026-09-03 23:46:57 +08:00
DemonGatanjieu e6088fc2a1 refactor(materials): upgrade image snapshot inspector to wide studio modal with direct PNG workflow parser and dedicated scroll container 2026-09-03 22:42:06 +08:00
DemonGatanjieu cbb6b9adb5 feat(materials): introduce progressive disclosure for image material details with metric tiles and collapsible model/node accordions 2026-09-03 22:26:51 +08:00
DemonGatanjieu b357380fa1 feat(gallery): expose material snapshot details 2026-09-03 22:16:40 +08:00
DemonGatanjieu b55a23b529 feat(materials): add image workflow snapshot library 2026-09-03 22:11:25 +08:00
DemonGatanjieu 932555c448 fix(ui): remove duplicate summary::before gear icon and add missing recipeRawNodesCount localization string 2026-09-03 20:42:28 +08:00
DemonGatanjieu 54e8e1f6cf style(dark-mode): tone down bright saturated colors to clean monochromatic slate for eye-comfort 2026-09-03 20:39:40 +08:00
DemonGatanjieu 6f6cd6a4fc style(recipe-presets): eliminate raw bootstrap-style buttons, fix broken card layout, and replace awkward hero image with sleek compact gallery deck 2026-09-03 20:34:09 +08:00
DemonGatanjieu 1fde428db1 feat(recipe-studio): overhaul recipe detail to modern studio layout with plain language and zero-overhead performance 2026-09-03 20:27:44 +08:00
DemonGatanjieu 81463e70a8 fix(recipe-detail): fix ReferenceError on resDisplay breaking parameter notebook tab 2026-09-03 20:19:16 +08:00
DemonGatanjieu 69afcb834f fix(recipe-ui): fix display none conflict on recipe list and resolve resolution object display 2026-09-03 18:48:35 +08:00
DemonGatanjieu 7c8876f08c feat(recipe-ui): redesign workflow recipes with bento cards, health indicators, and lazy-loading 2026-09-03 18:43:01 +08:00
DemonGatanjieu 61c0918498 checkpoint: save recipe model lifecycle and provenance baseline 2026-09-03 18:34:12 +08:00
DemonGatanjieu 780f60af35 Add scope-aware recipes and model verification 2026-09-03 16:55:11 +08:00
DemonGatanjieu b106111430 Fix canvas focus after browser close 2026-09-03 16:07:55 +08:00
DemonGatanjieu 6eec3b82c3 Split architecture documentation 2026-08-27 16:14:06 +08:00
DemonGatanjieu 039d982316 Fix live language switching 2026-08-27 14:34:33 +08:00
DemonGatanjieu 7c94cb9fea Add shortcut control to interface settings 2026-08-26 21:36:27 +08:00
DemonGatanjieu 4b828e8cb4 Add configurable browser shortcut 2026-08-26 21:07:49 +08:00
DemonGatanjieu e7719a76e6 feat(ui): refine native interface preferences 2026-08-26 18:51:33 +08:00
DemonGatanjieu cd813c778a fix(ui): make browser entry modes exclusive 2026-08-26 13:24:34 +08:00
DemonGatanjieu cbd18181fc feat(ui): customize model browser entries 2026-08-26 13:12:41 +08:00
DemonGatanjieu ec308da9e9 feat(ui): add optional floating browser entry 2026-08-26 12:07:04 +08:00
DemonGatanjieu 141b5f8271 Fix interrupted scans and show progress 2026-08-25 22:29:02 +08:00
DemonGatanjieu 6c6be650c0 docs: streamline README with modern card layout and collapsible guides 2026-08-21 18:00:27 +08:00
DemonGatanjieu a8db942e43 docs: add YouTube and Bilibili video demo links to README 2026-08-21 16:20:26 +08:00
DemonGatanjieu 838904900e Update .gitignore 2026-08-21 03:29:15 +08:00
DemonGatanjieu 5a5552286e feat(models): clarify aliases and foundation recovery 2026-08-21 02:48:40 +08:00
DemonGatanjieu ca730fb08d fix(models): protect foundation components from physical rename 2026-08-21 00:17:44 +08:00
DemonGatanjieu c4e9c2eda2 Merge pull request #12 from DemonGatanjieu/fix/modal-keybinding-guard
fix(ui): restore ComfyUI command shortcuts
2026-08-20 23:57:53 +08:00
DemonGatanjieu 0ada2aab7d fix(ui): restore ComfyUI command shortcuts 2026-08-20 22:31:20 +08:00
DemonGatanjieu f56d337ea0 docs: deprecate ai_changelog and rely on Git history and ARCHITECTURE.md 2026-08-18 18:34:18 +08:00
DemonGatanjieu b38770cfcc feat(recipes): support renaming parameter notebooks with atomic updates and dialog prompt 2026-08-18 04:29:55 +08:00
DemonGatanjieu ec65e6f0c8 docs: clean up public changelog by removing internal .agents doc reference 2026-08-18 04:06:51 +08:00
DemonGatanjieu e4025cadf4 docs: release notes for v1.56 Beta 2026-08-18 04:05:28 +08:00
DemonGatanjieu 6e188d34e9 fix(ui): remove redundant key emoji from hash modal title 2026-08-18 03:58:46 +08:00
DemonGatanjieu cfdf892b7b docs: update ARCHITECTURE.md with view hash modal documentation 2026-08-18 03:53:02 +08:00
DemonGatanjieu d29103e70c feat(doctor): add view hash details dialog with provenance vs local hash comparison and copy button 2026-08-18 03:51:09 +08:00
DemonGatanjieu d2575cd0a0 fix(doctor): support dynamic on-demand hash resolution, unet scan folder and smart civitai domain routing 2026-08-18 03:36:39 +08:00
466 changed files with 94240 additions and 20030 deletions
+24
View File
@@ -0,0 +1,24 @@
name: 报告问题 / Bug report
description: 哪里坏了、报错了,或者和你想的不一样 / Something broke or did not do what you expected
title: "[Bug] "
labels: ["bug"]
body:
- type: textarea
id: what
attributes:
label: 遇到了什么问题? / What went wrong?
description: 一两句话就行,中文英文都可以。 / A sentence or two is enough, in any language.
placeholder: 比如:在模型页点扫描,进度停在 30% 就不动了。 / e.g. The scan on the Models page stops at 30%.
validations:
required: true
- type: textarea
id: more
attributes:
label: 截图或报错(可选) / Screenshots or errors (optional)
description: 截图可以直接拖进来。 / Drop screenshots here.
- type: textarea
id: environment
attributes:
label: 环境信息(可选) / Environment (optional)
description: 在插件里点 设置 → 帮助与反馈 → 复制诊断信息,粘贴到这里。 / In the plugin, Settings → Help and feedback → Copy diagnostics, then paste here.
render: text
+2
View File
@@ -0,0 +1,2 @@
# A blank issue stays possible: the plugin's feedback window opens one with what was written.
blank_issues_enabled: true
@@ -0,0 +1,13 @@
name: 提建议 / Suggestion
description: 想要的功能、哪里不顺手、希望往什么方向发展 / A feature you want, something awkward, where the plugin should go
title: "[Idea] "
labels: ["enhancement"]
body:
- type: textarea
id: idea
attributes:
label: 你希望有什么? / What would you like?
description: 随便写,一两句话就行,中文英文都可以。 / Anything goes; a sentence or two is enough, in any language.
placeholder: 比如:希望素材库能按标签筛选。 / e.g. Filter the material library by tag.
validations:
required: true
+25 -5
View File
@@ -1,5 +1,6 @@
# Local configuration and credentials
config.json
allow_remote_changes
.env
.env.*
!.env.example
@@ -24,6 +25,10 @@ temp/
*.pid
*.tmp
*.temp
*.scan_in_progress
*.scan_progress.json
.global_scan_in_progress
.global_scan_progress.json
*.bak
*.backup
*.sqlite
@@ -52,17 +57,32 @@ api/notebooks/
# AI Dev Scripts & Temporary text dumps
scripts/
doctor_code.txt
docs/
# Only the Markdown of these doc folders is tracked: screenshots, logs and raw test
# data stay local (put them under .agents/).
docs/*
!docs/architecture/
docs/architecture/*
!docs/architecture/*.md
!docs/decisions/
docs/decisions/*
!docs/decisions/*.md
!docs/guides/
docs/guides/*
!docs/guides/*.md
# The README's pictures (small WebP from the isolated demo ComfyUI).
!docs/images/
docs/images/*
!docs/images/*.webp
AI_SYSTEM_TEMPLATE.md
# AI Agents Directory
.agents/
# Test files
test_*.js
test*.mjs
tests/
# Scratch test files at the top level (the plugin's tests live in tests/, see docs/guides/testing.md)
/test_*.js
/test*.mjs
# Local diagnostic/test artifacts
grep_doctor.txt
test_folders.json
*.bat
+168
View File
@@ -0,0 +1,168 @@
# 开发与维护规范
适用于本插件的所有修改,包括 Gemini、Codex 和人工维护。目标是让 UX 持续改善,同时让代码职责、状态来源和验证方式保持清楚。
用户明确的当前要求优先;确需偏离本规范时,采用最小例外,在交付说明中写明原因、影响和验证。常规实现选择自行处理,不因本规范增加无必要的确认步骤。
## 0. 每次提交的硬性要求
以下几条每次提交都要做到,没有“这次改动小”的例外;其余各节是怎么做好的指引。
1. **结构和文档同步。** 按下表更新文档,与代码放在同一次提交里。新增、删除、改名、拆分源码文件一律算改了模块职责:职责清单同时是文件目录,每个源码文件都要有一行。
2. **跑结构检查。** `node tools/check_structure.mjs`,失败不提交。它检查每个源码文件都登记在架构文档里、每个前端模块都能从入口引用到、每个前端文件都能按浏览器的方式解析(`node --check` 对 .js 文件会漏掉浏览器报错的写法)、CSS 里没有按 `style` 属性匹配的 `:has()`(会让拖动画布掉帧,见 [lessons.md](docs/architecture/lessons.md)),并执行规模阈值(第 2 节):新文件不能一开始就超线,已超线的文件比上次提交只能变短或持平。
3. **不留旧实现。** 被取代的模块、样式、文案 key、文档说明同一次提交删掉(第 6 节)。
4. **按风险验证**(第 7 节),只提交本任务的文件,没有明确同意不推送。收尾时工作区干净,或在交付说明里列出每个有意留下的未提交文件。
5. **测试产物不进仓库。** 测试代码放 `tests/`;截图、录屏、日志、原始测试数据、审计附件放在 `.agents/`(已忽略,只留本机),不用 `git add -f` 绕过忽略规则。提交的文件里不写本机路径、用户名或 `file:///` 链接,文档之间用相对链接。
6. **交付说明**写清:改了什么和为什么、验证结果和没验证到的地方、遗留问题、提交号;用了规范例外就写原因和影响。
| 改了…… | 更新 |
| --- | --- |
| 新增 / 删除 / 改名 / 拆分源码文件,模块职责 | `ARCHITECTURE.md` 职责清单,或最窄的 `docs/architecture/` 专题 |
| 数据流、接口、持久化格式、安全边界、跨系统规则 | 对应的 `docs/architecture/` 专题;系统图或阅读地图变了才改 `ARCHITECTURE.md` 入口 |
| 用户能感觉到的行为 | `CHANGELOG.md`;使用方法变了同步 `README.md` 或 `docs/guides/` |
| 长期有效的产品或架构取舍 | `docs/decisions/` |
| 反复出现的坑、严重故障 | `docs/architecture/lessons.md` |
| 开发规范本身 | 只改本文件 |
普通修复、样式微调不写进架构文档;没有变化的边界不写“本次未改变”。
## 1. 修改前先定位责任
1. 查看 `git status` 和相关差异,保留已有工作;不覆盖、回退或提交他人的无关修改。
2. 阅读 [ARCHITECTURE.md](ARCHITECTURE.md),再按阅读地图打开与任务有关的专题,避免每次加载全部文档。
3. 搜索目标函数的调用方、相关状态、CSS 选择器及已有辅助函数,再确定修改位置。
4. 简短说明本次行为变化和负责它的模块。一个修复优先落在能解释问题的最小责任范围内。
UX 请求默认允许调整布局、交互和文案,不代表允许改变文件存储位置、接口协议、工作流语义、模型识别依据或用户数据。
### 小型 UX 修改快速流程
仅调整文案、图标、颜色、间距或局部交互时:查看相关差异 → 定位负责规则 → 最小修改 → 验证受影响界面 → 本地提交。
同一任务已读过且未变化的规范不反复通读;只读相关专题,不做全仓审查,不顺手重构。
按第 7 节选择相关检查,不默认运行全部 JS/Python 测试。检查通过后,除非又改了代码或出现新问题,不重复运行。
没有改变职责、接口或持久化契约时,不更新架构文档或新增经验记录。提交说明由 AI 根据实际差异编写,无需用户代写。
### 任务中断后的接续与快照
- 开工先检查状态和差异。工作区干净时直接继续,不制造空提交。
- 上次因额度或其他原因中断,存在属于该任务的改动时,先确认文件范围,再选择性暂存这些文件;不得使用无差别的 `git add .` 收入用户数据、凭据、临时文件或无关工作。
- 已完成且有验证记录的改动可补正式提交;尚未验证或未完成的改动可先保存 `chore(checkpoint): <实际改动摘要>` 恢复快照,提交正文明确“未完成/验证待完成”、已做检查和下一步。快照是恢复点,不代表验收通过。
- 后续修改和验证另作正式提交,不自动 amend、reset 或改写快照历史。若无法区分遗留文件归属,保留原状并说明,不猜测打包。
- 长任务按可独立验证的完整阶段提交,避免把所有 Git 收尾留到最后。测试范围内检查完成后及时提交,不为额外润色拖延快照。
- 上述快照和正式提交均只保存在本地,未经明确授权不推送。
## 2. 模块职责与规模
- `web/main.js` 负责注册和接线;`ui_sidebar.js` 的 `createDOM` 负责外壳装配。新工具的完整实现放在自己的模块,不继续塞进这两个入口。
- 界面模块负责交互和渲染;组合、转换、校验等纯逻辑放在对应领域模块。底层模块不能反向导入上层界面,也不能读取 DOM 或选中节点来暗中决定业务行为。
- 一个模块应有可用一句话解释的主要职责。按“谁拥有状态、谁负责关闭、谁发起请求”拆分,不按随意的行号切割。
- 复用现有 `ui_lifecycle.js`、`ui_dom.js`、`translation_service.js`、`node_material_actions.js` 等接口。不要为同一行为再复制一套实现。
- 通用文件不能成为杂物箱。不要为了省一个文件,把新领域逻辑塞进 `utils`、素材详情渲染器或浏览器主对象。
- 不引入没有当前调用方的管理器、适配层、配置项、事件总线或依赖。一次性的简单逻辑可以留在其责任模块内。
**规模阈值(只减不增):** 普通逻辑文件以 600 行为线,单函数以约 100 行为线。
- 新文件不超过 600 行;写不下说明有两种以上职责,先拆。
- 已经超过 600 行的老文件,每次改动后只能变短或持平,不能变长。要在里面加功能,先把一块独立职责搬出去,腾出行数再加。
- 不为了达标专门大拆;改到哪个文件,就顺手从它身上拆走一块,让超线文件随日常修改逐步变少。
- 语言目录、数据表和样式表不按这个行数计算。不能通过空壳包装、压缩代码、删注释或碎片化文件绕过。
- `tools/check_structure.mjs` 按上次提交比对,新文件超线或老文件变长都会失败。
## 3. 状态、关闭与异步
- 每份可变状态只有一个权威来源。预览、计数、序列化结果从它派生,不维护几份会互相覆盖的数据。
- 子视图状态默认留在子视图。跨界面确需保留的状态才放到 `owner`;新增共享字段时说明拥有者、初始化、重置和释放时机。
- 父子视图传容器、必要数据和明确回调,不让子模块随意修改父视图的私有 DOM 和字段。
- 新建临时窗口优先使用 `createViewScope()`。按钮、Esc、遮罩、替换窗口、父窗口关闭必须走同一个清理入口;重复关闭应安全。
- 视图创建的全局事件、观察器、定时任务、拖拽监听和临时预览都要有对应释放路径。禁止仅 `element.remove()` 就当作关闭完成。
- 拖拽中关闭也必须释放鼠标监听,恢复此前的 cursor 和 user-select。子窗口消费 Esc 时,不能顺带关闭父窗口。
- 视图读取请求使用其生命周期的 `AbortSignal`;重新查询时取消或隔离旧请求。请求返回后检查当前视图和请求身份,不能让旧结果覆盖新内容。
- 异步写入画布前捕获原 graph/node/widget/value,返回后复核身份及输入;不能在返回后重新找“当前选中节点”然后直接写。
- 已发出的保存可能在窗口关闭后完成。不要假称取消了服务端写入,也不要让完成回调重新打开或修改已关闭的界面。
提示词工坊的具体状态约定见 [frontend.md](docs/architecture/frontend.md)。不要恢复已删除的嵌入式工坊、旧导入抽屉或第二套草稿来源。
## 4. CSS 和 UX 迭代
- 改样式前搜索同名选择器及主题、响应式覆盖,修改实际负责该属性的规则。禁止每轮都在文件末尾追加“最终修复 / override v2”覆盖包。
- 变更后删除本次取代的重复声明、失效选择器和旧布局分支;不能仅依靠更高 specificity 让旧实现暂时失效。
- 优先使用现有 `--amb-*` 设计变量。新增变量必须有实际用途和清楚的归属,不为每个常量包装一层变量。
- 不新增无解释的 `!important`。确因宿主样式或既有层叠边界需要时,在附近注释说明冲突对象与原因,并验证普通模式和受影响主题。
- 静态布局和外观放在 CSS。行内样式用于实际运行时值,例如拖动宽度、定位和数据决定的颜色,不用 JavaScript 重写整套固定样式。
- 保持插件选择器作用域,避免影响 ComfyUI 的其他按钮、弹窗和输入框。
- 添加功能时检查窄宽度、长中文、空列表、加载中、失败和禁用状态,以及焦点、键盘访问和嵌套窗口层级。
- 不为了视觉调整改变按钮的写入、覆盖、删除含义。成功提示必须来自真实成功结果,失败和取消不能显示“已应用”。
## 5. 文案、数据与文件
- 新增固定界面文案放入 `locales.js`,同步中英文;不继续增加散落的 `lang === 'zh' ? ... : ...` 分支。
- 用户输入、文件名、模型名和元数据通过 `textContent` 或已验证的转义工具进入 DOM;不要拼进未转义的 `innerHTML`。
- 列表摘要不能当作详情。正文通过对应详情接口读取;分页、去重、空结果和取消语义必须明确。
- 路径与文件名在后端使用现有边界校验;不能只检查 `..`。前端校验不能替代后端校验。
- 修改模型、封面、附属文件或导入包前先完整校验和暂存。失败时保留旧数据,避免先删除旧文件再尝试保存新文件。
- 修改多文件发布流程要覆盖中途失败和冲突。单个 JSON 的原子写入不能冒充整组文件的事务。
- UI 适配不能偷偷迁移用户数据、修改全局配置或改变现有路由与存储格式。确需改协议时同步调用方、兼容策略和测试。
### 安全底线
ComfyUI-Manager 收录审核检查的就是这几类口子(2026-10 审 Anomalous_TTS 时逐条指出过)。没有“先这样、以后再补”的例外:
1. **不执行下载来的或用户文件里的代码。** 数据不用 `pickle`、`eval`、`exec`、动态导入读;配置文件当数据解析。模型文件只读 safetensors / GGUF 的文件头,`.ckpt` / `.pt` / `.pth` / `.bin` 不打开,界面提醒它们可能带代码。
2. **下载要能核对。** 只从允许的 https 站点下载,先写 `.part`,核对 SHA-256 后才改名放入;没有可核对的指纹时在界面上说明。下载的东西只当数据用。
3. **接口不接受任意本机路径。** 只收“类型 + 根目录序号 + 相对路径”,在后端用 `resolve_within` 校验;需要指向本机某个文件时,由服务端自己找到它,只给页面一个令牌。模型文件本身可以是链接(云服务器上模型文件夹里全是指向平台模型盘的链接):文件夹部分照样用 `resolve_within`,文件用 `model_file_within` / `model_reference_within` 按名字取,不跟着链接走出去,封面、信息和改动都在链接旁边。
4. **会写入、移动、删除文件或替用户联网的接口只接受本机请求**(请求来自本机,没有经过代理,Host 也是本机)。用 `--listen` 开放到局域网时,别的设备只能查看。由 `api/local_guard.py` 的中间件统一把关:`/anomalous/` 下 GET 以外的请求默认只接受本机,确实只读的 POST 列进 `OPEN_POSTS`,不只是读的 GET(备份文件、联网、本机文件夹)列进 `LOCAL_GETS`;新接口要想清楚属于哪一类,不要为了方便放进 `OPEN_POSTS`。唯一的例外是云服务器用户在服务器上的插件文件夹里放了 `allow_remote_changes` 文件(启动时读一次,网页建不了也删不掉):这时 `local_guard.may_change` 也放行别的设备;接口里要自己判断时用 `may_change`,只有“浏览器就在这台电脑上”才成立的事(比如在本机的下载文件夹里找原文件)才用 `is_local`。MCP 始终只接受本机。
5. **用户的文件删除一律进回收站,没有回收站就拒绝;从不覆盖**(同名另存为“名字 (2)”或拒绝)。插件自己产生的临时文件(`.part`、暂存区)可以直接删。
6. **只读的接口也按第 3 条来。** “只是读”不是例外;能按名字、编号取的,就不收路径,由服务端在自己登记的范围里找到文件。
7. **读用户文件要有上限。** 限制读取的大小和条数;读不了或超大的文件跳过并在结果里说明,不让一个坏文件拖垮整个请求。读到的内容进页面只当纯文本(本节第二条)。
### 资源占用
ComfyUI 的内存、显存和 CPU 是留给出图的。插件闲着的时候应该几乎不占资源:
1. **用户动了才做事。** 不在后台轮询、定时扫描或预加载;确需定时的(扫描进度、跟随选中的节点)只在对应界面打开期间进行,关掉就停。
2. **耗时的活放后台。** 大目录遍历、哈希和大量磁盘 I/O 使用已有工作线程/进程路径,不阻塞 aiohttp 事件循环。
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. 清理旧实现
- 替换一条路径时搜索它的导入、导出、调用、字段、事件和选择器;确认无用后一起移除。
- 不保留注释掉的大段旧代码、`EXTRACTED` 占位、无人使用的备选实现或“以后可能需要”的分支。
- 拆模块后检查移动函数引用的闭包变量、导入、回调参数和返回值。语法通过不代表按钮点击不会出现未定义变量。
- 不保留两个相互覆盖的 `onclick` 或两条完成同一请求的路径;让所有入口调用同一个实现。
- 不用空 `catch`、伪造成功值或任意 optional chaining 掩盖结构错误。可选宿主能力才允许局部降级,并给出准确结果。
## 7. 按风险验证
从插件根目录运行相关检查。选择与变化相称的验证,不为纯文案、文档或低风险样式改动制造镜像测试。
| 修改范围 | 至少验证 |
| --- | --- |
| 导入、导出、模块拆分 | JS 语法、模块链接,以及受影响按钮的真实执行路径 |
| 提示词工坊、词块 | `node tests/prompt_tags.mjs`、`node tests/prompt_boxes.mjs` |
| 素材提示词、节点参数 | `node tests/material_prompt_cross_node.mjs`、`node tests/node_material_actions.mjs`、`node tests/node_parameter_sets.mjs` |
| 搭配、往画布插节点、撤销 | `node tests/combo_slots.mjs`、`node tests/graph_splice.mjs`、`node tests/canvas_undo.mjs` |
| 提交前全部跑一遍 | `node tools/run_tests.mjs`(说明见 `docs/guides/testing.md`) |
| CSS、布局、交互 | 实际浏览器验证受影响状态;涉及宿主行为时验证 ComfyUI |
| 文件修改、导入、存储 | 对应 Python 测试,至少覆盖成功、冲突和中途失败保留旧数据 |
| 仅文档 | 检查链接、命令、实际代码一致性和 Git 差异 |
- 模拟测试尽量加载实际模块,只替换外部系统边界;不要把待验证的业务函数也 mock 成成功。
- 修复故障时,优先补充能复现原故障的行为测试。不能只通过源码字符串“存在某段代码”来证明修复。
- UX 明确替换旧交互时,更新测试到新的用户流程,并保留原本要保护的行为;不要仅删除失败断言或重新塞回废弃功能。
- 新增应随仓库交付的回归测试要检查 `.gitignore`,确认确实进入版本控制。
- Python 测试要在 ComfyUI 的 Python 环境里跑(`node tools/run_tests.mjs` 会自己找),核对实际发现的数量;零项测试不算通过。
- 无法运行真实宿主、网络服务或完整测试时,准确说明缺口;不要将模拟画布、语法检查或已有通过数量称为本轮宿主验证。
## 8. 文档与交付
- 架构文档只记录职责、数据流、契约和关键约束。颜色、像素值及审美描述以 CSS 为准,不每次 UX 更新都往架构入口追加长篇设计说明。
- 该更新哪份文档见第 0 节的表。规范以本文件为唯一正文,避免几份要求各自漂移。
- 完成前审查差异、死代码、临时文件和空白错误;保留现有文件编码及换行风格。
**交付自查:** 新职责放对位置了吗?是否只有一个状态来源?每条关闭路径会释放资源吗?迟到请求会写错对象吗?旧代码和旧 CSS 清掉了吗?新文案已本地化吗?用户文件失败时还在吗?新接口会动文件或联网吗,只接受本机请求了吗?接口收了路径吗,读用户文件有上限吗?插件闲着时还占着 CPU、内存或显存吗?测试验证了实际行为吗?
+801 -328
View File
File diff suppressed because it is too large Load Diff
+653 -50
View File
@@ -1,53 +1,652 @@
# 📈 Anomalous Model Browser Changelog
## v1.55 Beta (Workflow Recipes Preview)
> **Beta scope:** Workflow Recipes, Parameter Notebooks, and the recipe-powered Parameter Presets tab in Node Assistant are preview features. The Node Assistant's Actions tab—visual model replacement and LoRA insertion—is not part of this beta. Save the current canvas and back up `workflows/anomalous_recipes` plus `workflows/anomalous_parameters` before updating, importing, restoring, deleting, or applying recipe data. The stable target for these preview features is v1.6.
### 🚀 Major Features
- **Workflow Recipes (工作流配方)**: Save a complete workflow with a cover, name, tags, notes, model references, prompts, sampler settings, safe node parameters, and a reproducibility fingerprint. Recipes append to the current canvas without replacing it.
- **Recipe Detail Workspace (配方详情)**: Added Overview, Parameter Notebook, Gallery, and Versions tabs. Long values can be expanded or copied, model requirements are shown as separate cards, and the overview avoids exposing local model paths as display names.
- **Parameter Notebooks (参数笔记本)**: Create edited parameter notes, capture a fresh note from a matching live canvas, switch between saved notes with clear feedback, delete individual notes, and apply one note back to a workflow only after the complete recipe skeleton matches. Volatile values such as seeds are intentionally ignored.
- **Node Assistant Recipe Presets (节点助手配方预设)**: The Parameter Presets tab reads same-type node values saved by Workflow Recipes. Select a node such as KSampler and apply sampler, scheduler, steps, CFG, denoise, and other safe values in one click without replacing the rest of the workflow.
- **Model Identity and Local Matching (模型身份与本地匹配)**: Recipe model cards retain hashes, size, model category, available Civitai origin names/links, and previews. Imported recipes can match another author's model references to local files without trusting their folder paths.
- **Recipe Gallery and Parameter Comparison (配方图库与参数对比)**: Find historical outputs with the same node structure, inspect embedded generation data, compare differing parameters, and promote an output image to the recipe cover.
- **Version History (版本历史)**: Recipe updates retain bounded local history. Compare meaningful changes, restore an older version safely, and preserve the current version before restoration.
- **Portable Recipe Packages (便携配方包)**: Import or export recipes with optional preview snapshots and history. Model identity hashes can be removed for privacy. Preview snapshots contain presentation images only and never include model files.
- **Conservative Prompt Roles (保守的正负提示词识别)**: Native supported prompt paths are classified without title guessing. Unknown third-party text nodes remain unclassified until the user labels them as positive, negative, shared, or ignored.
### ✨ Interface and Interaction
- Renamed the combined Notes and Recipes area to **Creative Workspace / 创作工作台**, with separate Prompt Notes and Workflow Recipes sections.
- Recipe models, LoRAs, prompt text, common parameters, and generic node controls use dedicated responsive layouts with copy and full-content expansion where needed.
- Returning from a model detail page restores the previous recipe position instead of jumping back to the top.
- The main output gallery refreshes when opened, avoiding continuous background polling.
- Beta and data-protection guidance is available in the Recipe list, the Node Assistant Parameter Presets tab, Settings Help, and README in both Chinese and English.
### 🐛 Reliability Fixes
- Fixed a cross-panel state leak where opening a model from a Recipe Overview could overlap Node Assistant, then leave the next Workflow Recipe visit with an empty list. Returning normally, switching directly to another main panel, reopening Workspace, and closing Workspace mid-return now all restore one coherent surface.
- Fixed blank Workspace/Parameter Notebook surfaces and restored the plugin trigger when frontend module errors prevented registration.
- Fixed parameter application against current ComfyUI widget callbacks and added transactional rollback when a node update fails.
- Fixed positive/negative prompt duplication and legacy role recovery while keeping ambiguous third-party prompt nodes visibly unknown.
- Fixed panel closing and canvas handoff behavior, docked notebook confirmation controls, recipe scroll restoration, long model status text, and narrow parameter layouts.
- Added backend validation and regression coverage for recipe round-tripping, linked inputs, parameter-note deletion, model matching, and prompt-role metadata.
## v1.5.1 (The Workflow Exchange & Deep Scan Update)
### 🚀 Major Features
- **Lossless Workflow Exchange (无损工作流交互)**: Added dedicated Import/Export buttons to the bottom-left of the plugin UI. This feature allows users to directly extract, share, and import pure workflow data without relying on original PNG images, completely bypassing the metadata-stripping compression used by social platforms (like WeChat or Discord) that causes workflow loss.
- **Force Overwrite Configs (强制覆盖已有配置)**: Added a new "Force Overwrite" toggle in the Scan Wizard. When enabled, the scanner will deliberately ignore existing local `.info` files and force a fresh SHA256 hash calculation and a complete metadata pull from Civitai. This is a lifesaver for repairing incorrectly matched or corrupted model profiles.
- **Model Card Settings (模型卡片设置)**: The Settings Hub now opens one focused panel for video-cover playback (`Always play` or recommended `Play on hover`) and card image quality (`Optimized thumbnail` or `Original cover`). Existing autoplay preferences remain compatible, while image-quality choice is intentionally simple and local to the browser.
# 📈 Anomalous Model Browser Changelog
### 🐛 Bug Fixes
- **Base Model Hash Misattribution**: Fixed a critical bug in the fast scanner where it would aggressively fuzzy-match any 64-character hex string in the metadata. This previously caused LoRAs (trained via Kohya) to inherit their Base Model's hash (`ss_sd_model_hash`), resulting in totally incorrect Civitai mappings. The scanner now strictly adheres to the official `modelspec.hash.sha256/blake3` standard.
- **Lossless Cover Reset**: Reset now restores the persistent `.civitai_bak.*` copy first, falls back to a bare original cover for non-Civitai models, and preserves the active preview with a visible warning when no recoverable source exists.
- **Safe Sidecar Lifecycle**: Deleting a model cleans recognized sidecars, while renaming migrates them—including the Civitai backup—to the new stem. Same-stem model files with different model extensions are never treated as sidecar garbage.
- **Bounded Sidecar I/O**: Repeated extension literals in the API and scanner were replaced with fixed tuples and exact-path checks. Rename, delete, and reset remain constant-bounded and never scan an entire model directory.
- **Reset/Rename Extension Integrity**: Fixed a reused loop variable that could change a model's extension when cover reset and physical rename were requested together.
- **Signature-Aware Metadata Cache**: Unchanged model metadata and embedded safetensors hashes are now served from bounded caches keyed by physical file signatures. Sidecar or model changes invalidate entries automatically, while defensive copies prevent cache pollution.
- **Stable Cover Caching**: Removed per-refresh random cover URLs and switched to nanosecond file-version tokens, allowing the browser to reuse unchanged images and videos while still updating changed covers immediately.
- **Responsive Large Libraries**: Folder/model disk work now runs outside the aiohttp event loop, current-folder discovery uses one `os.scandir()` inventory, stale requests are aborted, and cards render in animation-frame chunks with lazy media activation.
- **Batched Model Doctor Resolution**: Provenance-rich workflows skip a redundant full hash-cache walk, then resolve missing nodes through a type-grouped batch endpoint so each required category is walked once per repair pass. Legacy workflows still refresh the filename cache when needed. The original hash/size/type/ambiguity rules remain unchanged, with automatic fallback to the single-item endpoint.
- **Direct Preview Fast Path**: Visual replacement resolves contained relative model paths directly and performs a full library walk only for unresolved basename fallbacks.
- **Balanced Cover Memory Use**: Static grid covers default to cached 512px WebP derivatives while detail views retain the original. Derived files are stored in ComfyUI's temporary area, capped at 256 MiB, and never alter source covers. Closing the browser releases active media immediately and releases the warm card DOM after 90 seconds of inactivity.
## 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.
- **Before an online scan, the plugin checks whether Civitai can be reached**, on both its addresses. When it cannot, it asks instead of waiting on every model: **Scan from the files** (base models found, no covers or trigger words; a later scan fills them in), **Set a proxy** (opens Settings → Models → Network), or Cancel. A check that reached Civitai is trusted for five minutes.
- **Civitai's other address (civitai.red) when civitai.com does not answer**: some networks reach only the second, a cloud server in mainland China among them (tested on AutoDL: civitai.com timed out, civitai.red answered in about a second). Scans, import and Model Check's lookups and **Settings → Network → Test** ask civitai.red then, so models still get their names, trigger words and base models; the test says which address answered. The covers are on Civitai's picture server, which such networks often cannot reach either: after two covers in a row that would not download, a scan fetches no more covers instead of waiting on each.
### 🧩 Combos (搭配)
- **A combo's models are cover cards**, as in the model library: the cover on top, the name below. In a combo saved from picked nodes, the slot (Main model, LoRA…) is written on the cover and a click on the card changes the model; in a default combo, the main models and LoRAs to pick from are cover cards side by side. They used to be rows as wide as the window, with **Change** far from the name (模型卡片不再横向拉满整个窗口).
### 🎙️ Character voices (角色语音)
- **Update Anomalous_TTS from GPT-SoVITS Settings (在 GPT-SoVITS 设置里更新 Anomalous_TTS)**: a new *Anomalous_TTS version* line shows the installed version; **Check for updates** looks up its published stable releases (only then does it go online), **Update to v…** switches to the newest one and offers to restart ComfyUI, and **Back to v…** undoes it. Anomalous_TTS now publishes numbered stable releases (the first is v1.0.0), so an update never lands on unfinished work. Only an Anomalous_TTS installed with git from its GitHub repository is changed (as ComfyUI Manager installs it), only from the computer ComfyUI runs on, and never over files you changed. When an import needs a newer Anomalous_TTS, the message points here.
- **Generation presets (生成预设)**: GPT-SoVITS Settings has three presets, **Steady** (fewer slips, flatter), **Standard** (the old default) and **Lively** (livelier, slips now and then): pick the default one for every character, and one for each pair of languages, such as a Japanese character speaking Chinese (until now every such line was made steadier without asking; that is the rule you start with). With nothing changed, voices sound exactly as before.
- **Presets you can hear and compare (听得到、比得出的预设)**: pick a preset and see where it sits between *steadier* and *livelier* next to your default (a ring for the default, a dot for the one picked), with a sentence such as "a little livelier than your default (Standard)". **▶ Listen to this one** has a character read a line with that preset; every take uses the same seed, so only the preset changes, and the presets already heard stay as buttons to click back and forth. Folded under it are the numbers as bars against your default, the way the Current node card shows a change (old → new, ↑ ↓); each has its plain name and the usual one beside it (`top_k`, `top_p`, `temperature`, `repetition_penalty`), so values from the GPT-SoVITS WebUI are easy to match. Click a number to change it; a changed built-in number shows its built-in value, and **Reset** puts it back. **Make default** sets the preset every character uses. **+ New** makes your own preset from a copy of the one picked, with a name of your own (such as 念旁白 for narration); it can be picked everywhere the built-in ones can, renamed, and deleted (the question says what uses it; those places go back to the general setting).
- **Modes on Voice-over (配音时直接选模式)**: under *How it is read*, **Auto** (the presets and the character's own settings decide, line by line) or any preset for the whole script, shown against Auto the same way and heard from the **▶** on each line: it reads that line in the mode picked, and the modes already heard on it stay on the side as buttons to compare. The scale and the numbers fold away there, so the narrow side stays short. A preset's numbers can be changed right there and are saved into that preset. Links lead to the presets and to the character's generation settings. This replaces the folded fine-tuning numbers, which showed the node's defaults rather than what the presets used; **Save as default** now keeps the language and speed only.
- **A character's own generation settings (角色特调)**: a new **Generation settings** button on each character card sets the preset and speed for the character as a whole and for each language it speaks, with the numbers themselves when a preset is not enough. Under each row are the numbers its lines will get, blue where they differ from the general presets. The node follows these settings too, so a workflow in ComfyUI sounds like generating here; numbers changed on the node itself still go first. Needs Anomalous_TTS v1.1.0 (update it from the same dialog).
- **More room on Voice-over (配音页更宽敞)**: each line is one row (number, text, its buttons), so more lines fit; a bar between the lines and the generating side can be dragged to share the width (remembered; double-click for the default, about half each); *Into your own workflow* is folded to one line; who reads and their voices share one row, and the how-to line shows only until there are lines. When the side is wide enough, the modes' scale and numbers are shown open.
### 🪟 The window (窗口)
- **Full window (全屏)**: a new button beside *Dock* makes the plugin fill the whole ComfyUI page, and turns it back; a double-click on the title bar does the same. It is remembered.
### ☁️ ComfyUI on a cloud server (云服务器上使用)
- **How far it goes**: fully supported on overseas cloud servers (RunPod…). On servers in mainland China (AutoDL…) only in part: browsing, offline scans and tidying work, but Civitai is mostly out of reach there, so lookups are unreliable and covers rarely download (海外服务器完整支持;国内服务器只能部分支持). Not fully tested yet: one AutoDL server so far, no overseas one. The cloud guide ends with a section for AI assistants (what is going on, the steps, how to check, what to do when it did not work, what to tell the user about safety), so a user can hand the link to their AI.
- On a rented cloud server (AutoDL, RunPod…) the page is always opened from another device, so since v1.58.1 the plugin only let it look: scans, downloads, moves and saves were refused. Now there are two ways: open ComfyUI through an **SSH tunnel** (the address starts with 127.0.0.1; it counts as the computer ComfyUI runs on, nothing to change), or make an empty file named **`allow_remote_changes`** in the plugin folder on the server and restart ComfyUI. With the file, every device that can open the page may change things; the page can neither make nor remove it, and the MCP endpoint stays for this computer only. How, and the risk: [docs/guides/cloud-server.md](docs/guides/cloud-server.md).
- The **View only** chip beside the page title can be pressed: it says what is refused and the two ways above, with a **Copy** button for the one line to paste into the server's terminal (the plugin's real path on that server already filled in), so there is no folder to find. With the file there it reads **Remote changes on**, in yellow, and its dialog gives the line that turns it off again.
- **Models that are links (模型是快捷方式/链接时)**: on a cloud server such as AutoDL every model in the models folder is a link into the platform's shared model disk. Scans wrote the info beside the link, but the cards read it from the disk behind it: a scanned model showed as not scanned, under the disk's file name (`6b4679…`) instead of its own, and editing, deleting, opening its folder or finding it from a saved workflow could be refused as "outside the models folder". A linked model is now taken as named, with its covers and info beside the link; edits, renames and deletes act on the link, never on the shared disk; moving one in Tidy moves the link instead of copying the model disk's file onto the server. Names leaving the folder (`..`) are refused as before.
- **Models linked from another tool keep their info**: a model that is a link with no info beside it shows the info beside the file it links to (A1111's Civitai Helper leaves `.civitai.info` there). Only read there; covers, edits and scans stay beside the link.
## v1.60.0 (Model Import, Tidy, Parameter Compare, Workflows Rebuilt, Base Model Filter, Settings Tabs) — 2026-10-22
### 📥 Drop a model in, it goes to the right folder (拖进来自动归位)
- **Models page → + Import**, or drop model files anywhere on the models page: each file gets a card saying what it is — Checkpoint, LoRA, VAE, text encoder, diffusion model, ControlNet, Embedding, CLIP Vision, upscale model — with its base model, how sure that is, and one line on what such a model is for. It is read from the file's header only; .ckpt/.pt files, which can carry code, are never opened (you pick their type).
- Where it will go is shown and can be changed; by default it follows the download setting (by base model, such as loras/Illustrious). A file found in your Downloads folder or on the Desktop is **moved** there (instant on the same drive; tick "Keep the original" to copy); any other file is sent as a copy.
- While you look, the whole file is checked against Civitai: SDXL-family LoRAs get their exact base model (Pony, Illustrious, NoobAI cannot be told apart from the file alone) and the model and version name. **A file you already have** (identical content) is pointed out and left out unless you tick it; a different file of the same name is saved as "name (2)". Nothing is ever replaced. The new files are scanned right after.
### 🧹 Tidy: models in the wrong folder, files you have twice (整理模型)
- **The models page has tabs: All models, Tidy and Sources** (Sources was a chip among the model types). Tidy shows a count when it found something.
- **Tidy** reads the "table of contents" inside every model file and shows, as cards with the model's cover and name, two steps. **1. In the wrong folder**: what the file really is and where it belongs, drawn as *Checkpoint → UNet / Anima*, with one plain reason (for example an Anima, Flux or Wan model that is only the main part, which "Load Checkpoint" cannot read — Civitai labels all of these "Checkpoint"). **Move there** takes the model with its cover, info and your notes, keeping its subfolder ("Change place" to pick another); nothing is replaced, and it says which loader node your workflows need instead. **2. Identical files**: the copies side by side, the one in the right folder marked to keep (click another cover to keep that one), and one button sends the extra copies to the Recycle Bin with their covers and info. Scanned models are compared at once; same-size files never scanned when you press **Compare**.
- Models that load where they are anyway (a whole checkpoint in the UNet folder) are not listed.
- **Not used lately (很久没用的模型和节点包)**, under the two steps, when you press **Look for them**, for models under Tidy and for node packs in **Settings → Node packs** (a line under Tidy leads there), from *not used for 30 days*, *for 90 days* to *never used*. AMB now notes what each run uses and what each workflow you open holds (nodes and models, kept on this computer), and counts the tabs open when you look, so a node pack making sounds or videos, a workflow never saved, or a node only put on the canvas counts too; before that note began, your images and sounds (each records its models and nodes) and your saved workflows, recipes and combos tell. Nothing that came in the last 30 days is judged, and while the note is younger than the group you look at, the page says so.
- **Models**, biggest first, with the day they were last used and a warning when a saved workflow, recipe or combo still names one. Tick the ones to drop and send them to the Recycle Bin with their covers and info.
- **Node packs** with how many nodes and how long installed (from the day its folder was made), and a warning when another pack's nodes take what only this one makes. Packs that work only through nodes come first; those that also add buttons, menus or panels to the page are listed apart, since their nodes going unused does not say the rest is. A pack used only through its panels or buttons leaves no record: press **I still use it** and it is listed apart from then on (**Unmark** undoes it). **Find it in the Manager** opens ComfyUI Manager's node list already searched for that pack (its name is also copied), where you disable or remove it. Packs without nodes, which only work on the page or the server, are listed apart: no record can tell whether they are used.
- Importing, uploading and Tidy's moves only work on the computer ComfyUI runs on; opened from another device (ComfyUI started with `--listen`), they say so instead of writing into your model folders.
### 🧬 Filter models by base model (按底模筛选)
- Under the type chips, one chip per base model the listed models have, with counts: **All · Illustrious 21 · Anima 7 · SDXL 9 · Unknown 2**. Spellings of one family count as one (SDXL 1.0 and SDXL). Models not scanned yet are listed by what their file's header tells. Handy for LoRAs: pick the base model of your checkpoint and only the LoRAs that fit are left. It works together with the search.
- The scanner's offline guess of a base model (for models Civitai does not know) now also recognizes LoRAs, Qwen-Image, Wan, Chroma and Hunyuan Video files.
### ✏️ A new model editor, with a source link (模型编辑页重做,能填来源链接)
- **Edit** on a model opens a cleaner editor: the cover large on the left with the choices under it (the current one, Civitai's own cover when you replaced it, your newest pictures, upload, or the whole gallery), and on the right **Name**, **Source link** and **Notes**, each saying what it does. Nothing is written until **Save**; Esc or a click outside cancels. Renaming the file on disk is a tick under the name that says what it means.
- **Source link (来源链接)**: where you downloaded a model (LiblibAI, HuggingFace…), its site named as you type. The details get a button that opens it, beside Civitai's; shared workflows and Model Check point to it when someone misses that model. Only web addresses are kept.
- **Reset** clears the name and notes you set and brings Civitai's cover back; the source link stays.
- Saving no longer marks the name or notes as yours when you did not touch them.
### 🪪 Model details, rearranged (模型详情页重排)
- From a Bilibili comment: the buttons were small and spread over five places, and the file name showed twice. Now the title bar is gone and everything sits beside the cover: a small **← Models / LoRA** to go back (Esc does the same; from Model Check or the assistant it says where it leads), the model's **name**, the size / base model / source line with the Civitai and source links, and one row of buttons: **Add node** (the one coloured button), **Edit**, **Folder** (shows the file in Explorer; pointing at it shows the path) and **Outputs** (pictures made with it).
- The file's full path shows once, at the foot of the details with **Copy** and **Delete model**. Delete is no longer a red button next to Add node; it asks first in AMB's own dialog, and the files still go to the Recycle Bin. The ⬇ (jump to the bottom) button is gone.
- In a narrow window (the browser docked beside the canvas) the cover column is hidden as before, so a small cover now sits beside the name.
- A model's description (Civitai's own text) keeps its headings a little above the text instead of poster size, and a prompt quoted in it wraps instead of making the details scroll sideways.
### 🗂️ The folder column follows the type; open folders in Explorer (文件夹栏跟着类型走,一键打开文件夹)
- The column left of the models used to list every type's folders (raw names like `checkpoints (1)`, `[Root]` rows with 0, nothing marking where you were), repeating the chips above it. Now it shows the folders of the type you picked: **All**, then its subfolders as a tree whose numbers include their subfolders (empty ones dimmed, ▾ folds one), and **In the top folder** for models lying directly in the type's folder. The folder you are looking at is marked.
- **📂 Open** next to the type's name opens that type's folder in Explorer, and a 📂 on any folder you point at opens that one, so you can drop files in or tidy up by hand. Like the details' Folder button, it works only on the computer ComfyUI runs on.
- In a narrow window (docked beside the canvas) the column opens as a drawer over the page; closing it no longer squeezes the page for a moment as it slides away.
### 🎨 The Combos page looks like the others (搭配页和其他页面统一)
- It no longer sits in a lighter, inset box with a shadow, a second "Combos" title and a red ✕: it fills the page in the same colours as Models and Workflows. The explanation of what a combo is folds behind **?** (shown by itself while you have no combos), and the editor's Back reads **← All combos**.
### 🧩 Combos with rgthree's and Easy-Use's LoRA nodes (搭配支持常用的第三方 LoRA 节点)
- Stacking many LoRAs used to mean a long chain of Load LoRA nodes. **New** on the Combos page now also offers **Default combo, LoRAs in Power Lora Loader** (rgthree: every LoRA in one node, each row with its own switch and strength) and **…in Easy-Use LoRA stack** (up to ten per stack node, put on the model by Easy Apply LoraStack), each only when that node pack is installed. You still just pick the main model and the LoRAs; the combo's editor can switch between them later.
- A combo set to a node pack this ComfyUI does not have is put down with ComfyUI's own LoRA loaders, and says so.
- **Save as combo** on canvas nodes keeps a Power Lora Loader's LoRAs: before, its rows were lost and it came back empty. Each row's LoRA is now a slot you can change in the editor, its strength and switch shown beside it.
- **The LoRAs inside a Power Lora Loader are seen everywhere else too (Power Lora Loader 里的 LoRA 也认得了)**: before, AMB looked only at ordinary drop-downs, so a downloaded workflow using one never said its LoRAs were missing. Now **Model Check** lists each switched-on row's LoRA (missing, found by fingerprint, download, put back in), saving a workflow records their fingerprints, workflow cards and a picture's parameters list them with their strengths, a workflow's **models used** include them, and a LoRA's **Outputs** finds the pictures made with it. A workflow's models used also include Easy-Use LoRA stack rows. Only these widely used nodes are read this way; ComfyUI's own loaders remain the reference.
### 🚀 First-run setup on Home (新手一键准备)
- Until your models have been scanned once, Home opens with **New here? Let's get set up**: four steps and **Set up for me**. It checks that Civitai answers (many need a proxy for it; if not, **Retry** or **Scan offline**), offers the optional Civitai key, scans every model with a progress bar in the card (it renames no files and runs in the background), then starts the 30-second tour. It stops to ask when the network is down. **Later** hides it; Home's **New here?** line has **Set up** to bring it back. It is a card on Home, never a popup.
### ≈ What was read from the file says so (离线推断标清楚)
- A model Civitai does not know has its base model inferred from the file, as before; now it says so. Its card reads **推断 SDXL** (it was ≈), and its details open with a note: why Civitai did not have it, that the base model was read from the file and may be off, and **Add a name and source link**, instead of an English placeholder description.
- The scan page says it too: an online scan "looks up 5 on Civitai and infers the base model from the file for any it does not find", and the state chip reads **Not found · inferred from file**.
### 🏷️ Version labels on model cards (卡片显示版本号)
- A scanned model's card shows its Civitai version ("v2.0", "Illustrious", "fp8"…) as a small label above the name, so versions of one model that share a file name can be told apart.
### ⇄ Compare parameters (参数对比)
- Two pictures from the same workflow often differ in only a few values. Press ⇄ on one picture in the Gallery, then on another (or **Compare with the canvas**); the picture details have the same ⇄ for the canvas. The window says whether both use the same nodes and lists only what differs, node by node, A and B each in its own colour: prompts by the tags only A or only B has (marked positive or negative), long names one line per side with the differing part marked, seeds folded away, nodes only one side has listed apart. Two workflows numbered differently still compare the nodes each has one of, such as their only KSampler.
- Against the canvas, tick the values to take from the picture and put them on the canvas in one go (seeds stay); one Ctrl+Z or the toast's Undo takes them back.
- **What changed since the last picture (和最新出图对比)**: **⇄ Canvas vs. newest picture** at the top of the Gallery and of Activity compares the canvas with the newest picture in the output folder, edits made by hand on the canvas included, and can put the picture's values back.
- **Key parameters (关键参数)**: press ☆ before a value's name (tick **Show equal values too** to star one that matches) and it stays at the top of every comparison, for every workflow, kept with your ComfyUI settings.
- **Current node shows where saved values came from (当前节点的参数方案:出处、出图、并排)**: each saved set for the selected node is a card with the picture made with those values (or the picture they were kept from; click to see it large) and a line on where it came from, with the day: **a parameter set of workflow X** (with how many pictures it made, and **Open** to show that set in the workflow), **kept with picture Y** (**See it**), or **saved here**. The values it would change are drawn instead of written: numbers as two bars (now and then) with ↑ or ↓, choices as the old one struck through → the new one. What stays (seeds, models, choices this computer lacks) is said with why; **Show all** lists every value, equal ones too (there is no more "4 shown, N more"). **⇄ Compare** opens the comparison of the whole workflow with the canvas, not only this node.
- **Where the node's values came from (上次套用的是哪套)**: a line on top of the parameters says which set was last put into this node and when ("上次套用「阿罗娜K4.0 · 02」· 今天 18:02"), and what changed since ("之后变了:cfg 5 → 6.5"), with **Put them back**; that set's card is marked. It is remembered in this browser, per workflow and node, and forgotten when the change is undone. Without it, the line says which saved set the values match, or come closest to ("只差:cfg 6 → 5").
- **Side by side (并排)**: with two sets or more, switch to a table: the current values and each set in columns (its picture on top), the values a set would change marked, **Use N** under each. The choice is remembered.
- **Parameter sets in view (参数方案更好找)**: a workflow card with saved sets shows **▤ N** (how many), which opens them directly. Each set there shows its key values (those unlike the workflow's own in colour), how many values differ, and ⇄ to compare it with the workflow.
### 🗂️ A tidier Workflows page (工作流页面重做)
- One bar on top: search, **Filter** (kind and tag in one menu), grid or list, share and import, and **Save the canvas**. The drag tip strip and the red ✕ are gone (Esc still closes the page; the empty page explains dragging).
- **A workflow opens on its values (打开工作流先看参数方案)**: the details are rebuilt around them. On the left: **默认参数** (the workflow's own), your saved sets (each with its key values and how many values differ), **+ 从画布保存** and **+ 新建参数方案**, then **全部出图** and **版本**. On the right, the chosen set: **应用到当前画布**, **⇄ 对比** (with the default values or the canvas), ⋯ (copy as a new set, rename, delete), its main values, its prompts (long ones folded, the role still chosen from a menu), **the pictures made with these values**, and, folded, the models and every node's values. The bar keeps the name (click to rename), the models' state, Open and ⋯ (edit, export). The old Overview tab is gone: cover, notes and tags sit at the top of 默认参数.
- A new set is named after the workflow with a number (阿罗娜K4.0 2); values saved without names show their real names instead of 控件 1, 控件 2. A set equal to the workflow's own values (saving a workflow keeps one) is no longer listed twice.
- **Compare pictures inside a workflow too**: every picture in 全部出图 and under a set has ⇄ — pick two, or one and the canvas or the default values. The pictures there now load as thumbnails.
- Cards keep the picture, the name, a line of steps, sampler, LoRAs and size, and the model. The models' state is a dot on the cover, with words only when some are missing; 功能模块 marks a partial workflow. **▶ Open** and **⋯** (edit, export, delete) are two small round buttons in the cover's corner, shown when the pointer is on a card, so pressing a card to look inside no longer opens it on the canvas by mistake.
- **Focus values (重点参数)**: mark what matters most in a workflow. Under 全部节点参数 every value has a ☆; marked values show at the top of 默认参数 and of every set as large tiles with the node they belong to, a note you can add (「0.6–0.8 最好」), and, on a set, the default value when it differs. Set rows list them first, and in ⇄ 对比 they lead the key values with their note. A star turns at once with a little bounce, the value fades in at the top, and marking or noting one saves only that (no new version of the workflow). They belong to that workflow (the ☆ in the comparison window still stars a value for every workflow), are kept when the workflow is updated from the canvas, and travel with it in an exported workflow package (share codes carry only the canvas workflow).
- **Tips where they help (参数方案的引导)**: a workflow without sets says what they are for, with **+ 从画布保存** right there; on a set, a line points at **⇄ 对比** until you have used it (or pressed 知道了).
- **Folds remember you (折叠记住你的选择)**: 用到的模型 and 全部节点参数 open as you last left them. 用到的模型 says on its title whether the models are all here or how many are missing, and opens by itself when some are.
- **Docked, the details fit (侧边栏模式不再挤)**: in a narrow panel the left column becomes one row of pills above the values (默认参数, your sets, new, 全部出图, 版本).
- **Saving a workflow, redone (保存工作流的窗口重做)**: the cover is large on the left, chosen from the canvas, the current cover or your recent pictures (as thumbnails); name, tags and notes on the right; Cancel and Save always in view. The name starts as the canvas's workflow name when ComfyUI has saved it under one. Esc cancels, Enter in the name saves.
### 🔇 Models you never want scanned (不需要扫描的模型)
- A model whose header looks like no drawing model (a voice model such as F5-TTS kept in checkpoints, say) is no longer just "not scanned" forever. The scan page asks about them: **都不扫**, **都照常扫**, or **逐个看**. Set to not be scanned, a model shows **不扫描** instead of 未扫描, a scan of everything skips it (picked by hand it is still scanned), and the scan page lists these models to change it back. A model's details have the same button. Nothing is decided for you: scanning still keeps a fingerprint that repairs workflows.
- **Not on Civitai is no fault (Civitai 上没有不再像出错)**: the count of models Civitai did not find warns only when some file is damaged or a lookup is still to do; each such model says its fingerprint is kept for repairing workflows.
### 🎯 The scan page, rearranged: pick models beside the button, settings in place, components apart (扫描页重排)
- **Quieter the further down.** On top one card answers "what now": two rows of your models' covers (an empty dashed frame for each model not scanned yet, gently pulsing while a scan runs), what is left in one line ("3 models still to look up"), the scan button, **Pick models to scan…**, and **Online / Offline** right beside them. Then the models' state as a coloured bar with a chip per state; then, only when there is something, what needs a look (damaged files, voice-like models, models set to never be scanned, other formats), one row each with its icon; then the settings as tiles with an icon and a switch each; the last scan is one line at the foot. "How scanning works" is folded under the title.
- **Pick models to scan… sits beside the scan button.** The picks are named under it (each with a ✕, with their count and total size) and the button scans just them; **Clear, scan everything again** goes back. There is no "all / only ones I pick" switch any more. When some picks are already found, it says so: a scan only reads their record again unless Fetch again is on.
- **Under the button, what this scan will do**, in one sentence; **Details** unfolds the numbers: how many will be looked up on Civitai (not scanned yet, and those last scanned without a connection; how many of them are components), how many are already found and keep their record, how many Civitai does not have and are not looked up again, how many are set to never be scanned. Turning on offline or Fetch again changes the lines at once.
- **The scan settings are on the page** instead of a page of their own: Civitai names on cards, fixing the workflow afterwards and the Civitai key as tiles; renaming files and fetching everything again are folded under **More**, marked as changing files, and when one is on the scan card says so in yellow.
- **Components on their own (基础组件)**: VAE, text encoders, CLIP, CLIP Vision and upscalers get a chip of their own, "7 components · not found is fine", whose list (found, not found, not scanned yet) says that many of them are not on Civitai at all and that this does not matter (the fingerprint is still kept for Model Check). The counts above, and the Not found and Not scanned yet lists, leave them out; their own list shows all of them.
- **The model picker is new**: every model a scan reads, as a list (name, folder, state, base model, size) or as covers. Chips by state, the same words as the scan page — **Not scanned yet, Not found, Damaged, Found, Never scan** — with counts; a search; the folders on the left with how many are picked in each, two folders of one type told apart by where they are. **Pick these N** picks everything shown (so "the 12 not scanned yet in LoRA" is two clicks), shift-click picks a run, **Picked only** shows the picks. The footer says how many are picked and how big they are.
- Only files a scan can read (.safetensors) are listed; the old picker also offered .ckpt and other files a scan never opens.
- **Quick to open with many models, or models on a network drive** (a NAS, a cloud server's model disk): the page reads the headers of models not scanned yet 16 at a time and remembers them until the file changes, so it opens again at once; while it counts, it says **Counting your models…** instead of staying blank. On a cloud server with 1041 unscanned models it had taken 79 seconds each time.
### ⚙️ Settings in tabs (设置分组)
- The settings page is split into tabs: **Look, Models, Workflows, Node packs, Prompts, Backup, Help**. "Change where downloads go" opens Models directly.
- **Where downloads (and now imports) go is a choice**: by base model (the new default), in Downloads then by base model, all in a Downloads folder, straight in the type's folder, or your own folder typed by hand; an example path shows the result. Without a saved setting, downloads now go by base model instead of into a Downloads folder.
### 🔖 Take single tags from a saved prompt (从存过的提示词里挑词)
- **In Prompt Studio a clicked card opens as a reference** between the cards and the prompt you are editing, as its tags (with their meanings when a language is picked). Click one tag and only that tag goes into the prompt; tags the prompt has already are ticked. **Add all** puts the whole card in, as a click on a card used to; the preview's Add and dragging a card work as before. The ✕ closes the reference; beside the canvas the drawer widens by the column while it is open.
- **It does not squeeze a narrow window**: where three columns do not fit (a side-panel browser window, a narrow drawer), the reference takes the card list's place and **‹ Cards** brings the list back, so the reference and the prompt you edit each get half the width.
### Fixes
- **The browser no longer opens blank after Put on canvas (搭配放到画布后再打开不再空白)**: putting a combo on the canvas hid the combo page before closing the browser, so the next time it opened the title said Combos and nothing was below. It now opens again on the combo list, or on the combo you put down.
- **Edit emotions: every clip in the list, and the line follows the clip (编辑情绪:下拉列出全部音频,换音频台词跟着换)**: a box that already held a file listed only the files whose names contain it (the browser filters by the box's text), so a character's other clips seemed missing. Pressing the box now lists all of them, with the current file shown greyed; leaving without a pick keeps it. Picking another clip no longer keeps the old clip's line beside it: the line the node already reads for that clip is filled in, otherwise the line the node finds for the clip (its .txt / .lab or the annotation list) is filled in, with Anomalous_TTS from interface 14 on (older versions leave the box blank and the node reads the line when it speaks); going back to the first clip brings its line back. A blank line box shows, greyed, the line the node uses for its clip.
- **Search a character's clips by line (按台词找参考音频)**: Edit emotions has a search box above the clips: type part of a line or a file name and the matches list with their lines, to hear (▶), **Use as main voice** or **Add as emotion** (a new emotion row with that clip and its line, ready for its name). Searching lines needs Anomalous_TTS interface 14; with an older version it searches file names.
## v1.59.1 Beta (GPT-SoVITS v3 / v4 voices) — 2026-10-17
### 🎙️ Character voices: v3 and v4 models (角色语音支持 v3 / v4 模型)
- **Anomalous_TTS now runs GPT-SoVITS v3 and v4 models** (its first stable release, [v1.0.0](https://github.com/DemonGatanjieu/Anomalous_TTS/releases/tag/v1.0.0); update it in ComfyUI Manager), and this version of the plugin goes with it: v3 / v4 files are imported instead of refused, and a file whose version cannot be read is refused with a clearer reason. With an Anomalous_TTS too old for v3 / v4, the import says to update it.
- **Each character card shows its GPT-SoVITS model version** (v2, v2ProPlus, v4…) after the name (角色卡片显示模型版本).
- **v3 and v4 need their own official base model and vocoder** (v4 about 0.8 GB, v3 about 1.2 GB). **GPT-SoVITS Settings** lists them, with their own labels, only when one of your characters uses that version, and the import card says what is still missing for the model you dropped in. They download on first use too; a GPT-SoVITS package already on the computer is used as it is.
## v1.59.0 Beta (Model Downloads, Backup, Share Codes, Node Combos, MCP, Light Theme) — 2026-10-17
### ⬇️ Download missing models from Model Check (模型检查一键下载缺失模型)
- **Download** next to a missing model, and **Download all** for every one, when its source is certain: Civitai found the very file by the workflow's fingerprint, or the workflow carries a link to the file (a shared workflow's model links, or the download links in ComfyUI's own templates on Hugging Face). A file name alone is never enough, and a link to a different file than the workflow's is not offered.
- **You see where it goes before it starts**: the dialog shows, per file, its size, source and base model, the full path it will be saved to, and the free disk space. The models folder (when you have several, e.g. through extra_model_paths) and the subfolder can be changed right there; the folder you pick is remembered per type.
- **Your folder habits (按你的文件夹习惯放)**: Settings → Model downloads sets the default. *Where the workflow says* (default) puts the file in the subfolder the workflow names, so nothing in the workflow changes; a model without one goes into the folder set below (`Downloads`). *A set folder* always uses that folder, and the node is moved to the new place (Ctrl+Z undoes it). Write `{base}` in the folder to sort by base model (SDXL, Flux, Illustrious…); a folder of that name you already have is used, in whatever spelling.
- **Safe by default**: it downloads into a temporary file and checks the fingerprint before giving it its name, so a broken or wrong download never shows up in your models; an existing file is never replaced; an interrupted download continues where it stopped. `.ckpt` / `.pt` files get a warning (they can run code), and a source without a fingerprint says it cannot be checked. Your Civitai API key (needed for some models; Settings → Model downloads, or the scan page) is only sent to Civitai.
- Progress shows in the row with Cancel; when a file is done, the nodes using it get it, a short message says so, the new files are scanned for their information and cover, and the activity log records the download (source, size, whether the fingerprint matched).
- **Base models, VAEs, text encoders… from ComfyUI-Manager's list (Manager 模型清单)**: models that are not on Civitai (Flux's VAE, T5 and CLIP text encoders, upscalers, ControlNets…) are now looked up by file name in the model list of the ComfyUI-Manager installed here, which has a few hundred of them, mostly on Hugging Face. When the workflow carries the model's fingerprint, only the listed file with that fingerprint is offered, so it is certainly the same file; without one, a single listed file of that name is offered as **Download by name**, with a warning, and never in Download all.
- **Hugging Face first with the mirror (开镜像时优先抱脸)**: when a model is on Civitai and the very same file (same fingerprint) is on Hugging Face too, through the workflow's link or ComfyUI-Manager's list, it now downloads from Hugging Face while the China mirror is on: usually much faster there, and no Civitai login. Civitai's name, base model and page are still shown.
- **Covers and prompts from Hugging Face (抱脸模型的封面和示例提示词)**: a model downloaded from Hugging Face gets its model card's first example image as its cover, and its trigger words, example prompts and base model in its notes, with the repository as its link. Nothing you or a scan already set is replaced. Hugging Face cannot be searched by fingerprint, so this is for files downloaded from it.
- **Text encoders, CLIP vision and upscalers shown by default (文本编码器等默认显示)**: these folders are now shown and scanned by default, also where an earlier version saved them as hidden (once; you can hide them again in Settings → Model folders). Scanned models get fingerprints, so workflows you share carry them for T5, CLIP and the like too and Model Check can find the very file for whoever opens them.
- **One site down does not stop the others (一个网站连不上不影响其他)**: when Civitai does not answer (common in mainland China), the rest of the lookup no longer gives up: it stops asking Civitai and still asks Hugging Face (through the mirror) and the others.
- **AI apps can download too (AI 也能下载)**: three new MCP tools: `download_missing_models` (what Model Check's Download all does, for the open workflow), `download_model` (one model from a Civitai version link, a Hugging Face or GitHub file link, or a Civitai hash, into the folder of its type) and `download_status`. Files go where Settings → Model downloads says, are checked the same way, and are never written over an existing file.
- **Hugging Face through the China mirror (Hugging Face 国内镜像)**: Settings → Model downloads can send Hugging Face downloads through hf-mirror.com, which is on by default for the Chinese interface. When a download from Hugging Face itself fails, the row suggests the mirror.
### 🌐 A proxy for the plugin, and why Civitai cannot be reached (代理设置与连不上的原因)
- **Settings → Network → Proxy address**: the plugin's own requests (scans, model downloads, translation, update checks) go through the proxy written here, e.g. `127.0.0.1:7890`. Empty uses the system proxy as before. For a VPN app in PAC mode or one that only proxies the browser: the browser reached Civitai, the scan did not. Only this plugin uses it; ComfyUI and other node packs are untouched.
- **Test connection** right below says whether Civitai answers now, and if not, why.
- **A scan that cannot reach Civitai says why**: no answer (often no proxy), refused (wrong proxy port, VPN off), the address not found, a failed secure connection, or an error from Civitai, with what to try.
### 💾 Backup and restore (备份与恢复)
- **Settings → Backup → Export** packs your Workflow Recipes, combos, materials and saved prompts, parameter sets, the plugin settings and the names, notes and covers you gave your models into one .zip, optionally with ComfyUI's own saved workflows. Keep it on a cloud drive or a USB stick. The Civitai API key is never included.
- **Import** first lists what is in the backup next to what this computer has (new, the same, different), and the models it finds again by file hash, even after they were renamed or moved. By default it only adds what is missing; "take the backup's" replaces differing files and puts this computer's in the Recycle Bin. A model's name and notes are filled in where empty, and your own cover takes the place of the Civitai one. Nothing is deleted.
- Models' scan results (.info: names, trigger words, base models from Civitai) are in the backup too, so they survive Civitai removing a model; Civitai's covers can be added (they can be hundreds of MB). Both are put back only where this computer has none.
- **Download what the new computer lacks**: the backup also keeps a model list (each model's place, size, fingerprint and link). Importing says how many of those models this computer lacks; **Find downloads…** looks them up like Model Check (Civitai by hash, the saved link, Hugging Face), shows sizes and sources, and downloads the ticked ones one by one into the folder and file name they had, each checked against its fingerprint and scanned after.
### 🔗 Short share codes, one place to import (短分享码与统一导入)
- **Share codes are 5–10 times shorter (AMB2)**: only what a workflow needs is kept — node types, values, links, positions, titles, groups and the models' fingerprints — and ComfyUI fills in the rest when it opens. A Chinese-character spelling ("汉字码") is the shortest; a letter spelling ("字母码") posts anywhere, for chats or comment sections whose word filters stop the characters. Before a code is given, it is opened once and compared with the canvas; a workflow it cannot hold exactly (subgraphs, for one) is shared whole. Nodes from node packs keep their links on a computer that has not installed the pack yet. AMB0 and AMB1 codes still open.
- **Share and import in one place**: the Workflows page's ⇅ makes the code as soon as it opens (with the fingerprint and download-link notes), and takes anything in: a pasted share code or workflow JSON, or a dropped or chosen workflow file, an image with a workflow, or a backup .zip.
### 📦 Recipe packages are back (配方包重新开放)
- A recipe card's export button (and Export in the recipe's More menu) makes a **recipe package**: one .zip with the recipe's workflow, name, notes, tags, parameters, cover and model thumbnails (its versions only when ticked), to give to someone. Dropped into the ⇅ of their Workflows page, it becomes a new recipe card, optionally opened on the canvas; a recipe of the same name is never replaced (the new one is kept as "Name (2)"). Packages are checked for size, paths and checksums before anything is written, and a failed import leaves nothing half done. Send it as a file in chat apps.
### 🧱 Combos from your own nodes (按自己的节点结构做搭配)
- A combo is no longer only "a main model, LoRAs and a prompt". Pick nodes on the canvas (a Flux UNet with its two text encoders and VAE, a video model, a node pack's loader…), choose **New → From the nodes picked on the canvas**, and the combo keeps those nodes and how they are wired. Its editor then lists the **slots**: each model drop-down (a node with several has several) to change with a picker that shows that folder's models with covers, filtered by base model, and each prompt box to write in. **Put on canvas** lays the nodes out wired as saved, with your choices in them.
- Only certain things are kept: nodes that pick model files and nodes that hold text, and the links between them. Other picked nodes (samplers, latents…) are left out and their ports stay open for you to wire, as before. Every other value of the kept nodes (strengths, CLIP type…) is stored by name and put back untouched. When the dialog saves, you see what is kept and what is not, and can rename slots or untick the ones that should stay as they are.
- A node combo's page is grouped into Models, LoRAs and Prompts, in the order they flow (loaders first, LoRAs from the model outwards, the positive prompt before the negative); "LoRA 1" is the one next to the model. Each model shows its cover (looked up once for models chosen on the canvas), a two-line name, and its node's saved values (strength, CLIP strength, type) as small read-only tags. A whole row opens the picker; prompt boxes grow with their text. The top bar of the combo editor no longer floats 20 px down with the page showing above it.
- Each slot names the node it is on, and its node pack when that is not ComfyUI's own. A prompt has a small "译" button: a read-only panel beside it shows the translation (English for Chinese, Chinese for English); Prompt Studio stays the place to edit prompts.
- Nodes holding several models (Load CLIP Triple, LoRA stacks of rgthree, Comfyroll and Easy-Use, Easy-Use's full loader): each model is its own slot, and each saved value goes to the row with its number (strength_02 under the second LoRA); more than four values fold behind "+N". Empty rows of a stack (None) start unticked when saving, so a ten-row stack does not make ten empty slots. rgthree's Power Lora Loader draws its own rows instead of drop-downs and is not kept.
- **Right-click → Save as combo**: the canvas's node menu saves the picked nodes (or the node you right-clicked, when it is not among them) the same way, without opening the browser first. It shows only when there is a model or text node to keep.
- Every other value of a saved node stays as it was saved: the combo always puts down exactly what you saved, so a Flux combo cannot fall back to a node's default CLIP type or strength. For other values, save another combo.
- On another computer, missing nodes are named with their node pack before anything is placed; a box or link a node pack has since renamed is reported after placing, never guessed. **New → Same structure as …** starts another combo from a structure you already saved.
- **Save as combo keeps every picked node**, not only the model and text nodes: an upscale model's loader with the node that uses it and the Save Image after it, a ControlNet's nodes, any small group of nodes that work together. The models and prompts in it can still be changed in the combo; the other nodes are listed under **Nodes put back as saved** with their values, and go back on the canvas exactly as saved (change them on the canvas and save the combo again).
- A link coming in from a node that was not picked cannot be kept; the save dialog, the combo and its card (**1 input to wire**) say which inputs to connect after putting it down. Subgraphs cannot be kept yet. A combo with no models shows its nodes' names on the card. Combos saved before work as they did.
### 🤖 AI apps can use your library (MCP,让 AI 用你的库)
- While ComfyUI runs, AI apps that speak MCP (Claude Code, Claude Desktop, Cursor, Cherry Studio, VS Code…) can connect to `http://127.0.0.1:8188/anomalous/mcp` and ask about your library: models with their Civitai information, trigger words and your notes, which models are unmatched and why, output images and the settings that made them, combos, workflow recipes, saved prompts, generated speech and the activity log. A model's cover or an image's thumbnail can be attached for apps that look at pictures.
- It can also act on the workflow open in ComfyUI: rewrite or extend the prompts, swap a node's model, add a LoRA (it goes after the model loader and the LoRAs already there; one that also trains the text encoder takes the CLIP line too, from the checkpoint or, in a UNet workflow, from its CLIP loader, and everything those fed, such as both prompts, moves behind it; one that does not, like most Flux and Anima LoRAs, patches the model only), place one of your combos, check and put back missing models, and run the workflow; and start scans, or have an Anomalous TTS voice read a line. Every canvas change is one Ctrl+Z step, shows a note on the page and is in the activity log marked "AI". Nothing deletes or renames files; AI apps ask before calling a tool that changes something.
- Only this computer can connect, and requests from web pages, other hosts or through a proxy are refused. It costs no memory while nobody is connected. Setup for each app: `docs/guides/mcp.md`.
### ☀️ Light theme (浅色主题)
- **Settings → Appearance → Theme**: **Follow ComfyUI** (the default), **Dark**, **Light** or **Abyssal Scarlet**. Following ComfyUI switches with ComfyUI's color palette at once, light with a light palette and dark with a dark one. The same choice is in ComfyUI's settings under Anomalous Model Browser (it replaces the Abyssal Scarlet on/off switch; Abyssal Scarlet stays chosen if it was on).
- Every page, the Prompt Studio drawer and the dialogs follow the theme. The image viewer stays dark, like ComfyUI's own, and so do the labels and buttons over pictures (a model card's name, a recipe's cover labels, the gallery's View parameters button), so their text reads on any picture. Grey hint and secondary text is darker in the light theme so it reads clearly. The dark themes look as before.
- The "all models are here" banner on a recipe now reads green instead of pink, and the voice page's **Import character** button has dark text on its light blue in the dark themes.
### 🎯 Opens where your node is; number keys for the pages (按节点打开、数字键切页)
- **Opening follows the selected node**: open the browser with a text node selected on the canvas and Prompt Studio opens on that node; with a KSampler, a model loader or any other node, Current node opens. With nothing selected it reopens the page you used last, as before. It can be turned off in Settings → Opening and window.
- **Prompts is a page like the others (提示词变成普通页面)**: the rail's Prompts entry (and 7) now shows Prompt Studio inside the browser window instead of folding the window away. **Beside the canvas** on the page moves it next to the canvas as before, to see the canvas while you edit (its side and width are remembered); **Back to the window** returns it.
- **Prompt Studio's own shortcut is gone** (Ctrl+Shift+L, and its row in ComfyUI's settings and the Extensions menu): it opens from the rail, with 7 while the browser is open, or by opening the browser with a text node selected. The browser's shortcut (Ctrl+Shift+M) stays.
- **1–9 go to the pages on the left** while the browser is open (Home, Activity, Models, Gallery, Workflows, Combos, Prompts, Voices, Audio library). Numbers typed into a box stay in the box, and the rail's tooltips show each number. W/S were not used because W is ComfyUI's own Workflows key.
### 🔎 Model search in the header (模型搜索放到顶栏)
- The models page's search sits in the header now, instead of inside the folder list you had to open first. It matches the name, file name, folder, trigger words, base model and notes, every word you type, in the type you are looking at; Esc clears it. It also searches all the models of the type, where the old box only filtered the cards on screen.
### 🧩 Inserting a LoRA wires it properly (插入 LoRA 连线更聪明)
- **Current node → Insert LoRA before / after** now works on the usual workflows it refused before: a checkpoint whose CLIP feeds both prompts (both now hang behind the LoRA), and UNet / Flux / Anima workflows with their own CLIP loader. Whether the CLIP line goes through the LoRA is decided by the LoRA file itself: one that also trains the text encoder (most SD 1.5 / SDXL / Illustrious LoRAs) takes it, from the checkpoint or the CLIP loader; one that does not (most Flux and Anima LoRAs) is inserted as a model-only LoRA loader. Inserting before a sampler moves only that sampler's model, and the CLIP of the prompts wired into it.
### 🌐 Your own DeepL key for translation (DeepL 翻译密钥)
- **Settings → Translation → DeepL key**: prompts are translated with Google's free endpoint (MyMemory when Google cannot be reached); with your own DeepL API key, DeepL comes first. Saving the key translates one sentence to show it works, or says why not (a key DeepL refuses, a used-up month). The Free plan gives 500,000 characters a month. The key stays on this computer and is never put in a backup.
- A DeepL key saved by hand in `config.json` before did not work any more: DeepL stopped taking the key as a request parameter in 2025. It now goes in the Authorization header.
### 🎬 Videos in the gallery (图库显示视频)
- **The gallery shows your video outputs** (MP4, WebM, MOV, MKV) next to the pictures: each card shows the video's first frame with a ▶ badge naming its format and plays it muted while the pointer is on it (or always, as Settings → Video covers says for model covers); clicking it plays the video with sound in the viewer, filling the window, and a double-click goes full screen. Closing the viewer (×, a click beside it or Esc) stops it. Videos are deleted (to the Recycle Bin) like pictures, and a video dragged onto the canvas becomes a Load Video node; View parameters, the star and the right-click menu stay with pictures, which carry the parameters. A first contribution from outside: thanks to [@namnamjeopjeop](https://github.com/namnamjeopjeop) (cocorang) and [@wkdtjs](https://github.com/wkdtjs) (PR #21).
- **All / Images / Videos (全部 / 图片 / 视频)** beside the gallery's search, shown once your output folder has a video. **Settings → Gallery** hides formats you never want to see (GIF, say); click a format again to show it. Both are remembered in this browser. The recipe cover picker and AI apps (MCP) still see pictures only.
### 🧪 Tests in the repository (测试公开)
- The plugin's tests are now part of the repository: `tests/` (Python tests for the backend, `.mjs` tests for the frontend rules), `node tools/run_tests.mjs` runs them all with the structure check, and [docs/guides/testing.md](docs/guides/testing.md) says how to run and write them. They need no network or GPU and only touch temporary folders. Developer notes no longer point at tests that were removed earlier.
### ⭐ A Star for the project (点个 Star)
- Home's "Found a problem, or have an idea?" band ends with **⭐ Star on GitHub**, and Settings → Help and feedback has **Star it on GitHub**: both open the project page, where the ☆ Star button is. Nothing pops up and nothing is fetched; it is there for whoever wants it.
### ⚠️ Damaged model files are named as such (模型文件损坏会直接说出来)
- A model whose file header cannot be read (a download cut short, or another tool that rewrote the header and failed) used to look like a model Civitai simply does not know, inferred from the file. A scan now checks the header of every model it cannot match and names these **文件损坏**: a red badge on the card, a line in the details, a warning on the scan page, and first place in its unmatched list with advice to download the model again (then **Look up again**). Only the header is read. Models scanned before keep their old reason until they are looked up again.
### Fixes
- **Ctrl+Z after a scan (扫描后的 Ctrl+Z)**: after any scan, the first Ctrl+Z used to seem to do nothing: the workflow's saved fingerprints had changed, which ComfyUI counted as a step. A change of fingerprints alone no longer takes a step.
- **Esc in a model's Outputs window (作品窗口里按 Esc)**: Esc closes the window of pictures made with a model; it used to close the model's details behind it and leave the window open.
## v1.58.3 Beta (Fix: canvas speed with other extensions) — 2026-10-15
### 🐢 The canvas stays smooth with other extensions (画布不再被其他插件拖慢)
- One style rule of the browser (the list column stepping aside while a workspace is open) made every inline-style change on the page, by any extension and also while the browser is closed, restyle the whole page. Extensions that move their buttons on every canvas frame (ComfyUI-Prompt-Assistant, for one) then brought a large workflow's canvas down to 2–3 fps. The list column now follows a class on the window instead. Thanks to **@Lindemellis** for finding it, measuring it and proposing the fix ([#22](https://github.com/DemonGatanjieu/Anomalous_Model_Browser/issues/22)).
## v1.58.2 Beta (Small fixes: Esc closes, Show in folder, insert a picture as a node) — 2026-10-13
### 💬 From a Bilibili comment (来自 B 站评论)
- **Esc closes the browser (Esc 关闭浏览器)**: Esc closes a dialog or menu first; on a page one level down (a model's details, one combo, one workflow) it goes back to the list, as the page's back button does; on any other page, Workflows and Combos included, it closes the browser. The shortcut and the Extensions menu entry now close the browser when it is open. Esc in a text box still belongs to the text box.
- **Shift + B opens the browser (Shift + B 打开浏览器)**: the default shortcut is now **Shift + B** (again to close it). Ctrl + Shift + M is taken by Chrome and Edge (switch profile) before ComfyUI sees it, and B alone already opens comfyui-browser's window. A shortcut you changed yourself stays as it is; change it in ComfyUI Settings → Keybinding.
- **Dragging a gallery picture hides the browser (拖图时浏览器让开)**: while you drag a picture, the browser fades out as it does for other cards, so the canvas and the node under it take the drop.
- **Show in folder (打开所在文件夹)**: a model's details have **Show in folder** next to **Copy path**: Explorer (or Finder) opens in front of the browser with the file selected. Only on the computer ComfyUI runs on.
- **Insert a picture as a node (图片作为节点插入)**: right-click a gallery picture → **Insert as a Load Image node** gives you a Load Image node showing that picture: the browser closes and the node follows the pointer until a click puts it down (Esc cancels), like a combo's **Put on canvas**. The picture is copied into ComfyUI's input folder, as when you drop a file on the node (the same picture is kept once).
### ⌨️ Arrow keys in picture details (查看参数里方向键切图)
- In a picture's details, **← / →** (and **A / D**) switch pictures again after you ticked a node's checkbox under **All parameters**; the ticked checkbox kept the keyboard and the keys did nothing until you clicked elsewhere. Typing in a text box still keeps the arrows for the text.
### 🌐 English interface (英文界面)
- Picture details no longer show Chinese in the English interface: the step count ("20 steps"), **Show model** on the Models tab, the copied-JSON note and the empty-tab notes.
- Counts read right for one: "Changed 1 thing on the canvas", "1 model", "1 file" instead of "1 things", "1 models", "1 files".
- The voice guides name the node as English ComfyUI shows it: **Character Speech (GPT-SoVITS)**.
## v1.58.1 Beta (Security: only this computer can change things) — 2026-10-12
### 🔒 Other devices can look, not change (别的设备只能看,不能改)
- When ComfyUI is started with `--listen`, other devices on your network can open it. Until now they could also use most of Anomalous Model Browser's actions: delete or rename models, save and delete workflows, combos and prompts, start downloads and scans, export a backup of all your data, translate with your translation key, change settings, switch the plugin's version. Now every change answers only the computer ComfyUI runs on; other devices can still browse models, pictures and workflows.
- A window opened from another device shows **View only** next to the page title; pressing a button that changes something there says why it did nothing.
- A web page that reaches ComfyUI through another name for this computer, or a proxy, counts as another device too.
## v1.58.0 Beta (New Layout, Keep & Reuse, Prompt Studio, Model Check, Voices) — 2026-10-09
### 🧭 New layout (新布局)
- **Icon rail (左侧图标栏)**: every page now has one place on the left: Home, Activity, Models, Gallery, Workflows, Combos, Prompts, then Voices and Audio for the audio side, then scan, Model Check and Current node, with settings at the bottom. It replaces the tabs at the top, the image/audio switch and the tool bar under the folder list.
- **Folder list opens and closes per page (列表按页面收放)**: click the current page's icon again, or the button at the top left, to hide or show its list; each page remembers your choice. Pages without a list (Home, Gallery) give the space to the content. In a narrow or docked window the list starts closed and opens as a drawer against the rail, over a dimmed page; picking a folder or character, pressing the dimmed page or Esc closes it.
- **Home page (主页)**: "What would you like to do?" with a card per task (find a model, fix missing models, give a character a voice, look through outputs, keep workflows, use a combo, put prompts together) and first steps (scan model folders, tour the interface, what's new). The first time you open the browser it starts here; after that it reopens the page you used last.
- The header shows the current page's name; the workflow and combo workspaces keep the rail visible, so you can switch pages without closing them first.
- **No more toolbox (工具箱拆掉了)**: every tool now opens from the page it belongs to. Workflow share codes (import / export) are the ⇅ button at the top right of **Workflows** (where a disabled import button used to sit); **Prompt Studio** opens from **Prompts** on the rail (Prompt Notes became **Combos**, a page of their own); **Model Sources** is at the end of the type chips on **Models**, and Model Check has **Where to download** for the open workflow's models; translation is on the prompt boxes in **Current node**. The rail keeps scan, Model Check and Current node.
- **No more Material Library page (素材页拆掉了)**: it held four unrelated things, so each went where it is used. **Prompts** on the rail opens Prompt Studio beside the canvas, where your saved prompts are (and can be renamed, deleted and dragged onto the canvas); the browser comes back when you close the studio, and the second keyboard shortcut (Ctrl+Shift+L) now opens the studio too. Saved node values are in **Current node** when you select a node of their kind, which can now delete them. Recent results are the gallery's ☆, combos have their own page, and whole workflows are recipes. The recipe page's buttons that copied parameter sets into the library are gone (Current node lists recipe sets already), and saving nodes from an image's details says where they went. Nothing is deleted: your saved prompts and values stay in the same files.
- **A settings page (设置页)**: the gear opens a page instead of a small pop-up with a second dialog inside: Look (view mode, atmosphere, interface scale as a slider, theme, language), Model cards and memory (thumbnails or originals, video covers, the thumbnail cache with its size and an Empty button), Model folders, Workflows (model fingerprints), Opening and window (where the browser opens from, reset layout) and Help and feedback. Each setting says what it does and what it costs; Back returns to the page you came from.
- **One look everywhere (配色统一)**: pages made at different times used their own greys (bluish slate here, neutral grey there) and their own accent (sky blue, violet, indigo). They now all take the Home page's colours: neutral surfaces, the same text greys and one accent for links and selection, and they follow the Abyssal Scarlet theme too (the Material Library, Prompt Studio, voices and several dialogs used to stay blue-grey in it). The workflow share-code dialogs lost their blue and orange buttons for the usual ones. Status colours and prompt-role colours are unchanged.
- **Easy feedback (反馈更方便)**: Home ends with "Found a problem, or have an idea?" and Settings has the same under Help and feedback. **Report a problem** and **Suggest something** open a small window with one text box (a sentence or two, any language); **Submit on GitHub** then opens a new issue with it written in, titled from its first words, in the browser's language, so only Submit is left to press. The environment (plugin and ComfyUI versions, Python and PyTorch, GPU and memory, OS and browser, the page you were on; never a file path or ComfyUI's command line) is attached folded at the end when ticked, and can be looked at first; **Copy diagnostics** copies it for anywhere else. The repository's issue forms ask just what went wrong, with optional screenshots.
- **No more manual pop-up (使用手册弹窗去掉了)**: the long help text, which had fallen behind the window (it still called recipes and materials "in testing"), is gone; each page now explains itself, Home lists what you can do, and **!** shows what changed. Settings → Help and feedback has **Interface tour**, **What's new**, **Full guide** (the README on GitHub, at its Chinese half in Chinese) and the feedback buttons. The README's backup note no longer calls these features a test.
- **Model folders, readable (模型文件夹设置更好懂)**: folder management is a view of the settings page instead of a pop-up. Grouping is two plain choices, **By model type** or **By folders on disk** (it was "Category mode / Physical mode"); the types shown on the Models page come first, each with its usual name (Checkpoint, Diffusion…) and folder name, a switch and ↑↓ to order them; the ones that are off follow, and the many folders ComfyUI registers that few people use (configs, datasets…) fold away. Each change applies at once; there is no Save and reload button any more.
- **Up-to-date guides (说明跟上新界面)**: the **!** "What's new" guide and the README now describe the rail, Home, keeping results, Prompt Studio, Model Check and character voices instead of the old tabs and bottom bar.
- **The tour follows how you work (导览按使用顺序讲)**: the interface tour now goes the way the pages are used: the rail as one line (pick models, keep a good result, use it again), the Gallery's ☆ and where each kind of keep goes, Workflows (with share codes), Combos, Prompt Studio, Current node, then scanning, Model Check, the **!** guide and Settings. Steps that still described translating on Current node and the old scan wizard are rewritten, and the tour's text follows the interface language like the rest of the browser.
### ⭐ Keep and use again (收藏与复用)
- **Keep a result from the gallery (图库里点 ☆ 收藏)**: every image in the gallery has a ☆. It asks what to keep, and says where each goes: **The whole workflow** (a Workflow Recipe on the Workflows page, with the picture as its cover), **Its combo** (the main model, LoRAs and positive prompt, on the Combos page, ready to put on the canvas; models this computer does not have are left out and the message says how many) or **Just the prompts** (positive and negative, in your saved prompts). No name is asked: it is named after the prompt. Kept images show ★; a row already kept shows ✓ and opens it, and its **Remove** (取消收藏) moves what was kept to the Recycle Bin after asking, so the star goes back to ☆. The image workbench's **Keep** opens the same menu (its name-and-tags form is gone). The Material Library's Recent shelf, which did the same for whole workflows only, is gone: the gallery lists the same images. Deleting an image from the gallery asks in the usual dialog instead of over the picture.
- **Whole workflows live on the Workflows page (完整工作流都在“工作流”页)**: a result kept whole (now with the gallery's ☆, or from the image workbench) is a Workflow Recipe with that picture as its cover (it was a material), so versions, parameter sets and Model Check work on it. The Material Library keeps the pieces (nodes, parameters, prompts) and lost its Workflow category. Whole workflows kept as materials before show on the Workflows page as "N more in the library — Move them here"; moving saves each as a recipe and leaves the material file where it is, just no longer listed. Recipes that are not whole workflows are called Modules everywhere (the filter said "Sub-graph").
- **Prompt Notes are now Combos, a page of their own (提示词笔记改名“搭配”,有了自己的页面)**: a note was always a main model, a few LoRAs and a prompt kept together, so it is called a combo and sits on the rail as **Combos**, with a card on Home. The page shows every combo as a card with its model's cover, its LoRAs and the start of its prompt, a search box and **New combo**; a card opens the combo to edit, and **‹ All combos** goes back. The old notes column with its quick-jump list is gone. Your notes are the same files and show up as combos; prompts saved from notes before still show in the Material Library and can be saved as a new combo.
- **Put a combo on the canvas (搭配一键放到画布)**: **Put on canvas** on a combo's card or in its editor (formerly Send to canvas, under More) lays the combo out as a group of nodes wired together: its main model loader, its LoRAs and a positive and a negative prompt node; the group follows the pointer until you click, and Esc takes it off again (nothing is logged or left behind). The positive node holds the combo's prompt (left empty when it has none, rather than leaving the prompt nodes out) and the negative node gets Prompt Studio's common negative card. A combo card can also be dragged onto the canvas, and the group is built where it is dropped. Nothing already on the canvas is changed (to change an existing node's values, use Current node). A new group from a UNet combo connects its prompt nodes to the canvas's CLIP loader when there is exactly one, and says so once the group is down (the message no longer pops up while the group still follows the pointer, where closing it put the group down).
- **Recipe detail, plainer (配方详情更清爽)**: the models a recipe uses are one list: preview, name, the node, whether this computer has it under the saved name, size, your note and its download page. The recipe's own "match local model / apply match" buttons, the identity badges, the advanced blocks with paths and hashes, the origin editor and the recipe fingerprint are gone: when the workflow opens, Model Check finds renamed or moved models and puts them in, and saving the recipe again keeps that (the old version is kept). The status says "Every model is on this computer" or "N models missing" with what happens next. The save dialog and the empty page speak plainly (no more "unclosed node chain" or "self-healing"), and the parameters page lost its save-to-library button on every node (pick nodes with "select some parameters" instead).
- **Shared workflows say where their models come from (分享的工作流带上模型来源)**: saving a workflow or an image now also stores each model's download link (the one you set in the model editor, else its Civitai page) unless the workflow already keeps one, and drops links of models it no longer uses, so after Model Check swaps in your own model, what you export names your file, fingerprint and link while the workflow you opened stays as it was. GGUF, .pth and .sft models get fingerprints too. The share-code export says how many models carry a fingerprint, warns when some have none, and can leave the links out.
### ✍️ Prompts and Current node (提示词与当前节点)
- **Prompt Studio edits the node itself (提示词工坊直接改节点)**: no more assembly board, "write to node" step or positive / negative tabs. Select a prompt node and its prompt boxes appear in the studio, positive and negative together, as tags: click one to make it stronger or weaker, double-click to edit it, drag to reorder, × to remove. Every change goes straight into the node, and the studio's **Undo** (or Ctrl+Z on the canvas) takes it back; line breaks in your prompt stay where they were. Type in a box and press Enter to add tags: Chinese, Japanese, Korean or any other language is translated to English first, and tags the box has already are not added twice; a box that holds words that are not English offers to translate them. The meaning menu in the top bar puts each tag's meaning under it in Simplified or Traditional Chinese, Japanese, Korean, Russian, Spanish, French, German or Portuguese; **Text** shows the whole text instead. With no prompt node selected you write a draft, which can be saved or dragged onto the canvas by its ⠿ handle; the studio keeps editing the last prompt node until you select another one or press the ✕ beside its name. A clicked card goes into the box of its role (a negative card never lands in a positive box). The six starter cards are down to three (quality, common negative, hands and anatomy), your saved prompts are under **My prompts** with their first words showing, and the studio no longer reads every saved prompt again every 30 seconds, only new ones.
- **Saved prompts are managed in Prompt Studio (存过的提示词在提示词工坊里管理)**: the studio's library cards for prompts you saved used to be read-only ("managed by the source material"). Their preview now has **Rename** and **Delete** (the whole saved prompt, positive and negative together, to the Recycle Bin), and any card can be dragged out of the studio onto the canvas: onto a prompt box it fills that box (outlined by role, with the text previewed before you let go, one Ctrl+Z or Undo in the message to take it back), onto empty canvas it makes a prompt node.
- **Prompt boxes found by what they are (提示词框按节点定义识别)**: dropping or writing a prompt (material library, Prompt Studio, translator) now finds a node's prompt boxes by ComfyUI's own definition (multiline text inputs) instead of guessing from names, so a filename or "value" box no longer takes a prompt and third-party prompt nodes are recognised. Whether a box is positive or negative follows its wiring to the sampler, not the node's title: a CLIP Text Encode connected to the negative input gets the negative prompt even if it is still called "CLIP Text Encode". Prompt Studio's "take prompts from the selected node" also reads the roles right again (it could not follow links in current ComfyUI).
- **See where a dragged prompt goes (拖提示词前先看清楚落在哪)**: while you drag a prompt from the material library, every prompt box on the canvas is outlined (green for positive, red for negative, grey when it is not wired to a sampler) and the box under the pointer lights up. The label next to the pointer says exactly what releasing does, e.g. "Release: into #6's positive prompt box; the negative prompt box on the same sampler (#7) too". Drop on a box and that box is filled; the matching negative (or positive) box of the same sampler gets the other half of the material. On a node with several prompt boxes, point at the one you mean instead of letting it guess. A short message confirms what was filled; the library's receipt undoes it. Dropping a prompt plan on empty canvas used to create an empty prompt node; it now creates a positive and, when the plan has one, a negative node with their text.
- **See it before you let go (拖的时候先看到结果)**: dragging a prompt over a prompt box shows, in that box and in the opposite box on the same sampler, the text releasing would put there; move away and they go back. Dragging saved values over a node of their kind lists what would change, such as "steps 12 → 28, cfg 4.5 → 5.5", or says nothing would change.
- **Undo what you dropped (拖错了可以撤回)**: anything Anomalous writes on the canvas (a material dropped on a node, a prompt put in a box, values applied in Current node, a model swapped or a LoRA inserted, a recipe or combo sent to the canvas) is now one step of ComfyUI's own **Ctrl+Z**; before, a drop was not, and Ctrl+Z could undo the step before it instead. After a drop, the message at the top right has **Undo** and stays while the pointer is on it. Dropping a prompt on empty canvas can be undone the same way. In **Activity**, canvas changes made since the page was opened have **Undo** while nothing has changed them since; entries that removed nodes or rewired existing ones only record.
- **Current node (当前节点, formerly Node Assistant)**: the panel no longer splits into "actions" and "parameter sets" tabs; it is one page with the model actions on top and **Parameters** below. Parameters lists every saved set of values for this kind of node in one list — from the material library and from recipes' parameter sets, the same values saved twice shown once — and each entry says exactly what it would change on the selected node ("steps: 20 → 30"). **Apply these N** changes only those; seeds and model files stay (change the model above), and the receipt undoes it. **Save these values** keeps the node's current values in the material library, so the next node of that kind can use them.
- **Prompts on Current node (当前节点列出提示词框)**: Current node now lists the selected node's prompt boxes, each marked positive or negative by its wiring, and **Edit in Prompt Studio** opens them in the studio, where they are edited and translated. The separate Prompt Translator window is gone.
### 🩺 Model Check (模型检查)
- **Model Check, formerly Model Doctor, redone (模型检查,原“模型医生”,重做)**: when a workflow opens with models this computer does not have, a bar over the canvas says how many, and how many are here under another name or folder; **Put in** swaps those in and **Show** opens the doctor. The doctor page lists what needs you first and says why in plain words (same file found elsewhere, same size only, not on this computer, no fingerprint saved in the workflow), with a button for each: put in, use it, pick one yourself, or look it up on Civitai; the models that are fine fold away. Opening the doctor no longer changes the workflow by itself, and a model put in by the doctor or its picker no longer stays marked red by ComfyUI. The "auto-detect on startup" switch (it did nothing) and the doctor's own deep hash scan are gone: the check hashes same-size files itself, and the page points to the scan page when unscanned models could be the missing ones.
- **The Model Check bar can be turned off (模型检查横条可以关掉)**: the bar now starts with "Model Check:" so it is clear it comes from the browser, not ComfyUI, and has **Don't show again**, which stops checking workflows when they open and says where to turn it back on. The same switch is in Settings → Workflows ("Check for missing models when a workflow opens", on by default); with it off, run Model Check from the rail when you want it.
- **A model put in by Model Check stays put (模型检查换上的模型不会再变回去)**: ComfyUI was not told about the change, so reloading the page right after **Put in** (or Ctrl+Z) brought back the old, missing name and its red mark. Each put-in is now one step of ComfyUI's history, saved like any edit of your own.
- **Model Check finds renamed models by fingerprint again (按指纹找回改名的模型)**: when a workflow's model was renamed or moved and no scan had hashed the file yet, Model Check failed to hash the same-size files itself (an error in the background) and called the model missing. It now hashes them and finds it.
- **Model Check is honest about same-name files (模型检查不再把同名当成同一个文件)**: for a workflow without fingerprints, a missing LoRA or checkpoint with a file of the same name on this computer was listed as "the same file under another name or folder" and counted among those to put in at once. It is now a candidate you take or leave on its row: "same name", or "same name and exactly the same size, very likely the one" (listed first). VAE, CLIP and other foundation components still go by fingerprint only. Model Check now also checks GGUF models and finds upscale models moved to another folder. Each one carries a level next to its name: ●●● sure (same fingerprint), ●●○ likely, ●○○ maybe, ○○○ unclear / does not match / not found.
### 🔍 Scanning and model information (扫描与模型信息)
- **Scan page (扫描页)**: the rail's scan button and Home's "Scan model folders" open a page instead of the old seven-switch dialog. It shows how many models are matched on Civitai, not matched, and not scanned yet; one button scans the new ones, with progress in the page (it runs in the background, so the window can be closed). Offline scanning, renaming files on disk, fetching everything again, picking models and the Civitai API key are in **Scan settings**; the two risky switches turn themselves off after each scan. The "record model fingerprints in workflows" switch moved to Settings → Model settings.
- **The scan page in plain words (扫描页说人话)**: it says what a scan does (the file's fingerprint is looked up on Civitai), and why a model was not found: not published on Civitai (voice, upscale and detection models mostly come from HuggingFace or GitHub), trained or merged by you, or changed after download; it works as usual, and you can add its download link. A model Civitai does not know that is no known image model says it may be a voice or upscale model. Model files in other formats (.pth, .ckpt, .bin…), which scans never read, are listed under **Other formats** with why, so they are no longer a mystery.
- **A tidier scan page (扫描页更清爽)**: the scan page now holds only the counts, the scan button, the last scan in one card and a card saying how the next scan goes. **Not found**, **Not scanned yet**, the other formats and the last scan's models each open their own page with **‹ Back to scan**, a line on why those models are there, one button for all of them (scan these / look them up again) and the list; a model opened from it comes back to the same list where you were. **Show the result** after a scan opens the last scan's page. The old fold-out **Advanced** is now **Scan settings**, a page of its own like the lists: its card on the scan page shows how the next scan goes (all models or the picked ones, online or offline, renaming files…, with the ones that change files in yellow), and the page asks one question per card, which models as two choices side by side and each option with a switch, the ones that change files or take long marked so, the online-only ones under their own heading (faded for an offline scan).
- **Unmatched models can be looked up again (没匹配上的模型可以重新查找)**: a model that Civitai did not know, or that was scanned while Civitai could not be reached, used to stay "local only" for good unless every model was fetched again (which also replaced your own covers). **Look up the N unmatched models again** retries just those, keeps your covers, and reuses the saved hash when the file is unchanged, so it takes seconds instead of re-hashing gigabytes.
- **Which models, and what a scan did (是哪些模型、扫描做了什么)**: on the scan page, **Not matched** and **Not scanned yet** open a list of those models, with their type and folder; unmatched ones say why (Civitai does not have the file, Civitai could not be reached then, or it was an offline scan) and the base model inferred. Each row opens the model (Back returns to the scan page), scans it or looks it up again, or searches Civitai for its name. After a scan the page shows its result: when, how long, how many matched, inferred, failed, covers downloaded and files renamed, and each model it changed with what happened; the progress box says the same and, when it floats over the canvas, has **Show the result**. A model's card scan looks an unmatched model up again and its message says what it found.
- **Online and offline in one scan (联网和离线在同一次扫描里)**: a scan asks Civitai first and infers the base model from the file for what Civitai does not know, as before; now it also remembers why. When Civitai stops answering (two models in a row), the rest of the scan infers from the files right away instead of waiting out every timeout, and the next online scan looks those models up again by itself, as it does for models from an offline scan. Models Civitai answered "not found" for wait for **Look up again**. A model that fails (a locked file, say) no longer ends the scan of the others; it is listed as failed with the reason.
- **Your edits come first (你改的永远优先)**: names, notes and download links you set in the model editor now have a file of their own next to the model (`<model>.anomalous.json`) that scans never write, and they win over Civitai's information, which wins over what is inferred from the file. Edits saved by earlier versions keep working. Editing a model that was never scanned used to make it look scanned, so scans skipped it for good; it is now scanned like any new model.
- **Covers you chose are never replaced (你换的封面不会被替换)**: a cover counts as Civitai's only while it is the very image the scan downloaded; any other cover is yours. **Fetch scanned models again** now keeps your covers (and names and notes), updates only Civitai's own, and asks before it starts.
- **Where a model's information comes from (信息来源标注)**: cards look as before for models matched on Civitai; a base model inferred from the file shows as "≈ SDXL" with a dashed outline, and a model not scanned yet says so, each with a tooltip on what to do. The detail header adds the source (Civitai, inferred from the file, not scanned) and which fields you edited.
- **Faster rescans (重复扫描更快)**: a scan no longer downloads every cover and Civitai model page again for models whose information is already on disk; only new or missing ones go to the network.
- **Model types at the top (模型页顶部类型标签)**: the models page starts with a chip per model type (Checkpoint, LoRA, VAE…) and how many models it holds; a chip shows that whole type, subfolders included, so the grid no longer looks like it holds one folder's models. Picking a folder in the list narrows the grid to that folder, shown as a chip with ✕ that goes back to the whole type. The browser remembers the type you picked last.
- **Model sources on the Models page (模型来源进了模型页)**: the Sources chip at the end of the model types now shows, in place of the cards, where each model is downloaded, for **This workflow** or **All models**, filtered by with / without a link. Every link can be typed or pasted straight into its row (any site, not only Civitai), found automatically, opened and saved with one button; a link is kept in the model's own information (the same as the editor's download link) and, for this workflow's models, in the workflow too, so it travels when the workflow is shared. The list can go on the canvas as a note or to the clipboard. Model Check's **Where to download** opens it on This workflow. The separate pop-up, its lock-then-edit links and its four ways of saving are gone.
- **GGUF and .pth models show up (GGUF、.pth 模型能看到了)**: the Models page, the folder list's counts, search and model lookups only knew `.safetensors`, `.ckpt` and `.pt`, in lower case, so quantized GGUF models (Wan, Flux, Qwen…), most upscalers (`.pth`), `.sft` and `.bin` files and names like `MODEL.SAFETENSORS` were missing. They are all listed now. Scans still read only `.safetensors`; the others stay under **Not scanned yet**.
### 🗑️ Safer with your files (删除更安全)
- **Deleting goes to the Recycle Bin (删除进回收站)**: deleting a model (with its info and covers), an output image, generated audio, a recipe (with its versions and images), a material, a prompt note or a parameter note, replacing or resetting a cover, and the duplicate copy a file rename finds, all move the files to the system Recycle Bin instead of erasing them. On a USB stick or network drive, which has no Recycle Bin, nothing is deleted and the message says to delete it by hand. The confirmations say where things go. An unused route that could erase every `.civitai.info` in the library (with the names you set) is gone.
### 🪶 Lighter on memory (更省内存)
- **Model cards show up sooner (模型卡片出得更快)**: card thumbnails used to live in ComfyUI's temp folder, which ComfyUI empties on every start, so the first visit after each restart remade them all. They are now kept in ComfyUI's user folder (still at most 256 MB) and survive restarts. Opening a model type also makes the missing ones in the background, one at a time, so scrolling down finds them ready; the first screen of cards loads before the rest.
- **Video covers show a still first (视频封面先显示第一帧)**: a video cover now shows its first frame right away and downloads nothing until it plays. Playing on hover is the default now (it was always-play, though the setting called hover "recommended"), so fewer videos decode at once; "Always play" is still in Settings.
- The output gallery shows 512 px thumbnails instead of full images (cached like model cards), so scrolling through hundreds of outputs no longer fills the browser's memory and graphics memory with full-size pictures. Dragging an image to the canvas and the large viewer still use the original. When the browser has been closed for 90 seconds, its gallery images are released too, like the model grid.
### 🕘 Activity log (操作记录)
- **See what Anomalous changed (看 Anomalous 改了什么)**: the new **Activity** page on the rail lists, by day, every change the plugin made: on the canvas, which node settings changed (from → to), which nodes were added or removed, and which workflow was opened; in files, models, covers, recipes, materials, notes, presets, images, audio and imported characters. Filter by canvas or files; open a canvas entry for its details and **Find on canvas** to jump to the node. Your own edits on the canvas are not recorded as the plugin's. The home page shows the latest few.
- **More detail (记录更详细)**: a scan is recorded when it ends, with what it found and each model it changed (open one from the entry), instead of a bare "scanned models" when it started. Editing a model records each field from → to, and deleting one lists the files moved to the Recycle Bin.
- Nothing is undone by itself (canvas entries have **Undo**, see "Undo what you dropped" above). **Clear** removes the log itself, never your changes. The newest 500 entries (at most 2 MB) are kept in ComfyUI's user folder.
### 🎙️ Audio & Voice Studio (音频与声音工作台)
- **Characters and Voice-over, one page (角色和配音在同一页)**: the Script Director used to be a drawer opened by one of four equal buttons on Voices, over the character cards. Voices now has two views switched at the top, **Characters** and **Voice-over**, sharing the character list on the left: on Voice-over, clicking a character makes it the one who speaks. Each character card has **Voice-over** to go there with that character chosen; Home's "Give a character a voice" opens it too, and the rail's Voices entry reopens the view you used last. **Generate** is the big button; when it is done it says how long it took (instead of the node's count of sentences, batches and cache) and **Saved to Audio ›** opens the audio gallery. Pushing to a node, dragging onto one and copying sit below under **Into your own workflow**, and a push or drop is one Ctrl+Z step.
- **Generation settings you can read (生成设置不再挤成一团)**: language is a row of choices and speed a slider, always in view; the four sampling numbers fold under **Fine-tuning (usually left alone)**, one per row with a plain name (Candidates, Randomness…) and the node's own name under it. The side no longer scrolls sideways.
- **Calmer line cards (台词卡片更清爽)**: split, merge and delete show when the pointer is on a card; another take of a line stays in view. The ▶ that played the emotion's reference clip (easily taken for the generated line) is now a small speaker beside the emotion chips. A character with only its main voice shows no chips at all, just one line on how to add emotions. Plainer words: "Split into lines" instead of "Parse lines", "3 lines · using main voice" instead of "Bundled 3 lines".
- **Audio gallery (音频库)**: each file's lines show once (the full text was always shown under the summary), 💬 opens them on Voice-over with their character for another take, and the list on the left no longer repeats the gallery as "Audio history".
- **Generate right on the Voice-over page (配音页直接生成)**: for GPT-SoVITS characters, **Generate** runs the script in ComfyUI without touching the canvas (it waits in the queue while ComfyUI is busy), plays the result and saves it to `output/audio/<character>/`. **↻** on a line card gives just that line another take while the others come from cache; **New take of all** starts over with a new seed. Language, speed and the sampling parameters (one folded **Generation settings** line, with a hint on each) start from the character's saved defaults; when a take sounds right, **Save these settings as this character's default** keeps them for next time. The drawer is wider: the line cards get the full height with generating in a column beside them, and in a narrow or docked browser the drawer covers the studio with generating below the cards; either way only the cards scroll, however long the script is. Needs Anomalous_TTS interface 11.
- **Pronunciation table (读音表)**: **Pronunciation** on a GPT-SoVITS character card lists what the script says and what the character should read instead (`C站 → 西站`); each line can be heard as the character reads it now and as replaced, without filling the audio gallery.
- **Audio page tour (音频页遮罩导览)**: the tour offered in the audio page's "!" guide now walks through the audio page itself (import, dragging a character onto a node, emotions and pronunciation, emotion tags, Voice-over, audio gallery, GPT-SoVITS settings) instead of the image-side tour, whose buttons are not there. Tour steps whose button is not on screen are skipped.
- **Suggested folder layout (推荐的文件夹放法)**: the "many characters" import screen shows, folded, how to arrange a folder so it sorts cleanly: one folder per character, named after it, with its GPT and SoVITS models and 3–10 s clips (and same-name line files) inside.
- **Emotion tag buttons (情绪标签按钮)**: a clip's copy button now reads "Copy {happy}" and its tooltip says what pasting it into the speech text does; characters with only a main voice have no copy button, since their text needs no tag.
- **Audio page tabs (音频页顶部标签)**: the "Audio Workflows" tab is gone from the audio page; generating now happens on the Voice-over page, and saved workflows stay under **Workflows** on the image side.
- **Voice-over (配音)**: paste a script for one character; each sentence becomes a card with emotion chips, inline editing, split at cursor, merge and delete. Generate it right there (below), or push the tagged script to the selected GPT-SoVITS node or drag it onto one.
- **Audio gallery (音频库)**: lists generated audio with its speech text, voice and seed read from the file itself, supports search, and can save unsaved previews into `output/audio/`.
- **Clear drag rules (拖动规则明确)**: drag a character card's header onto a GPT-SoVITS node to set its character. The hint says what releasing will do; unsupported nodes are refused with the reason instead of being guessed.
- **Usage guide (使用说明)**: in the audio page, the header **!** opens a five-step how-to; the full guide is `docs/guides/audio-studio.md`.
- **GPT-SoVITS characters (GPT-SoVITS 角色)**: the audio page manages characters of the separate Anomalous_TTS node pack, detected at runtime; while it is missing, the page shows what it does, how to install it (ComfyUI Manager or the project page) and nothing else depends on it. The node's character menu has **Import or edit characters**, which opens this page on that character. Preview reference clips, pick a reference clip per emotion ("Edit emotions", saved through the node's API), drag a character onto the node, and write or generate scripts on the Voice-over page.
- **Audio sidebar (音频侧栏)**: characters are grouped by language in foldable groups; a character unfolds into its clips, and a click plays one. Characters drag onto nodes from the sidebar too, a red dot marks characters that need a look, a search box appears from six characters on, and files dropped on a character open **Add files** for it.
- **GPT-SoVITS setup and import (GPT-SoVITS 准备与导入)**: an empty studio is a drop area with **Import character**, which also sits in the toolbar. **GPT-SoVITS settings** at the bottom of the sidebar opens a dialog with where characters are kept, pretrained files and missing Python packages; missing pretrained files only get a small dot on that entry, with "don't remind me", once your characters need them, and the import form notes what the chosen language will download. **Batch import (批量导入)**: the import window takes one or many characters. It first asks what you have (one character, many characters or a package, or files for a character you already have), with three steps at the top and a spotlight tour the first time ("?" replays it). Each character card leads with a checklist that lights up the next step (GPT model, SoVITS model, clips, main voice, name) and offers its button right there; the card says how many steps are left. Drop files or whole folders (a GPT-SoVITS package too), or pick them in the browser; everything is uploaded to the node: a folder holding one set of GPT + SoVITS weights and its clips is one character named after the folder (two characters whose weights are both called `model` stay apart; `派蒙/v2` becomes "派蒙 v2"); weights in a package's weight folders pair up by name (latest epoch kept); clips and line files follow their character folder, the weights in their folder or the character their folder or file name points at; an annotation file (`.list`) no character claims goes to every character; two clips with the same name (`开心/01.wav`, `难过/01.wav`) both go in, the second as `难过_01.wav`; anything unclear waits in a card of its own, one line per folder that can be assigned at once, and is uploaded only once it is put into a character. Clips outside 3–10 s are left out with their line files. Python environments and base models (`runtime`, `pretrained_models`, `venv`…) are skipped, and a package's program and training folders (`GPT_SoVITS`, `logs`, `output`, `TEMP`, `tools`) only inside a package: your own folders called `output` or `GPT_SoVITS`, and the folder you chose or dropped, are always read; the window says which folders were skipped instead of files going missing quietly. A big add opens 20 characters (about 800 files) at a time; **Next batch** opens the rest, and it opens by itself once a batch is imported, so a huge folder does not freeze the window. Every clip has **▶** to listen while importing — uploading or unassigned — and clicking another clip switches to it for quick comparing. Long clip lists show the first 60, and a very large upload is confirmed first; uploads run three at a time. Each character is a card: one line with its name, a short status and what it holds, unfolding into its model tiles and clips; files move between characters with the picker on each row. **Import N characters** imports each ready one on its own, so one failure leaves the others alone. Status is colour-coded throughout: green ready, amber still missing something, red a failed or refused file, blue uploading; **Import** stays at the bottom. Adding to a character skips files it already has and merges annotation files (Anomalous_TTS interface 5). Characters are kept in one storage place (default `models/gpt_sovits`). It, other character folders and GPT-SoVITS packages to take pretrained files from are set in Anomalous_TTS's settings file, which the dialog names with a copy button: the node takes no folder or file path on this computer from the page, so a page cannot choose where folders are made or which files are read. Download pretrained files with progress. **Import character** builds a character from dropped or picked GPT / SoVITS weights, reference clips and annotation files: the name, SoVITS version, clip lengths and lines are filled in automatically, and everything is copied (originals stay). Drop files on a card, or use **Add files**, to add clips and emotions to an existing character. Lines are found in same-name `.txt` / `.lab` files, annotation files or, as a marked guess, the file name; a text file dropped on a clip's line box fills it. Needs Anomalous_TTS with interface version 13; file actions only work on the computer running ComfyUI.
- The first "Import character" on a fresh install no longer says the storage place cannot be written to: the voices page asks GPT-SoVITS again before refusing, instead of trusting what it heard up to a minute earlier.
- **A character voice dropped on a TTS node stays put (拖到 TTS 节点的角色声音刷新后还在)**: the same gap as above: reloading right after the drop brought back the node's previous voice, and Ctrl+Z skipped it. Every drop from the browser onto the canvas is now recorded as one history step.
### Fixes
- **Models found offline no longer get their name doubled (离线推断的模型名字不再越叠越长)**: a model Civitai had no record of (or a scan without a connection) is named after its file, as both model and version. With renaming on, a scan renamed it to name_name, and each later scan doubled it again; the details showed "name - name". Such models now keep their file name, a doubled name set by an earlier virtual rename is dropped at the next scan, and the details show the name once. Files already renamed keep their current name (scanning again no longer lengthens it).
- **The Workflows page was blank after opening a workflow (在新画布打开后再打开插件是空的)**: opening a workflow on the canvas (Open on a card or in its details) and then the browser again showed an empty page. The Workflows list is there again.
- **Language follows ComfyUI again (语言跟随 ComfyUI)**: with the language left on "Follow ComfyUI" (the default), changing ComfyUI's language now switches the browser the next time it opens; before, it kept the old language until the page was reloaded.
- **Empty list on the audio pages (音频页左边列表是空的)**: after reloading ComfyUI, a browser that reopened straight onto Voices or Audio showed an empty list column until you switched to the image side and back.
- Prompt Studio's new card form showed "cancel" instead of 取消 on its Cancel button.
- All-in-one loader recipes and cross-node prompt injection use one verified adapter table; prompt text never crosses between positive and negative.
- The Workflows and Combos workspace no longer covers the header: the Models / Gallery / Workflows tabs stay clickable while it is open, and **Esc** closes it (back to what was open before) unless a dialog is above it or a text field has focus. Closing the Prompt Translator brings back the browser it folded away.
- **Gallery pictures dragged to the canvas open their workflow again (图库拖到画布又能直接打开工作流)**: since the gallery shows small thumbnails, Chrome handed ComfyUI the thumbnail, which has no workflow in it, so only a Load Image node appeared. The original is dragged now: on an empty spot it opens the workflow, on a Load Image node it becomes that node's picture (full size, not the thumbnail).
- **The picture details' thumbnail rail keeps its place (查看参数左边的缩略图不再重新翻一遍)**: picking a picture near the end loads the next gallery page; the rail used to be rebuilt and scroll down again from the top, now the new pictures are added below.
- **Back and the tabs work on a scrolled page (页面往下滚后,返回和顶部标签点得动)**: on a combo's page and on a Workflow Recipe's details, once the content was scrolled under the header, clicks on **‹ All combos**, **← Back to recipes** and the recipe's tabs went to the model tiles or the cover hidden behind them (Chrome still counted those as being there). Both pages now give the click to what you see.
- **Picking a model keeps your place (换模型时列表不再跳回顶部)**: in Current node's model picker, choosing a model further down redrew the whole list and scrolled back to the top (the model was chosen all the same). Only the chosen card's outline changes now.
- **A combo is saved once, not once per tag (搭配不再一下存几十次)**: opening a combo's bilingual tags saved it once for nothing, then once more for every tag's translation as it came back, so a long prompt wrote the file (and the activity log) dozens of times in a second. The translations are now saved together, half a second after the last one.
- **Activity shows where a long prompt changed (操作记录能看到长提示词改了哪里)**: values are kept to 400 characters, cut from the start, so an edit late in a long prompt showed the same text before and after. A long before/after pair is now cut around its first difference.
- **Keeping a picture no longer changes the canvas (收藏图片不再改动画布上的参数)**: keeping a picture with ☆ as a workflow or a combo reads its workflow off the canvas, but when the canvas had the same workflow open (a picture dragged in, say), ComfyUI took the two for one and the canvas's values became the picture's. The workflow is now read under a name of its own.
## v1.57.3 Beta (Hotfix: Offline Scan, Prompt Workshop) — 2026-09-29
### 🔌 Offline scan and small fixes (离线扫描与小修复)
- **Offline scan stays offline (离线扫描不再联网)**: "Offline fast read" no longer sends the greyed-out rename and overwrite options, and an offline scan never fetches Civitai model pages or preview images, even for models whose `.info` came from Civitai. Scan requests without options are refused instead of starting an online scan.
- Clicking the drop zone at the end of the Prompt Workshop's assembly track focuses the card search again instead of failing silently.
- Malformed requests to translation, deletion, scanning and image-material routes get a clear 400 instead of a server error.
## v1.57.2 Beta (Hotfix: Canvas Performance, Saving Workflows) — 2026-09-27
### 💾 Saving workflows and Prompt Notes (保存工作流与提示词笔记)
- **Saving a workflow works again (保存工作流失败)**: since v1.57, **Save Current Workflow** failed with "Could not save this Workflow Recipe" for any workflow with a model loader, because splitting the backend into smaller files left the save path calling a function that was no longer imported. The same split also made model cards load full-size cover images instead of thumbnails (slow on big folders), and made editing details of a model that had moved fail.
- **Prompt Notes with UNet models (提示词笔记与 UNet 模型)**: sending a note with a UNet / diffusion model to the canvas built a graph that could not run, because its LoRAs waited for a CLIP the UNet does not have. LoRAs of a UNet now load model-only, and the note says to connect your own text encoder to the two prompt nodes. Models whose metadata names no base model (most models not downloaded from Civitai) were missing from every group; they now appear under **No base model listed**. Listing models no longer blocks ComfyUI while it reads the folders.
- Installed v1.57.2 before 2026-09-27 through the version panel? Pick v1.57.2 there once more to get this build; updating through ComfyUI Manager gets it as usual.
### ⚡ Smooth canvas dragging again (画布拖动卡顿修复)
- **Dragging and zooming the canvas no longer slows down with this plugin installed (安装插件后拖动画布掉帧)**: one stylesheet rule, which only hid the sidebar tooltip while the Toolbox or Settings popup was open, made the browser re-check the whole page every time any element's inline style changed. ComfyUI moves every text-box widget on every frame while you pan or zoom, so each frame paid for it, even with the browser closed. Measured on a 300-node workflow in ComfyUI: style work while panning drops from about 11× the no-plugin cost back to the same as without the plugin.
- **Large workflows with ComfyUI Prompt Assistant (与提示词小助手同时安装时的严重卡顿)**: the cost grew with the number of page elements, and Prompt Assistant adds a row of buttons to every text box, so together style work took about 19 ms per frame (more than a whole 60 fps frame): single-digit frame rates or freezes on big workflows. It is now back to the level of Prompt Assistant alone. The two plugins never conflicted otherwise.
- The tooltip still stays hidden while the Toolbox or Settings popup is open; the popups already hide it when they open and it does not reappear until they close.
### 🧩 Material Library with a node selected (选中节点时素材库卡片错位)
- **One apply button per card (每张卡片只有一个应用按钮)**: with a node selected, the Material Library showed every card's **Apply Scheme** button twice, each with a doubled ⚡ icon.
- **No more overlapping text (卡片文字不再重叠)**: the extra button squeezed the badges, date, node types and tags on top of each other. Cards in apply mode now get a taller row with the button at the bottom; applying works as before.
## v1.57.1 Beta (Version Panel: Update, Switch & Roll Back) — 2026-09-23
### 🏷️ Version & Updates Panel (版本号与一键更新 / 版本切换)
- **Version entry in the update guide (更新引导顶部的版本号与更新入口)**: the installed version (e.g. `v1.57.1-beta`) and a **Check for updates / switch version** button now appear at the top of the update guide (opened via the header **!** button), avoiding accidental clicks near the tool dock or crowding the header in docked/scaled layouts. Reading it is local only; nothing goes online.
- **Manual "Check for updates" (手动检查更新)**: the plugin never checks by itself. Clicking **Check for updates / switch version** opens the panel, and only the **Check for updates** button contacts GitHub. Each published release shows its notes, date and a GitHub link; pre-releases are hidden by default and are never offered as the latest version.
- **One-click update to published releases (一键更新到正式发布版本)**: updates only target releases published on GitHub, not in-progress commits, so what you install is a released build.
- **Switch or roll back (切换 / 回退版本)**: any published release can be installed from the list. Rolling back warns that older versions may not read settings or recipes saved by newer ones; nothing is deleted, and it all works again after returning.
- **Back to latest & undo (回到最新版 / 撤销上次切换)**: **Back to latest** returns to the default branch and pulls the newest code; **Undo last switch** returns to exactly where you were before. Versions older than v1.57.1 have no panel, so from those use **Update** for this plugin in ComfyUI Manager to come back.
- **Safe by default (安全保护)**: every switch is refused while plugin files have local modifications (the files are listed), and a local branch with unpublished commits is never overwritten.
- **Restart (重启生效)**: after a switch the panel offers to restart ComfyUI through ComfyUI Manager; without Manager it asks you to restart by hand.
## v1.57.0 Beta (Toolbox Architecture, Gallery Search & Polymorphic Drag) — 2026-09-23
### 🎨 Pure Vector Iconography Overhaul (纯代码矢量图标体系全面升级)
- **Universal Code-Native SVG Icons (全套规范化纯代码矢量 SVG 图标)**:
- Completely phased out legacy emojis and pixel icons in favor of lightweight, mathematically crisp 24x24 code-native vector SVGs (`stroke-width: 2`, rounded joins/caps).
- Redesigned the floating entry trigger orb with the **Anomalous Hypercube Core** (`1b985ba`) and frosted glass lighting effect, maintaining smooth rendering and subtle hover micro-interactions.
- Sidebar and toolbox action buttons upgraded with distinct vector geometry: 3D Asset Cube for Toolbox, Radar Pulse for Scan Wizard, Stethoscope for Model Doctor, Four-Point Spark for Node Assistant, Layered Sheets for Material Library, and Obsidian Gear for Settings.
- Eliminated icon flicker, CSS pseudo-element clipping, and alignment shifts across all display scales.
### 🔍 Multi-Dimensional Gallery Search (历史出图图库多维检索与标签块)
- **Multi-Field Real-Time Search (提示词/模型/种子/哈希多维检索)**:
- Upgraded the native output gallery header with an intelligent search bar capable of parsing prompts, model file names, exact numeric seeds, and cryptographic SHA-256 model hashes simultaneously (`1476faf`).
- Search terms are dynamically converted into visually distinct, standalone **Search Blocks (搜索标签块)** (`e077689`).
- Each search block clearly displays its matched category and provides an accessible `×` button for one-click removal, enabling flexible compound filtering without retyping.
- Backed by optimized chunk parsing (`api/image_search.py`) that reads only header metadata prior to pixel buffers, ensuring zero-lag gallery navigation.
### 🚀 Polymorphic Canvas Drag & Drop (全域多态画布拖拽架构)
- **Universal Canvas Drag (全域多态画布拖拽)**:
- Dragging workflow materials onto empty canvas automatically reconstructs and opens the full workflow.
- Dragging prompt-only materials onto empty canvas auto-creates native `CLIPTextEncode` nodes with pure prompt text, bilingual titles, and standard ComfyUI dark theme colors (`#532323` for negative / `#235327` for positive).
- Dragging materials onto existing canvas nodes prioritizes the target node, injecting matching widget values and prompt text via semantic widget sniffing while strictly filtering out model file paths and loader blocks.
- **Floating Trigger Pre-Mount & Anti-FOUC (悬浮入口预挂载防闪烁与安全吸附)**:
- Completely eliminated FOUC flicker on page refresh via pre-mount positioning and smooth opacity reveal (`53c9f1b`).
- Hardened PointerEvents persistence, safe coordinate validation, and boundary clamping to prevent trigger entrapment or collapsing to the viewport origin (`09b06d1`).
### 🧰 Toolbox Catalog & Dedicated Bottom Dock (实用工具箱与底部固定快捷栏架构)
- **Dedicated Bottom Action Bar (结构化底部快捷栏)**:
- Streamlined the lower-left sidebar into a dedicated 6-button utility bar:
1. **Toolbox (🧰 实用工具箱)**: Anchored as the primary launcher on the left to access secondary tools.
2. **Scan Wizard (🔄 扫描向导)**: Direct shortcut to launch model library scanning.
3. **Model Doctor (🩺 模型医生)**: Direct shortcut to diagnose and auto-heal missing workflow models.
4. **Node Assistant (🤖 节点助手)**: Direct shortcut for canvas node inspection, model swapping, and parameter injection.
5. **Material Library (✨ 素材库)**: Direct shortcut to manage curated material bundles and polymorphic canvas drag-and-drop.
6. **Settings (⚙️ 设置)**: Fixed anchor on the right for interface preferences.
- **Toolbox Modal Catalog (实用工具箱浮窗面板)**:
- Clicking the first icon (**🧰 实用工具箱**) opens a dedicated popup panel housing 5 secondary tools not on the main dock:
1. **Workflow Transfer Center (导入导出)**: Lossless AMB format workflow share code import and export.
2. **Prompt Studio (提示词工坊)**: Modular prompt mixer deck for lego-block prompt extraction, reordering, and assembly.
3. **Prompt Translator (翻译助手)**: Bilingual real-time English/Chinese prompt translation.
4. **Model Source Hub (模型来源)**: Online model source URL inspector with instant canvas Note node generation.
5. **Prompt Notes (提示词笔记)**: Lightweight notebook for drafting and organizing reusable prompt snippets (`workflows/anomalous_notebooks`).
- **Enhanced Sidebar Tooltips & Reveal Timing (侧边栏操作提示与动画优化)**:
- 100ms quick hover text replacement on shortcut buttons and 600ms rich singleton floating tooltip bubble (`#anomalous-sidebar-tooltip-bubble`) with bold title and muted explanation, cleanly avoiding CSS pseudo-element clipping issues.
- Clicking or pressing any action button immediately suppresses hover text and floating tooltips.
- Maintained elegant obsidian glass / dark monochromatic aesthetics without harsh or distracting bright colors.
### Navigation and update guide
- Sidebar icons reveal compact text labels on hover or keyboard focus while keeping their existing button size and actions. Detailed hints explain each tool; scan-state and language changes retain labels. Unclipped tooltips ensure explanations are fully readable.
- Update guide opens only from the persistent top-right exclamation button or Help. Dismissing never removes the entry. Sidebar labels slide upward on hover/focus; duplicate source-panel and obsolete notice styles were removed. See `docs/architecture/update-guide.md`.
### Model Source Hub (模型来源中控中心)
- Added a unified Model Source Hub under Toolbox for dual-scope (current workflow and full library) model source URL inspection, one-click browser navigation to Civitai/HuggingFace/Liblib/ModelScope, custom source editing, workflow metadata synchronization (`extra.anomalous_model_sources`), and instant canvas `Note` node generation for non-intrusive workflow documentation.
### Export availability
- Removed Prompt Studio's JSON export action and closed the Material Library file-import entry; copying prompts and local saves remain available.
- Recipe package import/export is disabled in the UI and API, including both upload inspection and import commit. The verified AMB0/AMB1 workflow Import / Export Center is restored in Toolbox as the only active import/export center. Ordinary downloads and image/workflow hash metadata are unchanged.
- Studio source cards automatically follow library additions, edits and deletions, display their origin, and cannot be deleted from the studio. Failed synchronization preserves existing cards; mixer copies remain editable.
### Material Library
- Fixed grouped material filters, Recipe scope/tag filters, and prompt studio imports using actual detail payloads. Fixed side-studio draft reset/return state and guarded recipe drops against canvas changes. Recipe model status no longer treats saved hash verification as current availability.
- Separated the lower-left Material Library from the Recipes/Prompt Notes workspace. Added canvas drag-and-drop: parameter materials replace matching node values, while the simplified prompt panel inserts text at the beginning or end, with undo.
- Material Library now opens from the lower-left control; Workflow Recipes stays at the upper right. The workflow Import / Export Center is available in Toolbox.
- Added Prompt Combinations: ordered general/specific fragments, enable switches, separate positive/negative content, and saved snapshots.
- Library cards can apply matching parameters to one selected node through the same transaction as Node Assistant, with seed preservation, native-option validation and guarded undo. Prompt combinations support explicit text replacement or append.
- Material details initially expand prompt cards only; other node parameters render on first expansion. Details omit the complete source workflow, which loads separately when opening a full snapshot. Selected-node materials never return their hidden source graph.
- Material capture supports PNG workflows, Recipe parameters and Prompt Notes. Applying reference images to LoadImage remains future work.
- Added search by name, tag, or node type, type/tag filters, and pagination. Material names and tags can be edited in place without changing their workflow.
- Saving the same source image and node selection now prompts before creating another copy. Save labels distinguish full image/workflow snapshots, node parameters, and Prompt Notes.
- Shared parameter inspection between the library and image workbench, improved keyboard card access and clipboard failure feedback, and fixed confirmation dialogs being covered by the workbench.
### Reliability
- Material browsing reuses signature-validated summaries and reads full workflows only for matching node types. Gallery pagination shares a short-lived directory snapshot; refresh and deletion invalidate it immediately.
- Prompt Notes now use the ComfyUI user directory with recoverable legacy copying, atomic saves, and visible save failures. Material saves stage their files and roll back new assets if persistence fails.
- File identity now uses full-file SHA-256 only. BLAKE3 and tensor/header digests cannot become verified file identities. Older offline metadata is reverified on demand while preserving personal notes.
### 🩺 Model Doctor
- **Manual size fallback (模型大小手动兜底)**: A unique same-size local model is now presented for explicit confirmation instead of being applied automatically. Background checks never apply it, while hash/size identity conflicts are rejected and shown in the Doctor dashboard.
### 🧰 Workflow Recipes
- **Scope-aware recipes (局部/整体配方)**: Graphs with open required connections or no runnable output are saved as partial recipes and append to the current canvas. Independently runnable graphs are saved as complete recipes and open in a new workflow canvas.
- **Optional save-time model verification (保存时可选模型核验)**: Recipe saving now performs a lightweight cached-identity check. Recognized model references missing identity are listed in the save dialog, while full SHA-256 calculation remains explicitly opt-in. This is especially useful for hash-only Model Doctor categories such as VAE and text encoders.
- **Model lifecycle consistency (模型生命周期一致性)**: Partial append remaps and merges node-scoped model Hash records, while applying a local model match updates the workflow value, structured model reference, base-model summary, and Hash index together.
- **Recipe-scoped model notes (配方模型备注)**: Each model reference can keep a bounded personal note. Package export asks whether to include these notes, and version comparison reports note and identity changes.
- **Material Library MVP (统一素材库 MVP)**: Main Gallery and Recipe gallery images now expose a non-intrusive two-column parameter detail view and can be saved as one curated bundle containing the original image, exact embedded workflow, model provenance, and reusable node blocks. Core metrics remain concise, while a lazy two-level inspector exposes every serialized node widget value, node properties, mode, and volatile-parameter marker on demand. Every node card now has a direct “Save to Library” action; checked nodes share a colocated multi-save action above the list, while the footer is reserved for the full image/workflow snapshot. One generation-settings action saves the sampler and size nodes together, and prompt cards expose topology-based positive/negative detection, manual role overrides, and direct node saving. Saved Material details preserve those overrides and allow returning to automatic detection. Workflow Recipe parameters and saved Parameter Notebooks can now publish all widget-bearing nodes, one node, or an explicit node selection as image-free Recipe Parameter materials. The ambiguous plain-text share action has been removed in favor of a visible Material Library save entry. Focused node materials feed Node Assistant without exposing a misleading full-workflow action. Saved Material cards stay compact and open a focused in-library inspector with the uncropped reference image on the left and scoped model/node parameters on the right; returning releases the full response and detailed DOM. Detail navigation bounds parsed metadata to a 16-entry LRU, cancels obsolete reads, avoids a second full-PNG browser allocation, removes duplicated widget payloads, and defers local-model preview resolution until the Models tab opens. Main Gallery keeps its existing click, drag, delete, and cover-selection behavior. The Workspace Material Library opens complete snapshots in a new canvas, while Node Assistant filters matching blocks by the selected node type, skips volatile seeds, and hands remapped Hash evidence to Model Doctor.
### ✨ Material capture and simpler pages
- 图片、配方参数与提示词笔记均可保存到素材库,保存后直接查看。提示词笔记支持整份组合或仅提示词,并可从素材载入为新笔记。
- 图片保存表单、用途调整、节点技术信息、笔记模型区与配方多选改为按需展开;配方参数页去掉重复数值,统一使用“参数方案”名称。
### ✨ Interface & Access
- **Native Configurable Shortcut (原生可配置快捷键)**: Added `Ctrl + Shift + M` as the default shortcut for opening the browser. The plugin's Interface settings show the currently active binding and open ComfyUI's native key recorder for customization. Conflict detection, overwrite confirmation, removal, reserved-key guidance, and modal guards therefore remain centralized without a separate global keyboard listener.
### 🐛 Bug Fixes
- **Prompt Studio Preview Popover Safe Zone (提示词预览浮窗全域安全区与防消失机制)**: 解决鼠标从词卡划向右侧浮窗按钮过程中浮窗提前消失的问题。引入全局动态指针安全区判定(`isPointInSafeZone`),将卡片、浮窗(向外各扩展 40px)与空间通道全部纳入安全驻留区,光标移动即实时取消关闭计时;并在定时器回调中执行终审坐标校验;结合 100ms 展开防抖与 `[📌 固定]` 钉住功能,确保用户可从容点击复制与添加按钮。
- **Live Language Switching (语言即时切换)**: Changing the plugin language now immediately re-translates the open native Interface settings—including labels, tooltips, and combo choices—as well as the browser UI, without refreshing ComfyUI.
## v1.56.1 Beta (Interface Preferences & Scan Reliability) — 2026-08-26
### ✨ Interface & Access
- **Native Interface Preferences (原生界面偏好)**: Added one native ComfyUI entry-mode setting with three strictly exclusive choices: floating button, action-bar button beside the run controls, or Extensions menu only. Floating size/style controls now appear only when floating mode is selected. A colocated language preference follows ComfyUI by default or applies a plugin-only Chinese/English override, and the existing in-browser language shortcut updates that same native setting. Entry selection remains authoritative during action-bar redraws, and every entry reuses the same browser instance and open path.
### 🐛 Reliability & Scan Feedback
- **Interrupted Scan Recovery (异常扫描自动恢复)**: Closing ComfyUI during a folder or global scan no longer leaves the browser permanently locked. Versioned scan ownership records distinguish live workers from stale or legacy markers, clean interrupted state automatically, and allow an immediate retry without reinstalling the extension.
- **Real Scan Progress (真实扫描进度)**: Folder, custom-selection, and global scans now show a non-blocking localized progress panel backed by the scanner's actual folder/model counts and current filename. Reopening the browser reconnects to the running task through the existing status polling contract.
## v1.56 Beta (Model Doctor Intelligence & Smart Civitai Routing)
### 🚀 Major Improvements & Features
- **On-Demand Dynamic Hash Resolution (按需动态算哈希与智能认亲)**: Upgraded Model Doctor's fallback resolution engine. When a local candidate model matches the exact byte size of a missing reference but lacks local metadata (e.g., after renaming and deleting `.info` files), the backend dynamically calculates its SHA256 on demand. Upon cryptographic verification, the node is seamlessly resolved and an offline inferred `.info` record is saved locally.
- **Dedicated Hash Inspection Modal (🔑 查看哈希透视详情)**: Added a dedicated `🔑 查看哈希 (View Hash)` button on every model card in the Doctor Dashboard. Clicking it opens a crystal-clear inspection modal comparing the workflow-embedded provenance hash & size with local disk hash & size, featuring one-click clipboard copying.
- **Smart Civitai by-hash Routing & NSFW Domain Dispatch (C 站精准直达与 .red/.com 智能分流)**: Upgraded the "🌐 Civitai" action buttons across Model Doctor and Workflow Import. Instead of crude search bar queries, it now queries the official Civitai `by-hash` API to resolve exact Model IDs and Version IDs, automatically routing NSFW models to `civitai.red` and safe models to `civitai.com`.
- **UNET Default Scan Coverage (UNET 目录扫描补齐)**: Added `models/unet` to the default scanned model types, ensuring UNET / Diffusion Models are consistently indexed during background deep scans.
- **Parameter Notebook Renaming (参数笔记本重命名)**: Users can now rename saved parameter notebooks directly from both the recipe sidebar list and the detail heading via inline `✏️` action buttons and modal prompts, safely updating names with atomic writes without disrupting parameter provenance or canvas matching.
- **Cross-Platform Path Separator Normalization (跨平台斜杠全链路归一化)**: Fully unified Windows `\` and Unix `/` path handling across workflow serialization, hash retrieval, and node diagnostic matching, preventing lookup misses caused by cross-platform workflow exchange.
### 🐛 Bug Fixes & UX Polish
- Fixed a false-negative rejection issue where size-matching renamed models without local `.info` files were incorrectly rejected as `identity_conflict`.
- Fixed duplicate key icons on the Model Doctor Hash Inspection modal header.
- Fixed a loop duplication issue in the workflow import modal preflight inspection.
- Updated unit test suite with comprehensive coverage for dynamic hash resolution and unet folder discovery.
## v1.55 Beta (Workflow Recipes Preview)
> **Beta scope:** Workflow Recipes, Parameter Notebooks, and the recipe-powered Parameter Presets tab in Node Assistant are preview features. The Node Assistant's Actions tab—visual model replacement and LoRA insertion—is not part of this beta. Save the current canvas and back up `workflows/anomalous_recipes` plus `workflows/anomalous_parameters` before updating, importing, restoring, deleting, or applying recipe data. The stable target for these preview features is v1.6.
### 🚀 Major Features
- **Workflow Recipes (工作流配方)**: Save a complete workflow with a cover, name, tags, notes, model references, prompts, sampler settings, safe node parameters, and a reproducibility fingerprint. Recipes append to the current canvas without replacing it.
- **Recipe Detail Workspace (配方详情)**: Added Overview, Parameter Notebook, Gallery, and Versions tabs. Long values can be expanded or copied, model requirements are shown as separate cards, and the overview avoids exposing local model paths as display names.
- **Parameter Notebooks (参数笔记本)**: Create edited parameter notes, capture a fresh note from a matching live canvas, switch between saved notes with clear feedback, delete individual notes, and apply one note back to a workflow only after the complete recipe skeleton matches. Volatile values such as seeds are intentionally ignored.
- **Node Assistant Recipe Presets (节点助手配方预设)**: The Parameter Presets tab reads same-type node values saved by Workflow Recipes. Select a node such as KSampler and apply sampler, scheduler, steps, CFG, denoise, and other safe values in one click without replacing the rest of the workflow.
- **Model Identity and Local Matching (模型身份与本地匹配)**: Recipe model cards retain hashes, size, model category, available Civitai origin names/links, and previews. Imported recipes can match another author's model references to local files without trusting their folder paths.
- **Recipe Gallery and Parameter Comparison (配方图库与参数对比)**: Find historical outputs with the same node structure, inspect embedded generation data, compare differing parameters, and promote an output image to the recipe cover.
- **Version History (版本历史)**: Recipe updates retain bounded local history. Compare meaningful changes, restore an older version safely, and preserve the current version before restoration.
- **Portable Recipe Packages (便携配方包)**: Import or export recipes with optional preview snapshots and history. Model identity hashes can be removed for privacy. Preview snapshots contain presentation images only and never include model files.
- **Conservative Prompt Roles (保守的正负提示词识别)**: Native supported prompt paths are classified without title guessing. Unknown third-party text nodes remain unclassified until the user labels them as positive, negative, shared, or ignored.
### ✨ Interface and Interaction
- Renamed the combined Notes and Recipes area to **Creative Workspace / 创作工作台**, with separate Prompt Notes and Workflow Recipes sections.
- Recipe models, LoRAs, prompt text, common parameters, and generic node controls use dedicated responsive layouts with copy and full-content expansion where needed.
- Returning from a model detail page restores the previous recipe position instead of jumping back to the top.
- The main output gallery refreshes when opened, avoiding continuous background polling.
- Beta and data-protection guidance is available in the Recipe list, the Node Assistant Parameter Presets tab, Settings Help, and README in both Chinese and English.
### 🐛 Reliability Fixes
- Fixed a cross-panel state leak where opening a model from a Recipe Overview could overlap Node Assistant, then leave the next Workflow Recipe visit with an empty list. Returning normally, switching directly to another main panel, reopening Workspace, and closing Workspace mid-return now all restore one coherent surface.
- Fixed blank Workspace/Parameter Notebook surfaces and restored the plugin trigger when frontend module errors prevented registration.
- Fixed parameter application against current ComfyUI widget callbacks and added transactional rollback when a node update fails.
- Fixed positive/negative prompt duplication and legacy role recovery while keeping ambiguous third-party prompt nodes visibly unknown.
- Fixed panel closing and canvas handoff behavior, docked notebook confirmation controls, recipe scroll restoration, long model status text, and narrow parameter layouts.
- Added backend validation and regression coverage for recipe round-tripping, linked inputs, parameter-note deletion, model matching, and prompt-role metadata.
## v1.5.1 (The Workflow Exchange & Deep Scan Update)
### 🚀 Major Features
- **Lossless Workflow Exchange (无损工作流交互)**: Added dedicated Import/Export buttons to the bottom-left of the plugin UI. This feature allows users to directly extract, share, and import pure workflow data without relying on original PNG images, completely bypassing the metadata-stripping compression used by social platforms (like WeChat or Discord) that causes workflow loss.
- **Force Overwrite Configs (强制覆盖已有配置)**: Added a new "Force Overwrite" toggle in the Scan Wizard. When enabled, the scanner will deliberately ignore existing local `.info` files and force a fresh SHA256 hash calculation and a complete metadata pull from Civitai. This is a lifesaver for repairing incorrectly matched or corrupted model profiles.
- **Model Card Settings (模型卡片设置)**: The Settings Hub now opens one focused panel for video-cover playback (`Always play` or recommended `Play on hover`) and card image quality (`Optimized thumbnail` or `Original cover`). Existing autoplay preferences remain compatible, while image-quality choice is intentionally simple and local to the browser.
### 🐛 Bug Fixes
- **Base Model Hash Misattribution**: Fixed a critical bug in the fast scanner where it would aggressively fuzzy-match any 64-character hex string in the metadata. This previously caused LoRAs (trained via Kohya) to inherit their Base Model's hash (`ss_sd_model_hash`), resulting in totally incorrect Civitai mappings. The scanner now strictly adheres to the official `modelspec.hash.sha256/blake3` standard.
- **Lossless Cover Reset**: Reset now restores the persistent `.civitai_bak.*` copy first, falls back to a bare original cover for non-Civitai models, and preserves the active preview with a visible warning when no recoverable source exists.
- **Safe Sidecar Lifecycle**: Deleting a model cleans recognized sidecars, while renaming migrates them—including the Civitai backup—to the new stem. Same-stem model files with different model extensions are never treated as sidecar garbage.
- **Bounded Sidecar I/O**: Repeated extension literals in the API and scanner were replaced with fixed tuples and exact-path checks. Rename, delete, and reset remain constant-bounded and never scan an entire model directory.
- **Reset/Rename Extension Integrity**: Fixed a reused loop variable that could change a model's extension when cover reset and physical rename were requested together.
- **Signature-Aware Metadata Cache**: Unchanged model metadata and embedded safetensors hashes are now served from bounded caches keyed by physical file signatures. Sidecar or model changes invalidate entries automatically, while defensive copies prevent cache pollution.
- **Stable Cover Caching**: Removed per-refresh random cover URLs and switched to nanosecond file-version tokens, allowing the browser to reuse unchanged images and videos while still updating changed covers immediately.
- **Responsive Large Libraries**: Folder/model disk work now runs outside the aiohttp event loop, current-folder discovery uses one `os.scandir()` inventory, stale requests are aborted, and cards render in animation-frame chunks with lazy media activation.
- **Batched Model Doctor Resolution**: Provenance-rich workflows skip a redundant full hash-cache walk, then resolve missing nodes through a type-grouped batch endpoint so each required category is walked once per repair pass. Legacy workflows still refresh the filename cache when needed. The original hash/size/type/ambiguity rules remain unchanged, with automatic fallback to the single-item endpoint.
- **Direct Preview Fast Path**: Visual replacement resolves contained relative model paths directly and performs a full library walk only for unresolved basename fallbacks.
- **Balanced Cover Memory Use**: Static grid covers default to cached 512px WebP derivatives while detail views retain the original. Derived files are stored in ComfyUI's temporary area, capped at 256 MiB, and never alter source covers. Closing the browser releases active media immediately and releases the warm card DOM after 90 seconds of inactivity.
## v1.5.0 (The Workflow & UI Evolution Update)
### 🚀 Major Features
@@ -115,7 +714,11 @@
- **Offline Base Model Inference**: Added a zero-API local inference engine! For models downloaded purely from HuggingFace (or private unreleased models) that return a 404 on Civitai, the scanner no longer gives up. It now forcibly parses the .safetensors structure and uses **Tensor Fingerprinting** (e.g., detecting double_blocks.0.img_attn for Flux) to accurately deduce the underlying base architecture with 100% precision.
- **Universal UI Integration**: Successfully inferred offline models are dynamically assigned a virtual .info payload (ID: -1). This instantly grants them full VIP access to the frontend ecosystem—they seamlessly appear in the Cross-Folder Radar, interact perfectly with the bilingual Notebook, and support one-click Auto-Inject loaders, all completely completely offline!
# 📈 Anomalous Model Browser Changelog
## v2.0.0 (The Workflow & UI Evolution Update)
### 🚀 Major Features
+208 -113
View File
@@ -1,159 +1,254 @@
# Anomalous Model Browser
<div align="center">
[![Changelog](https://img.shields.io/badge/📖_Read_the-Changelog-blue?style=for-the-badge)](CHANGELOG.md) [![ComfyUI Manager](https://img.shields.io/badge/ComfyUI-Manager-green?style=for-the-badge)](https://github.com/ltdrdata/ComfyUI-Manager)
# 🚀 Anomalous Model Browser
[English](#english) | [中文](#中文)
**A model browser and creative workspace for ComfyUI**
*模型库 · 出图图库 · 工作流配方 · 搭配 · 提示词 · 角色配音 · 模型检查 · 操作记录*
<br/>
[![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.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)
<br/>
[**English**](#english) | [**中文说明**](#中文)
<br/>
<img src="docs/images/en-models.webp" width="900" alt="Models with their Civitai covers, base model and real names">
</div>
---
<h2 id="english">🇬🇧 English</h2>
> A comprehensive Creative Workspace and Model Manager for ComfyUI. Features zero-dependency Civitai scraping, an auto-resolving Model Doctor, a visual Node Assistant, lossless Workflow Exchange, and reusable Workflow Recipes with Parameter Notebooks.
> **Anomalous Model Browser** takes care of the chores around ComfyUI in one window: browse your models with their Civitai covers and trigger words, look back through your outputs, keep workflows you have tuned, keep model combos and prompts, repair workflows whose models are missing, and, with the companion node pack [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS), give your characters a voice. An activity log shows everything the plugin changed.
> [!WARNING]
> **Beta features and data protection:** Workflow Recipes and only the recipe-powered **Parameter Presets** tab inside Node Assistant are currently in testing; the Node Assistant's **Actions** tab is not part of this beta. Before updating the plugin, importing someone else's recipe, restoring or deleting recipe data, or applying saved parameters, save the current canvas and back up `workflows/anomalous_recipes` and `workflows/anomalous_parameters` inside your ComfyUI user directory. Recipe model-preview snapshots do not contain model files and are not model backups.
### 🎬 Videos and discussion
* 📺 **Bilibili** (Chinese): [Video demo](https://www.bilibili.com/video/BV1boHx61Enc/) · [Character voices demo](https://www.bilibili.com/video/BV1bkpZ6uEZQ/)
* 💬 **Reddit**: [The post on r/comfyui](https://www.reddit.com/r/comfyui/s/n38ihf3DLf)
### 🌟 Core Capabilities
### 💡 What it helps with
| 🚀 Feature | 💡 Description |
**1. "Which LoRA was that again?"** A file called `download (3).safetensors` tells you nothing. Press **Scan** once: every model is looked up on Civitai by its file hash and gets its real name, cover, trigger words and base model. From then on you pick models by their covers.
<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, 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>
**3. "How did I make this picture?"** Search your outputs by prompt words, model or LoRA, then drag the picture onto the canvas: its workflow comes back.
<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 → **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>
**5. Long prompts are hard to edit.** Select a prompt node and Prompt Studio shows its text as tags: click to weight, drag to reorder, type to add (Chinese and other languages become English).
<p align="center"><img src="docs/images/en-prompt-studio.webp" width="760" alt="Prompt Studio editing a prompt node as tags"></p>
**6. Characters that talk.** With the companion node pack [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS): GPT-SoVITS characters with a reference clip per emotion, and a script page to voice them.
<p align="center"><img src="docs/images/en-voices.webp" width="760" alt="Character voices page"></p>
**Changed your mind?** Everything the plugin changes is listed in **Activity**, with **Undo** and **Find on canvas**.
<p align="center"><img src="docs/images/en-activity.webp" width="620" alt="Activity log with an undo button"></p>
**Light on your computer**: the plugin itself loads no models, so it takes no graphics memory from image generation; the browser shows small thumbnails instead of full images, and its pictures are released a minute and a half after you close it. The voice models of Anomalous_TTS give their graphics memory back whenever ComfyUI needs it for image generation, and all of it on **Unload models**.
### 📦 Install
1. In **ComfyUI Manager**, search `Anomalous Model Browser`, install it and restart ComfyUI. Or clone it into `custom_nodes`:
```bash
cd custom_nodes
git clone https://github.com/DemonGatanjieu/Anomalous_Model_Browser.git
```
2. Press **Shift + B** to open it. On **Home**, **Scan model folders** fills in covers and trigger words, and **Tour the interface** points at each part of the window.
3. Optional: for character voices, also install [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS) (search `Anomalous TTS` in ComfyUI Manager).
Scanning needs to reach Civitai. Without it, scan offline: base models are read from the files, and a later online scan fills in the rest (see **Network and backups** below).
<details>
<summary><b>📖 Every page and tool, in detail</b></summary>
Every page has an icon on the rail at the left of the window.
| Page | What you do there |
| :--- | :--- |
| **Comprehensive Model Management** | Automatically extract Base Model architectures, cover images, trigger words, and author info by scanning model hashes. Customize model names, notes, and covers. Safely delete unwanted models or quickly replace them visually with cover icons. |
| **Workflow Node Repair (🩺)** | As long as a workflow/image was exported with this plugin active, the **Model Doctor (🩺)** can auto-detect missing nodes and replace them with correct local paths. Stubborn models? Use "Deep Hash Scan" or get Civitai links. |
| **Node Assistant & Recipe Presets (🤖)** | Select a canvas node to visually replace a model, safely insert a LoRA into a compatible chain, or load same-type node parameters saved by Workflow Recipes. A saved KSampler note, for example, can apply sampler, scheduler, steps, CFG, and denoise in one click while volatile seeds are ignored. |
| **Workflow Recipes (🧰)** | Save a complete workflow with a cover, notes, tags, model identity, prompts, sampler settings, node parameters, matching output images, and version history. Append recipes to the canvas, compare output/version parameters, match foreign model references to local files, and import or export portable recipe packages. |
| **Smart Notebook (📑)** | Leveraging scanned local architectures, it offers rapid matching between Checkpoints, UNet, and compatible LoRAs. Features a built-in translation tool, auto-chunks prompts into tags, and supports 1-click canvas deployment. |
| **History Gallery (🖼️)** | Natively reads your local `output` folder. Supports 1-click viewing, deleting, and mouse-wheel zooming. Drag and drop any generated image directly onto the canvas to instantly load its embedded workflow! |
| **Additional Features** | Click the top-right `➕` of any model card to deploy it to the canvas. The UI supports side-docking to leave space for your canvas operations. |
| **🏠 Home** | Pick what you want to do: a card per task, first steps (scan your model folders, a guided tour of the interface) and your latest activity. |
| **🕘 Activity** | Everything the plugin changed, by day: on the canvas (which node settings changed from what to what, nodes added or removed, workflows opened) and in files (models, covers, recipes, materials, notes, images, audio). **Find on canvas** jumps to the node; a canvas change can be undone with **Undo** until something changes it again, the rest is recorded only. Your own edits are not listed. |
| **📦 Models** | Your models with covers, trigger words and base model. Chips at the top switch between types (Checkpoint, LoRA, VAE…) and list a whole type, subfolders included; the folder list narrows it to one folder. **+** adds a loader node to the canvas; edit a model's name, notes and cover, or scan just that model. |
| **🖼️ Gallery** | Your ComfyUI `output` folder. Search by prompt, model, LoRA, seed, file name or model hash; open an image to see the parameters it was made with, or drag it onto the canvas to get its workflow back. **☆** keeps a good one as a whole workflow, a combo (model, LoRAs, prompt) or just its prompts. |
| **🪡 Workflows** | Workflow recipes: save a whole workflow or a part of one together with its models, cover, notes and parameters. Each card shows whether the models are on this computer; drag it onto the canvas to load it. Versions can be compared and restored. A card's export button makes a **recipe package** (.zip) to give to someone; dropped into their ⇅, it becomes a recipe card. |
| **🧩 Combos** | A main model, a few LoRAs and a prompt you like, kept together. **Put on canvas** (or dragging the card onto the canvas) lays them out as a wired group of nodes; nothing already on the canvas changes. |
| **✍️ Prompts** | Prompt Studio, a page of the browser; **Beside the canvas** moves it next to the canvas to see both. Select a prompt node and its prompt boxes appear as tags, edited in place: click a tag to make it stronger or weaker, double-click to edit, drag to reorder, type to add (Chinese, Japanese and other languages are translated to English), and show each tag's meaning in Chinese, Japanese, Korean and other languages. Click a card (a few common ones, plus your saved prompts) to add it, or drag it straight onto a prompt box on the canvas (outlined green for positive, red for negative) or onto empty canvas for a new prompt node. With no prompt node selected you write a draft. |
| **🎙️ Voices / 🎧 Audio** | With [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS) installed: import GPT-SoVITS characters, pick a reference clip per emotion, fix pronunciations, write a script under Voice-over on the same page and generate it right there; generated audio is listed in the audio gallery. Without it, the page explains how to install it and nothing else depends on it. |
### 📖 Step-by-Step Operating Guide
Tools on the rail:
#### 1. Initialization (Scan Wizard) 🔄
* **Location**: The Scan icon (**🔄**) at the bottom left of the plugin interface.
* **Operation**: This is the most important prerequisite. Open the Scan Wizard, select your configuration, and execute the scan. Ensure your network is connected.
* **Result**: This activates all core features of the plugin by building your local model database.
* **Scan** reads your model folders and fetches covers, trigger words and base models from Civitai (by file hash, no extra Python packages).
* **Model Check** (formerly Model Doctor) checks each workflow you open: when models are missing, a bar over the canvas says so and puts back, with one press, the ones you have under another name or folder, recognised by the fingerprints (hash and file size) the plugin records in workflows and images (**Settings → Record model fingerprints in workflows**, on by default); without them, a file of the same name is offered for you to confirm. Its page lists the rest with what you can do.
* **Current node** (formerly Node Assistant) shows the model of the node you select, lets you swap it from the covers or insert a LoRA, and lists every set of values saved for that kind of node with exactly what applying it would change; saved values can be deleted there.
* **Each tool on its page**: share and import on Workflows (⇅: a short share code of the canvas workflow, in Chinese characters or letters; paste a code, or drop a workflow file, an image or a backup .zip), Model Sources (where each model can be downloaded) on Models and in Model Check, translation on the prompt boxes in Current node.
* **Settings** (at the bottom): language, font size, thumbnails, video covers, which model folders to show, and feedback. Home and Settings have **Report a problem**, which opens a GitHub issue with your versions and GPU already filled in.
#### 2. Workflow Repair (Model Doctor) 🩺
* **Location**: The second Stethoscope icon (**🩺**) from the bottom left.
* **Operation**: When you import someone else's workflow and the nodes turn red due to missing paths, click the Model Doctor. It will intelligently identify all broken nodes and replace them with your correct local paths.
**AI apps (MCP)**: Claude, Cursor, Cherry Studio and other apps that support MCP can use your library through `http://127.0.0.1:8188/anomalous/mcp`: answer questions about your models, outputs and saved prompts, and change the open workflow for you (prompts, models, LoRAs, combos, missing models), each change one Ctrl+Z step. Local only; setup in [docs/guides/mcp.md](docs/guides/mcp.md).
#### 3. Visual Swapping (Node Assistant) 🤖
* **Location**: The third Robot icon (**🤖**) from the bottom left.
* **Actions**: Select a node on the ComfyUI canvas. For model nodes, the Actions tab can visually replace the current model or insert a LoRA before/after a compatible MODEL + CLIP chain. Picker cards support previews, model-category/base-model badges, folders, full-path search, sorting, and safe handling of ambiguous branches.
* **Parameter Presets (Beta)**: Switch to **Parameter Presets** to read parameter notes associated with Workflow Recipes. Presets are grouped by recipe and contain saved values for the same node type as the selected node. Clicking a preset applies it transactionally to that one node; runtime-changing values such as seeds are ignored.
</details>
#### 4. Workflow Recipes & Parameter Notebooks 🧰
* **Location**: Open **Creative Workspace (📑)** and switch from **Prompt Notes** to **Workflow Recipes**.
* **Save and reuse**: Save the complete current graph with a cover, name, tags, notes, model references, prompts, sampler settings, and bounded node summaries. Recipes can be appended to the current canvas without replacing it.
* **Details**: Overview shows model composition and reproducibility information. Models can be matched to local files by hash, size, and model category rather than the author's path; Civitai origin names/links and local previews can be retained when available.
* **Parameter Notebook**: Browse the recipe's saved values, copy or expand long fields, create an edited parameter note, or read a fresh matching canvas into a new note. **Apply to Current Workflow** first checks that the recipe skeleton matches, then updates safe widgets; volatile values such as seeds are ignored. Stored notes can be deleted individually. Unknown third-party prompt roles can be labelled manually.
* **Gallery and Versions**: Find historical outputs with the same node structure, open an image to compare parameter differences, set an output as the recipe cover, compare recipe versions, or restore an older version while archiving the current one.
* **Sharing**: Export/import portable recipe packages with optional model-preview snapshots and history. Identity hashes can be removed during export. A preview snapshot is presentation data only—it never includes a model file.
<details>
<summary><b>⚙️ Opening, shortcuts and updating</b></summary>
#### 5. Settings Panel ⚙️
* **Location**: The Gear icon (**⚙️**) at the bottom left.
* **Operation**: Adjust the UI language and main UI font size. Open **Model Settings** to choose always-play or hover-play video covers and optimized card thumbnails or original covers. Detail pages continue to use the original cover. **Folder Manager** can hide folders from both the sidebar and background reads, and reorder them. **Help** contains the current safety and feature guide.
Open the browser with **Shift + B** (again to close), the floating button on the canvas, or **Extensions → Anomalous Model Browser** in ComfyUI's menu. **ComfyUI Settings → Anomalous Model Browser → Interface** changes the shortcut, the language and how the browser is opened (floating button, a button next to Run, or the Extensions menu only).
#### 6. Top Navigation & Prompt Notes 📑
**Location**: The top tabs: **Models (📦)**, **Gallery (🖼️)**, **Creative Workspace (📑)**, and **Dock Side (◧)**.
**Updating**: click the **!** button at the top right of the browser; it shows the current version, and **Check for updates / switch version** opens the version panel. Update to the newest release, go back to an earlier one or return to the latest, then restart ComfyUI. The plugin never checks for updates on its own.
**Prompt Notes Operation**:
1. Open **Creative Workspace (📑)**, choose **Prompt Notes**, then click the **➕ (New)** button and confirm.
2. Select a Base Model (e.g., SD1.5). You can then select compatible Main Models and LoRAs.
3. Click **📝 Edit Raw / Paste** to paste your prompts. Select the language at the top-left for bilingual translation.
4. After confirming, the plugin will automatically chunk your prompt into tags. You can find/replace or directly edit tags.
5. Click **🚀 Send to Canvas** to automatically wire and deploy the Checkpoint, LoRA, and CLIP Text Encode directly to your canvas!
</details>
<details>
<summary><b>🌐 Network and backups</b></summary>
**On a cloud server (AutoDL, RunPod…):** fully supported on overseas servers; on servers in mainland China only in part (Civitai is mostly out of reach there: lookups are unreliable, covers rarely download). Not fully tested yet (one AutoDL server so far). The guide has a section for AI assistants to walk you through it. Opened from another computer, the plugin is view only by default. Open ComfyUI through an SSH tunnel, or see [docs/guides/cloud-server.md](docs/guides/cloud-server.md).
**Network:** Scan looks models up on Civitai and prompt translation uses Google Translate, so both need to reach those sites (from mainland China usually through a proxy; when civitai.com cannot be reached the plugin tries Civitai's other address, civitai.red, which some networks reach, a cloud server in mainland China among them: names and trigger words come, covers usually not). Without them, pick **Offline** on the scan page (an online scan does the same by itself when it cannot connect): base models are read from the files, without covers or trigger words, and a later online scan fills in the rest. Downloading missing models needs the network too; for Hugging Face, **Settings → Model downloads** can use the hf-mirror.com mirror (Civitai has none). Browsing models, outputs, workflows and combos needs no network.
**Keep a copy of your own data:** **Settings → Backup → Export** packs Workflow Recipes, combos, saved prompts and node values, parameter presets, and the names, notes and covers you gave your models into one .zip; keep it on a cloud drive or a USB stick, and **Import** puts it back, also on another computer. (They live in `workflows/anomalous_recipes`, `workflows/anomalous_notebooks`, `workflows/anomalous_materials` and `workflows/anomalous_parameters` in your ComfyUI user folder.)
</details>
---
### 📦 Installation
<h2 id="中文">🇨🇳 中文说明</h2>
Open your terminal, go to your ComfyUI `custom_nodes` folder, and run:
```bash
cd custom_nodes
git clone https://github.com/DemonGatanjieu/Anomalous_Model_Browser.git
```
> **Note**: Restart ComfyUI after cloning. Alternatively, search for `Anomalous Model Browser` inside the ComfyUI Manager and click Install!
> **Anomalous Model Browser** 在一个窗口里帮你打理 ComfyUI 周边的杂事:
> - 带 C 站封面和触发词浏览模型;
> - 翻看以前的出图;
> - 保存调好的工作流,存下常用的模型搭配和提示词;
> - 别人的工作流缺模型时自动找回;
> - 配合配套节点包 [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS) 给角色配音。
>
> 插件改动过什么,都能在“操作记录”里查到。
---
### 🎬 视频与讨论
* 📺 **哔哩哔哩 (Bilibili)**:[功能演示](https://www.bilibili.com/video/BV1boHx61Enc/) · [角色配音演示](https://www.bilibili.com/video/BV1bkpZ6uEZQ/)
* 💬 **Reddit**:[r/comfyui 上的介绍帖](https://www.reddit.com/r/comfyui/s/n38ihf3DLf)
<h2 id="中文">🇨🇳 中文</h2>
### 💡 它能帮你解决什么
> 一个综合性的 ComfyUI 创作工作台与模型管家。集成零依赖的 C 站信息抓取、支持自动修复的模型医生、可视化节点助手、无损工作流交互,以及支持参数复用的工作流配方与笔记本体系。
**1. 模型太多,认不出谁是谁。** 文件叫 `下载 (3).safetensors`,根本看不出是什么。点一次 **扫描**,每个模型都按文件哈希去 C 站认一遍,显示出它真正的名字、封面、触发词和底模。以后看封面挑模型就行。
> [!WARNING]
> **测试功能与数据保护:** 工作流配方以及节点助手中由配方驱动的“参数笔记本”目前属于测试功能;节点助手的“动作”页不在测试范围内。更新插件、导入他人配方、恢复或删除配方数据、应用已保存参数之前,请先保存当前画布,并备份 ComfyUI 用户目录中的 `workflows/anomalous_recipes` 与 `workflows/anomalous_parameters`。配方中的模型预览快照不包含模型文件,不能替代模型备份。
<p align="center"><img src="docs/images/scan.webp" width="520" alt="扫描结果:每个文件都在 C 站认出来了"></p>
### 🌟 核心功能特性
**2. 打开别人的工作流,一片红框。** 模型检查会告诉你缺哪些模型。如果工作流里带着模型指纹(装了本插件的人出图、保存工作流时会自动记下,除非他在设置里关掉),你电脑里其实有、只是改了名或换了文件夹的,点一下就换上;真没有的可以从 C 站、Hugging Face 或 ComfyUI-Manager 的模型清单下载,下载完按指纹核对过才用(Manager 清单里只是同名、没法核对的,会标出来让你决定)。没有指纹的工作流只能按文件名找:别处有同名文件,会列出来让你确认。
| 🚀 功能板块 | 💡 详细说明 |
<p align="center"><img src="docs/images/model-check.webp" width="760" alt="模型检查一键换上 3 个缺失的模型"></p>
**3. 这张图当时是怎么出的?** 在图库里按提示词、模型或 LoRA 搜,再把图拖到画布上,当时的工作流就回来了。
<p align="center"><img src="docs/images/gallery.webp" width="760" alt="图库搜索后把图拖到画布"></p>
**4. 每次都要重新配一遍模型、LoRA 和提示词。** 在画布上选中这几个节点,右键 → **AMB** → **存为搭配**,它们就成了画布旁的一张卡片。下次点 **放到画布**(或者直接拖卡片),点哪里,那组连好线的节点就出现在哪里,画布上原有的东西不动。搭配里的模型和提示词以后还能换。
<p align="center"><img src="docs/images/combo.webp" width="760" alt="选中的节点存为搭配,再放回画布变成连好线的节点"></p>
**5. 提示词太长,改起来麻烦。** 选中提示词节点,提示词工坊把它拆成词块:点一下调权重,拖动换位置,输入就加(中文会自动译成英文,比如输入“蓝宝石”加进去的是 sapphire)。
<p align="center"><img src="docs/images/prompt-studio.webp" width="760" alt="提示词工坊按词块编辑提示词节点"></p>
**6. 想让角色开口说话。** 配合配套节点包 [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS):导入 GPT-SoVITS 角色,每种情绪一段参考音频,在配音页写台词直接生成。
<p align="center"><img src="docs/images/voices.webp" width="760" alt="角色语音页面"></p>
**改错了也不怕。** 插件做的每一处改动都列在“操作记录”里,带 **撤销** 和 **在画布上找到**。
<p align="center"><img src="docs/images/activity.webp" width="560" alt="操作记录和撤销按钮"></p>
**不占你的资源**:
- 插件本身不加载任何模型,不和生图抢显存。
- 浏览时显示的是小缩略图,不加载原图;关闭窗口一分半后,图片也会释放掉。
- Anomalous_TTS 的语音模型在 ComfyUI 生图需要显存时会自动让出,点 **卸载模型** 时全部释放。
### 📦 安装
1. 在 **ComfyUI Manager** 里搜索 `Anomalous Model Browser`,安装后重启 ComfyUI。也可以在 `custom_nodes` 目录下手动安装:
```bash
cd custom_nodes
git clone https://github.com/DemonGatanjieu/Anomalous_Model_Browser.git
```
2. 按 **Shift + B** 打开。在 **主页** 点 **扫描模型文件夹** 补全封面和触发词;点 **界面导览** 会逐一指给你看窗口的各个部分。
3. 想用角色配音的话,再装 [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS)(在 ComfyUI Manager 搜索 `Anomalous TTS`)。不装也不影响其他功能。
扫描要连 C 站。连不上也能用:选离线扫描,只从模型文件推断底模,以后能连上时再扫一次会自动补上(见下面的“网络和备份”)。
<details>
<summary><b>📖 每个页面和工具的详细说明</b></summary>
窗口左侧的图标栏,每个页面一个图标。
| 页面 | 在这里做什么 |
| :--- | :--- |
| **全方位模型管理** | 通过扫描哈希值,自动获取模型的基础架构、封面、提示词、作者简介等信息。支持自定义名称、备注和封面。点开模型即刻呈现封面图标,支持看图一键替换模型,彻底告别复杂的模型路径! |
| **节点智能修复 (🩺)** | 凡是由本插件导出的工作流和图片,在载入他人工作流时出现节点爆红,只需点开**模型医生 (🩺)**,即可自动识别正确的模型路径并一键替换。内置深度扫描与 C 站直达链接。 |
| **节点助手与配方参数 (🤖)** | 选中画布节点后,可视化替换模型、向兼容链路安全插入 LoRA,或读取工作流配方保存的同类型节点参数。例如保存过的 K 采样器笔记可一键应用采样器、调度器、步数、CFG 与降噪,同时忽略易变的种子。 |
| **工作流配方 (🧰)** | 保存完整工作流及封面、备注、标签、模型身份、提示词、采样参数、节点参数、匹配出图与版本历史。支持追加到画布、比较出图/版本参数、将他人的模型引用匹配到本地文件,以及导入导出便携配方包。 |
| **智能笔记本 (📑)** | 基于本地基础架构,提供快速的架构匹配功能,极速选择兼容的主模型与 LoRA。内置强大的翻译功能,自动将提示词分块打上标签,实现双语对照,并支持一键发布组装到工作流画布中。 |
| **原生图库管理 (🖼️)** | 无缝读取本地的 `output` 文件夹。支持鼠标滚轮缩放、一键查看和安全删除。更绝的是,您可以**直接将图片拖动到画布上,瞬间原地加载内嵌的工作流!** |
| **其它便捷功能** | 点击模型卡片右上角的 `➕` 号,快捷将节点发布到画布。支持侧边栏停靠 (Dock),将界面吸附在左侧,为您的画布留出充足的操作空间。 |
| **🏠 主页** | 想做什么就点哪张卡片。还有上手第一步(扫描模型文件夹、界面导览)和最近的操作记录。 |
| **🕘 记录** | 按天列出插件做过的每一处改动。<br>画布上:哪个节点的设置从什么改成了什么、加了或删了哪些节点、打开了哪个工作流。<br>文件上:模型、封面、配方、素材、笔记、图片、音频。<br>**在画布上找到** 可以直接跳到那个节点;画布上的改动在没被再改过之前可以点 **撤销**,其余只记录。你自己在画布上的修改不会记进来。 |
| **📦 模型** | 模型带封面、触发词和底模信息。<br>顶部标签切换类型(Checkpoint、LoRA、VAE…),连同子文件夹一起列出;左侧文件夹列表可以只看某个文件夹。<br>**+** 一键在画布上创建加载节点;也可以改名、写备注、换封面,或只扫描这一个模型。 |
| **🖼️ 图库** | 读取 ComfyUI 的 `output` 文件夹。<br>可以按提示词、模型、LoRA、seed、文件名或模型哈希搜索。<br>点开图片能看到生成参数;拖到画布上可以还原当时的工作流。<br>觉得好就点 **☆**:存成整个工作流、搭配(模型 + LoRA + 提示词),或者只存提示词。 |
| **🪡 工作流** | 工作流配方:把完整工作流或其中一段,连同模型、封面、备注和参数一起存下来。<br>卡片会显示这台电脑上模型是否齐全;拖到画布上即可载入。<br>可以比较、恢复历史版本。<br>卡片上的导出按钮能打成**配方包**(压缩包)发给别人,对方拖进 ⇅ 就多一张配方卡片。 |
| **🧩 搭配** | 把常用的主模型、几个 LoRA 和一段提示词存成一个搭配。<br>按 **放到画布**(或把卡片拖到画布上),就放出一组连好线的节点;画布上原有的东西不动。 |
| **✍️ 提示词** | 提示词工坊,浏览器里的一个页面;点“停到画布旁”可以移到画布旁边,边看画布边改。选中一个提示词节点,它的提示词框就以词块的样子出现在工坊里,直接改:点一下调权重,双击改字,拖动换位置,输入就加(中文、日文等会自动译成英文),还能在每个词下面显示它的中文、日文、韩文等语言的意思。<br>点词卡(几张常用的,加上你存的)加进去,或者直接拖到画布的提示词框里(正面绿框、负面红框),拖到空白处就新建一个提示词节点。没选中提示词节点时写的是草稿。 |
| **🎙️ 角色语音 / 🎧 音频库** | 装了 [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS) 后可用:<br>导入 GPT-SoVITS 角色,给每种情绪选参考音频,修正读音;切到“配音”写台词并直接生成。<br>生成的音频都在音频库里。没装时,页面会说明怎么安装,其他功能不受影响。 |
### 📖 标准操作指南
图标栏上的工具:
为了让插件发挥最大效能,请按照以下流程进行操作:
* **扫描**:读取模型文件夹,按文件哈希从 C 站获取封面、触发词和底模。不需要额外安装 Python 包。
* **模型检查**(原“模型医生”):打开工作流时自动检查,缺模型就在画布上方提示;本地改过名、换过文件夹的同一个文件一键换上,靠的是插件记在工作流和图片里的模型指纹(哈希和文件大小;**设置 → 在工作流里记下模型指纹**,默认打开);没有指纹时,同名文件会列出来让你确认。其余的在检查页里自己挑或去 Civitai 找。
* **当前节点**(原“节点助手”):选中画布上的节点,查看它用的模型。可以看图换模型、插入 LoRA;存过的同类节点参数列在一起,每条写明会改哪几项,一键套用;存下的参数也在这里删除。
* **工具回到各自的页面**:分享与导入在“工作流”页(⇅:把画布上的工作流变成一段短短的分享码,有汉字码和字母码;也能粘贴分享码,或拖入工作流文件、图片、备份压缩包),模型来源(每个模型去哪下载)在“模型”页和模型检查里,翻译在“当前节点”的提示词框上。
* **设置**(最下面):语言、字号、缩略图、视频封面、显示哪些模型文件夹,以及提交反馈。主页和设置里的 **报告问题** 会打开一个已经填好版本和显卡信息的 GitHub 页面。
#### 1. 前置准备 (扫描向导) 🔄
* **具体位置**:界面左侧底部的 **扫描图标 (🔄)**。
* **操作步骤**:这是最重要的一步!请先打开扫描向导,根据需求选择扫描配置并执行扫描(切记保持网络畅通)。扫描完成后,您的本地数据库建立完毕,插件的各项核心功能全面激活。
**接入 AI 软件(MCP)**:Claude、Cursor、Cherry Studio 等支持 MCP 的 AI 软件,可以通过 `http://127.0.0.1:8188/anomalous/mcp` 查你的模型、出图和存下的提示词,回答相关问题;也能帮你改当前工作流(提示词、模型、LoRA、搭配、缺失的模型),每次改动都能用 Ctrl+Z 撤销。只限本机。设置方法见 [docs/guides/mcp.md](docs/guides/mcp.md)。
#### 2. 拯救爆红 (模型医生) 🩺
* **具体位置**:左侧底部第二个 **听诊器按钮 (🩺)**。
* **操作步骤**:当导入别人使用该插件输出的工作流或者图片时,若发现模型路径爆红报错,点击模型医生,它就能智能识别报错节点,并一键实现正确路径的替换。
</details>
#### 3. 选中交互 (节点助手) 🤖
* **具体位置**:左侧底部第三个 **机器人按钮 (🤖)**。
* **动作**:打开后在 ComfyUI 画布上选中节点。对于模型节点,“动作”页可以可视化替换当前模型,或在兼容的 MODEL + CLIP 链前后插入 LoRA。选择器提供模型预览、模型类别/基础模型标记、文件夹、完整路径搜索、排序与多分支安全保护。
* **参数笔记本(测试)**:切换到“参数笔记本”,可以读取与工作流配方关联的参数笔记。内容会按配方分组,只展示和当前选中节点类型相同的保存记录。点击一条记录即可事务式应用到该节点;种子等每次运行都会变化的值会被忽略。
<details>
<summary><b>⚙️ 打开方式、快捷键和更新</b></summary>
#### 4. 工作流配方与参数笔记本 🧰
* **具体位置**:打开 **创作工作台 (📑)**,从“提示词笔记”切换到 **工作流配方**。
* **保存与复用**:保存当前完整节点图,并附带封面、名称、标签、备注、模型引用、提示词、采样设置与受限节点摘要。配方可以追加到当前画布,不会覆盖当前内容。
* **详细信息**:概览展示模型组成与复现信息。模型匹配根据哈希、文件大小与模型类别查找本地文件,不依赖原作者路径;可用时还会保留 C 站官方名称/链接与本地预览。
* **参数笔记本**:查看配方保存的参数,复制或展开长内容,新建可编辑参数笔记,或者从骨架匹配的当前画布读取全新参数。点击“应用到当前工作流”时会先检查配方骨架,再更新安全控件;种子等易变值会被忽略。保存的参数笔记可以单独删除;无法可靠识别的第三方提示词节点可以手动标注正负角色。
* **图库与版本**:查找节点结构相同的历史出图,打开图片比较参数差异,将出图设为配方封面,比较配方历史版本,或在自动归档当前版本后恢复旧版本。
* **分享**:导入导出便携配方包,可选择包含模型预览快照与历史版本,也可在导出时移除身份哈希。预览快照只是展示数据,绝不会包含模型文件。
打开方式有三种:按 **Shift + B**(再按一次关闭)、点击画布上的悬浮按钮,或者从 ComfyUI 菜单 **扩展 → Anomalous Model Browser** 打开。在 **ComfyUI 设置 → Anomalous Model Browser → 界面** 里,可以修改快捷键、插件语言和打开方式(悬浮按钮、运行按钮旁的按钮,或只用扩展菜单)。
#### 5. 个性化配置 (设置面板) ⚙️
* **具体位置**:左侧底部的 **齿轮按钮 (⚙️)**。
* **操作步骤**:在此可以调节语言和主页面字体大小。进入 **模型设置** 后,可选择视频封面始终播放或悬停播放,也可选择卡片使用流畅缩略图或原始封面;模型详情页始终使用原始封面。**文件夹管理**可以让指定目录同时退出侧栏和后台读取,并支持调整顺序;**帮助**中会持续更新安全提醒与功能说明。
**更新插件**:
- 点窗口右上角的 **!**,可以看到当前版本;
- 点 **检查更新 / 切换版本** 打开版本面板,可以更新到最新版、回退到以前的版本,或回到最新版;
- 换完版本后重启 ComfyUI。插件不会自己联网检查更新。
#### 6. 顶部导航与提示词笔记 📑
**具体位置**:右侧顶部的按钮分别为 **模型 (📦)**、**图库 (🖼️)**、**创作工作台 (📑)**、**停靠侧边栏 (◧)**。
</details>
**提示词笔记操作步骤**:
1. 打开**创作工作台 (📑)**,进入“提示词笔记”,点击新建 **➕** 号并确认。
2. 首先选择基础模型(例如 SD1.5),系统会过滤出兼容的主模型与对应的 LoRA 模型供你选择。
3. 点击下方的 **📝 纯文本/粘贴** 复制提示词进去,选择左上角的语言即可实现双语对照翻译。
4. 确认后,插件会自动生成标签 (Tags),支持查找替换,也支持直接双击修改单个标签。
5. 最后,点击下方的 **🚀 发送到画布**,系统会将打包好的主模型、LoRA 和 CLIP,一键全自动连线发布到画布!
<details>
<summary><b>🌐 网络和备份</b></summary>
**在云服务器上(AutoDL 等):** 海外的云服务器完整支持;国内的云服务器只能部分支持(多半连不上 Civitai,查模型不稳定,封面基本下不来)。目前还没有完整测过(只实测过一台 AutoDL 服务器),用的时候请留意。说明里有一节写给 AI 看的步骤,把链接丢给 AI 就能让它带你做。从别的电脑打开时插件默认只能浏览。用 SSH 隧道打开就能照常用,或者见 [docs/guides/cloud-server.md](docs/guides/cloud-server.md)。
**关于网络:** 扫描模型要连 Civitai(C 站)查封面和触发词,提示词翻译默认用谷歌翻译,这两样在国内一般要开网络代理才能用。不开代理也行:扫描页选 **离线**(联网扫描连不上时也会自动改成这样),只从模型文件推断底模,没有封面和触发词,以后能连上时再扫一次会自动补上。下载缺失的模型也要联网:Hugging Face 的模型可以在 **设置 → 模型下载** 里打开“Hugging Face 用国内镜像”,不用代理;C 站的没有镜像。(civitai.com 连不上时插件会自动试 Civitai 的另一个地址 civitai.red,有的网络能连上,比如国内的云服务器,这时能认出模型名字和触发词,封面多半还是下不来。)浏览模型、图库、工作流和搭配都不用联网。
**备份自己的数据:** **设置 → 备份 → 导出**,把工作流配方、搭配、存下的提示词和节点参数、参数预设,以及你给模型改的名字、备注和封面打成一个压缩包,放到云端硬盘或 U 盘里;换电脑或重装后用 **导入** 恢复。(它们存在 ComfyUI 用户目录下的 `workflows/anomalous_recipes`、`workflows/anomalous_notebooks`、`workflows/anomalous_materials` 与 `workflows/anomalous_parameters`。)
</details>
---
### 📦 安装指南
### 🧪 Tests (测试)
打开命令行终端,进入 ComfyUI 的 `custom_nodes` 文件夹,执行以下命令:
```bash
cd custom_nodes
git clone https://github.com/DemonGatanjieu/Anomalous_Model_Browser.git
```
> **注意**:克隆完成后,重启 ComfyUI 即可。您也可以直接在 ComfyUI Manager(管理器)中搜索 `Anomalous Model Browser` 并一键点击安装!
---
The plugin's tests are in `tests/`; run them all with `node tools/run_tests.mjs` from the plugin folder. See [docs/guides/testing.md](docs/guides/testing.md).
插件的测试在 `tests/` 里,在插件文件夹运行 `node tools/run_tests.mjs` 一次跑完。改代码或提 PR 之前跑一遍,说明见 [docs/guides/testing.md](docs/guides/testing.md)。
### 📝 License & Branding (开源与品牌声明)
**Code License (代码授权)**
The source code of this project is released under the [MIT License](LICENSE). You are free to use, modify, and distribute the code.
本项目的源代码基于 MIT 许可证开源,您可以自由地使用、修改和分发代码。
**Branding & Trademarks (品牌与商标保护)**
The MIT License covers the repository's code, documentation, stylesheets, and ordinary UI resources. The name **Anomalous Model Browser** and the official project logo identify official releases; the MIT License does not imply that a modified distribution is official or endorsed. Publicly distributed forks should use a distinct primary name and logo, while they may truthfully say that they are based on Anomalous Model Browser. See the [Trademark and Brand Policy](TRADEMARKS.md).
MIT 许可证适用于本仓库的代码、文档、样式表和普通 UI 资源。名称 **Anomalous Model Browser** 与官方项目 Logo 用于识别官方版本;采用 MIT 许可证并不表示修改后的发行版属于官方版本或获得认可。公开分发的修改版本应使用不同的主要名称与 Logo,但可以如实说明其基于 Anomalous Model Browser。详见[商标与品牌政策](TRADEMARKS.md)。
* **Code License (代码授权)**: The source code is released under the [MIT License](LICENSE). 本项目源代码基于 MIT 许可证开源,可自由使用、修改与分发。
* **Branding & Trademarks (商标与品牌保护)**: The name **Anomalous Model Browser** and the official logo identify official releases. Forks should use distinct names/branding. 详见 [Trademark and Brand Policy](TRADEMARKS.md)。
+5 -1
View File
@@ -1,11 +1,15 @@
import os
from .api import setup_routes
from .api.usage_ledger import note_prompt
from server import PromptServer
WEB_DIRECTORY = "./web"
# 注册 API 路由
setup_routes(PromptServer.instance.app)
if hasattr(PromptServer, "instance") and PromptServer.instance is not None:
setup_routes(PromptServer.instance.app)
# Each queued run's nodes and models, for the Tidy tab's "Not used lately" (api/usage_ledger.py).
PromptServer.instance.add_on_prompt_handler(note_prompt)
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
+152 -71
View File
@@ -1,71 +1,152 @@
from .models import *
from .scanner import *
from .config import *
from .notebooks import *
from .parameters import *
from .recipes import *
from .recipe_packages import *
from .utils import *
def setup_routes(app):
app.router.add_get('/anomalous/folders', api_get_folders)
app.router.add_get('/anomalous/all_folder_types', api_get_all_folder_types)
app.router.add_get('/anomalous/models', api_get_models)
app.router.add_get('/anomalous/all_scan_models', api_get_all_scan_models)
app.router.add_get('/anomalous/batch_select', api_batch_select)
app.router.add_get('/anomalous/image', api_serve_image)
app.router.add_post('/anomalous/scan', api_scan_folder)
app.router.add_get('/anomalous/scan_status', api_scan_status)
app.router.add_get('/anomalous/find_model', api_find_model)
app.router.add_get('/anomalous/config', api_get_config)
app.router.add_post('/anomalous/save_config', api_save_config)
app.router.add_post('/anomalous/delete_model', api_delete_model)
app.router.add_post('/anomalous/clean_civitai_info', api_clean_civitai_info)
app.router.add_get('/anomalous/compatible_models', api_compatible_models)
app.router.add_get('/anomalous/notebooks', api_get_notebooks)
app.router.add_post('/anomalous/save_notebook', api_save_notebook)
app.router.add_post('/anomalous/delete_notebook', api_delete_notebook)
# Parameter Routes
app.router.add_get('/anomalous/parameters', api_get_parameters)
app.router.add_get('/anomalous/parameters/by_node_type', api_get_parameters_by_type)
app.router.add_post('/anomalous/save_parameter', api_save_parameter)
app.router.add_post('/anomalous/delete_parameter', api_delete_parameter)
app.router.add_get('/anomalous/parameter_gallery', api_get_parameter_gallery)
# Recipe Routes
app.router.add_get('/anomalous/recipes', api_get_recipes)
app.router.add_get('/anomalous/recipe_full', api_get_recipe_full)
app.router.add_get('/anomalous/recipe_asset', api_get_recipe_asset)
app.router.add_get('/anomalous/recipe_gallery', api_get_recipe_gallery)
app.router.add_get('/anomalous/recipe_parameter_gallery', api_get_recipe_parameter_gallery)
app.router.add_get('/anomalous/recipe_gallery_compare', api_get_recipe_gallery_compare)
app.router.add_post('/anomalous/save_recipe', api_save_recipe)
app.router.add_post('/anomalous/update_recipe', api_update_recipe)
app.router.add_post('/anomalous/set_recipe_gallery_cover', api_set_recipe_gallery_cover)
app.router.add_post('/anomalous/delete_recipe', api_delete_recipe)
app.router.add_get('/anomalous/recipe_history', api_get_recipe_history)
app.router.add_get('/anomalous/recipe_version', api_get_recipe_version)
app.router.add_post('/anomalous/restore_recipe_version', api_restore_recipe_version)
app.router.add_post('/anomalous/refresh_recipe_identity', api_refresh_recipe_identity)
app.router.add_post('/anomalous/export_recipe_package', api_export_recipe_package)
app.router.add_post('/anomalous/import_recipe_package_inspect', api_import_recipe_package_inspect)
app.router.add_post('/anomalous/import_recipe_package_commit', api_import_recipe_package_commit)
app.router.add_post('/anomalous/translate', api_translate)
app.router.add_get('/anomalous/base_models', api_base_models)
app.router.add_get('/anomalous/gallery_images', api_get_gallery_images)
app.router.add_post('/anomalous/delete_gallery_image', api_delete_gallery_image)
app.router.add_get('/anomalous/resolve_hash', api_resolve_hash)
app.router.add_post('/anomalous/resolve_hash_batch', api_resolve_hash_batch)
app.router.add_get('/anomalous/all_hashes', api_get_all_hashes)
app.router.add_post('/anomalous/scan_all', api_scan_all)
app.router.add_get('/anomalous/global_scan_status', api_global_scan_status)
app.router.add_get('/anomalous/scan_missing_models_status', api_scan_missing_models_status)
app.router.add_post('/anomalous/clear_cache', api_clear_cache)
app.router.add_post('/anomalous/update_metadata', api_update_metadata)
app.router.add_post('/anomalous/set_custom_cover', api_set_custom_cover)
app.router.add_post('/anomalous/upload_custom_cover', api_upload_custom_cover)
app.router.add_get('/anomalous/model_images', api_get_model_images)
app.router.add_post('/anomalous/resolve_paths_to_previews', api_resolve_paths_to_previews)
app.router.add_post('/anomalous/scan_missing_models', api_scan_missing_models)
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, quick_index, node_material, recipe_packages, recipes, scan_choice, scan_report, scan_summary,
local_guard, lora_info, mcp_server, model_download, model_import, model_placement, translation_routes, version_manager, tts_version, backup,
network_check, model_heads, model_usage, usage_ledger, sidecar_layout,
)
from .scanner import *
from .config import *
from .notebooks import *
from .parameters import *
import os
from aiohttp import web
# ComfyUI serves the web folder under the plugin folder's own name, whatever it is called.
EXTENSION_STATIC_PREFIX = f'/extensions/{os.path.basename(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))}/'
@web.middleware
async def no_cache_extension_middleware(request, handler):
"""Make browsers revalidate this plugin's JS/CSS so a normal refresh picks up updates.
`no-cache` still allows 304 responses via ETag/Last-Modified. This replaces
per-import `?v=` query strings, which load a second module instance whenever
two files import the same module with different URLs.
"""
response = await handler(request)
if request.path.startswith(EXTENSION_STATIC_PREFIX):
response.headers['Cache-Control'] = 'no-cache'
return response
def setup_routes(app):
model_heads.keep_on_disk()
if hasattr(app, 'middlewares') and no_cache_extension_middleware not in app.middlewares:
app.middlewares.append(no_cache_extension_middleware)
# Other devices on the network may look; only this computer may change things (local_guard.py).
if hasattr(app, 'middlewares') and local_guard.local_only_middleware not in app.middlewares:
app.middlewares.append(local_guard.local_only_middleware)
app.router.add_get('/anomalous/access', local_guard.api_access)
try:
folder_types.upgrade_folder_defaults()
except Exception as error:
print(f"[Anomalous Browser] Could not update the shown model folders: {error}")
app.router.add_get('/anomalous/folders', model_catalog.api_get_folders)
app.router.add_get('/anomalous/all_folder_types', folder_types.api_get_all_folder_types)
app.router.add_get('/anomalous/models', model_catalog.api_get_models)
app.router.add_get('/anomalous/type_models', model_type_listing.api_get_type_models)
app.router.add_get('/anomalous/all_scan_models', model_catalog.api_get_all_scan_models)
app.router.add_get('/anomalous/image', media_routes.api_serve_image)
app.router.add_get('/anomalous/card_cache', media_routes.api_card_cache)
app.router.add_post('/anomalous/card_cache/clear', media_routes.api_clear_card_cache)
app.router.add_post('/anomalous/scan', api_scan_folder)
app.router.add_get('/anomalous/scan_status', api_scan_status)
app.router.add_get('/anomalous/find_model', model_catalog.api_find_model)
app.router.add_get('/anomalous/config', api_get_config)
app.router.add_post('/anomalous/save_config', api_save_config)
app.router.add_post('/anomalous/delete_model', model_metadata.api_delete_model)
app.router.add_post('/anomalous/open_model_folder', model_folder.api_open_model_folder)
app.router.add_get('/anomalous/compatible_models', model_catalog.api_compatible_models)
app.router.add_get('/anomalous/notebooks', api_get_notebooks)
app.router.add_post('/anomalous/save_notebook', api_save_notebook)
app.router.add_post('/anomalous/delete_notebook', api_delete_notebook)
# Parameter Routes
app.router.add_get('/anomalous/parameters', api_get_parameters)
app.router.add_get('/anomalous/parameters/by_node_type', api_get_parameters_by_type)
app.router.add_get('/anomalous/parameters/signatures', api_get_parameter_set_signatures)
app.router.add_post('/anomalous/save_parameter', api_save_parameter)
app.router.add_post('/anomalous/rename_parameter', api_rename_parameter)
app.router.add_post('/anomalous/delete_parameter', api_delete_parameter)
app.router.add_get('/anomalous/parameter_gallery', api_get_parameter_gallery)
app.router.add_get('/anomalous/parameter_pictures', api_get_parameter_pictures)
# Recipe Routes
app.router.add_get('/anomalous/recipes', recipes.api_get_recipes)
app.router.add_get('/anomalous/recipe_full', recipes.api_get_recipe_full)
app.router.add_get('/anomalous/recipe_asset', recipes.api_get_recipe_asset)
app.router.add_get('/anomalous/recipe_gallery', recipes.api_get_recipe_gallery)
app.router.add_get('/anomalous/recipe_parameter_gallery', recipes.api_get_recipe_parameter_gallery)
app.router.add_get('/anomalous/recipe_gallery_compare', recipes.api_get_recipe_gallery_compare)
app.router.add_post('/anomalous/save_recipe', recipes.api_save_recipe)
app.router.add_post('/anomalous/update_recipe', recipes.api_update_recipe)
app.router.add_post('/anomalous/recipe_focus', recipes.api_set_recipe_focus)
app.router.add_post('/anomalous/set_recipe_gallery_cover', recipes.api_set_recipe_gallery_cover)
app.router.add_post('/anomalous/delete_recipe', recipes.api_delete_recipe)
app.router.add_get('/anomalous/recipe_history', recipes.api_get_recipe_history)
app.router.add_get('/anomalous/recipe_version', recipes.api_get_recipe_version)
app.router.add_post('/anomalous/restore_recipe_version', recipes.api_restore_recipe_version)
app.router.add_post('/anomalous/refresh_recipe_identity', recipes.api_refresh_recipe_identity)
app.router.add_post('/anomalous/export_recipe_package', recipe_packages.api_export_recipe_package)
app.router.add_post('/anomalous/import_recipe_package_inspect', recipe_packages.api_import_recipe_package_inspect)
app.router.add_post('/anomalous/import_recipe_package_commit', recipe_packages.api_import_recipe_package_commit)
# Curated Material Library Routes
app.router.add_get('/anomalous/materials', materials.api_get_materials)
app.router.add_get('/anomalous/material_full', materials.api_get_material_full)
app.router.add_get('/anomalous/material_asset', materials.api_get_material_asset)
app.router.add_get('/anomalous/materials/by_node_type', materials.api_get_materials_by_node_type)
app.router.add_post('/anomalous/inspect_image_material', materials.api_inspect_image_material)
app.router.add_post('/anomalous/save_image_material', materials.api_save_image_material)
app.router.add_post('/anomalous/save_prompt_plan', materials.api_save_prompt_plan)
app.router.add_post('/anomalous/save_node_material', node_material.api_save_node_material)
app.router.add_post('/anomalous/delete_material', materials.api_delete_material)
app.router.add_post('/anomalous/update_material', materials.api_update_material)
app.router.add_post('/anomalous/mark_material_moved', materials.api_mark_material_moved)
app.router.add_post('/anomalous/translate', translation_routes.api_translate)
app.router.add_get('/anomalous/base_models', model_catalog.api_base_models)
app.router.add_get('/anomalous/gallery_images', gallery_routes.api_get_gallery_images)
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)
app.router.add_post('/anomalous/scan_all', api_scan_all)
app.router.add_get('/anomalous/global_scan_status', api_global_scan_status)
app.router.add_post('/anomalous/clear_cache', media_routes.api_clear_cache)
app.router.add_post('/anomalous/update_metadata', model_metadata.api_update_metadata)
app.router.add_post('/anomalous/set_custom_cover', model_media.api_set_custom_cover)
app.router.add_post('/anomalous/upload_custom_cover', model_media.api_upload_custom_cover)
app.router.add_get('/anomalous/model_covers', model_media.api_model_covers)
app.router.add_get('/anomalous/model_images', media_routes.api_get_model_images)
app.router.add_post('/anomalous/resolve_paths_to_previews', model_catalog.api_resolve_paths_to_previews)
app.router.add_get('/anomalous/scan_summary', scan_summary.api_scan_summary)
app.router.add_get('/anomalous/scan_matched', scan_summary.api_scan_matched)
app.router.add_get('/anomalous/scan_pickable', scan_summary.api_scan_pickable)
app.router.add_get('/anomalous/last_scan', scan_report.api_last_scan)
app.router.add_get('/anomalous/scan_model', scan_report.api_scan_model)
app.router.add_post('/anomalous/scan_choice', scan_choice.api_set_scan_choice)
app.router.add_get('/anomalous/lora_info', lora_info.api_lora_info)
# Audio & Voice Studio Routes
app.router.add_get('/anomalous/audio_stream', audio_catalog.api_serve_audio)
app.router.add_get('/anomalous/audio_gallery', audio_catalog.api_get_audio_gallery)
app.router.add_post('/anomalous/delete_audio_gallery', audio_catalog.api_delete_audio_gallery)
app.router.add_get('/anomalous/audio_previews', audio_catalog.api_get_audio_previews)
app.router.add_post('/anomalous/save_audio_preview', audio_catalog.api_save_audio_preview)
version_manager.register_routes(app)
tts_version.register_routes(app)
activity_log.register_routes(app)
mcp_server.register_routes(app)
model_download.register_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)
+281
View File
@@ -0,0 +1,281 @@
"""Activity log: what Anomalous changed, newest first, kept in the ComfyUI user folder.
Two sources feed it. The middleware records this plugin's own write requests
(and Anomalous_TTS's) after they succeed: files such as models, covers, recipes,
materials, notes, presets, images and audio. The browser posts the canvas changes
it made (`POST /anomalous/activity`). Only the newest MAX_ENTRIES are kept and long
values are shortened, so the file stays small. Recording never fails a request:
the log is a convenience, not part of the action.
"""
import asyncio
import json
import logging
import os
import threading
import time
import uuid
from aiohttp import web
import folder_paths
from .path_utils import atomic_write_json
MAX_ENTRIES = 500
MAX_LOG_BYTES = 2 * 1024 * 1024 # long canvas entries could otherwise outgrow the JSON writer's limit
MAX_VALUE_CHARS = 400
DIFF_CONTEXT_CHARS = 60 # kept before the first difference of a long before/after pair
MAX_CHANGES = 60
# Write routes worth a line in the log → the action id the browser has words for. Scans are
# recorded when they end, with what they found (api/scan_report.py), not here.
ROUTE_ACTIONS = {
'/anomalous/delete_model': 'model_delete',
'/anomalous/update_metadata': 'model_edit',
'/anomalous/set_custom_cover': 'model_cover',
'/anomalous/upload_custom_cover': 'model_cover',
'/anomalous/save_recipe': 'recipe_save',
'/anomalous/update_recipe': 'recipe_edit',
'/anomalous/delete_recipe': 'recipe_delete',
'/anomalous/restore_recipe_version': 'recipe_restore',
'/anomalous/set_recipe_gallery_cover': 'recipe_cover',
'/anomalous/import_recipe_package_commit': 'recipe_import',
'/anomalous/save_image_material': 'material_save',
'/anomalous/save_prompt_plan': 'material_save',
'/anomalous/update_material': 'material_edit',
'/anomalous/delete_material': 'material_delete',
'/anomalous/save_notebook': 'note_save',
'/anomalous/delete_notebook': 'note_delete',
'/anomalous/save_parameter': 'preset_save',
'/anomalous/rename_parameter': 'preset_rename',
'/anomalous/delete_parameter': 'preset_delete',
'/anomalous/delete_gallery_image': 'image_delete',
'/anomalous/delete_audio_gallery': 'audio_delete',
'/anomalous/save_audio_preview': 'audio_save',
'/anomalous/clear_cache': 'cache_clear',
'/anomalous_tts/import/commit': 'voice_import',
'/anomalous_tts/settings': 'voice_settings',
}
# Request fields that name what was changed, in order of preference.
TARGET_FIELDS = ('name', 'character', 'filename', 'target_filename', 'recipe_filename',
'notebook_filename', 'new_name', 'path', 'file', 'target')
# What a handler attaches to its request for the log: request[ACTIVITY_DETAIL] = {fields, files}.
# web.RequestKey is newer than the aiohttp some ComfyUI installs have; a plain key works there.
ACTIVITY_DETAIL = web.RequestKey("activity_detail", dict) if hasattr(web, "RequestKey") else "activity_detail"
_lock = threading.RLock()
_entries = None
def log_path():
user_dir = folder_paths.get_user_directory() if hasattr(folder_paths, 'get_user_directory') else None
if not user_dir:
user_dir = os.path.join(folder_paths.base_path, 'user')
directory = os.path.join(user_dir, 'anomalous')
os.makedirs(directory, exist_ok=True)
return os.path.join(directory, 'activity_log.json')
def _load():
global _entries
if _entries is None:
try:
with open(log_path(), encoding='utf-8') as source:
data = json.load(source)
_entries = [entry for entry in data if isinstance(entry, dict)] if isinstance(data, list) else []
except (OSError, ValueError):
_entries = []
return _entries
def _encoded_size(entries):
return len(json.dumps(entries, ensure_ascii=False, separators=(',', ':')).encode('utf-8'))
def _trim(entries):
"""Drop the oldest entries until the list fits MAX_ENTRIES and MAX_LOG_BYTES."""
del entries[MAX_ENTRIES:]
while len(entries) > 1 and _encoded_size(entries) > MAX_LOG_BYTES:
del entries[max(1, len(entries) * 9 // 10):]
def _text(value):
return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
def _short(value):
text = _text(value)
return text if len(text) <= MAX_VALUE_CHARS else text[:MAX_VALUE_CHARS] + '…'
def _short_values(item, keys):
"""`keys` of `item` shortened. A long before/after pair is cut around its first difference
instead of at the start, so a change late in a long prompt still shows."""
out = {key: _short(item[key]) for key in keys if key in item and item[key] is not None}
if 'before' not in out or 'after' not in out:
return out
old, new = _text(item['before']), _text(item['after'])
if len(old) <= MAX_VALUE_CHARS and len(new) <= MAX_VALUE_CHARS:
return out
same = 0
while same < min(len(old), len(new)) and old[same] == new[same]:
same += 1
start = max(0, same - DIFF_CONTEXT_CHARS)
def cut(text):
return ('…' if start else '') + text[start:start + MAX_VALUE_CHARS] + ('…' if start + MAX_VALUE_CHARS < len(text) else '')
out['before'], out['after'] = cut(old), cut(new)
return out
def add_entry(source, action, target='', detail=None):
"""Append one entry (newest first) and save. Returns the entry."""
entry = {
'id': uuid.uuid4().hex[:12],
'time': time.time(),
'source': source,
'action': action,
'target': _short(target or ''),
}
if detail:
entry['detail'] = detail
with _lock:
entries = _load()
entries.insert(0, entry)
_trim(entries)
atomic_write_json(log_path(), entries)
return entry
def list_entries(limit=100, source=None):
with _lock:
entries = _load()
picked = [entry for entry in entries if not source or entry.get('source') == source]
return picked[:limit]
def clear():
global _entries
with _lock:
_entries = []
atomic_write_json(log_path(), [])
def _target_of(body):
if not isinstance(body, dict):
return ''
for field in TARGET_FIELDS:
value = body.get(field)
if isinstance(value, str) and value.strip():
return os.path.basename(value.strip().rstrip('/\\')) or value.strip()
return ''
def _failed(response):
"""True when a response reports failure, including the 200 + {"status": "error"} style."""
if response.status >= 300:
return True
if getattr(response, 'content_type', '') != 'application/json' or not getattr(response, 'body', None):
return False
try:
data = json.loads(response.body)
except (TypeError, ValueError):
return False
return isinstance(data, dict) and (data.get('success') is False or data.get('ok') is False
or data.get('status') in ('error', 'failed'))
async def _request_body(request):
"""The body the handler already read (aiohttp caches it); {} when it was not JSON or a form."""
try:
if request.content_type == 'application/json':
return await request.json()
if request.content_type in ('multipart/form-data', 'application/x-www-form-urlencoded'):
return dict(await request.post())
except (ValueError, UnicodeDecodeError, RuntimeError):
pass
return {}
def _clean_detail(detail):
"""What a handler attached for the log (request[ACTIVITY_DETAIL]): the fields it changed
({field, before, after}) and the files it moved, values shortened."""
if not isinstance(detail, dict):
return None
cleaned = {}
if isinstance(detail.get('fields'), list):
cleaned['fields'] = [_short_values(item, ('field', 'before', 'after'))
for item in detail['fields'][:MAX_CHANGES] if isinstance(item, dict)]
if isinstance(detail.get('files'), list):
cleaned['files'] = [_short(name) for name in detail['files'][:MAX_CHANGES]]
return {key: value for key, value in cleaned.items() if value} or None
@web.middleware
async def activity_middleware(request, handler):
response = await handler(request)
action = ROUTE_ACTIONS.get(request.path) if request.method == 'POST' else None
if action and not _failed(response):
try:
target = _target_of(await _request_body(request))
detail = _clean_detail(request.get(ACTIVITY_DETAIL))
await asyncio.get_running_loop().run_in_executor(None, add_entry, 'file', action, target, detail)
except Exception: # noqa: BLE001 - the log must never fail the action it records
logging.getLogger(__name__).warning('Anomalous activity log: could not record %s', request.path, exc_info=True)
return response
def _clean_changes(changes):
cleaned = []
for change in changes[:MAX_CHANGES]:
if not isinstance(change, dict):
continue
item = _short_values(change, ('kind', 'node', 'type', 'widget', 'before', 'after'))
if item.get('kind') in ('changed', 'added', 'removed', 'opened'):
cleaned.append(item)
return cleaned
async def api_get_activity(request):
try:
limit = max(1, min(MAX_ENTRIES, int(request.query.get('limit', '100'))))
except ValueError:
limit = 100
source = request.query.get('source') or None
entries = await asyncio.get_running_loop().run_in_executor(None, list_entries, limit, source)
return web.json_response({'entries': entries})
async def api_post_activity(request):
"""Canvas changes from the browser: {changes: [...], total, workflow, via?}; via "mcp" marks
changes an AI app made through the MCP endpoint."""
try:
body = await request.json()
except ValueError:
return web.json_response({'error': 'Invalid JSON body'}, status=400)
if not isinstance(body, dict) or not isinstance(body.get('changes'), list):
return web.json_response({'error': 'changes must be a list'}, status=400)
changes = _clean_changes(body['changes'])
if not changes:
return web.json_response({'error': 'No recognizable changes'}, status=400)
total = body.get('total') if isinstance(body.get('total'), int) else len(changes)
detail = {'changes': changes, 'total': max(total, len(changes))}
if body.get('via') == 'mcp':
detail['via'] = 'mcp'
entry = await asyncio.get_running_loop().run_in_executor(
None, add_entry, 'canvas', 'canvas', body.get('workflow') if isinstance(body.get('workflow'), str) else '', detail)
return web.json_response({'entry': entry})
async def api_clear_activity(request):
await asyncio.get_running_loop().run_in_executor(None, clear)
return web.json_response({'ok': True})
def register_routes(app):
if hasattr(app, 'middlewares') and activity_middleware not in app.middlewares:
app.middlewares.append(activity_middleware)
app.router.add_get('/anomalous/activity', api_get_activity)
app.router.add_post('/anomalous/activity', api_post_activity)
app.router.add_post('/anomalous/activity/clear', api_clear_activity)
+256
View File
@@ -0,0 +1,256 @@
"""Generated audio: the audio gallery, Preview Audio results, and streaming them."""
import asyncio
import os
import shutil
import time
from urllib.parse import quote
import folder_paths
from aiohttp import web
from .audio_metadata import generation_info
from .path_utils import resolve_within
from .trash import move_to_trash, trash_failure
AUDIO_EXTENSIONS = {'.wav', '.mp3', '.flac', '.ogg', '.opus', '.m4a'}
AUDIO_CONTENT_TYPES = {
'.wav': 'audio/wav',
'.mp3': 'audio/mpeg',
'.flac': 'audio/flac',
'.ogg': 'audio/ogg',
'.opus': 'audio/ogg',
'.m4a': 'audio/mp4',
}
AUDIO_ROOTS = {
"output": lambda: folder_paths.get_output_directory(),
"temp": lambda: folder_paths.get_temp_directory(),
}
PREVIEW_LIMIT = 30
SAVED_PREVIEW_SUBFOLDER = "audio"
# Revalidate instead of forbidding caches: overwritten files get a new ?v=mtime URL.
AUDIO_CACHE_HEADERS = {"Cache-Control": "no-cache"}
def _audio_url(rel_path, dir_type, version):
return f"/anomalous/audio_stream?path={quote(rel_path, safe='/')}&type={dir_type}&v={int(version)}"
async def api_serve_audio(request):
"""GET /anomalous/audio_stream?path=...&type=output|temp - Stream an audio file."""
rel_path = request.query.get("path", "").strip("/\\")
dir_type = request.query.get("type", "output")
if not rel_path:
return web.Response(status=400, text="Missing audio path")
if dir_type not in AUDIO_ROOTS:
return web.Response(status=400, text="Unknown audio folder")
base_dir = AUDIO_ROOTS[dir_type]()
try:
resolved_path = resolve_within(base_dir, rel_path)
except ValueError:
return web.Response(status=403, text="Forbidden path")
ext = os.path.splitext(resolved_path)[1].lower()
if ext not in AUDIO_EXTENSIONS:
return web.Response(status=415, text="Not an audio file")
if not os.path.isfile(resolved_path):
return web.Response(status=404, text="Audio file not found")
return web.FileResponse(resolved_path, headers={"Content-Type": AUDIO_CONTENT_TYPES[ext], **AUDIO_CACHE_HEADERS})
def _collect_audio_files(root_dir, dir_type):
"""Scan a folder tree for audio files, newest first."""
audios = []
if not os.path.isdir(root_dir):
return audios
for root, _, files in os.walk(root_dir):
for name in files:
if os.path.splitext(name)[1].lower() not in AUDIO_EXTENSIONS:
continue
try:
stat = os.stat(os.path.join(root, name))
except OSError:
continue
subfolder = os.path.relpath(root, root_dir)
clean_sub = "" if subfolder == "." else subfolder.replace(os.sep, '/')
rel_path = "/".join(part for part in (clean_sub, name) if part)
audios.append({
"filename": name,
"subfolder": clean_sub,
"size_bytes": stat.st_size,
"mtime": stat.st_mtime,
"audio_url": _audio_url(rel_path, dir_type, stat.st_mtime),
})
audios.sort(key=lambda a: a["mtime"], reverse=True)
return audios
_info_cache = {}
def _cached_generation_info(path, mtime, size):
"""Generation info keyed by (mtime, size) so unchanged files are parsed once."""
cached = _info_cache.get(path)
if cached and cached[0] == (mtime, size):
return cached[1]
info = generation_info(path)
if len(_info_cache) > 5000:
_info_cache.clear()
_info_cache[path] = ((mtime, size), info)
return info
def _attach_generation_info(root_dir, items):
for item in items:
path = os.path.join(root_dir, item["subfolder"], item["filename"])
item["generation"] = _cached_generation_info(path, item["mtime"], item["size_bytes"])
return items
def _search_text(item):
speech = (item.get("generation") or {}).get("speech") or ""
return f"{item['subfolder']}/{item['filename']}\n{speech}".lower()
def _gallery_page(output_dir, page, limit, query):
all_audios = _collect_audio_files(output_dir, "output")
if query:
# Searching reads every file's comments once; the cache keeps later searches cheap.
_attach_generation_info(output_dir, all_audios)
all_audios = [item for item in all_audios if query in _search_text(item)]
total = len(all_audios)
start = (page - 1) * limit
items = all_audios[start:start + limit]
if not query:
_attach_generation_info(output_dir, items)
return items, total, start + limit < total
def _positive_int(value, default, maximum=None):
try:
number = int(value)
except (TypeError, ValueError):
return default
number = max(1, number)
return min(maximum, number) if maximum else number
async def api_get_audio_gallery(request):
"""GET /anomalous/audio_gallery?page=1&limit=50&q=text - Generated audio in output, with the speech that produced it."""
page = _positive_int(request.query.get("page"), 1)
limit = _positive_int(request.query.get("limit"), 40, 100)
query = request.query.get("q", "").strip().lower()
items, total, has_more = await asyncio.to_thread(_gallery_page, folder_paths.get_output_directory(), page, limit, query)
return web.json_response({
"success": True,
"audios": items,
"total": total,
"page": page,
"has_more": has_more,
})
def _list_previews(temp_dir):
items = _collect_audio_files(temp_dir, "temp")[:PREVIEW_LIMIT]
return _attach_generation_info(temp_dir, items)
async def api_get_audio_previews(request):
"""GET /anomalous/audio_previews - Unsaved preview audio in ComfyUI's temp folder (cleared on restart)."""
items = await asyncio.to_thread(_list_previews, folder_paths.get_temp_directory())
return web.json_response({"success": True, "audios": items})
def _save_preview(temp_dir, output_dir, subfolder, filename):
source = resolve_within(temp_dir, subfolder, filename)
ext = os.path.splitext(filename)[1].lower()
if ext not in AUDIO_EXTENSIONS:
raise ValueError("Only audio files can be saved")
if not os.path.isfile(source):
raise FileNotFoundError(filename)
dest_dir = resolve_within(output_dir, SAVED_PREVIEW_SUBFOLDER)
os.makedirs(dest_dir, exist_ok=True)
stamp = time.strftime("%Y%m%d-%H%M%S", time.localtime(os.path.getmtime(source)))
for counter in range(1, 1000):
name = f"Preview_{stamp}_{counter:02}{ext}"
target = os.path.join(dest_dir, name)
try:
# O_EXCL: never overwrite an existing file in the library.
fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0))
except FileExistsError:
continue
try:
with os.fdopen(fd, "wb") as out, open(source, "rb") as src:
shutil.copyfileobj(src, out)
except Exception:
try:
os.remove(target)
except OSError:
pass
raise
stat = os.stat(target)
rel_path = f"{SAVED_PREVIEW_SUBFOLDER}/{name}"
item = {
"filename": name,
"subfolder": SAVED_PREVIEW_SUBFOLDER,
"size_bytes": stat.st_size,
"mtime": stat.st_mtime,
"audio_url": _audio_url(rel_path, "output", stat.st_mtime),
}
return _attach_generation_info(output_dir, [item])[0]
raise FileExistsError("No free file name")
async def api_save_audio_preview(request):
"""POST /anomalous/save_audio_preview {filename, subfolder} - Copy a temp preview into output/audio."""
try:
data = await request.json()
except Exception:
return web.json_response({"success": False, "error": "Invalid JSON payload"}, status=400)
filename = str(data.get("filename", "")).strip()
subfolder = str(data.get("subfolder", "")).strip().strip("/\\")
if not filename or os.path.basename(filename) != filename:
return web.json_response({"success": False, "error": "Invalid filename"}, status=400)
try:
item = await asyncio.to_thread(
_save_preview, folder_paths.get_temp_directory(), folder_paths.get_output_directory(), subfolder, filename)
except ValueError as e:
return web.json_response({"success": False, "error": str(e)}, status=403)
except FileNotFoundError:
return web.json_response({"success": False, "code": "missing", "error": "Preview no longer exists"}, status=404)
except OSError as e:
return web.json_response({"success": False, "error": f"Failed to save: {e}"}, status=500)
return web.json_response({"success": True, "audio": item})
async def api_delete_audio_gallery(request):
"""POST /anomalous/delete_audio_gallery - Delete one generated audio file."""
try:
data = await request.json()
except Exception:
return web.json_response({"success": False, "error": "Invalid JSON payload"}, status=400)
filename = str(data.get("filename", "")).strip()
subfolder = str(data.get("subfolder", "")).strip().strip("/\\")
if not filename or os.path.basename(filename) != filename:
return web.json_response({"success": False, "error": "Invalid filename"}, status=400)
if os.path.splitext(filename)[1].lower() not in AUDIO_EXTENSIONS:
return web.json_response({"success": False, "error": "Only audio files can be deleted here"}, status=415)
try:
target = resolve_within(folder_paths.get_output_directory(), subfolder, filename)
except ValueError:
return web.json_response({"success": False, "error": "Forbidden path"}, status=403)
if not os.path.isfile(target):
return web.json_response({"success": False, "error": "File not found"}, status=404)
try:
await asyncio.to_thread(move_to_trash, target)
except OSError as e:
return web.json_response({"success": False, "error": trash_failure(e)}, status=409)
return web.json_response({"success": True})
+136
View File
@@ -0,0 +1,136 @@
"""Read ComfyUI generation info embedded in saved audio (FLAC and Ogg/Opus comments).
ComfyUI's audio save/preview nodes store the API prompt as a `prompt` comment.
Only the fields the audio gallery shows are extracted; nothing is written.
"""
import json
import os
MAX_TAG_BYTES = 16 * 1024 * 1024
MAX_OGG_SCAN_BYTES = 32 * 1024 * 1024
SPEECH_INPUTS = ("speech", "text", "gen_text")
# "character": Anomalous_TTS (GPT-SoVITS) names the voice by character instead of a file.
SAMPLE_INPUTS = ("sample", "ref_audio", "audio_prompt", "character")
def _parse_vorbis_comment(data, offset=0):
"""Parse a Vorbis comment block into {lowercase key: value}."""
tags = {}
try:
vendor_len = int.from_bytes(data[offset:offset + 4], "little")
pos = offset + 4 + vendor_len
count = int.from_bytes(data[pos:pos + 4], "little")
pos += 4
for _ in range(min(count, 1024)):
length = int.from_bytes(data[pos:pos + 4], "little")
pos += 4
entry = data[pos:pos + length].decode("utf-8", "replace")
pos += length
key, sep, value = entry.partition("=")
if sep:
tags.setdefault(key.lower(), value)
except (IndexError, ValueError):
pass
return tags
def _flac_tags(handle):
if handle.read(4) != b"fLaC":
return {}
while True:
header = handle.read(4)
if len(header) < 4:
return {}
is_last = header[0] & 0x80
block_type = header[0] & 0x7F
length = int.from_bytes(header[1:4], "big")
if block_type == 4:
if length > MAX_TAG_BYTES:
return {}
return _parse_vorbis_comment(handle.read(length))
handle.seek(length, os.SEEK_CUR)
if is_last:
return {}
def _ogg_packets(handle, wanted=2):
"""Yield the first `wanted` logical packets of an Ogg stream."""
packet = b""
scanned = 0
found = 0
while found < wanted and scanned < MAX_OGG_SCAN_BYTES:
header = handle.read(27)
if len(header) < 27 or header[:4] != b"OggS":
return
segments = header[26]
lacing = handle.read(segments)
scanned += 27 + segments
for size in lacing:
packet += handle.read(size)
scanned += size
if size < 255:
yield packet
packet = b""
found += 1
if found >= wanted:
return
if len(packet) > MAX_TAG_BYTES:
return
def _ogg_tags(handle):
packets = list(_ogg_packets(handle, wanted=2))
if len(packets) < 2:
return {}
comment = packets[1]
if comment.startswith(b"OpusTags"):
return _parse_vorbis_comment(comment, 8)
if comment.startswith(b"\x03vorbis"):
return _parse_vorbis_comment(comment, 7)
return {}
def read_audio_tags(path):
"""Return {lowercase key: value} comments for FLAC/Ogg/Opus files, else {}."""
ext = os.path.splitext(path)[1].lower()
try:
with open(path, "rb") as handle:
if ext == ".flac":
return _flac_tags(handle)
if ext in (".ogg", ".opus"):
return _ogg_tags(handle)
except OSError:
pass
return {}
def generation_info(path):
"""Speech text, sample voice and seed of the TTS node that produced this file, if recorded."""
raw = read_audio_tags(path).get("prompt")
if not raw:
return None
try:
prompt = json.loads(raw)
except ValueError:
return None
if not isinstance(prompt, dict):
return None
nodes = [node for node in prompt.values() if isinstance(node, dict) and isinstance(node.get("inputs"), dict)]
# Prefer TTS nodes; otherwise any node with a speech-like text input.
nodes.sort(key=lambda node: "tts" not in str(node.get("class_type", "")).lower())
for node in nodes:
inputs = node["inputs"]
speech = next((inputs[key] for key in SPEECH_INPUTS if isinstance(inputs.get(key), str) and inputs[key].strip()), None)
if speech is None:
continue
sample = next((inputs[key] for key in SAMPLE_INPUTS if isinstance(inputs.get(key), str)), None)
seed = inputs.get("seed")
return {
"node_type": str(node.get("class_type") or ""),
"speech": speech[:4000],
"sample": sample.replace("\\", "/") if sample else None,
"seed": seed if isinstance(seed, (int, float)) else None,
}
return None
+591
View File
@@ -0,0 +1,591 @@
"""Backups of the user's own data (Settings -> Backup): one .zip to keep anywhere (a cloud
drive, a USB stick), and putting it back on this or another computer.
A backup holds the plugin's stores (Workflow Recipes, combos, materials and saved prompts,
parameter sets), optionally ComfyUI's own saved workflows, the plugin settings without the
Civitai key, what the user set on models: <model>.anomalous.json (name, notes, link) and
the user's cover (any cover that is not a copy of Civitai's <model>.civitai_bak.*), what a
scan found (<model>.info; Civitai's cover only when asked, it can be hundreds of MB), and the
model list (each model's folder, path, size, SHA-256 and link) so the models a new computer
lacks can be downloaded again. Models are found again by SHA-256, else by models folder, path
and size, so they match after a move.
Putting back never erases: a file missing here is added; a file that differs stays unless
"replace" is chosen, and then the one here goes to the Recycle Bin first. A model's user file
is merged field by field (empty fields are filled; "replace" takes the backup's values), and
the backup's cover takes the place of a Civitai cover (whose copy stays as civitai_bak).
"""
import asyncio
import hashlib
import json
import os
import shutil
import tempfile
import time
import uuid
import zipfile
import folder_paths
from aiohttp import web
from .activity_log import add_entry
from .folder_types import config_path
from .material_store import get_materials_dir
from .metadata import USER_FIELDS, clear_metadata_cache, get_metadata
from .model_constants import CIVITAI_BACKUP_SUFFIXES, MEDIA_EXTENSIONS, PREVIEW_SUFFIXES, RESOLVABLE_MODEL_TYPES, is_model_file
from .model_download import settings_path as download_settings_path
from .notebooks import get_notebooks_dir
from .parameters import _invalidate_parameter_notebooks_cache, get_parameters_dir
from .path_utils import atomic_write_json, require_filename, resolve_within
from .recipe_store import get_recipes_dir
from .trash import TrashUnavailable, move_to_trash, trash_failure
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"
ACTIVE_COVER_SUFFIXES = PREVIEW_SUFFIXES + MEDIA_EXTENSIONS # as scraper.py: <model>.preview.png or <model>.png
SECRET_KEYS = {"CIVITAI_API_KEY", "DEEPL_API_KEY"} # never leave this computer
SCAN_INFO_SUFFIXES = (".info", ".civitai.info")
SKIP_FILES = {".legacy_imported.json"} # this computer's own migration marker
MAX_BACKUP_BYTES = 4 * 1024 ** 3 # unpacked size of a backup that is still read
MAX_MEMBERS = 200_000
KEEP_EXPORTS = 3 # finished exports kept in the temp folder for downloading
CHUNK = 1024 * 1024
PART_ORDER = ("recipes", "combos", "materials", "parameters", "comfy_workflows")
def _comfy_workflows_dir():
return os.path.join(folder_paths.get_user_directory(), "default", "workflows")
def part_dirs():
"""Each kept store and where it lives on this computer."""
return {
"recipes": get_recipes_dir(),
"combos": get_notebooks_dir(),
"materials": get_materials_dir(),
"parameters": get_parameters_dir(),
"comfy_workflows": _comfy_workflows_dir(),
}
def work_dir():
path = os.path.join(folder_paths.get_temp_directory(), "anomalous_backups")
os.makedirs(path, exist_ok=True)
return path
def _sha256_file(path):
digest = hashlib.sha256()
with open(path, "rb") as handle:
for block in iter(lambda: handle.read(CHUNK), b""):
digest.update(block)
return digest.hexdigest()
def _store_files(directory):
"""(path, '/'-relative path) of every file kept in a store."""
if not os.path.isdir(directory):
return
for root, dirs, files in os.walk(directory):
dirs[:] = sorted(d for d in dirs if d != "__pycache__")
for name in sorted(files):
if name in SKIP_FILES or name.endswith(".tmp"):
continue
path = os.path.join(root, name)
yield path, os.path.relpath(path, directory).replace(os.sep, "/")
def _model_roots():
"""(models folder type, folder) once per folder: clip and text_encoders often share one."""
seen = set()
for folder_type in RESOLVABLE_MODEL_TYPES:
try:
roots = folder_paths.get_folder_paths(folder_type) or []
except Exception: # noqa: BLE001 - a type this ComfyUI does not know
continue
for root in roots:
key = os.path.normcase(os.path.abspath(root))
if key in seen or not os.path.isdir(root):
continue
seen.add(key)
yield folder_type, root
def _models():
"""(type, root, path, '/'-relative path) of every model file."""
for folder_type, root in _model_roots():
for walk_root, dirs, files in os.walk(root):
dirs.sort()
for name in sorted(files):
if is_model_file(name):
path = os.path.join(walk_root, name)
yield folder_type, root, path, os.path.relpath(path, root).replace(os.sep, "/")
def _model_facts(path):
"""(SHA-256, download page) from the model's sidecars; nothing is computed."""
try:
meta = get_metadata(path) or {}
except Exception: # noqa: BLE001 - an unreadable sidecar only means "match by path"
return "", ""
return str(meta.get("hash") or "").lower(), str(meta.get("source_url") or meta.get("civitai_url") or "")
def _model_hash(path):
return _model_facts(path)[0]
def _settings_for_backup():
data = read_json(config_path()) or {}
return {key: value for key, value in data.items() if key not in SECRET_KEYS}
def _version():
try:
return version_manager.current_state().get("label") or ""
except Exception: # noqa: BLE001 - a copy that is no git checkout
return ""
# ---------- export ----------
def build_backup(include_models=True, include_comfy=True, include_library=True, include_scan=True, include_civitai_covers=False):
"""Writes the backup .zip into the temp folder: (path, manifest)."""
stamp = time.strftime("%Y%m%d-%H%M%S")
path = os.path.join(work_dir(), f"AMB-backup-{stamp}.zip")
manifest = {"format": FORMAT, "app": APP, "version": _version(), "created": time.time(), "parts": {}, "settings": False, "models": []}
parts = [part for part in PART_ORDER if include_comfy or part != "comfy_workflows"]
dirs = part_dirs()
temporary = path + ".tmp"
try:
with zipfile.ZipFile(temporary, "w", zipfile.ZIP_DEFLATED, allowZip64=True) as archive:
for part in parts:
count = 0
for file_path, rel in _store_files(dirs[part]):
archive.write(file_path, f"stores/{part}/{rel}")
count += 1
manifest["parts"][part] = count
archive.writestr("settings/config.json", json.dumps(_settings_for_backup(), ensure_ascii=False))
if os.path.isfile(download_settings_path()):
archive.write(download_settings_path(), "settings/download_settings.json")
manifest["settings"] = True
if include_library:
manifest["library"] = []
if include_models or include_library:
for folder_type, _root, model_path, rel in _models():
if include_library:
digest, page = _model_facts(model_path)
manifest["library"].append({"type": folder_type, "rel": rel, "size": os.path.getsize(model_path),
"sha256": digest, "url": page})
if not include_models:
continue
base = os.path.splitext(model_path)[0]
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 = 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,
"size": os.path.getsize(model_path), "sha256": _model_hash(model_path), "user": bool(user_data), "cover": ""}
folder = f"models/{entry['id']}/"
if user_data:
archive.writestr(folder + "user.json", json.dumps(user_data, ensure_ascii=False))
if covers:
entry["cover"] = covers[0][len(base):]
archive.write(covers[0], f"{folder}cover{entry['cover']}")
if scan_info:
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 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)
finally:
if os.path.exists(temporary):
os.remove(temporary)
_prune_exports()
return path, manifest
def _prune_exports():
"""Keeps the newest finished exports; older ones are this plugin's own temp files."""
exports = sorted((name for name in os.listdir(work_dir()) if name.startswith("AMB-backup-") and name.endswith(".zip")), reverse=True)
for name in exports[KEEP_EXPORTS:]:
try:
os.remove(os.path.join(work_dir(), name))
except OSError:
pass
# ---------- reading a backup ----------
class BackupError(ValueError):
"""A file that is no backup of this plugin, or one that cannot be read safely."""
def _safe_rel(rel):
parts = rel.split("/")
if not rel or rel.startswith("/") or "\\" in rel or ":" in rel or any(part in ("", ".", "..") for part in parts):
raise BackupError(f"unsafe path in the backup: {rel}")
return parts
def open_backup(path):
"""The zip and its manifest, after checking it is a backup and safe to unpack."""
try:
archive = zipfile.ZipFile(path)
except (zipfile.BadZipFile, OSError) as error:
raise BackupError("not a zip file") from error
try:
infos = archive.infolist()
if len(infos) > MAX_MEMBERS or sum(info.file_size for info in infos) > MAX_BACKUP_BYTES:
raise BackupError("the backup is too large")
try:
manifest = json.loads(archive.read("manifest.json"))
except (KeyError, ValueError) as error:
raise BackupError("no manifest: not a backup of this plugin") from error
if not isinstance(manifest, dict) or manifest.get("app") != APP or manifest.get("format") != FORMAT:
raise BackupError("not a backup of this plugin, or one from a newer version")
for info in infos:
if not info.is_dir():
_safe_rel(info.filename)
return archive, manifest
except Exception:
archive.close()
raise
def _store_members(archive):
"""(part, '/'-relative path, ZipInfo) of each kept store file in the backup."""
for info in archive.infolist():
name = info.filename
if info.is_dir() or not name.startswith("stores/"):
continue
bits = name.split("/", 2)
if len(bits) == 3 and bits[1] in PART_ORDER:
_, part, rel = bits
_safe_rel(rel)
yield part, rel, info
def _member_sha256(archive, info):
digest = hashlib.sha256()
with archive.open(info) as handle:
for block in iter(lambda: handle.read(CHUNK), b""):
digest.update(block)
return digest.hexdigest()
def _file_state(archive, info, target):
if not os.path.isfile(target):
return "new"
if os.path.getsize(target) == info.file_size and _sha256_file(target) == _member_sha256(archive, info):
return "same"
return "differs"
def _local_models():
"""This computer's models: by SHA-256 and by (type, path) -> (path, size)."""
by_hash, by_rel = {}, {}
for folder_type, _root, path, rel in _models():
size = os.path.getsize(path)
digest = _model_hash(path)
if digest:
by_hash.setdefault(digest, path)
by_rel[(folder_type, rel.lower())] = (path, size)
return by_hash, by_rel
def _match(entry, by_hash, by_rel):
digest = str(entry.get("sha256") or "").lower()
if digest and digest in by_hash:
return by_hash[digest]
found = by_rel.get((entry.get("type"), str(entry.get("rel") or "").lower()))
return found[0] if found and found[1] == entry.get("size") else None
def inspect_backup(path):
"""What putting the backup back would do, before anything is written."""
archive, manifest = open_backup(path)
with archive:
dirs = part_dirs()
parts = {}
for part, rel, info in _store_members(archive):
counts = parts.setdefault(part, {"new": 0, "same": 0, "differs": 0})
counts[_file_state(archive, info, resolve_within(dirs[part], *rel.split("/")))] += 1
models = {"total": len(manifest.get("models") or []), "matched": 0, "missing": []}
library = manifest.get("library") if isinstance(manifest.get("library"), list) else None
summary = {"created": manifest.get("created"), "version": manifest.get("version") or "",
"parts": {part: parts[part] for part in PART_ORDER if part in parts},
"settings": bool(manifest.get("settings")), "models": models}
if models["total"] or library:
by_hash, by_rel = _local_models()
for entry in manifest.get("models") or []:
if _match(entry, by_hash, by_rel):
models["matched"] += 1
else:
models["missing"].append(entry.get("rel") or "")
if library is not None:
# The models the old computer had and this one lacks: what can be downloaded again
# (two copies of one file there are one download here).
missing, seen = [], set()
for entry in library:
if not isinstance(entry, dict) or _match(entry, by_hash, by_rel):
continue
digest = str(entry.get("sha256") or "").lower()
if digest and digest in seen:
continue
seen.add(digest)
missing.append({key: entry.get(key) for key in ("type", "rel", "size", "sha256", "url")})
summary["library"] = {"total": len(library), "missing": missing}
return summary
# ---------- putting back ----------
def _write_member(archive, info, target):
os.makedirs(os.path.dirname(target), exist_ok=True)
fd, temporary = tempfile.mkstemp(prefix=".restore-", suffix=".tmp", dir=os.path.dirname(target))
try:
with os.fdopen(fd, "wb") as output, archive.open(info) as source:
shutil.copyfileobj(source, output, CHUNK)
os.replace(temporary, target)
finally:
if os.path.exists(temporary):
os.remove(temporary)
def _restore_stores(archive, wanted, replace, result):
dirs = part_dirs()
for part, rel, info in _store_members(archive):
if part not in wanted:
continue
target = resolve_within(dirs[part], *rel.split("/"))
state = _file_state(archive, info, target)
if state == "same" or (state == "differs" and not replace):
result["skipped"] += 1
continue
if state == "differs":
try:
move_to_trash(target)
except (TrashUnavailable, OSError) as error:
result["failed"].append(f"{part}/{rel}: {trash_failure(error)}")
continue
result["replaced"] += 1
else:
result["added"] += 1
_write_member(archive, info, target)
def _restore_scan(archive, entry, base, folder, replace, result):
"""What a scan found: the scan file where there is none (or replaced on request), and
Civitai's cover where the model has no cover at all."""
suffix = str(entry.get("info") or "")
if suffix:
if suffix not in SCAN_INFO_SUFFIXES:
raise BackupError(f"unknown scan file name: {suffix}")
info = archive.getinfo(folder + "scan" + suffix)
local = scan_info_path(base)
if not local or (replace and _sha256_file(local) != _member_sha256(archive, info)):
if local:
move_to_trash(local)
_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 = 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):
base = os.path.splitext(model_path)[0]
folder = f"models/{int(entry['id'])}/"
_restore_scan(archive, entry, base, folder, replace, result)
if entry.get("user"):
backup = json.loads(archive.read(folder + "user.json"))
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:
value = str(backup.get(key) or "").strip()
if value and (replace or not str(merged.get(key) or "").strip()):
merged[key] = backup[key]
if merged != (local or {"format": 1}):
if local and replace:
move_to_trash(user_file) # the old values stay recoverable
atomic_write_json(user_file, merged)
result["model_notes"] += 1
suffix = str(entry.get("cover") or "")
if suffix:
if suffix not in ACTIVE_COVER_SUFFIXES:
raise BackupError(f"unknown cover name: {suffix}")
info = archive.getinfo(folder + "cover" + suffix)
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)
result["model_covers"] += 1
def _restore_settings(archive, result):
names = set(archive.namelist())
if "settings/config.json" in names:
backup = json.loads(archive.read("settings/config.json"))
local = read_json(config_path()) or {}
merged = {**{key: value for key, value in backup.items() if key not in SECRET_KEYS},
**{key: local[key] for key in SECRET_KEYS if key in local}}
atomic_write_json(config_path(), merged)
result["settings"] = True
if "settings/download_settings.json" in names:
os.makedirs(os.path.dirname(download_settings_path()), exist_ok=True)
atomic_write_json(download_settings_path(), json.loads(archive.read("settings/download_settings.json")))
def apply_backup(path, parts=PART_ORDER, models=True, settings=False, replace=False):
"""Puts the backup back as chosen; returns the counts for the user and the activity log."""
archive, manifest = open_backup(path)
result = {"added": 0, "replaced": 0, "skipped": 0, "model_notes": 0, "model_covers": 0, "model_infos": 0,
"model_civitai_covers": 0, "covers_kept": 0, "models_missing": 0, "settings": False, "failed": []}
with archive:
_restore_stores(archive, set(parts) & set(PART_ORDER), replace, result)
if models and manifest.get("models"):
by_hash, by_rel = _local_models()
for entry in manifest["models"]:
model_path = _match(entry, by_hash, by_rel)
if not model_path:
result["models_missing"] += 1
continue
try:
_restore_model(archive, entry, model_path, replace, result)
except (TrashUnavailable, OSError, KeyError, ValueError) as error:
result["failed"].append(f"{entry.get('rel')}: {trash_failure(error) if isinstance(error, (TrashUnavailable, OSError)) else error}")
clear_metadata_cache()
if settings and manifest.get("settings"):
_restore_settings(archive, result)
_invalidate_parameter_notebooks_cache()
return result
# ---------- routes ----------
def _error(message, status=400):
return web.json_response({"status": "error", "error": str(message)}, status=status)
async def api_export(request):
try:
body = await request.json()
except Exception: # noqa: BLE001 - no body: the defaults
body = {}
path, manifest = await asyncio.to_thread(build_backup, body.get("models", True) is not False,
body.get("comfy_workflows", True) is not False, body.get("library", True) is not False,
body.get("scan", True) is not False, body.get("civitai_covers") is True)
counts = {**manifest["parts"], "models": len(manifest["models"]), "library": len(manifest.get("library") or [])}
await asyncio.to_thread(add_entry, "file", "backup_export", os.path.basename(path), {"backup": counts})
return web.json_response({"status": "success", "name": os.path.basename(path), "size": os.path.getsize(path), "counts": counts})
async def api_file(request):
try:
name = require_filename(request.query.get("name", ""))
if not name.startswith("AMB-backup-") or not name.endswith(".zip"):
raise ValueError("not a backup")
path = resolve_within(work_dir(), name)
except ValueError as error:
return _error(error)
if not os.path.isfile(path):
return _error("This backup is gone; export it again.", 404)
return web.FileResponse(path, headers={"Content-Disposition": f'attachment; filename="{name}"'})
async def api_inspect(request):
"""Receives the chosen .zip, keeps it in the temp folder and says what putting it back would do."""
try:
reader = await request.multipart()
field = await reader.next()
except (AssertionError, ValueError): # not a file upload at all
return _error("no file")
if field is None or field.name != "file":
return _error("no file")
token = uuid.uuid4().hex
path = os.path.join(work_dir(), f"incoming-{token}.zip")
size = 0
with open(path, "wb") as output:
while chunk := await field.read_chunk(CHUNK):
size += len(chunk)
if size > MAX_BACKUP_BYTES:
output.close()
os.remove(path)
return _error("the backup is too large")
output.write(chunk)
try:
summary = await asyncio.to_thread(inspect_backup, path)
except BackupError as error:
os.remove(path)
return _error(error)
return web.json_response({"status": "success", "token": token, **summary})
async def api_apply(request):
try:
body = await request.json()
token = str(body.get("token") or "")
if not token.isalnum():
raise ValueError("bad token")
path = resolve_within(work_dir(), f"incoming-{token}.zip")
except Exception as error: # noqa: BLE001
return _error(error)
if not os.path.isfile(path):
return _error("This backup is gone; choose the file again.", 404)
parts = [part for part in body.get("parts") or [] if part in PART_ORDER]
try:
result = await asyncio.to_thread(apply_backup, path, parts, body.get("models") is not False,
body.get("settings") is True, body.get("replace") is True)
except BackupError as error:
return _error(error)
os.remove(path)
await asyncio.to_thread(add_entry, "file", "backup_import", str(body.get("name") or ""), {"backup": {**result, "failed": result["failed"][:20]}})
return web.json_response({"status": "success", **result})
def register_routes(app):
app.router.add_post("/anomalous/backup/export", api_export)
app.router.add_get("/anomalous/backup/file", api_file)
app.router.add_post("/anomalous/backup/inspect", api_inspect)
app.router.add_post("/anomalous/backup/apply", api_apply)
+57 -28
View File
@@ -9,6 +9,13 @@ from aiohttp import web
import folder_paths
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__))
config_path = os.path.join(script_dir, "config.json")
@@ -20,20 +27,27 @@ async def api_get_config(request):
cfg = json.load(f)
has_key = bool(cfg.get("CIVITAI_API_KEY", "").strip())
folder_types = cfg.get("folder_types_config", [])
except:
pass
if not has_key:
legacy_config_path = os.path.join(os.path.dirname(script_dir), "config.json")
if os.path.exists(legacy_config_path):
try:
with open(legacy_config_path, 'r', encoding='utf-8') as f:
legacy_cfg = json.load(f)
has_key = bool(legacy_cfg.get("CIVITAI_API_KEY", "").strip())
except Exception:
pass
return web.json_response({"has_api_key": has_key, "folder_types_config": folder_types})
except:
pass
if not has_key:
legacy_config_path = os.path.join(os.path.dirname(script_dir), "config.json")
if os.path.exists(legacy_config_path):
try:
with open(legacy_config_path, 'r', encoding='utf-8') as f:
legacy_cfg = json.load(f)
has_key = bool(legacy_cfg.get("CIVITAI_API_KEY", "").strip())
except Exception:
pass
deepl_key = ""
try:
with open(config_path, 'r', encoding='utf-8') as f:
deepl_key = json.load(f).get("DEEPL_API_KEY", "")
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(), "sidecar_layout": _side_file_layout(), "folder_types_config": folder_types})
async def api_save_config(request):
async def api_save_config(request):
try:
data = await request.json()
api_key = data.get("api_key")
@@ -50,17 +64,32 @@ async def api_save_config(request):
except:
pass
if api_key is not None and not isinstance(api_key, str):
return web.json_response({"status": "error", "message": "Invalid API key"}, status=400)
if folder_types_config is not None and not isinstance(folder_types_config, list):
return web.json_response({"status": "error", "message": "Invalid folder configuration"}, status=400)
if "physical_folders_config" in data and not isinstance(data.get("physical_folders_config"), list):
return web.json_response({"status": "error", "message": "Invalid physical folder configuration"}, status=400)
if "folder_view_mode" in data and data.get("folder_view_mode") not in {"abstract", "physical"}:
return web.json_response({"status": "error", "message": "Invalid folder view mode"}, status=400)
if api_key is not None:
cfg["CIVITAI_API_KEY"] = api_key.strip()
deepl_key = data.get("deepl_key")
if api_key is not None and not isinstance(api_key, str):
return web.json_response({"status": "error", "message": "Invalid API key"}, status=400)
if deepl_key is not None and not isinstance(deepl_key, str):
return web.json_response({"status": "error", "message": "Invalid DeepL key"}, status=400)
proxy = None
if "proxy" in data: # Settings -> Network (net_proxy.py); "" = the system's proxy
try:
proxy = normalize_proxy(data.get("proxy"))
except ValueError as error:
return web.json_response({"status": "error", "message": "Invalid proxy", "code": str(error)}, status=400)
if folder_types_config is not None and not isinstance(folder_types_config, list):
return web.json_response({"status": "error", "message": "Invalid folder configuration"}, status=400)
if "physical_folders_config" in data and not isinstance(data.get("physical_folders_config"), list):
return web.json_response({"status": "error", "message": "Invalid physical folder configuration"}, status=400)
if "folder_view_mode" in data and data.get("folder_view_mode") not in {"abstract", "physical"}:
return web.json_response({"status": "error", "message": "Invalid folder view mode"}, status=400)
if api_key is not None:
cfg["CIVITAI_API_KEY"] = api_key.strip()
if deepl_key is not None: # Settings -> Translation (translation_routes.py)
cfg["DEEPL_API_KEY"] = deepl_key.strip()
if proxy is not None:
cfg[PROXY_KEY] = proxy
if folder_types_config is not None:
cfg["folder_types_config"] = folder_types_config
@@ -71,10 +100,10 @@ async def api_save_config(request):
if "folder_view_mode" in data:
cfg["folder_view_mode"] = data.get("folder_view_mode")
temp_path = config_path + ".tmp"
with open(temp_path, 'w', encoding='utf-8') as f:
json.dump(cfg, f, indent=4)
os.replace(temp_path, config_path)
temp_path = config_path + ".tmp"
with open(temp_path, 'w', encoding='utf-8') as f:
json.dump(cfg, f, indent=4)
os.replace(temp_path, config_path)
return web.json_response({"status": "ok"})
except Exception as e:
+373
View File
@@ -0,0 +1,373 @@
"""Where a model the open workflow is missing can be downloaded from, for Model Check's
"Download" (api/model_download.py fetches it).
Sources that name one exact file: Civitai by the file's hash (the workflow's fingerprint), a
Civitai model version or file link, or a direct file link on Hugging Face (or its mirror) or
GitHub, as a shared workflow carries them (AMB's model links, ComfyUI's own `properties.models`).
Then ComfyUI-Manager's model list by file name (manager_catalog.py): with the fingerprint, only
the entry whose file has it; without, the one entry of that name, marked `by_name` because a
name is no proof (the page asks before it downloads one). Each answer says the file's size and
SHA-256 when the site gives them, so the download can be checked.
"""
import json
import re
import urllib.error
import urllib.parse
import urllib.request
try:
from ..civitai_client import USER_AGENT, _load_api_key, answered, api_hosts
from ..net_proxy import urlopen
except ImportError: # loaded outside the package (tests)
from civitai_client import USER_AGENT, _load_api_key, answered, api_hosts
from net_proxy import urlopen
from . import manager_catalog
CIVITAI_HOSTS = ("civitai.com", "civitai.red")
HF_HOSTS = ("huggingface.co", "hf-mirror.com")
HF_MIRROR = "hf-mirror.com" # Hugging Face's mirror for mainland China (it sends other networks back)
OTHER_HOSTS = ("github.com",)
TIMEOUT = 15
SHA256 = re.compile(r"^[0-9a-fA-F]{64}$")
class SourceUnreachable(Exception):
"""The site did not answer (network, timeout, server error)."""
def host_of(url):
try:
parsed = urllib.parse.urlsplit(str(url).strip())
except ValueError:
return ""
return (parsed.hostname or "").lower() if parsed.scheme == "https" else ""
def _site(host, sites):
return any(host == site or host.endswith("." + site) for site in sites)
def allowed_download_url(url):
"""Whether `url` is an https link on a site downloads may start from."""
host = host_of(url)
return bool(host) and _site(host, CIVITAI_HOSTS + HF_HOSTS + OTHER_HOSTS)
def is_civitai(url):
return _site(host_of(url), CIVITAI_HOSTS)
def civitai_headers():
headers = {"User-Agent": USER_AGENT}
key = _load_api_key()
if key:
headers["Authorization"] = f"Bearer {key}"
return headers
def _get_json(url, headers):
"""The JSON at `url`; None on 404; SourceUnreachable when it could not be asked."""
try:
with urlopen(urllib.request.Request(url, headers=headers), timeout=TIMEOUT) as response:
return json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as error:
if error.code in (400, 404):
return None
raise SourceUnreachable(f"HTTP {error.code}") from error
except (urllib.error.URLError, OSError, ValueError) as error:
raise SourceUnreachable(str(error)) from error
def _civitai_json(path):
"""Civitai's API answer at `path` (/api/v1/…), from whichever of its addresses answers
(civitai_client.api_hosts); None on 404."""
unreachable = None
for host in api_hosts():
try:
data = _get_json(f"https://{host}{path}", civitai_headers())
except SourceUnreachable as error:
if isinstance(error.__cause__, urllib.error.HTTPError):
raise # Civitai answered with an error: the other address is the same Civitai
unreachable = error
continue
answered(host)
return data
raise unreachable
def _civitai_page(version):
domain = "civitai.red" if (version.get("model") or {}).get("nsfw") or (version.get("nsfwLevel") or 1) > 1 else "civitai.com"
model_id = version.get("modelId")
return f"https://{domain}/models/{model_id}?modelVersionId={version.get('id')}" if model_id else ""
def _pick_civitai_file(files, file_hash="", name=""):
"""The version's file with this hash (SHA-256 or a prefix such as AutoV2; None if it has
none such). Without a hash: the file of this name, else the version's primary model file —
the link names that version, and people rename their files."""
files = [item for item in files or [] if isinstance(item, dict) and item.get("downloadUrl")]
wanted = str(file_hash or "").upper()
if wanted:
for item in files:
hashes = {str(value).upper() for value in (item.get("hashes") or {}).values()}
sha = str((item.get("hashes") or {}).get("SHA256") or "").upper()
if wanted in hashes or (len(wanted) >= 10 and sha.startswith(wanted)):
return item
return None
if name:
for item in files:
if str(item.get("name", "")).lower() == name.lower():
return item
models = [item for item in files if item.get("type", "Model") == "Model"]
return next((item for item in models if item.get("primary")), models[0] if models else None)
def _from_civitai_version(version, file_hash="", name=""):
picked = _pick_civitai_file(version.get("files"), file_hash, name)
if not picked:
return None
sha = str((picked.get("hashes") or {}).get("SHA256") or "")
return {
"found": True,
"source": "civitai",
"download_url": picked["downloadUrl"],
"file_name": picked.get("name") or "",
"size": int(float(picked.get("sizeKB") or 0) * 1024),
"sha256": sha.lower() if SHA256.match(sha) else "",
"format": ((picked.get("metadata") or {}).get("format") or ""),
"base_model": version.get("baseModel") or "",
"model_name": (version.get("model") or {}).get("name") or "",
"version_name": version.get("name") or "",
"page": _civitai_page(version),
}
def civitai_by_hash(file_hash):
version = _civitai_json(f"/api/v1/model-versions/by-hash/{urllib.parse.quote(file_hash)}")
return _from_civitai_version(version, file_hash) if version else None
def _civitai_version_id(url):
parsed = urllib.parse.urlsplit(url)
query = urllib.parse.parse_qs(parsed.query)
if query.get("modelVersionId"):
return query["modelVersionId"][0]
match = re.search(r"/api/download/models/(\d+)", parsed.path) or re.search(r"/model-versions/(\d+)", parsed.path)
return match.group(1) if match else ""
def civitai_by_link(url, name, file_hash=""):
"""A Civitai link that names a model version: its file of this hash or name."""
version_id = _civitai_version_id(url)
if not version_id.isdigit():
return None
version = _civitai_json(f"/api/v1/model-versions/{version_id}")
return _from_civitai_version(version, file_hash, name) if version else None
class _NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *args, **kwargs):
return None
def _hf_file_url(url, host=""):
"""https://<hf>/<repo>/resolve/<rev>/<path> for a blob or resolve link to a file, else '';
on `host` (Hugging Face or its mirror) when given."""
parsed = urllib.parse.urlsplit(url)
parts = [part for part in parsed.path.split("/") if part]
for marker in ("resolve", "blob"):
if marker in parts:
at = parts.index(marker)
if at >= 2 and len(parts) > at + 2:
parts[at] = "resolve"
return urllib.parse.urlunsplit(("https", host or parsed.netloc, "/" + "/".join(parts), "", ""))
return ""
def _head(url):
"""(headers, redirected) of a HEAD request that does not follow redirects; None on 404,
"gated" on 401/403."""
opener = urllib.request.build_opener(_NoRedirect)
request = urllib.request.Request(url, method="HEAD", headers={"User-Agent": USER_AGENT})
try:
return opener.open(request, timeout=TIMEOUT).headers, False
except urllib.error.HTTPError as error:
if error.code in (401, 403):
return "gated"
if error.code == 404:
return None
if error.code not in (301, 302, 303, 307, 308):
raise SourceUnreachable(f"HTTP {error.code}") from error
return error.headers, True
except (urllib.error.URLError, OSError) as error:
raise SourceUnreachable(str(error)) from error
def hugging_face_file(url, host=""):
"""A Hugging Face file link, fetched from `host` (huggingface.co or the mirror; the link's
own when empty): its size and SHA-256 from the site's headers."""
file_url = _hf_file_url(url, host)
if not file_url:
return None
answer = _head(file_url)
if isinstance(answer, tuple) and answer[1] and not answer[0].get("x-linked-size"):
# Sent on to the other Hugging Face host (the mirror does that outside China): ask there.
location = urllib.parse.urljoin(file_url, answer[0].get("Location") or "")
if _site(host_of(location), HF_HOSTS) and host_of(location) != host_of(file_url):
answer = _head(location)
if answer is None:
return None
if answer == "gated":
# The repository's page, where the user logs in and accepts its terms.
parts = urllib.parse.urlsplit(file_url).path.strip("/").split("/")
return {"found": False, "reason": "gated", "page": f"https://huggingface.co/{'/'.join(parts[:2])}"}
headers, redirected = answer
etag = str(headers.get("x-linked-etag") or headers.get("etag") or "").strip('"').replace("W/", "").strip('"')
# A redirect's own length is not the file's: without x-linked-size (small files kept
# outside large-file storage) the size is unknown.
size = headers.get("x-linked-size") or (0 if redirected else headers.get("content-length")) or 0
return {
"found": True,
"source": "huggingface",
"mirror": host_of(file_url) == HF_MIRROR,
"download_url": file_url,
"file_name": urllib.parse.unquote(file_url.rsplit("/", 1)[-1]),
"size": int(size) if str(size).isdigit() else 0,
"sha256": etag.lower() if SHA256.match(etag) else "",
"format": "",
"base_model": "",
"model_name": "",
"version_name": "",
"page": url,
}
def plain_link(url):
"""A direct file link on GitHub (a release asset, or a file in a repository, fetched raw): no
size or hash to check against."""
parsed = urllib.parse.urlsplit(url)
path = parsed.path
if "/blob/" in path: # the page about the file; its raw form is the file
path = path.replace("/blob/", "/raw/", 1)
if "/releases/download/" not in path and "/raw/" not in path:
return None
return {
"found": True, "source": "link", "download_url": urllib.parse.urlunsplit(("https", parsed.netloc, path, "", "")),
"file_name": urllib.parse.unquote(path.rsplit("/", 1)[-1]), "size": 0, "sha256": "",
"format": "", "base_model": "", "model_name": "", "version_name": "", "page": url,
}
def same_file(expected, sha256):
"""Whether a fingerprint (full SHA-256 or a prefix of 10+, AutoV2) names this SHA-256."""
expected, sha256 = str(expected or "").lower(), str(sha256 or "").lower()
return len(expected) >= 10 and sha256.startswith(expected)
def _is_sha_prefix(value):
return len(value) >= 10 and bool(re.fullmatch(r"[0-9a-fA-F]+", value))
class _Sites:
"""Which sites stopped answering during one lookup, so the next models skip only those
(offline in mainland China, Civitai fails while Hugging Face through its mirror works)."""
def __init__(self, down=None):
self.down = down if down is not None else set()
self.missed = False
def ask(self, site, call, *args):
if site in self.down:
self.missed = True
return None
try:
return call(*args)
except SourceUnreachable:
self.down.add(site)
self.missed = True
return None
def _by_link(sites, url, name, file_hash, hf_host):
host = host_of(url)
if _site(host, CIVITAI_HOSTS):
return sites.ask("civitai", civitai_by_link, url, name, file_hash)
if _site(host, HF_HOSTS):
return sites.ask("huggingface", hugging_face_file, url, hf_host)
if _site(host, OTHER_HOSTS):
return plain_link(url)
return None
def _from_manager_list(sites, name, folder_type, file_hash, hf_host):
"""A file of this name in ComfyUI-Manager's model list. With the workflow's fingerprint,
only an entry whose file has that SHA-256 (several of the same name are each asked); without,
the one entry of that name, marked `by_name` (nothing can tell it is the same file)."""
entries = manager_catalog.candidates(name, folder_type)
if not entries:
return None
if _is_sha_prefix(file_hash):
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,
"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"],
"list_base": entries[0]["base"], "list_site": manager_catalog.site_of(entries[0]["url"])}
return found
def _same_file_on_hugging_face(sites, civitai, url, name, folder_type, hf_host):
"""With the mirror on (mainland China, where Civitai is slow or out of reach and some of its
files need a login), the same file (same SHA-256) on Hugging Face, from the workflow's link
or ComfyUI-Manager's list, is fetched there instead. Civitai's name, base model and page stay."""
sha = civitai.get("sha256") or ""
if not SHA256.match(sha):
return civitai
other = None
if url and _site(host_of(url), HF_HOSTS):
other = _by_link(sites, url, name, "", hf_host)
if not (other and other.get("found") and same_file(sha, other.get("sha256"))):
other = None
if other is None and name:
other = _from_manager_list(sites, name, folder_type, sha, hf_host)
if not other:
return civitai
keep = {key: civitai[key] for key in ("base_model", "model_name", "version_name", "page") if civitai.get(key)}
return {**other, **keep, "via": other.get("via", ""), "also_on": "civitai"}
def find_source(file_hash="", url="", name="", hf_mirror=False, folder_type="", down=None):
"""Where one missing model can come from: {found, source, download_url, file_name, size,
sha256, base_model, model_name, version_name, page}, or {found: False, reason}. Asked in
turn: Civitai by the fingerprint, the workflow's link, ComfyUI-Manager's model list by file
name. With `hf_mirror`, Hugging Face files come from its mirror (and without, never from it).
`down` (a set kept across one lookup) names the sites that stopped answering."""
sites = _Sites(down)
hf_host = HF_MIRROR if hf_mirror else "huggingface.co"
looked = False
found = None
if file_hash:
looked = True
found = sites.ask("civitai", civitai_by_hash, file_hash)
if not found and url and host_of(url):
looked = True
found = _by_link(sites, url, name, file_hash, hf_host)
if not (found and found.get("found")) and name:
listed = _from_manager_list(sites, name, folder_type, file_hash, hf_host)
looked = looked or listed is not None or bool(manager_catalog.candidates(name, folder_type))
found = listed or found
if found and found.get("found") and found.get("source") == "civitai" and hf_mirror:
found = _same_file_on_hugging_face(sites, found, url, name, folder_type, hf_host)
if found:
return found
if sites.missed:
return {"found": False, "reason": "network"}
return {"found": False, "reason": "not_found" if looked else "no_source"}
+281
View File
@@ -0,0 +1,281 @@
"""Configured model-folder visibility and scan-scope helpers.
Shown folders are also the ones scans read, so a workflow can carry each model's fingerprint
only for them. Text encoders, CLIP vision and upscalers are shown by default since
2026-10 (FOLDER_DEFAULTS_VERSION 2); an older config.json gets them turned on once.
"""
import json
import os
from aiohttp import web
import folder_paths
from .path_utils import atomic_write_json
DEFAULT_TYPES = ['checkpoints', 'loras', 'diffusion_models', 'unet', 'controlnet', 'vae',
'text_encoders', 'clip', 'clip_vision', 'upscale_models']
# Added to the defaults in version 2 (the components a workflow's fingerprints need most).
ADDED_IN_2 = {'text_encoders', 'clip', 'clip_vision', 'upscale_models'}
FOLDER_DEFAULTS_VERSION = 2
DEFAULT_PHYSICAL_FOLDER_NAMES = {
'checkpoints', 'loras', 'unet', 'diffusion_models', 'controlnet', 'vae',
'text_encoders', 'clip', 'clip_vision', 'upscale_models',
}
def config_path():
return os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json")
def upgrade_folder_defaults(path=None):
"""Once per config.json: the folders added to the defaults are shown (and scanned) even
where an earlier version saved them as hidden. True when the file changed."""
path = path or config_path()
try:
with open(path, encoding='utf-8') as source:
cfg = json.load(source)
except (OSError, ValueError):
return False # no config yet: the defaults apply as they are
if not isinstance(cfg, dict) or int(cfg.get("folder_defaults_version") or 1) >= FOLDER_DEFAULTS_VERSION:
return False
for key in ("folder_types_config", "physical_folders_config"):
for item in cfg.get(key) or []:
if isinstance(item, dict) and item.get("type") in ADDED_IN_2:
item["visible"] = True
cfg["folder_defaults_version"] = FOLDER_DEFAULTS_VERSION
atomic_write_json(path, cfg)
return True
def get_folder_view_mode():
script_dir = os.path.dirname(os.path.abspath(__file__))
config_path = os.path.join(script_dir, "config.json")
try:
if os.path.exists(config_path):
with open(config_path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
return cfg.get("folder_view_mode", "abstract")
except:
pass
return "abstract"
def get_all_physical_basenames():
basenames = set()
for t in folder_paths.folder_names_and_paths.keys():
try:
paths = folder_paths.get_folder_paths(t)
if not paths: continue
for p in paths:
bn = os.path.basename(os.path.normpath(p))
if bn: basenames.add(bn)
except:
pass
return list(basenames)
def get_active_physical_basenames():
script_dir = os.path.dirname(os.path.abspath(__file__))
config_path = os.path.join(script_dir, "config.json")
all_bns = get_all_physical_basenames()
active = []
configured = set()
try:
if os.path.exists(config_path):
with open(config_path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
pfc = cfg.get("physical_folders_config")
if pfc and isinstance(pfc, list):
for item in pfc:
bn = item.get("type")
if bn in all_bns:
configured.add(bn)
if item.get("visible", True):
active.append(bn)
for bn in all_bns:
if bn not in configured and bn in DEFAULT_PHYSICAL_FOLDER_NAMES:
active.append(bn)
return active
except:
pass
return [bn for bn in all_bns if bn in DEFAULT_PHYSICAL_FOLDER_NAMES]
def get_active_model_roots():
"""Return the folder-manager-visible roots with stable model locators.
This is intentionally separate from the scanner scope. The source hub only
needs directory discovery and must preserve ``type``/``path_idx`` so later
metadata writes resolve back to the exact ComfyUI root.
"""
mode = get_folder_view_mode()
active_types = set(get_active_folder_types()) if mode != "physical" else None
active_basenames = (
set(get_active_physical_basenames()) if mode == "physical" else None
)
roots = []
seen_realpaths = set()
for folder_type in folder_paths.folder_names_and_paths.keys():
if active_types is not None and folder_type not in active_types:
continue
try:
paths = folder_paths.get_folder_paths(folder_type) or []
except Exception:
continue
for path_idx, base_dir in enumerate(paths):
if active_basenames is not None:
basename = os.path.basename(os.path.normpath(base_dir))
if basename not in active_basenames:
continue
if not os.path.isdir(base_dir):
continue
real_dir = os.path.realpath(base_dir)
real_key = os.path.normcase(real_dir)
if real_key in seen_realpaths:
continue
seen_realpaths.add(real_key)
roots.append({
"type": folder_type,
"path_idx": path_idx,
"base_dir": real_dir,
})
return roots
def get_active_scan_paths():
mode = get_folder_view_mode()
paths = set()
if mode == "physical":
active_bns = set(get_active_physical_basenames())
for t in folder_paths.folder_names_and_paths.keys():
try:
ps = folder_paths.get_folder_paths(t)
if not ps: continue
for p in ps:
if not os.path.exists(p): continue
bn = os.path.basename(os.path.normpath(p))
if bn in active_bns:
paths.add(os.path.realpath(p))
except:
pass
else:
active_types = get_active_folder_types()
for t in active_types:
try:
ps = folder_paths.get_folder_paths(t)
if not ps: continue
for p in ps:
if not os.path.exists(p): continue
paths.add(os.path.realpath(p))
except:
pass
return list(paths)
def get_active_folder_types():
script_dir = os.path.dirname(os.path.abspath(__file__))
config_path = os.path.join(script_dir, "config.json")
all_types = list(folder_paths.folder_names_and_paths.keys())
default_types = DEFAULT_TYPES
try:
if os.path.exists(config_path):
with open(config_path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
ftc = cfg.get("folder_types_config")
if ftc and isinstance(ftc, list):
active = []
configured_types = set()
for item in ftc:
t = item.get("type")
if t in all_types:
configured_types.add(t)
if item.get("visible", True):
active.append(t)
# For any newly registered types not in config, do not show them by default
# unless they are in default_types
for t in all_types:
if t not in configured_types and t in default_types:
active.append(t)
return active
except Exception:
pass
# Fallback if no config exists
active = [t for t in default_types if t in all_types]
return active
async def api_get_all_folder_types(request):
mode = get_folder_view_mode()
script_dir = os.path.dirname(os.path.abspath(__file__))
config_path = os.path.join(script_dir, "config.json")
result = []
if mode == "physical":
all_bns = get_all_physical_basenames()
configured = set()
try:
if os.path.exists(config_path):
with open(config_path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
pfc = cfg.get("physical_folders_config")
if pfc and isinstance(pfc, list):
for item in pfc:
bn = item.get("type")
if bn in all_bns:
configured.add(bn)
result.append({
"type": bn,
"visible": item.get("visible", True)
})
except:
pass
for bn in all_bns:
if bn not in configured:
result.append({
"type": bn,
"visible": bn in DEFAULT_PHYSICAL_FOLDER_NAMES
})
else:
# Abstract mode
all_types = list(folder_paths.folder_names_and_paths.keys())
default_types = DEFAULT_TYPES
configured_types = set()
try:
if os.path.exists(config_path):
with open(config_path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
ftc = cfg.get("folder_types_config")
if ftc and isinstance(ftc, list):
for item in ftc:
t = item.get("type")
if t in all_types:
configured_types.add(t)
result.append({
"type": t,
"visible": item.get("visible", True)
})
except:
pass
for t in all_types:
if t not in configured_types:
result.append({
"type": t,
"visible": t in default_types
})
return web.json_response({"folder_types": result, "folder_view_mode": mode})
+182
View File
@@ -0,0 +1,182 @@
"""Generated-output gallery indexing and mutation routes."""
import asyncio
import os
import threading
import time
from aiohttp import web
import folder_paths
from .image_search import filter_images
from .media_routes import (
CARD_POSTER_VIDEO_EXTENSIONS,
CARD_THUMBNAIL_STATIC_EXTENSIONS,
_build_card_thumbnail,
_build_video_poster,
)
from .path_utils import require_filename, resolve_within
from .trash import move_to_trash, trash_failure
GALLERY_SNAPSHOT_SECONDS = 10
MAX_CACHED_GALLERY_IMAGES = 50_000
GALLERY_IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.webp', '.gif', '.avif'}
# Videos a browser plays in the viewer; not .avi (Chrome and Edge cannot).
GALLERY_VIDEO_EXTENSIONS = {'.mp4', '.webm', '.mov', '.mkv'}
GALLERY_MEDIA = ("all", "image", "video")
_gallery_snapshot_lock = threading.Lock()
_gallery_snapshot = {"root": None, "expires": 0, "images": []}
def _collect_gallery_images(output_dir):
images = []
for root, _, files in os.walk(output_dir):
for filename in files:
ext = os.path.splitext(filename)[1].lower()
if ext not in GALLERY_IMAGE_EXTENSIONS and ext not in GALLERY_VIDEO_EXTENSIONS:
continue
try:
mtime = os.path.getmtime(os.path.join(root, filename))
except OSError:
continue
subfolder = os.path.relpath(root, output_dir)
images.append({
"filename": filename,
"subfolder": "" if subfolder == "." else subfolder.replace(os.sep, '/'),
"type": "output",
"mtime": mtime,
"media_type": "video" if ext in GALLERY_VIDEO_EXTENSIONS else "image",
})
images.sort(key=lambda item: (item['mtime'], item['subfolder'], item['filename']), reverse=True)
return images
def _gallery_images(output_dir, refresh=False):
root = os.path.realpath(output_dir)
with _gallery_snapshot_lock:
if not refresh and _gallery_snapshot['root'] == root and time.monotonic() < _gallery_snapshot['expires']:
return _gallery_snapshot['images']
images = _collect_gallery_images(root)
_gallery_snapshot.update(root=root, expires=time.monotonic() + GALLERY_SNAPSHOT_SECONDS,
images=images if len(images) <= MAX_CACHED_GALLERY_IMAGES else [])
if len(images) > MAX_CACHED_GALLERY_IMAGES:
_gallery_snapshot['expires'] = 0
return images
def _invalidate_gallery_snapshot():
with _gallery_snapshot_lock:
_gallery_snapshot.update(root=None, expires=0, images=[])
def _format(filename):
"""The format name the gallery's settings use: the extension, with .jpeg counted as jpg."""
ext = os.path.splitext(filename)[1].lower().lstrip('.')
return 'jpg' if ext == 'jpeg' else ext
def _gallery_listing(output_dir, refresh, query, media="image", hidden=frozenset()):
"""Outputs, newest first. `media`: "image" (the default, for callers that show pictures),
"video" or "all"; `hidden`: format names left out (see _format)."""
images = _gallery_images(output_dir, refresh)
if media != "all" or hidden:
images = [item for item in images
if media in ("all", item["media_type"]) and _format(item["filename"]) not in hidden]
return filter_images(os.path.realpath(output_dir), images, query) if query else images
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&kept=1
- Outputs (pictures only unless `media` says otherwise), optionally searched by embedded
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))))
# Repeated `term` params keep phrases whole; `q` is split on whitespace.
terms = [term for term in request.query.getall('term', []) if term.strip()]
query = terms or request.query.get('q', '').strip()
media = request.query.get('media', 'image')
if media not in GALLERY_MEDIA:
raise ValueError(media)
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 - {''}, request.query.get('kept') == '1')
total = len(images)
start = (page - 1) * limit
return web.json_response({"images": images[start:start + limit], "total": total,
"page": page, "pages": (total + limit - 1) // limit,
"has_videos": has_videos})
except (TypeError, ValueError):
return web.json_response({"error": "Invalid page or filter"}, status=400)
except OSError:
return web.json_response({"error": "Could not list output images"}, status=500)
async def api_output_thumbnail(request):
"""GET /anomalous/output_thumbnail?filename&subfolder - the gallery grid's 512 px WebP of an
output image/video (cached like model cards); the original stays at ComfyUI's /view."""
output_dir = folder_paths.get_output_directory()
try:
filename = require_filename(request.query.get('filename'))
file_path = resolve_within(resolve_within(output_dir, request.query.get('subfolder', '')), filename)
except ValueError:
return web.json_response({"status": "error", "message": "Invalid parameters"}, status=400)
if not os.path.isfile(file_path):
return web.json_response({"status": "error", "message": "File not found"}, status=404)
served = file_path
ext = os.path.splitext(file_path)[1].lower()
if ext in CARD_THUMBNAIL_STATIC_EXTENSIONS:
served = await asyncio.to_thread(_build_card_thumbnail, file_path)
elif ext in CARD_POSTER_VIDEO_EXTENSIONS:
poster = await asyncio.to_thread(_build_video_poster, file_path)
if poster:
served = poster
# Revalidate (cheap 304s): an output file can be replaced under the same name.
return web.FileResponse(served, headers={'Cache-Control': 'no-cache'})
async def api_delete_gallery_image(request):
try:
data = await request.json()
except ValueError:
data = None
if not isinstance(data, dict):
return web.json_response({"status": "error", "message": "Invalid request body"}, status=400)
try:
filename = data.get("filename")
subfolder = data.get("subfolder", "")
output_dir = folder_paths.get_output_directory()
try:
filename = require_filename(filename)
target_dir = resolve_within(output_dir, subfolder)
file_path = resolve_within(target_dir, filename)
except ValueError:
return web.json_response({"status": "error", "message": "Invalid parameters"}, status=400)
if os.path.exists(file_path):
try:
await asyncio.to_thread(move_to_trash, file_path)
except OSError as e:
return web.json_response({"status": "error", "message": trash_failure(e)}, status=409)
_invalidate_gallery_snapshot()
return web.json_response({"status": "success"})
else:
return web.json_response({"status": "error", "message": "File not found"})
except Exception as e:
return web.json_response({"status": "error", "message": str(e)}, status=500)
+146
View File
@@ -0,0 +1,146 @@
"""What a Hugging Face repository says about a model downloaded from it (model_download.py):
the model card's first example image becomes its cover, and its trigger words, example prompts
and base model go into its notes, with the repository as its link. Hugging Face cannot be asked
by fingerprint, so this works only for files downloaded from it (the repository is known then).
Nothing the user or a scan already has is replaced: a cover only when the model has none, notes
and link only when empty (in the user's layer, <model>.anomalous.json).
"""
import json
import os
import urllib.parse
import urllib.request
from .model_constants import MEDIA_EXTENSIONS, PREVIEW_SUFFIXES
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
TIMEOUT = 15
MAX_COVER_BYTES = 20 * 1024 * 1024
IMAGE_TYPES = {"image/png": ".png", "image/jpeg": ".jpg", "image/webp": ".webp", "image/gif": ".gif"}
IMAGE_FILE = (".png", ".jpg", ".jpeg", ".webp")
HF_HOSTS = ("huggingface.co", "hf-mirror.com")
MAX_PROMPTS = 3
def repo_of(url):
"""("org/name", host) of a Hugging Face file link, or None."""
parsed = urllib.parse.urlsplit(url)
parts = [part for part in parsed.path.split("/") if part]
if (parsed.hostname or "") not in HF_HOSTS or "resolve" not in parts or parts.index("resolve") < 2:
return None
return "/".join(parts[:2]), parsed.hostname
def _get(url, limit=None):
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
with urlopen(request, timeout=TIMEOUT) as response:
data = response.read(limit + 1 if limit else -1)
return data, str(response.headers.get("Content-Type", "")).split(";")[0].strip().lower()
def _file_url(repo, host, path):
"""A link to a file of the repository, on `host` (the mirror when the download used it)."""
if path.startswith(("https://", "http://")):
parsed = urllib.parse.urlsplit(path)
if parsed.hostname not in HF_HOSTS:
return ""
return urllib.parse.urlunsplit(("https", host, parsed.path, parsed.query, ""))
return f"https://{host}/{repo}/resolve/main/{urllib.parse.quote(path.lstrip('/'))}"
def _as_list(value):
if isinstance(value, str):
return [value] if value.strip() else []
return [str(item) for item in value if str(item).strip()] if isinstance(value, list) else []
def read_card(repo, host):
"""{images, prompts, trigger, base} from the repository's model card (empty when it has none)."""
data, _type = _get(f"https://{host}/api/models/{repo}?expand[]=cardData&expand[]=siblings")
info = json.loads(data.decode("utf-8"))
card = info.get("cardData") or {}
widgets = [item for item in card.get("widget") or [] if isinstance(item, dict)]
images = [(item.get("output") or {}).get("url") for item in widgets]
images = [url for url in (_file_url(repo, host, str(path)) for path in images if path) if url]
if not images: # no examples in the card: the repository's own pictures
files = [item.get("rfilename", "") for item in info.get("siblings") or [] if isinstance(item, dict)]
images = [_file_url(repo, host, name) for name in files if name.lower().endswith(IMAGE_FILE)][:1]
prompts = [str(item.get("text")).strip() for item in widgets if isinstance(item.get("text"), str) and item["text"].strip()]
return {"images": images, "prompts": prompts[:MAX_PROMPTS],
"trigger": _as_list(card.get("instance_prompt")), "base": _as_list(card.get("base_model"))}
def save_cover(image_url, base_path):
"""The image as the model's cover (<model>.preview.<ext>) when it has none yet; True if saved."""
if any(os.path.exists(base_path + suffix) for suffix in PREVIEW_SUFFIXES + MEDIA_EXTENSIONS):
return False
data, content_type = _get(image_url, MAX_COVER_BYTES)
extension = IMAGE_TYPES.get(content_type)
if not extension or len(data) > MAX_COVER_BYTES:
return False
target = base_path + ".preview" + extension
temporary = target + ".part"
with open(temporary, "wb") as output:
output.write(data)
os.replace(temporary, target)
return True
def notes_text(repo, card):
lines = [f"Hugging Face: {repo}"]
if card["base"]:
lines.append("Base model: " + ", ".join(card["base"]))
if card["trigger"]:
lines.append("Trigger words: " + ", ".join(card["trigger"]))
if card["prompts"]:
lines.append("Example prompts:")
lines.extend("- " + prompt[:400] for prompt in card["prompts"])
return "\n".join(lines)
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 = 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}
changed = False
if not str(user.get("custom_notes") or "").strip():
user["custom_notes"] = notes_text(repo, card)
changed = True
if not str(user.get("source_url") or "").strip():
user["source_url"] = f"https://huggingface.co/{repo}"
changed = True
if changed:
atomic_write_json(path, user)
return changed
def enrich(model_path, download_url):
"""Cover and notes for a model just downloaded from Hugging Face: {cover, notes}."""
located = repo_of(download_url)
if not located:
return {"cover": False, "notes": False}
repo, host = located
base_path = os.path.splitext(model_path)[0]
card = read_card(repo, host)
cover = False
for image in card["images"][:2]:
try:
cover = save_cover(image, base_path)
break
except OSError:
continue
return {"cover": cover, "notes": save_notes(base_path, repo, card)}
+207
View File
@@ -0,0 +1,207 @@
"""Search output images by the generation info embedded in them.
Reads only the PNG text chunks before the pixel data (ComfyUI `prompt` /
`workflow`, A1111/Forge `parameters`), caches one small record per file keyed by
its mtime, and matches every query term against it. Terms that look like a
hash also match model SHA256 values recorded in the workflow and, through the
scanned model index, the local model files that carry that hash.
"""
import json
import os
import re
import struct
import threading
import time
import zlib
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
MAX_CHUNK_BYTES = 32 * 1024 * 1024
MAX_CACHED_RECORDS = 60_000
MODEL_INDEX_SECONDS = 120
HASH_TERM = re.compile(r"^[0-9a-f]{8,64}$", re.IGNORECASE)
MODEL_FILE = re.compile(r"\.(safetensors|ckpt|pt|pth|bin|sft|gguf)$", re.IGNORECASE)
NUMERIC_KEYS = {"seed", "noise_seed", "steps", "cfg", "denoise", "width", "height"}
_record_cache = {}
_cache_lock = threading.Lock()
_model_index = {"expires": 0, "entries": {}}
_model_index_lock = threading.Lock()
def _decode_text(raw):
"""tEXt is Latin-1 by spec, but ComfyUI/PIL may store UTF-8 bytes in it."""
try:
return raw.decode("utf-8")
except UnicodeDecodeError:
return raw.decode("latin-1", "replace")
def read_png_text(path):
"""Text chunks that precede IDAT, as {keyword: text}. Pixel data is never read."""
chunks = {}
with open(path, "rb") as handle:
if handle.read(8) != PNG_SIGNATURE:
return chunks
while True:
header = handle.read(8)
if len(header) < 8:
break
length, kind = struct.unpack(">I4s", header)
if kind in (b"IDAT", b"IEND") or length > MAX_CHUNK_BYTES:
break
data = handle.read(length)
handle.seek(4, os.SEEK_CUR)
try:
if kind == b"tEXt":
key, _, value = data.partition(b"\x00")
chunks[key.decode("latin-1")] = _decode_text(value)
elif kind == b"zTXt":
key, _, rest = data.partition(b"\x00")
chunks[key.decode("latin-1")] = _decode_text(zlib.decompress(rest[1:]))
elif kind == b"iTXt":
key, _, rest = data.partition(b"\x00")
compressed, rest = rest[0], rest[2:]
_, _, rest = rest.partition(b"\x00") # language tag
_, _, text = rest.partition(b"\x00") # translated keyword
chunks[key.decode("latin-1")] = (zlib.decompress(text) if compressed else text).decode("utf-8", "replace")
except (zlib.error, IndexError, ValueError):
continue
return chunks
def _normalise_model_ref(value):
ref = str(value).replace("\\", "/").strip().lower()
return {ref, ref.rsplit("/", 1)[-1]}
def build_record(path):
"""Searchable text, recorded model hashes, referenced model files and node types for one image."""
name = os.path.basename(path)
texts = [name]
hashes = set()
models = set()
types = set()
if path.lower().endswith(".png"):
try:
chunks = read_png_text(path)
except OSError:
chunks = {}
prompt = _load_json(chunks.get("prompt"))
if isinstance(prompt, dict):
for node in prompt.values():
if not isinstance(node, dict):
continue
texts.append(str(node.get("class_type", "")))
if isinstance(node.get("class_type"), str):
types.add(node["class_type"])
for key, value in (node.get("inputs") or {}).items():
if isinstance(value, dict) and isinstance(value.get("lora"), str):
value = value["lora"] # a row of rgthree's Power Lora Loader
if isinstance(value, str):
texts.append(value)
if MODEL_FILE.search(value):
models |= _normalise_model_ref(value)
elif isinstance(value, (int, float)) and not isinstance(value, bool) and key in NUMERIC_KEYS:
texts.append(f"{key}:{value} {value}")
workflow = _load_json(chunks.get("workflow"))
recorded = (workflow or {}).get("extra", {}).get("anomalous_hashes") if isinstance(workflow, dict) else None
if isinstance(recorded, dict):
for key, entry in recorded.items():
sha = entry.get("hash") if isinstance(entry, dict) else entry
if isinstance(sha, str) and sha:
hashes.add(sha.upper())
model_name = str(key).split("_", 1)[-1]
if MODEL_FILE.search(model_name):
models |= _normalise_model_ref(model_name)
if chunks.get("parameters"):
texts.append(chunks["parameters"])
return {"text": "\n".join(texts).lower(), "hashes": hashes, "models": models, "types": types}
def _load_json(raw):
if not raw:
return None
try:
return json.loads(raw)
except ValueError:
return None
def cached_record(path, mtime=None):
"""Record for one image; `mtime` from the gallery listing avoids a stat per file."""
if mtime is None:
try:
mtime = os.stat(path).st_mtime
except OSError:
return None
key = mtime
with _cache_lock:
cached = _record_cache.get(path)
if cached and cached[0] == key:
return cached[1]
if not os.path.isfile(path):
return None
record = build_record(path)
with _cache_lock:
if len(_record_cache) >= MAX_CACHED_RECORDS:
_record_cache.clear()
_record_cache[path] = (key, record)
return record
def _local_models_for_hash(term):
"""Model file refs whose scanned SHA256 starts with `term` (cached briefly)."""
# Imported lazily: api.utils re-exports gallery_routes, which imports this module.
from .model_resolution import collect_model_hash_index
with _model_index_lock:
if time.monotonic() >= _model_index["expires"]:
_model_index["entries"] = collect_model_hash_index()
_model_index["expires"] = time.monotonic() + MODEL_INDEX_SECONDS
entries = _model_index["entries"]
wanted = term.upper()
refs = set()
for key, value in entries.items():
sha = str((value or {}).get("hash") or "").upper()
if sha and sha.startswith(wanted):
refs |= _normalise_model_ref(key)
return refs
def parse_query(query):
"""Search terms: a list keeps each entry as one phrase; a string splits on whitespace."""
raw = query if isinstance(query, (list, tuple)) else str(query or "").split()
terms = []
for term in raw:
term = " ".join(str(term).lower().split())
if term and term not in terms:
terms.append(term)
return terms
def filter_images(output_dir, images, query):
"""Keep gallery entries whose record matches every term (phrases may contain spaces)."""
terms = parse_query(query)
if not terms:
return images
hash_refs = {term: _local_models_for_hash(term) for term in terms if HASH_TERM.match(term)}
matched = []
for image in images:
record = cached_record(os.path.join(output_dir, image.get("subfolder", ""), image["filename"]), image.get("mtime"))
if record is None:
continue
if all(_term_matches(term, record, hash_refs) for term in terms):
matched.append(image)
return matched
def _term_matches(term, record, hash_refs):
if term in record["text"]:
return True
if term in hash_refs:
upper = term.upper()
if any(sha.startswith(upper) for sha in record["hashes"]):
return True
return bool(hash_refs[term] & record["models"])
return False
+48
View File
@@ -0,0 +1,48 @@
"""What each output image was kept as (GET /anomalous/kept_images), for the gallery's star:
the Workflow Recipe whose source image it is, the combo made from it (a notebook file with
`source_image`), the saved prompt taken from it (a prompt plan material whose source names
the image). Each is {filename, name}, or null when the image was not kept as that."""
import asyncio
from aiohttp import web
from .material_store import _list_materials, get_materials_dir
from .notebooks import _list_notebooks
from .recipe_store import _list_recipes, get_recipes_dir
KINDS = ("recipe", "combo", "prompt")
def _kept_images():
kept = {}
def mark(image, kind, filename, name):
if not isinstance(image, dict) or not isinstance(image.get("filename"), str) or not image["filename"]:
return
subfolder = image.get("subfolder") if isinstance(image.get("subfolder"), str) else ""
entry = kept.setdefault((subfolder, image["filename"]), dict.fromkeys(KINDS))
if entry[kind] is None:
entry[kind] = {"filename": filename, "name": str(name or "")}
for recipe in _list_recipes(get_recipes_dir()):
mark(recipe["data"].get("source_image"), "recipe", recipe["filename"], recipe["data"].get("name"))
for note in _list_notebooks():
mark(note["data"].get("source_image"), "combo", note["filename"], note["name"])
for material in _list_materials(get_materials_dir()):
if material.get("kind") == "prompt_plan":
mark(material.get("source_image"), "prompt", material["filename"], material.get("name"))
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)
except (OSError, ValueError):
return web.json_response({"status": "error", "message": "Could not list kept images"}, status=500)
return web.json_response({"status": "success", "images": images})
+117
View File
@@ -0,0 +1,117 @@
"""What only this computer may do. ComfyUI started with --listen lets other devices on the
network open the page: they may look (lists, searches, pictures, covers), but every change
under /anomalous/ (writing, moving and deleting files, downloads, scans, translations, backups,
version switches, saved settings) answers only requests from the computer ComfyUI runs on
(AGENTS.md 安全底线, rule 4). One middleware decides it for all routes: a request other than
GET is local-only unless it is a read sent as POST (OPEN_POSTS); a GET is open unless it is not
a plain read (LOCAL_GETS). A request is local when it comes from a loopback address, through no
proxy, naming a loopback host; checking the Host also keeps out a page that reached this server
under another name (DNS rebinding).
ComfyUI on a rented cloud server is only ever opened from another device. An empty file named
`allow_remote_changes` in the plugin's folder, made on that server, lets every device that can
open the page change things too (may_change); it is read once, when ComfyUI starts, and the
page cannot make or remove it. MCP keeps to this computer either way."""
import ipaddress
import os
import shlex
import urllib.parse
from aiohttp import web
PREFIX = "/anomalous/"
PROXY_HEADERS = ("Forwarded", "X-Forwarded-For", "X-Real-IP", "X-Forwarded-Host")
LOCAL_NAMES = ("localhost",)
# Reads sent as POST (their lists are long): open to other devices too. They write nothing.
OPEN_POSTS = frozenset({
"/anomalous/resolve_paths_to_previews",
"/anomalous/resolve_hash_batch",
"/anomalous/inspect_image_material",
})
# GETs that are more than a read: a backup of all your data, a call to GitHub, your folders.
LOCAL_GETS = frozenset({
"/anomalous/backup/file",
"/anomalous/version/releases",
"/anomalous/tts_version/releases",
"/anomalous/import/folders",
"/anomalous/civitai_check",
"/anomalous/usage/check", # reads every output and saved workflow
})
# Routes that answer other devices themselves (MCP replies in JSON-RPC).
OWN_CHECK = frozenset({"/anomalous/mcp"})
READS = ("GET", "HEAD", "OPTIONS")
REMOTE_FILE = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "allow_remote_changes")
REMOTE_CHANGES = os.path.isfile(REMOTE_FILE)
if REMOTE_CHANGES:
print("[Anomalous Browser] allow_remote_changes 文件存在:任何能打开页面的设备都能扫描、移动、删除模型和改设置。"
" The allow_remote_changes file is there: every device that can open the page may change things.", flush=True)
MESSAGE = ("只能在运行 ComfyUI 的电脑上操作,别的设备只能浏览。"
" Only the computer ComfyUI runs on can do this; other devices can only look.")
def loopback_host(name):
if not name:
return False
name = name.strip("[]").lower()
if name in LOCAL_NAMES:
return True
try:
return ipaddress.ip_address(name).is_loopback
except ValueError:
return False
def is_local(request):
"""The request comes from this computer, directly, and names a loopback host."""
if not loopback_host(request.remote or ""):
return False
if any(header in request.headers for header in PROXY_HEADERS):
return False
return loopback_host(urllib.parse.urlsplit("//" + request.headers.get("Host", "")).hostname)
def may_change(request):
"""The request may change things: it comes from this computer, or remote changes are allowed."""
return REMOTE_CHANGES or is_local(request)
def needs_local(method, path):
"""Whether a request to `path` with `method` must come from this computer."""
if not path.startswith(PREFIX) or path in OWN_CHECK:
return False
if method.upper() in READS:
return path in LOCAL_GETS
return path not in OPEN_POSTS
def refusal():
return web.json_response({"status": "error", "success": False, "error": "local_only", "code": "local_only",
"message": MESSAGE}, status=403)
@web.middleware
async def local_only_middleware(request, handler):
if needs_local(request.method, request.path) and not may_change(request):
return refusal()
return await handler(request)
def switch_command():
"""The line to paste into the server's terminal that makes the allow_remote_changes file, or
removes it when it is there. On Windows `echo off >` makes the file in cmd and PowerShell
alike (only that it exists counts)."""
if os.name == "nt":
return f'del "{REMOTE_FILE}"' if REMOTE_CHANGES else f'echo off > "{REMOTE_FILE}"'
return ("rm -f " if REMOTE_CHANGES else "touch ") + shlex.quote(REMOTE_FILE)
async def api_access(request):
"""GET /anomalous/access - whether this page is on this computer and whether it may change
things: {"local": bool, "changes": bool}; for another device also "command", the line that
turns remote changes on or off (it only runs where someone types it, on the server)."""
local = is_local(request)
answer = {"local": local, "changes": may_change(request)}
if not local:
answer["command"] = switch_command()
return web.json_response(answer)
+40
View File
@@ -0,0 +1,40 @@
"""What a LoRA file trains, read from its safetensors header: whether it also has text-encoder
weights, so inserting it wires the CLIP line too (graph_splice.js planLoraInsertion).
GET /anomalous/lora_info?name=<the loader's lora_name> -> {text_encoder}.
"""
import asyncio
import json
import struct
from aiohttp import web
import folder_paths
# Key prefixes of text-encoder weights: kohya lora_te / lora_te1 / lora_te2, te1 / te2, diffusers text_encoder.
TEXT_ENCODER_KEYS = ("lora_te", "te_", "te1", "te2", "text_encoder")
MAX_HEADER_BYTES = 100 * 1024 * 1024
def trains_text_encoder(path):
"""Whether the LoRA at `path` has text-encoder weights. Other formats, or a header that
cannot be read, count as yes: the CLIP line then goes through it, which costs nothing."""
if not str(path).lower().endswith(".safetensors"):
return True
try:
with open(path, "rb") as source:
size = struct.unpack("<Q", source.read(8))[0]
if size > MAX_HEADER_BYTES:
return True
keys = json.loads(source.read(size))
except (OSError, ValueError, struct.error):
return True
return any(key.startswith(TEXT_ENCODER_KEYS) for key in keys if key != "__metadata__")
async def api_lora_info(request):
name = request.query.get("name", "")
path = folder_paths.get_full_path("loras", name) if name else None # only names ComfyUI lists
if not path:
return web.json_response({"error": "Unknown LoRA"}, status=404)
return web.json_response({"text_encoder": await asyncio.to_thread(trains_text_encoder, path)})
+108
View File
@@ -0,0 +1,108 @@
"""Where ComfyUI-Manager's model list says a model file comes from, for Model Check's downloads
(download_sources.find_source). The list (model-list.json, a few hundred common models: base
models, VAEs, text encoders, upscalers, ControlNets… mostly on Hugging Face) is read from the
Manager installed here; without it there is nothing to find. A list entry is only a file of the
same name: download_sources checks it against the workflow's fingerprint, or offers it as such.
"""
import importlib.util
import json
import os
import folder_paths
# The list's model types and save paths -> ComfyUI folder types.
TYPE_FOLDERS = {
"checkpoint": "checkpoints", "checkpoints": "checkpoints", "lora": "loras", "loras": "loras",
"vae": "vae", "taesd": "vae_approx", "vae_approx": "vae_approx", "clip": "text_encoders",
"text_encoders": "text_encoders", "clip_vision": "clip_vision", "unet": "diffusion_models",
"diffusion_model": "diffusion_models", "diffusion_models": "diffusion_models", "controlnet": "controlnet",
"upscale": "upscale_models", "upscale_models": "upscale_models", "embeddings": "embeddings",
"embedding": "embeddings", "gligen": "gligen", "style_models": "style_models",
}
# ComfyUI's older names for the same folders.
SAME_FOLDER = {"unet": "diffusion_models", "clip": "text_encoders"}
_cache = {"path": "", "mtime": 0.0, "index": {}}
def list_path():
"""The Manager's model-list.json on this computer, or ''."""
roots = []
try:
roots = list(folder_paths.get_folder_paths("custom_nodes") or [])
except Exception:
pass
for root in roots:
try:
names = sorted(os.listdir(root))
except OSError:
continue
for name in names:
path = os.path.join(root, name, "model-list.json")
if "manager" in name.lower() and os.path.isfile(path):
return path
try: # the Manager installed as a Python package
spec = importlib.util.find_spec("comfyui_manager")
for location in (spec.submodule_search_locations or []) if spec else []:
path = os.path.join(location, "model-list.json")
if os.path.isfile(path):
return path
except (ImportError, ValueError):
pass
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 ""
return TYPE_FOLDERS.get(first) or TYPE_FOLDERS.get(str(entry.get("type") or "").lower(), "")
def _index():
path = list_path()
if not path:
return {}
try:
mtime = os.path.getmtime(path)
except OSError:
return {}
if path != _cache["path"] or mtime != _cache["mtime"]:
index = {}
try:
with open(path, encoding="utf-8") as source:
models = json.load(source).get("models") or []
except (OSError, ValueError, AttributeError):
models = []
for entry in models:
if not isinstance(entry, dict) or not entry.get("filename") or not str(entry.get("url", "")).startswith("https://"):
continue
index.setdefault(str(entry["filename"]).lower(), []).append({
"name": str(entry.get("name") or entry["filename"]), "url": entry["url"],
"folder": _folder(entry), "base": str(entry.get("base") or ""),
})
_cache.update(path=path, mtime=mtime, index=index)
return _cache["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.
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 SAME_FOLDER.get(entry["folder"], entry["folder"]) != wanted:
continue
if entry["url"] not in seen:
seen.add(entry["url"])
found.append(entry)
return found
+83
View File
@@ -0,0 +1,83 @@
"""Image inspection and asset storage for curated materials."""
import hashlib
import os
import shutil
import tempfile
from .recipe_images import _embedded_workflow_payload, _output_source_path, _recipe_cover_webp_bytes
from .recipe_schema import _build_model_references, _normalise_source_image
from .workflow_schema import _validate_workflow
from .material_schema import _node_blocks, _suggested_name
from .utils import require_filename, resolve_within
MAX_SOURCE_IMAGE_BYTES = 64 * 1024 * 1024
def _material_assets_dir(materials_dir, filename, create=False):
stem = os.path.splitext(require_filename(filename))[0]
assets_dir = resolve_within(materials_dir, ".assets", stem)
if create:
os.makedirs(assets_dir, exist_ok=True)
return assets_dir
def _inspect_source_image(source_image):
source = _normalise_source_image(source_image)
if source is None:
raise ValueError("Missing source image")
if not source["filename"].lower().endswith(".png"):
raise ValueError("Only PNG output images can contain reusable workflow metadata")
source_path = _output_source_path(source)
payload = _embedded_workflow_payload(source_path)
workflow = payload.get("workflow") if isinstance(payload, dict) else None
if not isinstance(workflow, dict):
raise ValueError("Image has no reusable UI workflow")
_validate_workflow(workflow)
blocks = _node_blocks(workflow, include_values=False)
references = _build_model_references({"workflow": workflow, "params": {}}, verify_identities=False)
suggested_name = _suggested_name(workflow, source_path, references)
return source, source_path, workflow, blocks, references, suggested_name
def _store_assets(materials_dir, filename, source_path):
source_size = os.path.getsize(source_path)
if source_size > MAX_SOURCE_IMAGE_BYTES:
raise ValueError("Source image is too large")
digest = hashlib.sha256()
with open(source_path, "rb") as source_file:
for block in iter(lambda: source_file.read(1024 * 1024), b""):
digest.update(block)
assets_dir = _material_assets_dir(materials_dir, filename, create=True)
source_asset = f"source-{digest.hexdigest()}.png"
source_target = resolve_within(assets_dir, source_asset)
if not os.path.exists(source_target):
fd, temp_path = tempfile.mkstemp(prefix=".source-", suffix=".tmp", dir=assets_dir)
os.close(fd)
try:
shutil.copyfile(source_path, temp_path)
os.replace(temp_path, source_target)
finally:
if os.path.exists(temp_path):
os.remove(temp_path)
preview = _recipe_cover_webp_bytes(source_path)
preview_asset = None
if preview:
data, width, height = preview
preview_asset = f"preview-{hashlib.sha256(data).hexdigest()}.webp"
preview_target = resolve_within(assets_dir, preview_asset)
if not os.path.exists(preview_target):
with open(preview_target, "wb") as preview_file:
preview_file.write(data)
else:
width = height = None
return {
"source_asset_id": source_asset,
"preview_asset_id": preview_asset,
"preview_width": width,
"preview_height": height,
"source_sha256": digest.hexdigest(),
"source_size": source_size,
}
+517
View File
@@ -0,0 +1,517 @@
"""Validation and record-shaping helpers for curated materials."""
import copy
import hashlib
import json
import os
import re
from .notebooks import MAX_NOTEBOOK_BYTES
from .recipe_schema import _build_model_references
from .workflow_schema import _parameter_signature, _volatile_widget_indexes, _workflow_fingerprint
MAX_MATERIAL_NAME_LENGTH = 120
MODEL_PRIORITY = {"checkpoint": 0, "unet": 1, "lora": 2}
_PROMPT_NODE_TYPES = {"cliptextencode"}
_PROMPT_CONSUMERS = {
"ksampler": {"positive": "positive", "negative": "negative"},
"ksampleradvanced": {"positive": "positive", "negative": "negative"},
"cfgguider": {"positive": "positive", "negative": "negative"},
"basicguider": {"conditioning": "positive"},
"dualcfgguider": {"cond1": "positive", "cond2": "positive", "negative": "negative"},
}
_PROMPT_PASSTHROUGH = {
"conditioningaverage", "conditioningcombine", "conditioningconcat",
"conditioningmultiply", "conditioningsetarea", "conditioningsetareapercentage",
"conditioningsetareastrength", "conditioningsetmask", "conditioningsettimesteprange",
"conditioningzeroout",
}
_PROMPT_ROLES = {"positive", "negative", "both", "ignored", "unknown"}
_MAX_PROMPT_UPSTREAM = 96
def _node_blocks(workflow, include_values=True, selected_ids=None):
blocks = []
occurrences = {}
for node in workflow.get("nodes", []):
if not isinstance(node, dict):
continue
node_type = str(node.get("type") or "").strip()
if not node_type:
continue
occurrences[node_type] = occurrences.get(node_type, 0) + 1
if selected_ids is not None and str(node.get("id")) not in selected_ids:
continue
block = {
"node_id": node.get("id"),
"type": node_type,
"title": str(node.get("title") or node_type),
"occurrence": occurrences[node_type],
"widget_count": len(node.get("widgets_values") or []),
"volatile_widget_indexes": list(_volatile_widget_indexes(node)),
}
if include_values:
block["widgets_values"] = copy.deepcopy(node.get("widgets_values") or [])
block["properties"] = copy.deepcopy(node.get("properties") or {})
if node.get("mode") is not None:
block["mode"] = node.get("mode")
blocks.append(block)
return blocks
def _normalise_selected_node_ids(workflow, raw_node_ids):
"""Validate an optional node selection while preserving workflow ID types."""
if raw_node_ids is None:
return None
if not isinstance(raw_node_ids, list) or not raw_node_ids:
raise ValueError("Selected material nodes are required")
workflow_nodes = [node for node in workflow.get("nodes", []) if isinstance(node, dict)]
existing = {str(node.get("id")): node.get("id") for node in workflow_nodes if node.get("id") is not None}
selected = []
seen = set()
for raw_node_id in raw_node_ids:
key = str(raw_node_id)
if key not in existing:
raise ValueError("Selected material node does not exist")
if key in seen:
continue
seen.add(key)
selected.append(existing[key])
if not selected:
raise ValueError("Selected material nodes are required")
return selected
def _material_node_ids(material):
selection = material.get("selection") if isinstance(material, dict) else None
if not isinstance(selection, dict) or selection.get("scope") != "nodes":
return None
node_ids = selection.get("node_ids")
if not isinstance(node_ids, list):
return None
return {str(node_id) for node_id in node_ids}
def _material_node_blocks(material, include_values=True):
if material.get("kind") in ("prompt_note_bundle", "prompt_text", "prompt_plan"):
return []
return _node_blocks(material.get("workflow") or {}, include_values=include_values,
selected_ids=_material_node_ids(material))
def _prompt_excerpt(workflow):
for node in workflow.get("nodes", []):
if not isinstance(node, dict) or "cliptextencode" not in str(node.get("type") or "").lower():
continue
for value in node.get("widgets_values") or []:
if isinstance(value, str) and value.strip():
compact = re.sub(r"\s+", " ", value).strip()
return compact[:20]
return ""
def _extract_workflow_params(workflow):
params = {}
prompts = []
if not isinstance(workflow, dict):
return params, prompts
for node in workflow.get("nodes", []):
if not isinstance(node, dict):
continue
ntype = str(node.get("type") or "").strip()
ntype_lower = ntype.lower()
widgets = node.get("widgets_values") or []
if ntype_lower in ("ksampler", "ksampleradvanced") and isinstance(widgets, (list, tuple)):
is_adv = ntype_lower == "ksampleradvanced"
offset = 1 if is_adv else 0
if len(widgets) > offset and widgets[offset] is not None:
params.setdefault("seed", widgets[offset])
if len(widgets) > 2 + offset and widgets[2 + offset] is not None:
params.setdefault("steps", widgets[2 + offset])
if len(widgets) > 3 + offset and widgets[3 + offset] is not None:
params.setdefault("cfg", widgets[3 + offset])
if len(widgets) > 4 + offset and widgets[4 + offset] is not None:
params.setdefault("sampler_name", widgets[4 + offset])
if len(widgets) > 5 + offset and widgets[5 + offset] is not None:
params.setdefault("scheduler", widgets[5 + offset])
if len(widgets) > 6 + offset and widgets[6 + offset] is not None:
params.setdefault("denoise", widgets[6 + offset])
elif ntype_lower == "emptylatentimage" and isinstance(widgets, (list, tuple)):
if len(widgets) >= 2 and widgets[0] and widgets[1]:
params.setdefault("resolution", f"{widgets[0]}×{widgets[1]}")
if "cliptextencode" in ntype_lower and isinstance(widgets, (list, tuple)):
for val in widgets:
if isinstance(val, str) and val.strip():
cleaned = val.strip()
if cleaned not in prompts:
prompts.append(cleaned)
return params, prompts
def _norm_node_name(value):
return str(value or "").strip().lower()
def _is_prompt_node_type(node_type):
name = _norm_node_name(node_type)
return name in _PROMPT_NODE_TYPES or "cliptextencode" in name
def _first_prompt_text(node):
for value in node.get("widgets_values") or []:
if isinstance(value, str) and value.strip():
return value.strip()
return ""
def _title_prompt_role(node):
title = str(node.get("title") or "").lower()
if "neg" in title or "负向" in title or "反向" in title:
return "negative"
if "pos" in title or "正向" in title:
return "positive"
return None
def _workflow_link_origin(workflow, link_id):
if link_id is None:
return None
links = workflow.get("links")
if isinstance(links, list):
for link in links:
if isinstance(link, (list, tuple)) and len(link) > 1 and link[0] == link_id:
return link[1]
if isinstance(link, dict) and link.get("id") == link_id:
return link.get("origin_id")
elif isinstance(links, dict):
link = links.get(link_id)
if link is None:
link = links.get(str(link_id))
if isinstance(link, dict):
return link.get("origin_id")
if isinstance(link, (list, tuple)) and len(link) > 1:
return link[1]
return None
def _collect_prompt_nodes(workflow, nodes_by_id, start_id):
queue = [start_id]
visited = set()
found = []
while queue and len(visited) < _MAX_PROMPT_UPSTREAM:
node_id = queue.pop(0)
if node_id is None or node_id in visited:
continue
visited.add(node_id)
node = nodes_by_id.get(str(node_id))
if not isinstance(node, dict):
continue
if _is_prompt_node_type(node.get("type")):
if node not in found:
found.append(node)
continue
if _norm_node_name(node.get("type")) not in _PROMPT_PASSTHROUGH:
continue
for inbound in node.get("inputs") or []:
if not isinstance(inbound, dict):
continue
input_type = _norm_node_name(inbound.get("type"))
input_name = _norm_node_name(inbound.get("name"))
if input_type != "conditioning" and "conditioning" not in input_name:
continue
origin_id = _workflow_link_origin(workflow, inbound.get("link"))
if origin_id is not None and origin_id not in visited:
queue.append(origin_id)
return found
def _normalise_prompt_role_overrides(raw):
if not isinstance(raw, dict):
return {}
result = {}
for key, value in list(raw.items())[:80]:
if not isinstance(value, dict):
continue
role = value.get("role")
if role not in _PROMPT_ROLES:
continue
node_type = value.get("nodeType")
if node_type is not None and (not isinstance(node_type, str) or len(node_type) > 200):
continue
result[str(key)] = {
"role": role,
"nodeType": node_type if isinstance(node_type, str) else None,
"source": "manual",
}
return result
def _override_prompt_role(node, overrides):
if not isinstance(overrides, dict) or not isinstance(node, dict):
return None
entry = overrides.get(str(node.get("id")))
if not isinstance(entry, dict) or entry.get("role") not in _PROMPT_ROLES:
return None
expected = entry.get("nodeType")
if expected and expected != node.get("type"):
return None
return entry["role"]
def _prompt_roles_for_workflow(workflow, overrides=None):
roles = {}
if not isinstance(workflow, dict):
return roles
nodes = [node for node in workflow.get("nodes") or [] if isinstance(node, dict)]
nodes_by_id = {str(node.get("id")): node for node in nodes if node.get("id") is not None}
positive_ids = set()
negative_ids = set()
for node in nodes:
mapping = _PROMPT_CONSUMERS.get(_norm_node_name(node.get("type")))
if not mapping:
continue
for input_name, role in mapping.items():
for inbound in node.get("inputs") or []:
if not isinstance(inbound, dict) or _norm_node_name(inbound.get("name")) != input_name:
continue
origin_id = _workflow_link_origin(workflow, inbound.get("link"))
for prompt_node in _collect_prompt_nodes(workflow, nodes_by_id, origin_id):
node_id = str(prompt_node.get("id"))
if role == "negative":
negative_ids.add(node_id)
else:
positive_ids.add(node_id)
for node in nodes:
if not _is_prompt_node_type(node.get("type")):
continue
node_id = str(node.get("id"))
in_positive = node_id in positive_ids
in_negative = node_id in negative_ids
if in_positive and in_negative:
automatic_role, automatic_source = "both", "topology"
elif in_positive:
automatic_role, automatic_source = "positive", "topology"
elif in_negative:
automatic_role, automatic_source = "negative", "topology"
else:
titled = _title_prompt_role(node)
automatic_role = titled or "unknown"
automatic_source = "title" if titled else "unresolved"
override = _override_prompt_role(node, overrides)
roles[node_id] = {
"role": override or automatic_role,
"source": "manual" if override else automatic_source,
"automatic_role": automatic_role,
}
return roles
def _prompt_groups_from_roles(workflow, roles, selected_ids=None):
positive = []
negative = []
if not isinstance(workflow, dict) or not isinstance(roles, dict):
return {"positive": positive, "negative": negative}
for node in workflow.get("nodes") or []:
if not isinstance(node, dict):
continue
node_id = str(node.get("id"))
if selected_ids is not None and node_id not in selected_ids:
continue
info = roles.get(node_id)
if not isinstance(info, dict):
continue
text = _first_prompt_text(node)
if not text:
continue
role = info.get("role")
if role in ("positive", "both") and text not in positive:
positive.append(text)
if role in ("negative", "both") and text not in negative:
negative.append(text)
return {"positive": positive, "negative": negative}
def _display_model_name(reference):
value = str(reference.get("saved_value") or "").replace("\\", "/").split("/")[-1]
return re.sub(r"\.(?:safetensors|ckpt|pt|bin|sft)$", "", value, flags=re.IGNORECASE)
def _suggested_name(workflow, source_path, references=None):
stem = os.path.splitext(os.path.basename(source_path))[0] if source_path else ""
if stem:
return f"{stem} · 快照"[:MAX_MATERIAL_NAME_LENGTH]
if references is None:
references = _build_model_references({"workflow": workflow, "params": {}}, verify_identities=False)
references.sort(key=lambda item: MODEL_PRIORITY.get(item.get("category"), 99))
model_name = _display_model_name(references[0]) if references else "工作流快照"
return f"{model_name} · 快照"[:MAX_MATERIAL_NAME_LENGTH]
def _recipe_link_fingerprint(recipe):
if not isinstance(recipe, dict):
return ""
fingerprint = recipe.get("workflow_fingerprint")
workflow_hash = fingerprint.get("value") if isinstance(fingerprint, dict) else ""
if not workflow_hash and isinstance(recipe.get("workflow"), dict):
workflow_hash = (_workflow_fingerprint(recipe["workflow"]) or {}).get("value") or ""
params = recipe.get("params") if isinstance(recipe.get("params"), dict) else {}
overrides = params.get("promptRoleOverrides") if isinstance(params.get("promptRoleOverrides"), dict) else {}
payload = json.dumps(
{"workflow": workflow_hash, "promptRoleOverrides": overrides},
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
return hashlib.sha256(payload).hexdigest()
def _summary_source_image(source):
"""The output image a material was taken from, as {filename, subfolder}, or None."""
image = source.get("image")
if not isinstance(image, dict) or not isinstance(image.get("filename"), str) or not image["filename"]:
return None
return {"filename": image["filename"], "subfolder": image.get("subfolder") if isinstance(image.get("subfolder"), str) else ""}
def _material_summary(filename, material):
image = material.get("image") or {}
source = material.get("source") if isinstance(material.get("source"), dict) else {}
blocks = _material_node_blocks(material, include_values=False)
summary = {
"filename": filename,
"id": material.get("id"),
"name": material.get("name") or "未命名素材",
"kind": material.get("kind"),
"tags": _normalise_material_tags(material.get("tags") or []),
"source_sha256": image.get("source_sha256", ""),
"timestamp": material.get("timestamp", 0),
"node_count": len(blocks),
"node_types": sorted({block["type"] for block in blocks}),
"selection": copy.deepcopy(material.get("selection")),
"image": {
"preview_asset_id": image.get("preview_asset_id"),
"source_asset_id": image.get("source_asset_id"),
"width": image.get("preview_width"),
"height": image.get("preview_height"),
},
"capabilities": list(material.get("capabilities") or []),
"source": {
"type": source.get("type"),
"label": str(source.get("parameter_name") or source.get("recipe_name") or source.get("notebook_name") or "").strip(),
},
"source_fingerprint": str(source.get("parameter_signature") or image.get("source_sha256") or ""),
"moved_to_recipe": str(material.get("moved_to_recipe") or ""),
"source_image": _summary_source_image(source),
}
if source.get("recipe_filename"):
summary["source_recipe"] = {
"filename": source.get("recipe_filename"),
"name": str(source.get("recipe_name") or "").strip(),
"fingerprint": str(source.get("recipe_fingerprint") or ""),
}
return summary
def _workflow_hashes_for_blocks(workflow, blocks):
hashes = (workflow.get("extra") or {}).get("anomalous_hashes") if isinstance(workflow, dict) else None
if not isinstance(hashes, dict):
return {}
prefixes = tuple(f"{block.get('node_id')}_" for block in blocks)
return {
key: copy.deepcopy(value)
for key, value in hashes.items()
if isinstance(key, str) and key.startswith(prefixes)
}
def _normalise_material_tags(tags):
if not isinstance(tags, list) or len(tags) > 20:
raise ValueError("Invalid material tags")
result = []
seen = set()
for tag in tags:
if not isinstance(tag, str) or not tag.strip() or len(tag.strip()) > 60:
raise ValueError("Invalid material tag")
tag = tag.strip()
if tag.casefold() not in seen:
result.append(tag)
seen.add(tag.casefold())
return result
def _normalise_prompt_note(data, prompt_only=False):
if not isinstance(data, dict):
raise ValueError("Invalid prompt note")
if len(json.dumps(data, ensure_ascii=False, allow_nan=False).encode("utf-8")) > MAX_NOTEBOOK_BYTES:
raise ValueError("Prompt note is too large")
result = {}
for key in ("promptEn", "promptZh", "targetLang"):
value = data.get(key, "")
if not isinstance(value, str):
raise ValueError("Invalid prompt text")
result[key] = value
translations = data.get("translations", {})
if not isinstance(translations, dict) or any(not isinstance(value, str) for value in translations.values()):
raise ValueError("Invalid prompt translations")
result["translations"] = copy.deepcopy(translations)
if not prompt_only:
base = data.get("baseModel", "")
main = data.get("mainModel")
loras = data.get("loras", [])
if not isinstance(base, str) or (main is not None and not isinstance(main, dict)):
raise ValueError("Invalid prompt models")
if not isinstance(loras, list) or len(loras) > 100 or any(not isinstance(model, dict) for model in loras):
raise ValueError("Invalid prompt LoRAs")
for model in ([main] if main is not None else []) + loras:
if not isinstance(model.get("filename"), str) or not model["filename"]:
raise ValueError("Invalid prompt model filename")
result.update(baseModel=base, mainModel=copy.deepcopy(main), loras=copy.deepcopy(loras))
return result
def _normalise_prompt_plan(plan):
if not isinstance(plan, dict) or len(json.dumps(plan, ensure_ascii=False, allow_nan=False).encode("utf-8")) > MAX_NOTEBOOK_BYTES:
raise ValueError("Invalid prompt plan")
parts = plan.get("parts", [])
if not isinstance(parts, list) or len(parts) > 100:
raise ValueError("Invalid prompt parts")
result = {"parts": []}
if "version" in plan and isinstance(plan["version"], int):
result["version"] = plan["version"]
for key in ("positive", "negative"):
if not isinstance(plan.get(key, ""), str):
raise ValueError("Invalid prompt text")
result[key] = plan.get(key, "")
for part in parts:
if not isinstance(part, dict) or part.get("category") not in ("general", "specific", "base", "style", "subject", "trigger") or not isinstance(part.get("enabled", True), bool):
raise ValueError("Invalid prompt part")
item = {"category": part["category"], "enabled": part.get("enabled", True)}
if "role" in part and part["role"] in ("positive", "negative"):
item["role"] = part["role"]
if "id" in part and isinstance(part["id"], str) and len(part["id"]) <= 120:
item["id"] = part["id"]
for key in ("name", "positive", "negative"):
if not isinstance(part.get(key, ""), str):
raise ValueError("Invalid prompt part text")
item[key] = part.get(key, "")
if len(item["name"]) > 120:
raise ValueError("Prompt part name is too long")
result["parts"].append(item)
return result
def _material_category(material):
if material.get("kind") == "image_workflow_snapshot":
return "workflow"
if material.get("kind") in ("prompt_text", "prompt_note_bundle", "prompt_plan"):
return "prompts"
types = material.get("node_types", [])
if types and all(_is_prompt_node_type(value) for value in types):
return "prompts"
return "params"
+264
View File
@@ -0,0 +1,264 @@
"""Single-owner persistence, cache, and locking for curated materials."""
import copy
import json
import os
import shutil
import tempfile
import threading
from functools import lru_cache
import folder_paths
from .material_assets import _material_assets_dir, _store_assets
from .material_schema import (
_is_prompt_node_type,
_material_category,
_material_summary,
_normalise_prompt_note,
_normalise_prompt_plan,
_recipe_link_fingerprint,
)
from .recipe_constants import MAX_RECIPE_BYTES
from .recipe_store import _read_recipe, get_recipes_dir
from .trash import move_to_trash
from .utils import atomic_write_json as _atomic_write_json, require_filename, resolve_within
_material_write_lock = threading.RLock()
def get_materials_dir():
user_dir = (
folder_paths.get_user_directory()
if hasattr(folder_paths, "get_user_directory")
else None
)
if not user_dir:
user_dir = os.path.join(folder_paths.base_path, "user", "default")
materials_dir = os.path.join(user_dir, "workflows", "anomalous_materials")
os.makedirs(materials_dir, exist_ok=True)
return materials_dir
def _read_material(path):
if os.path.getsize(path) > MAX_RECIPE_BYTES:
raise ValueError("Material snapshot is too large")
with open(path, "r", encoding="utf-8") as material_file:
value = json.load(material_file)
if not isinstance(value, dict):
raise ValueError("Invalid material snapshot")
if value.get("kind") == "prompt_plan":
_normalise_prompt_plan(value.get("plan"))
elif value.get("kind") in ("prompt_note_bundle", "prompt_text"):
_normalise_prompt_note(value.get("note"), value["kind"] == "prompt_text")
elif not isinstance(value.get("workflow"), dict):
raise ValueError("Invalid material snapshot")
return value
def _snapshot_recipe_source(recipe_filename):
if not recipe_filename:
return {}
record = {"recipe_filename": recipe_filename}
try:
path = resolve_within(get_recipes_dir(), require_filename(recipe_filename))
recipe = _read_recipe(path)
except (OSError, ValueError, json.JSONDecodeError, TypeError):
return record
if not isinstance(recipe, dict):
return record
name = str(recipe.get("name") or "").strip()
if name:
record["recipe_name"] = name[:120]
fingerprint = _recipe_link_fingerprint(recipe)
if fingerprint:
record["recipe_fingerprint"] = fingerprint
return record
def _live_recipe_source(source):
if not isinstance(source, dict):
return None
filename = source.get("recipe_filename")
if not isinstance(filename, str) or not filename:
return None
info = {
"filename": filename,
"name": str(source.get("recipe_name") or "").strip(),
"status": "missing",
}
try:
path = resolve_within(get_recipes_dir(), require_filename(filename))
if not os.path.isfile(path):
return info
recipe = _read_recipe(path)
except (OSError, ValueError, json.JSONDecodeError, TypeError):
return info
if isinstance(recipe, dict):
live_name = str(recipe.get("name") or "").strip()
if live_name:
info["name"] = live_name[:120]
snapshot = str(source.get("recipe_fingerprint") or "")
current = _recipe_link_fingerprint(recipe)
if snapshot and current and snapshot != current:
info["status"] = "modified"
else:
info["status"] = "current"
return info
@lru_cache(maxsize=4096)
def _cached_material_summary(path, signature):
return _material_summary(os.path.basename(path), _read_material(path))
def _with_live_recipe_source(summary):
snapshot = summary.get("source_recipe")
if not isinstance(snapshot, dict) or not snapshot.get("filename"):
return summary
summary["source_recipe"] = _live_recipe_source({
"recipe_filename": snapshot.get("filename"),
"recipe_name": snapshot.get("name"),
"recipe_fingerprint": snapshot.get("fingerprint"),
})
return summary
def _summary_for_path(path, stat=None):
stat = stat or os.stat(path)
signature = (stat.st_size, stat.st_mtime_ns, stat.st_ctime_ns)
return _with_live_recipe_source(copy.deepcopy(_cached_material_summary(path, signature)))
def _list_materials(materials_dir):
result = []
try:
materials_dir = os.path.realpath(materials_dir)
entries = os.scandir(materials_dir)
except OSError:
return result
with entries:
for entry in entries:
if not entry.is_file() or not entry.name.endswith(".json") or entry.name.startswith("."):
continue
try:
path = resolve_within(materials_dir, entry.name) if entry.is_symlink() else entry.path
result.append(copy.deepcopy(_summary_for_path(path, entry.stat())))
except (OSError, ValueError):
continue
result.sort(key=lambda item: (item.get("timestamp", 0), item["filename"]), reverse=True)
return result
def _persist_material(materials_dir, filename, source_path, material, allow_duplicate=False):
with _material_write_lock:
return _persist_material_files(materials_dir, filename, source_path, material, allow_duplicate)
def _persist_parameter_material(materials_dir, filename, material, allow_duplicate=False):
with _material_write_lock:
target = resolve_within(materials_dir, filename)
if os.path.exists(target):
raise ValueError("Material already exists")
if not allow_duplicate:
selection = sorted(str(value) for value in (material.get("selection") or {}).get("node_ids", []))
fingerprint = str((material.get("source") or {}).get("parameter_signature") or "")
for summary in _list_materials(materials_dir):
candidate_selection = sorted(str(value) for value in (summary.get("selection") or {}).get("node_ids", []))
if (summary.get("kind") == material.get("kind")
and summary.get("source_fingerprint") == fingerprint
and candidate_selection == selection):
return summary
_atomic_write_json(target, material)
return None
def _persist_material_files(materials_dir, filename, source_path, material, allow_duplicate):
target = resolve_within(materials_dir, filename)
assets_target = _material_assets_dir(materials_dir, filename)
if os.path.exists(target) or os.path.exists(assets_target):
raise ValueError("Material already exists")
with tempfile.TemporaryDirectory(prefix=".save-", dir=materials_dir) as staging:
material["image"] = _store_assets(staging, filename, source_path)
if not allow_duplicate:
selection = sorted(str(value) for value in (material.get("selection") or {}).get("node_ids", []))
for summary in _list_materials(materials_dir):
candidate_selection = sorted(str(value) for value in (summary.get("selection") or {}).get("node_ids", []))
if summary.get("source_sha256") == material["image"]["source_sha256"] and summary.get("kind") == material.get("kind") and candidate_selection == selection:
return summary
staged_json = resolve_within(staging, filename)
_atomic_write_json(staged_json, material)
os.makedirs(os.path.dirname(assets_target), exist_ok=True)
os.replace(_material_assets_dir(staging, filename), assets_target)
try:
os.replace(staged_json, target)
except OSError:
shutil.rmtree(assets_target)
raise
def _query_materials(materials_dir, query):
materials = _list_materials(materials_dir)
all_tags = sorted({tag for material in materials for tag in material.get("tags", [])}, key=str.casefold)
search = query.get("q", "").strip().casefold()
tag = query.get("tag", "").strip().casefold()
kind = query.get("kind", "")
category = query.get("category", "all")
if category not in ("all", "workflow", "params", "prompts"):
raise ValueError("Invalid material category")
node_type = query.get("node_type", "")
if len(node_type) > 200:
raise ValueError("Invalid node type")
if len(search) > 200 or len(tag) > 60 or kind not in (
"", "image_workflow_snapshot", "image_node_selection", "recipe_parameter_selection", "node_parameter_selection",
"prompt_note_bundle", "prompt_text", "prompt_plan"
):
raise ValueError("Invalid material filter")
# Whole workflows live with the recipes: "all" is the pieces, and one moved there is not listed.
materials = [material for material in materials
if not material.get("moved_to_recipe")
and (not search or search in " ".join([material["name"], *material["node_types"], *material.get("tags", [])]).casefold())
and (not tag or tag in [value.casefold() for value in material.get("tags", [])])
and (not kind or material["kind"] == kind)
and (_material_category(material) == category if category != "all" else _material_category(material) != "workflow")
and (not node_type or node_type in material["node_types"])]
total = len(materials)
limit = min(100, max(1, int(query.get("limit", 48))))
pages = max(1, (total + limit - 1) // limit)
page = min(pages, max(1, int(query.get("page", 1))))
# Existing non-paginated callers retain their response; the library requests pages.
if "page" in query or "limit" in query:
materials = materials[(page - 1) * limit:page * limit]
return {"status": "success", "materials": materials, "total": total,
"page": page, "pages": pages, "tags": all_tags}
def _update_material_details(materials_dir, filename, name, tags, prompt_role_overrides=None,
update_prompt_roles=False):
with _material_write_lock:
path = resolve_within(materials_dir, filename)
material = _read_material(path)
material.update(name=name, tags=tags)
if update_prompt_roles:
if prompt_role_overrides:
material["promptRoleOverrides"] = prompt_role_overrides
else:
material.pop("promptRoleOverrides", None)
_atomic_write_json(path, material)
return _with_live_recipe_source(_material_summary(filename, material))
def _mark_material_moved(materials_dir, filename, recipe_filename):
"""Notes that a kept workflow now lives as recipe `recipe_filename`; the material file stays."""
with _material_write_lock:
path = resolve_within(materials_dir, filename)
material = _read_material(path)
material["moved_to_recipe"] = recipe_filename
_atomic_write_json(path, material)
def _delete_material(materials_dir, filename, path):
with _material_write_lock:
_read_material(path)
move_to_trash(path, _material_assets_dir(materials_dir, filename))
+459
View File
@@ -0,0 +1,459 @@
"""HTTP routes and compatibility facade for curated material modules."""
import asyncio
import hashlib
import json
import os
import time
import uuid
from aiohttp import web
import folder_paths
from . import material_schema as _schema
from . import material_store as _store
from .material_assets import _inspect_source_image, _material_assets_dir, _store_assets
from .material_schema import (
MAX_MATERIAL_NAME_LENGTH,
_extract_workflow_params,
_is_prompt_node_type,
_material_category,
_material_node_blocks,
_material_node_ids,
_material_summary,
_node_blocks,
_normalise_material_tags,
_normalise_prompt_role_overrides,
_normalise_selected_node_ids,
_prompt_excerpt,
_prompt_groups_from_roles,
_prompt_roles_for_workflow,
_workflow_hashes_for_blocks,
)
from .notebooks import MAX_NOTEBOOK_BYTES
from .recipe_schema import _build_model_references, _normalise_source_image
from .recipe_store import get_recipes_dir
from .utils import atomic_write_json as _atomic_write_json, require_filename, resolve_within
MATERIAL_SCHEMA_VERSION = 1
_store_get_materials_dir = _store.get_materials_dir
_store_read_material = _store._read_material
_store_snapshot_recipe_source = _store._snapshot_recipe_source
_store_live_recipe_source = _store._live_recipe_source
_store_with_live_recipe_source = _store._with_live_recipe_source
_store_summary_for_path = _store._summary_for_path
_store_list_materials = _store._list_materials
_store_persist_material = _store._persist_material
_store_persist_parameter_material = _store._persist_parameter_material
_store_query_materials = _store._query_materials
_store_update_material_details = _store._update_material_details
_store_delete_material = _store._delete_material
def get_materials_dir():
return _store_get_materials_dir()
def _sync_store_compatibility():
"""Keep historical monkeypatch points effective while storage has one owner."""
_store._atomic_write_json = _atomic_write_json
_store._read_material = _read_material
_store._material_summary = _material_summary
_store._normalise_prompt_note = _normalise_prompt_note
_store._normalise_prompt_plan = _normalise_prompt_plan
_store._material_assets_dir = _material_assets_dir
_store._store_assets = _store_assets
_store.get_materials_dir = get_materials_dir
def _normalise_prompt_note(data, prompt_only=False):
_schema.MAX_NOTEBOOK_BYTES = MAX_NOTEBOOK_BYTES
return _schema._normalise_prompt_note(data, prompt_only)
def _normalise_prompt_plan(plan):
_schema.MAX_NOTEBOOK_BYTES = MAX_NOTEBOOK_BYTES
return _schema._normalise_prompt_plan(plan)
def _read_material(path):
_store._normalise_prompt_note = _normalise_prompt_note
_store._normalise_prompt_plan = _normalise_prompt_plan
return _store_read_material(path)
def _snapshot_recipe_source(filename):
_store.get_recipes_dir = get_recipes_dir
return _store_snapshot_recipe_source(filename)
def _live_recipe_source(source):
_store.get_recipes_dir = get_recipes_dir
return _store_live_recipe_source(source)
_cached_material_summary = _store._cached_material_summary
def _with_live_recipe_source(summary):
_store._live_recipe_source = _live_recipe_source
return _store_with_live_recipe_source(summary)
def _summary_for_path(path, stat=None):
_sync_store_compatibility()
_store._with_live_recipe_source = _with_live_recipe_source
return _store_summary_for_path(path, stat)
def _list_materials(materials_dir):
_sync_store_compatibility()
_store._summary_for_path = _summary_for_path
return _store_list_materials(materials_dir)
def _persist_material(materials_dir, filename, source_path, material, allow_duplicate=False):
_sync_store_compatibility()
_store._list_materials = _list_materials
return _store_persist_material(materials_dir, filename, source_path, material, allow_duplicate)
def _persist_parameter_material(materials_dir, filename, material, allow_duplicate=False):
_sync_store_compatibility()
_store._list_materials = _list_materials
return _store_persist_parameter_material(materials_dir, filename, material, allow_duplicate)
def _query_materials(materials_dir, query):
_sync_store_compatibility()
_store._list_materials = _list_materials
return _store_query_materials(materials_dir, query)
def _update_material_details(materials_dir, filename, name, tags, prompt_role_overrides=None,
update_prompt_roles=False):
_sync_store_compatibility()
_store._with_live_recipe_source = _with_live_recipe_source
return _store_update_material_details(
materials_dir, filename, name, tags, prompt_role_overrides, update_prompt_roles
)
def _delete_material(materials_dir, filename, path):
_sync_store_compatibility()
return _store_delete_material(materials_dir, filename, path)
async def api_inspect_image_material(request):
try:
payload = await request.json()
source, source_path, workflow, blocks, references, suggested_name = await asyncio.to_thread(
_inspect_source_image, payload.get("source_image")
)
except (AttributeError, ValueError, json.JSONDecodeError):
return web.json_response({"status": "error", "message": "Image has no reusable UI workflow"}, status=400)
except FileNotFoundError:
return web.json_response({"status": "error", "message": "Output image not found"}, status=404)
except OSError:
return web.json_response({"status": "error", "message": "Could not inspect output image"}, status=500)
sampling_params, prompts = _extract_workflow_params(workflow)
prompt_roles = _prompt_roles_for_workflow(workflow)
return web.json_response({
"status": "success",
"source_image": source,
"suggested_name": suggested_name,
"node_count": len(blocks),
"node_blocks": blocks,
"workflow": workflow,
"model_references": references,
"prompt_excerpt": _prompt_excerpt(workflow),
"params": sampling_params,
"prompts": prompts,
"prompt_roles": prompt_roles,
"prompt_groups": _prompt_groups_from_roles(workflow, prompt_roles),
})
async def api_save_image_material(request):
try:
payload = await request.json()
source, source_path, workflow, blocks, references, suggested_name = await asyncio.to_thread(
_inspect_source_image, payload.get("source_image")
)
name = payload.get("name") or suggested_name
if not isinstance(name, str) or not (name := name.strip()) or len(name) > MAX_MATERIAL_NAME_LENGTH:
raise ValueError("Invalid material name")
tags = _normalise_material_tags(payload.get("tags", []))
allow_duplicate = payload.get("allow_duplicate", False)
if not isinstance(allow_duplicate, bool):
raise ValueError("Invalid duplicate preference")
selected_node_ids = _normalise_selected_node_ids(workflow, payload.get("selected_node_ids"))
selected_id_keys = {str(node_id) for node_id in selected_node_ids or []}
if selected_node_ids is not None:
blocks = [block for block in blocks if str(block.get("node_id")) in selected_id_keys]
references = [
reference for reference in references
if str(reference.get("node_id")) in selected_id_keys
]
material_id = uuid.uuid4().hex
filename = f"material_{int(time.time())}_{material_id}.json"
materials_dir = get_materials_dir()
now = int(time.time() * 1000)
source_record = {"type": "generated_image", "image": source}
recipe_filename = payload.get("recipe_filename")
if isinstance(recipe_filename, str) and recipe_filename.strip():
try:
recipe_filename = require_filename(recipe_filename.strip())
except (AttributeError, TypeError, ValueError):
recipe_filename = ""
if recipe_filename.endswith(".json"):
source_record.update(_snapshot_recipe_source(recipe_filename))
prompt_role_overrides = _normalise_prompt_role_overrides(payload.get("promptRoleOverrides"))
material = {
"schema_version": MATERIAL_SCHEMA_VERSION,
"id": material_id,
"kind": "image_node_selection" if selected_node_ids is not None else "image_workflow_snapshot",
"name": name,
"tags": tags,
"timestamp": now,
"source": source_record,
"workflow": workflow,
"model_references": references,
"capabilities": (
["apply_node_parameters", "reference_image"]
if selected_node_ids is not None
else ["open_workflow", "apply_node_parameters", "reference_image"]
),
}
if selected_node_ids is not None:
material["selection"] = {"scope": "nodes", "node_ids": selected_node_ids}
if prompt_role_overrides:
material["promptRoleOverrides"] = prompt_role_overrides
duplicate = await asyncio.to_thread(_persist_material, materials_dir, filename, source_path, material, allow_duplicate)
if duplicate:
return web.json_response({"status": "duplicate", "filename": duplicate["filename"], "name": duplicate["name"]}, status=409)
except (AttributeError, ValueError, json.JSONDecodeError):
return web.json_response({"status": "error", "message": "Could not save image material"}, status=400)
except FileNotFoundError:
return web.json_response({"status": "error", "message": "Output image not found"}, status=404)
except OSError:
return web.json_response({"status": "error", "message": "Could not save image material"}, status=500)
return web.json_response({
"status": "success",
"filename": filename,
"material": _with_live_recipe_source(_material_summary(filename, material)),
"node_blocks": blocks,
})
async def api_save_prompt_plan(request):
try:
payload = await request.json()
name = payload.get("name", "")
if not isinstance(name, str) or not name.strip() or len(name.strip()) > MAX_MATERIAL_NAME_LENGTH:
raise ValueError("Invalid plan name")
plan = _normalise_prompt_plan(payload.get("plan"))
tags = _normalise_material_tags(payload.get("tags", []))
allow_duplicate = payload.get("allow_duplicate", False)
if not isinstance(allow_duplicate, bool):
raise ValueError("Invalid duplicate preference")
# The output image the prompts were taken from, so the gallery can show it was kept.
source_image = _normalise_source_image(payload.get("source_image"))
signature = hashlib.sha256(json.dumps(plan, ensure_ascii=False, sort_keys=True).encode("utf-8")).hexdigest()
material_id = uuid.uuid4().hex
filename = f"material_{int(time.time())}_{material_id}.json"
material = {"schema_version": MATERIAL_SCHEMA_VERSION, "id": material_id,
"kind": "prompt_plan", "name": name.strip(), "tags": tags,
"timestamp": int(time.time() * 1000), "plan": plan,
"source": {"type": "prompt_plan", "parameter_signature": signature,
**({"image": source_image} if source_image else {})},
"capabilities": ["compose_prompt"], "selection": {"scope": "prompt_plan"}}
duplicate = await asyncio.to_thread(_persist_parameter_material, get_materials_dir(), filename, material, allow_duplicate)
if duplicate:
return web.json_response({"status": "duplicate", "filename": duplicate["filename"], "name": duplicate["name"]}, status=409)
return web.json_response({"status": "success", "filename": filename, "material": _material_summary(filename, material)})
except (AttributeError, TypeError, ValueError):
return web.json_response({"status": "error", "message": "Invalid prompt plan"}, status=400)
except OSError:
return web.json_response({"status": "error", "message": "Could not save prompt plan"}, status=500)
async def api_get_materials(request):
try:
payload = await asyncio.to_thread(_query_materials, get_materials_dir(), dict(request.query))
return web.json_response(payload)
except (TypeError, ValueError):
return web.json_response({"status": "error", "message": "Invalid material filter"}, status=400)
except OSError:
return web.json_response({"status": "error", "message": "Could not list materials"}, status=500)
async def api_mark_material_moved(request):
"""POST {filename, recipe}: the kept workflow `filename` was saved as recipe `recipe`."""
try:
payload = await request.json()
filename = require_filename(payload.get("filename", ""))
recipe = require_filename(payload.get("recipe", ""))
if not filename.endswith(".json") or not recipe.endswith(".json"):
raise ValueError("Invalid material")
await asyncio.to_thread(_store._mark_material_moved, get_materials_dir(), filename, recipe)
return web.json_response({"status": "success"})
except (AttributeError, TypeError, ValueError, json.JSONDecodeError):
return web.json_response({"status": "error", "message": "Invalid material"}, status=400)
except FileNotFoundError:
return web.json_response({"status": "error", "message": "Material not found"}, status=404)
except OSError:
return web.json_response({"status": "error", "message": "Could not update material"}, status=500)
async def api_update_material(request):
try:
payload = await request.json()
filename = require_filename(payload.get("filename", ""))
name = payload.get("name", "")
if not filename.endswith(".json") or filename.startswith(".") or not isinstance(name, str) or not name.strip() or len(name.strip()) > MAX_MATERIAL_NAME_LENGTH:
raise ValueError("Invalid material details")
tags = _normalise_material_tags(payload.get("tags", []))
update_prompt_roles = "promptRoleOverrides" in payload
prompt_role_overrides = (
_normalise_prompt_role_overrides(payload.get("promptRoleOverrides"))
if update_prompt_roles else None
)
material = await asyncio.to_thread(
_update_material_details,
get_materials_dir(),
filename,
name.strip(),
tags,
prompt_role_overrides,
update_prompt_roles,
)
return web.json_response({"status": "success", "material": material})
except (AttributeError, TypeError, ValueError):
return web.json_response({"status": "error", "message": "Invalid material details"}, status=400)
except FileNotFoundError:
return web.json_response({"status": "error", "message": "Material not found"}, status=404)
except OSError:
return web.json_response({"status": "error", "message": "Could not update material"}, status=500)
def _material_detail_response(path, include_workflow):
material = _read_material(path)
can_open = (material.get("kind") == "image_workflow_snapshot"
and (material.get("selection") or {}).get("scope") != "nodes"
and "open_workflow" in material.get("capabilities", []))
if include_workflow == "1" and not can_open:
return web.json_response({"status": "error", "message": "Material cannot open a full workflow"}, status=403)
workflow = material.get("workflow") or {}
prompt_roles = _prompt_roles_for_workflow(workflow, material.get("promptRoleOverrides"))
prompt_groups = _prompt_groups_from_roles(workflow, prompt_roles, _material_node_ids(material))
blocks = _material_node_blocks(material, include_values=True) if include_workflow != "1" else []
if include_workflow == "0" or not can_open:
material.pop("workflow", None)
return web.json_response({
"status": "success",
"data": material,
"source_recipe": _live_recipe_source(material.get("source") or {}),
"node_blocks": blocks,
"workflow_hashes": _workflow_hashes_for_blocks(workflow, blocks),
"prompt_roles": prompt_roles,
"prompt_groups": prompt_groups,
})
async def api_get_material_full(request):
try:
filename = require_filename(request.query.get("filename", ""))
if not filename.endswith(".json"):
raise ValueError("Invalid material filename")
include_workflow = request.query.get("include_workflow")
if include_workflow not in (None, "0", "1"):
raise ValueError("Invalid workflow inclusion flag")
return await asyncio.to_thread(_material_detail_response,
resolve_within(get_materials_dir(), filename), include_workflow)
except (AttributeError, ValueError, json.JSONDecodeError):
return web.json_response({"status": "error", "message": "Invalid material"}, status=400)
except FileNotFoundError:
return web.json_response({"status": "error", "message": "Material not found"}, status=404)
except OSError:
return web.json_response({"status": "error", "message": "Could not read material"}, status=500)
async def api_get_materials_by_node_type(request):
node_type = request.query.get("type", "")
if not isinstance(node_type, str) or not node_type.strip() or len(node_type) > 200:
return web.json_response({"status": "error", "message": "Invalid node type"}, status=400)
materials_dir = get_materials_dir()
def collect():
matches = []
for summary in _list_materials(materials_dir):
# A whole workflow moved to a recipe is offered through the recipe's own sets.
if node_type not in summary["node_types"] or summary.get("moved_to_recipe"):
continue
try:
material = _read_material(resolve_within(materials_dir, summary["filename"]))
except (OSError, ValueError, json.JSONDecodeError):
continue
blocks = [block for block in _material_node_blocks(material, include_values=True) if block["type"] == node_type]
if blocks:
# Where the values came from, for Current node: a kept picture, or a node saved there.
source = material.get("source") if isinstance(material.get("source"), dict) else {}
image = source.get("image") if isinstance(source.get("image"), dict) else {}
preview = str((material.get("image") if isinstance(material.get("image"), dict) else {}).get("preview_asset_id") or "")
matches.append({
"filename": summary["filename"],
"name": summary["name"],
"kind": summary["kind"],
"timestamp": summary["timestamp"],
"blocks": blocks,
"workflow_hashes": _workflow_hashes_for_blocks(material["workflow"], blocks),
"origin": str(source.get("type") or ""),
"source_image": {"filename": str(image["filename"]), "subfolder": str(image.get("subfolder") or "")}
if isinstance(image.get("filename"), str) and image["filename"] else None,
"preview": preview if preview.lower().endswith((".png", ".webp")) else "",
})
return matches
matches = await asyncio.to_thread(collect)
return web.json_response({"status": "success", "materials": matches})
async def api_get_material_asset(request):
try:
filename = require_filename(request.query.get("filename", ""))
asset_id = require_filename(request.query.get("asset", ""))
if not filename.endswith(".json") or not asset_id.lower().endswith((".png", ".webp")):
raise ValueError("Invalid material asset")
materials_dir = get_materials_dir()
await asyncio.to_thread(_summary_for_path, resolve_within(materials_dir, filename))
asset_path = resolve_within(_material_assets_dir(materials_dir, filename), asset_id)
if not os.path.isfile(asset_path):
raise FileNotFoundError
except (AttributeError, ValueError, json.JSONDecodeError):
return web.json_response({"status": "error", "message": "Invalid material asset"}, status=400)
except FileNotFoundError:
return web.json_response({"status": "error", "message": "Material asset not found"}, status=404)
except OSError:
return web.json_response({"status": "error", "message": "Could not read material asset"}, status=500)
return web.FileResponse(asset_path)
async def api_delete_material(request):
try:
payload = await request.json()
filename = require_filename(payload.get("filename", ""))
if not filename.endswith(".json"):
raise ValueError("Invalid material filename")
materials_dir = get_materials_dir()
path = resolve_within(materials_dir, filename)
await asyncio.to_thread(_delete_material, materials_dir, filename, path)
except (AttributeError, ValueError, json.JSONDecodeError):
return web.json_response({"status": "error", "message": "Invalid material"}, status=400)
except FileNotFoundError:
return web.json_response({"status": "error", "message": "Material not found"}, status=404)
except OSError:
return web.json_response({"status": "error", "message": "Could not delete material"}, status=500)
return web.json_response({"status": "success"})
+305
View File
@@ -0,0 +1,305 @@
"""The MCP tools that act: on the open canvas, through the page (mcp_bridge.py and
web/modules/mcp_bridge.js, which use Anomalous's own canvas code, so every change is one
Ctrl+Z step and lands in the activity log marked as the AI's), and on this ComfyUI server
through its own routes (scans, Anomalous TTS speech), so they behave exactly as from the UI.
Nothing here deletes or renames files. Tools that overwrite something on the canvas say so
(destructiveHint); AI apps ask the user before calling tools that are not read-only.
"""
import json
import os
import re
import ssl
import time
import urllib.error
import urllib.request
from .lora_info import trains_text_encoder
from .mcp_bridge import PageRefused, PageUnavailable, ask_page
from .mcp_tools import MAX_LIMIT, ToolError, _resolve_model, _schema
SPEAK_WAIT_SECONDS = 120
HISTORY_POLL_SECONDS = 1.5
def _page(action, args=None, timeout=30):
try:
return ask_page(action, args, timeout)
except (PageUnavailable, PageRefused) as error:
raise ToolError(str(error))
def _rel(base_dir, file_path):
return os.path.relpath(file_path, base_dir).replace(os.sep, "/")
def _model_ref(model_id):
"""{type, path} of a model, as a node's drop-down names it (relative to its folder)."""
folder_type, _idx, base_dir, file_path = _resolve_model(model_id)
return {"type": folder_type, "path": _rel(base_dir, file_path)}
def _local(ctx, method, path, body=None, timeout=20):
"""(status, JSON) of one request to this ComfyUI server's own routes."""
data = json.dumps(body).encode("utf-8") if body is not None else None
request = urllib.request.Request(ctx["base_url"] + path, data=data, method=method,
headers={"Content-Type": "application/json"})
# Loopback only (mcp_server checked the host); a self-signed certificate is the user's own.
context = ssl._create_unverified_context() if ctx["base_url"].startswith("https:") else None
try:
with urllib.request.urlopen(request, timeout=timeout, context=context) as response:
return response.status, json.loads(response.read() or b"null")
except urllib.error.HTTPError as error:
try:
return error.code, json.loads(error.read() or b"null")
except ValueError:
return error.code, None
except (urllib.error.URLError, OSError) as error:
raise ToolError(f"Could not reach ComfyUI's own server: {error}")
# ---------------------------------------------------------------- canvas (through the page)
def describe_canvas(ctx):
return _page("describe_canvas")
def set_prompt(ctx, text, role="positive", node_id=None, mode="replace"):
if role not in ("positive", "negative") or mode not in ("replace", "append"):
raise ToolError("role is positive or negative; mode is replace or append.")
return _page("set_prompt", {"text": str(text), "role": role, "node_id": node_id, "mode": mode})
def set_model(ctx, node_id, model_id):
return _page("set_model", {"node_id": node_id, "model": _model_ref(model_id)})
def add_lora(ctx, model_id, after_node_id=None, strength_model=1.0, strength_clip=1.0):
folder_type, _idx, base_dir, file_path = _resolve_model(model_id)
if folder_type != "loras":
raise ToolError("add_lora takes a model from the loras folder.")
return _page("add_lora", {"model": {"type": folder_type, "path": _rel(base_dir, file_path)},
"text_encoder": trains_text_encoder(file_path), "after_node_id": after_node_id,
"strength_model": float(strength_model), "strength_clip": float(strength_clip)})
def place_combo(ctx, name):
return _page("place_combo", {"name": str(name)})
def check_workflow_models(ctx):
return _page("check_models", timeout=90)
def fix_workflow_models(ctx):
return _page("fix_models", timeout=90)
def download_missing_models(ctx, names=None):
return _page("download_missing", {"names": [str(name) for name in names or []]}, timeout=120)
def download_model(ctx, type, url="", hash="", folder=None, name=""):
if not (url or hash):
raise ToolError("Give url (a Civitai, Hugging Face or GitHub link to the file or model version) or hash.")
return _page("download_model", {"url": str(url), "hash": str(hash), "type": str(type),
"folder": None if folder is None else str(folder), "name": str(name or "")}, timeout=90)
def download_status(ctx):
status, data = _local(ctx, "GET", "/anomalous/download/status")
if status != 200:
raise ToolError(f"Download status unavailable ({status}).")
jobs = []
for job in (data or {}).get("jobs", []):
total = job.get("total") or 0
jobs.append({"file": job.get("rel"), "type": job.get("type"), "state": job.get("state"), "error": job.get("error") or None,
"percent": round(job["received"] * 100 / total) if total else None, "size": total or None,
"checked": job.get("verified"), "for_model": job.get("value") or None})
return {"downloads": jobs, "meaning": "state: queued, running, verifying, done (checked: its fingerprint matched), "
"failed (error says why; starting it again continues it), cancelled."}
def run_workflow(ctx, batch=1):
batch = max(1, min(8, int(batch)))
return _page("run_workflow", {"batch": batch})
def open_in_anomalous(ctx, model_id):
folder_type, path_idx, base_dir, file_path = _resolve_model(model_id)
return _page("open_model", {"type": folder_type, "path_idx": path_idx, "rel": _rel(base_dir, file_path)})
# ---------------------------------------------------------------- this server
def scan_models(ctx, what="new", model_ids=None):
body = {"offline_only": False, "skip_rename": False, "virtual_rename": True, "physical_rename": False,
"force_overwrite": False, "retry_unmatched": what == "retry_unmatched"}
if what == "models":
folders = {}
for model_id in model_ids or []:
folder_type, path_idx, base_dir, file_path = _resolve_model(model_id)
subfolder, _, filename = _rel(base_dir, file_path).rpartition("/")
folders.setdefault((folder_type, path_idx, "/" + subfolder), []).append(filename)
if not folders:
raise ToolError("what=models needs model_ids from search_models.")
body["targets"] = [{"type": t, "path_idx": i, "subfolder": s, "files": files} for (t, i, s), files in folders.items()]
elif what not in ("new", "retry_unmatched"):
raise ToolError("what is new, retry_unmatched or models.")
status, data = _local(ctx, "POST", "/anomalous/scan_all", body)
if status == 409:
raise ToolError("A scan is already running; call scan_report later to see it.")
if status != 200 or (data or {}).get("status") != "ok":
raise ToolError(f"The scan did not start: {(data or {}).get('message') or status}")
return {"started": True, "note": "Scans run in the background and only read .safetensors files. "
"Call scan_report in a minute or two for what it found."}
def _tts_characters(ctx):
status, data = _local(ctx, "GET", "/anomalous_tts/characters")
if status == 404:
raise ToolError("Anomalous TTS is not installed, so there are no voices.")
if status != 200 or not isinstance(data, dict):
raise ToolError(f"Could not read the voices ({status}).")
return data.get("characters") or []
def list_voices(ctx):
return {"voices": [{"character": item.get("name"), "language": item.get("language") or "",
"emotions": sorted((item.get("emotions") or {}).keys()),
**({"problem": item.get("error") or item.get("settings_error")}
if item.get("error") or item.get("settings_error") else {})}
for item in _tts_characters(ctx)]}
def _output_prefix(character):
"""Where the audio page saves a character's lines (audio_script.js ttsOutputPrefix)."""
parts = [re.sub(r'[<>:"\\|?*\x00-\x1f]', "_", part).strip() or "_" for part in str(character).split("/")]
parts = ["_" if re.fullmatch(r"\.+", part) else part for part in parts]
return f"audio/{'/'.join(parts)}/{parts[-1]}"
def speak(ctx, character, text, seed=None, language="auto"):
names = {item.get("name") for item in _tts_characters(ctx)}
if character not in names:
raise ToolError(f"No voice called {character!r}; list_voices names them.")
if not str(text).strip():
raise ToolError("text is empty.")
seed = int(seed) if seed is not None else int(time.time() * 1000) % 2**31
prompt = {"1": {"class_type": "AnomalousTTS_CharacterSpeech",
"inputs": {"character": character, "text": str(text), "seed": seed, "language": language}},
"2": {"class_type": "SaveAudio", "inputs": {"audio": ["1", 0], "filename_prefix": _output_prefix(character)}}}
status, data = _local(ctx, "POST", "/prompt", {"prompt": prompt, "client_id": "anomalous-mcp"})
prompt_id = (data or {}).get("prompt_id")
if status != 200 or not prompt_id:
errors = (data or {}).get("node_errors") or (data or {}).get("error") or status
raise ToolError(f"ComfyUI refused the speech job: {json.dumps(errors, ensure_ascii=False)[:500]}")
deadline = time.monotonic() + SPEAK_WAIT_SECONDS
while time.monotonic() < deadline:
time.sleep(HISTORY_POLL_SECONDS)
_status, history = _local(ctx, "GET", f"/history/{prompt_id}")
record = (history or {}).get(prompt_id) or {}
state = record.get("status") or {}
if state.get("status_str") == "error":
messages = [m[1].get("exception_message") for m in state.get("messages") or [] if m and m[0] == "execution_error"]
raise ToolError(f"Speech failed: {messages[0] if messages else 'see the ComfyUI console'}")
if state.get("completed"):
files = [f"{item.get('subfolder')}/{item.get('filename')}".replace("\\", "/").strip("/")
for output in (record.get("outputs") or {}).values() for item in output.get("audio") or []]
return {"done": True, "seed": seed, "files": files,
"note": "Saved under ComfyUI's output folder; it shows in Anomalous's audio gallery."}
return {"done": False, "prompt_id": prompt_id, "seed": seed,
"note": "Still generating (ComfyUI may be busy with other jobs); search_audio will list it when done."}
# ---------------------------------------------------------------- registry
_NODE = {"type": "integer", "description": "A node id from describe_canvas."}
_MODEL = {"type": "string", "description": "model_id from search_models."}
def _action(name, title, description, run, properties=None, required=(), destructive=False, open_world=False,
read_only=False):
return name, {"run": run, "context": True, "spec": {
"name": name, "title": title, "description": description,
"inputSchema": _schema(properties, required),
"annotations": {"title": title, "readOnlyHint": read_only, "destructiveHint": destructive,
"idempotentHint": read_only, "openWorldHint": open_world},
}}
ACTIONS = dict([
_action("describe_canvas", "Read the canvas",
"The workflow open in ComfyUI now: each node's id, type, title, settings, which prompt box is "
"positive or negative, and where its inputs come from. Call it before changing the canvas.",
describe_canvas, read_only=True),
_action("set_prompt", "Write a prompt",
"Writes text into the workflow's positive or negative prompt box (or the one on node_id). "
"mode=append adds to what is there. One Ctrl+Z step.",
set_prompt, {"text": {"type": "string"}, "role": {"type": "string", "enum": ["positive", "negative"], "default": "positive"},
"node_id": _NODE, "mode": {"type": "string", "enum": ["replace", "append"], "default": "replace"}},
["text"], destructive=True),
_action("set_model", "Swap a node's model",
"Puts a model into a loader node (checkpoint, LoRA, VAE…) on the canvas, replacing the one it had.",
set_model, {"node_id": _NODE, "model_id": _MODEL}, ["node_id", "model_id"], destructive=True),
_action("add_lora", "Add a LoRA",
"Inserts a LoRA loader after node after_node_id (default: the workflow's main model loader, past "
"the LoRAs already on it) and moves everything that node fed behind it. LoRAs that train the text "
"encoder also take the CLIP line (from the same node, or a UNet workflow's CLIP loader); others "
"patch the model only.",
add_lora, {"model_id": _MODEL, "after_node_id": _NODE,
"strength_model": {"type": "number", "default": 1.0}, "strength_clip": {"type": "number", "default": 1.0}},
["model_id"]),
_action("place_combo", "Place a combo",
"Adds one of the user's combos (list_combos) to the canvas as a new wired group: model loader, LoRAs, "
"positive and negative prompt, or a node-structure combo's own nodes and links. Nothing already there "
"changes; ports to nodes outside the combo (sampler…) stay open.",
place_combo, {"name": {"type": "string", "description": "Combo name from list_combos."}}, ["name"]),
_action("check_workflow_models", "Check missing models",
"Which models the open workflow needs that this computer does not have under that name, and which "
"of them Anomalous can put back (same file found by hash, or a likely candidate).",
check_workflow_models, read_only=True),
_action("fix_workflow_models", "Fix missing models",
"Puts back every missing model that Anomalous found with certainty (same file by hash). "
"Likely candidates are left for the user. One Ctrl+Z step.",
fix_workflow_models, destructive=True),
_action("download_missing_models", "Download missing models",
"Downloads the models the open workflow is missing whose source is certain: Civitai found by the "
"workflow's fingerprint, or a file link the workflow carries. A file name alone is never used. Files go "
"where the user's download settings say and are checked against the fingerprint before use; finished "
"ones are put into their nodes. names limits it to those files. Large files take a while.",
download_missing_models, {"names": {"type": "array", "items": {"type": "string"}, "maxItems": MAX_LIMIT,
"description": "Only these model file names (default: all missing)."}},
open_world=True),
_action("download_model", "Download a model",
"Downloads one model into this computer's models folder of `type` (loras, checkpoints, vae, "
"diffusion_models, text_encoders, upscale_models…): from url (a Civitai model-version link, or a Hugging "
"Face / GitHub link to the file) or by its Civitai hash. folder is a subfolder inside that type's folder "
"(default: the user's download settings); name the file name (default: the site's). Never replaces a file.",
download_model, {"type": {"type": "string"}, "url": {"type": "string"}, "hash": {"type": "string"},
"folder": {"type": "string"}, "name": {"type": "string"}}, ["type"], open_world=True),
_action("download_status", "Downloads", "How the downloads of this ComfyUI run are going.", download_status,
read_only=True),
_action("run_workflow", "Run the workflow",
"Queues the open workflow in ComfyUI, like pressing Run. Uses the GPU; results appear in the output folder.",
run_workflow, {"batch": {"type": "integer", "minimum": 1, "maximum": 8, "default": 1}}),
_action("open_in_anomalous", "Show a model",
"Opens Anomalous Model Browser on this model's page, so the user sees its cover and details.",
open_in_anomalous, {"model_id": _MODEL}, ["model_id"]),
_action("scan_models", "Scan models",
"Starts a scan that fetches covers, trigger words and base models from Civitai (by file hash) for "
"models not scanned yet (new), looks unmatched ones up again (retry_unmatched), or scans model_ids.",
scan_models, {"what": {"type": "string", "enum": ["new", "retry_unmatched", "models"], "default": "new"},
"model_ids": {"type": "array", "items": {"type": "string"}, "maxItems": MAX_LIMIT}},
open_world=True),
_action("list_voices", "Voices",
"The Anomalous TTS characters (GPT-SoVITS voices) with their language and emotions.", list_voices,
read_only=True),
_action("speak", "Speak a line",
"Has an Anomalous TTS character read text and saves the audio. Emotion tags like {happy} switch "
"emotion mid-text; waits up to two minutes.",
speak, {"character": {"type": "string", "description": "From list_voices."}, "text": {"type": "string"},
"seed": {"type": "integer"}, "language": {"type": "string", "default": "auto"}},
["character", "text"]),
])
+78
View File
@@ -0,0 +1,78 @@
"""The way from an MCP tool to the open ComfyUI page, for what only the page can do (the
canvas lives in the browser).
`ask_page` sends {id, action, args} as the "anomalous.mcp" event over ComfyUI's websocket
to every open ComfyUI page and waits. One page claims it (the first claim wins, visible
pages claim first: web/modules/mcp_bridge.js), does the action with Anomalous's own canvas
code and posts the outcome back; `claim` and `resolve` take those posts
(routes in mcp_server.py). With no page open, or none answering in time, the tool fails
with words the AI can pass on.
"""
import threading
import uuid
TIMEOUT_SECONDS = 30
MAX_WAITING = 20
_lock = threading.Lock()
_waiting = {} # id -> {"event": Event, "claimed": bool, "outcome": dict | None}
class PageUnavailable(Exception):
"""No ComfyUI page took or finished the action."""
class PageRefused(Exception):
"""The page tried and said why it could not (missing node, wrong model type…)."""
def _server():
from server import PromptServer # ComfyUI's; imported late so tests can run without it
return PromptServer.instance
def ask_page(action, args=None, timeout=TIMEOUT_SECONDS):
"""Runs `action` in the open ComfyUI page; its result value. Call from a worker thread."""
server = _server()
if not getattr(server, "sockets", None):
raise PageUnavailable("ComfyUI is not open in a browser on this computer. Open it, then try again.")
request_id = uuid.uuid4().hex
entry = {"event": threading.Event(), "claimed": False, "outcome": None}
with _lock:
if len(_waiting) >= MAX_WAITING:
raise PageUnavailable("Too many canvas actions are waiting; try again in a moment.")
_waiting[request_id] = entry
try:
server.send_sync("anomalous.mcp", {"id": request_id, "action": action, "args": args or {}})
if not entry["event"].wait(timeout):
raise PageUnavailable("The ComfyUI page did not answer in time. Is it open and not frozen? "
"If the action was taken late, check the canvas before repeating it.")
finally:
with _lock:
_waiting.pop(request_id, None)
outcome = entry["outcome"] or {}
if not outcome.get("ok"):
raise PageRefused(str(outcome.get("error") or "The page could not do that."))
return outcome.get("value")
def claim(request_id):
"""True for the first page that asks to do request `request_id`."""
with _lock:
entry = _waiting.get(request_id)
if entry is None or entry["claimed"]:
return False
entry["claimed"] = True
return True
def resolve(request_id, outcome):
"""The claiming page's outcome: {ok, value} or {ok: false, error}. False when nobody waits."""
with _lock:
entry = _waiting.get(request_id)
if entry is None or not entry["claimed"] or entry["outcome"] is not None:
return False
entry["outcome"] = outcome if isinstance(outcome, dict) else {"ok": False, "error": "Malformed answer"}
entry["event"].set()
return True
+201
View File
@@ -0,0 +1,201 @@
"""MCP endpoint: AI apps (Claude, Cursor, Cherry Studio…) use Anomalous's library as tools.
POST /anomalous/mcp speaks the Model Context Protocol's Streamable HTTP transport, both eras:
- modern (2026-07-28): stateless; every request carries its protocol version in `_meta` and
the MCP-Protocol-Version / Mcp-Method / Mcp-Name headers, which must match the body;
- legacy (2025-03-26 to 2025-11-25): an `initialize` handshake first. No session is kept
(sessions are optional there), so both eras are served the same stateless way.
Only this computer may connect: the peer must be loopback, no proxy header may be present,
and Host and Origin (when sent) must name a loopback host, which stops DNS rebinding.
Answers are always one JSON object; there are no server-initiated messages. The tools are
in mcp_tools.py (reading) and mcp_actions.py (acting); POST /anomalous/mcp/bridge is where
the open ComfyUI page claims and answers canvas actions (mcp_bridge.py).
"""
import asyncio
import base64
import urllib.parse
from aiohttp import web
from . import mcp_bridge
from .local_guard import is_local as _is_local, loopback_host as _loopback_host
from .mcp_actions import ACTIONS
from .mcp_tools import TOOLS as READ_TOOLS, call_tool
TOOLS = {**READ_TOOLS, **ACTIONS}
MODERN_VERSIONS = ("2026-07-28",)
LEGACY_VERSIONS = ("2025-11-25", "2025-06-18", "2025-03-26")
SERVER_INFO = {"name": "anomalous-model-browser", "title": "Anomalous Model Browser", "version": "1.0.0"}
INSTRUCTIONS = (
"Tools over the user's ComfyUI library as Anomalous Model Browser keeps it: model files with "
"their Civitai information, output images with their generation settings, combos (a model, "
"LoRAs and a prompt), workflow recipes, saved prompts, generated audio, the scan status and "
"the activity log; and tools that act on the workflow open in ComfyUI (read it with "
"describe_canvas first), scan models, or have an Anomalous TTS voice speak. Canvas changes "
"are single Ctrl+Z steps and are logged as the AI's; nothing deletes files. Ids returned by "
"one tool (model_id, image, node ids) are what the others take. For what no tool does, tell "
"the user where in Anomalous to do it (the rail on the left: Models, Gallery, Workflows, "
"Combos, Prompts, Voices)."
)
LIST_TTL_MS = 300000
# Requests run tools in threads; a few at a time is plenty for one person's AI app.
_slots = asyncio.Semaphore(4)
PARSE_ERROR, INVALID_REQUEST, METHOD_NOT_FOUND, INVALID_PARAMS = -32700, -32600, -32601, -32602
HEADER_MISMATCH, UNSUPPORTED_VERSION = -32020, -32022
def _origin_ok(request):
origin = request.headers.get("Origin")
return origin is None or _loopback_host(urllib.parse.urlsplit(origin).hostname)
def _decode_header(value):
"""Mcp-Name values outside plain ASCII come as =?base64?…?=."""
if value and value.startswith("=?base64?") and value.endswith("?="):
try:
return base64.b64decode(value[9:-2], validate=True).decode("utf-8")
except (ValueError, UnicodeDecodeError):
return None
return value
def _error(id_, code, message, status=200, data=None):
error = {"code": code, "message": message}
if data is not None:
error["data"] = data
body = {"jsonrpc": "2.0", "error": error}
if id_ is not None:
body["id"] = id_
return web.json_response(body, status=status)
def _result(id_, result, modern):
if modern:
result = {"resultType": "complete", **result}
return web.json_response({"jsonrpc": "2.0", "id": id_, "result": result})
def _tool_list(modern):
result = {"tools": [tool["spec"] for tool in TOOLS.values()]}
if modern:
result.update(ttlMs=LIST_TTL_MS, cacheScope="public")
return result
def _initialize(params):
requested = params.get("protocolVersion")
return {
"protocolVersion": requested if requested in LEGACY_VERSIONS else LEGACY_VERSIONS[0],
"capabilities": {"tools": {"listChanged": False}},
"serverInfo": SERVER_INFO,
"instructions": INSTRUCTIONS,
}
def _discover():
return {
"supportedVersions": list(MODERN_VERSIONS + LEGACY_VERSIONS),
"capabilities": {"tools": {"listChanged": False}},
"_meta": {"io.modelcontextprotocol/serverInfo": SERVER_INFO},
"instructions": INSTRUCTIONS,
"ttlMs": LIST_TTL_MS,
"cacheScope": "public",
}
def _modern_header_problem(request, method, params, version):
"""Why the mirrored headers do not match the body (2026-07-28), or None."""
if request.headers.get("MCP-Protocol-Version") != version:
return "MCP-Protocol-Version header missing or not the body's protocol version"
if request.headers.get("Mcp-Method") != method:
return "Mcp-Method header missing or not the body's method"
if method in ("tools/call", "prompts/get", "resources/read"):
name = params.get("uri") if method == "resources/read" else params.get("name")
if _decode_header(request.headers.get("Mcp-Name")) != name:
return "Mcp-Name header missing or not the body's name"
return None
async def _call(params, request):
name = params.get("name")
arguments = params.get("arguments") or {}
if name not in TOOLS or not isinstance(arguments, dict):
return None
ctx = {"base_url": f"{request.scheme}://{request.host}"} # loopback, checked in api_mcp
async with _slots:
result = await asyncio.to_thread(call_tool, TOOLS, name, arguments, ctx)
return result
async def api_mcp(request):
"""POST /anomalous/mcp - one JSON-RPC request or notification per POST."""
if request.method != "POST":
return web.Response(status=405, headers={"Allow": "POST"})
if not _is_local(request) or not _origin_ok(request):
return _error(None, INVALID_REQUEST, "Only this computer may use the Anomalous MCP endpoint.", status=403)
try:
body = await request.json()
except (ValueError, UnicodeDecodeError):
return _error(None, PARSE_ERROR, "Body is not JSON", status=400)
if not isinstance(body, dict) or body.get("jsonrpc") != "2.0" or not isinstance(body.get("method"), str):
return _error(None, INVALID_REQUEST, "Expected one JSON-RPC 2.0 request (batches are not supported)", status=400)
if "id" not in body:
return web.Response(status=202) # notifications (initialized, cancelled) need no answer
id_, method = body["id"], body["method"]
params = body.get("params") if isinstance(body.get("params"), dict) else {}
if method == "initialize":
return _result(id_, _initialize(params), modern=False)
meta = params.get("_meta") if isinstance(params.get("_meta"), dict) else {}
version = meta.get("io.modelcontextprotocol/protocolVersion")
modern = version is not None
if modern:
if version not in MODERN_VERSIONS:
return _error(id_, UNSUPPORTED_VERSION, "Unsupported protocol version", status=400,
data={"supported": list(MODERN_VERSIONS + LEGACY_VERSIONS), "requested": version})
problem = _modern_header_problem(request, method, params, version)
if problem:
return _error(id_, HEADER_MISMATCH, f"Header mismatch: {problem}", status=400)
else:
header = request.headers.get("MCP-Protocol-Version")
if header is not None and header not in LEGACY_VERSIONS:
return _error(id_, INVALID_REQUEST, f"Unsupported protocol version {header}", status=400,
data={"supported": list(MODERN_VERSIONS + LEGACY_VERSIONS), "requested": header})
if method == "server/discover" and modern:
return _result(id_, _discover(), modern)
if method == "ping":
return _result(id_, {}, modern)
if method == "tools/list":
return _result(id_, _tool_list(modern), modern)
if method == "tools/call":
result = await _call(params, request)
if result is None:
return _error(id_, INVALID_PARAMS, f"Unknown tool or bad arguments: {params.get('name')}")
return _result(id_, result, modern)
return _error(id_, METHOD_NOT_FOUND, f"Method not found: {method}", status=404 if modern else 200)
async def api_mcp_bridge(request):
"""POST /anomalous/mcp/bridge - the open page claims a canvas action ({id, claim: true} ->
{granted}) and then answers it ({id, outcome: {ok, value} | {ok: false, error}})."""
if not _is_local(request) or not _origin_ok(request):
return web.json_response({"error": "This computer only"}, status=403)
try:
body = await request.json()
except (ValueError, UnicodeDecodeError):
body = None
if not isinstance(body, dict) or not isinstance(body.get("id"), str):
return web.json_response({"error": "Expected {id, claim} or {id, outcome}"}, status=400)
if body.get("claim") is True:
return web.json_response({"granted": mcp_bridge.claim(body["id"])})
return web.json_response({"accepted": mcp_bridge.resolve(body["id"], body.get("outcome"))})
def register_routes(app):
app.router.add_route("*", "/anomalous/mcp", api_mcp)
app.router.add_post("/anomalous/mcp/bridge", api_mcp_bridge)
+492
View File
@@ -0,0 +1,492 @@
"""The MCP endpoint's (mcp_server.py) reading tools: views of the library (mcp_actions.py acts).
`call_tool` runs any tool of a registry, these and mcp_actions.py's.
Each tool is a plain function over the same stores the browser's pages use (model catalog
and sidecars, output gallery, combos, workflow recipes, saved prompts, generated audio, scan
results, activity log). They never write. Results are small, already-summarised JSON (an AI
reads them), with ids the detail tools take back: model_id "type:path_idx:relative/path" and
image "subfolder/file.png" under ComfyUI's output folder. A tool's mistake (bad id, missing
file) comes back as a tool error the AI can read and correct, never as an exception.
"""
import base64
import datetime
import html
import json
import logging
import os
import re
import folder_paths
from .activity_log import list_entries
from .audio_catalog import _gallery_page as _audio_page
from .gallery_routes import _gallery_listing
from .image_search import read_png_text
from .kept_images import _kept_images
from .material_store import _list_materials, _read_material, get_materials_dir
from .media_routes import _build_card_thumbnail
from .metadata import get_metadata
from .model_catalog import _iter_search_models
from .model_constants import MEDIA_EXTENSIONS, MODEL_EXTENSIONS, PREVIEW_SUFFIXES
from .notebooks import _list_notebooks
from .path_utils import model_file_within, resolve_folder_subdir, resolve_within
from .recipe_store import _list_recipes, get_recipes_dir
from .scan_report import last_scan_path
from .scan_summary import _summarize
MAX_LIMIT = 50
MAX_TEXT = 1500
MAX_IMAGE_BYTES = 1_500_000
IMAGE_TYPES = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp"}
SOURCES = {"civitai": "civitai", "local": "inferred_from_file", "": "not_scanned"}
class ToolError(Exception):
"""A problem the AI can fix (bad id, nothing there); shown to it as the tool's result."""
# ---------------------------------------------------------------- helpers
def _text(value, limit=MAX_TEXT):
"""Plain text from HTML-ish Civitai descriptions, shortened."""
value = html.unescape(re.sub(r"<[^>]+>", " ", str(value or "")))
value = re.sub(r"\s+", " ", value).strip()
return value if len(value) <= limit else value[:limit] + "…"
def _when(seconds):
try:
return datetime.datetime.fromtimestamp(float(seconds)).isoformat(timespec="seconds")
except (TypeError, ValueError, OverflowError, OSError):
return None
def _limit(value, default=20):
try:
return max(1, min(MAX_LIMIT, int(value)))
except (TypeError, ValueError):
return default
def _terms(query):
return [term for term in str(query or "").lower().split() if term]
def _matches(terms, *texts):
haystack = " ".join(str(text or "") for text in texts).lower()
return all(term in haystack for term in terms)
def _model_id(folder_type, path_idx, base_dir, file_path):
return f"{folder_type}:{path_idx}:{os.path.relpath(file_path, base_dir).replace(os.sep, '/')}"
def _resolve_model(model_id):
"""(type, path_idx, base_dir, file_path) for a model_id from search_models."""
parts = str(model_id or "").split(":", 2)
if len(parts) != 3:
raise ToolError("model_id looks like 'loras:0:folder/file.safetensors'; take it from search_models.")
folder_type, path_idx, rel = parts
rel = rel.replace("\\", "/").strip("/")
subfolder, filename = os.path.split(rel)
try:
path_idx = int(path_idx)
base_dir, target_dir = resolve_folder_subdir(folder_type, path_idx, "/" + subfolder)
file_path = model_file_within(target_dir, filename)
except (ValueError, KeyError, TypeError):
raise ToolError(f"No models folder for {model_id}.")
if not filename.lower().endswith(MODEL_EXTENSIONS) or not os.path.isfile(file_path):
raise ToolError(f"No model file at {model_id}; it may have been moved or renamed.")
return folder_type, path_idx, base_dir, file_path
def _resolve_image(image):
"""The output image at `image` ('subfolder/file.png'), checked to stay inside the output folder."""
rel = str(image or "").replace("\\", "/").strip("/")
try:
path = resolve_within(folder_paths.get_output_directory(), rel)
except ValueError:
raise ToolError("image is a path under ComfyUI's output folder, as search_images returns it.")
if os.path.splitext(path)[1].lower() not in IMAGE_TYPES or not os.path.isfile(path):
raise ToolError(f"No output image at {image}.")
return rel, path
def _image_content(path):
"""An MCP image item: the 512 px thumbnail the gallery uses, or None when too big or unreadable."""
try:
thumb = _build_card_thumbnail(path)
with open(thumb, "rb") as source:
data = source.read(MAX_IMAGE_BYTES + 1)
except OSError:
return None
if len(data) > MAX_IMAGE_BYTES:
return None
mime = IMAGE_TYPES.get(os.path.splitext(thumb)[1].lower(), "image/webp")
return {"type": "image", "data": base64.b64encode(data).decode("ascii"), "mimeType": mime}
def _kept_lookup():
return {(item["subfolder"], item["filename"]): {kind: (value or {}).get("name") for kind, value in item.items()
if kind in ("recipe", "combo", "prompt") and value}
for item in _kept_images()}
def _model_brief(folder_type, path_idx, base_dir, file_path, meta):
rel_dir = os.path.relpath(os.path.dirname(file_path), base_dir).replace(os.sep, "/")
try:
size_mb = round(os.path.getsize(file_path) / 1048576, 1)
except OSError:
size_mb = None
return {
"model_id": _model_id(folder_type, path_idx, base_dir, file_path),
"name": meta.get("custom_name") or meta.get("name") or os.path.basename(file_path),
"file": os.path.basename(file_path),
"type": folder_type,
"folder": "" if rel_dir == "." else rel_dir,
"base_model": meta.get("baseModel") or "",
"source": SOURCES.get(meta.get("info_source", ""), "not_scanned"),
"trigger_words": list(meta.get("trainedWords") or [])[:8],
"size_mb": size_mb,
}
def _each_model():
"""Every model file once (folders registered twice, like unet and diffusion_models, count once).
ComfyUI also registers custom_nodes, whose node packs carry files of their own: not the library."""
seen = set()
for folder_type, path_idx, base_dir, file_path in _iter_search_models():
if folder_type == "custom_nodes" or not file_path.lower().endswith(MODEL_EXTENSIONS):
continue
real = os.path.normcase(os.path.realpath(file_path))
if real in seen:
continue
seen.add(real)
yield folder_type, path_idx, base_dir, file_path
# ---------------------------------------------------------------- models and scans
def search_models(query="", type="", base_model="", source="", limit=20):
terms, wanted_base, found = _terms(query), str(base_model or "").lower(), []
total = 0
for folder_type, path_idx, base_dir, file_path in _each_model():
if type and folder_type != type:
continue
meta = get_metadata(file_path)
if wanted_base and wanted_base not in str(meta.get("baseModel") or "").lower():
continue
if source and SOURCES.get(meta.get("info_source", ""), "not_scanned") != source:
continue
if not _matches(terms, os.path.relpath(file_path, base_dir), meta.get("name"), meta.get("custom_name"),
meta.get("baseModel"), " ".join(meta.get("trainedWords") or [])):
continue
total += 1
if len(found) < _limit(limit):
found.append(_model_brief(folder_type, path_idx, base_dir, file_path, meta))
return {"total_matches": total, "models": found}
def get_model(model_id, include_cover=False):
folder_type, path_idx, base_dir, file_path = _resolve_model(model_id)
meta = get_metadata(file_path)
detail = _model_brief(folder_type, path_idx, base_dir, file_path, meta)
detail.update({
"trigger_words": list(meta.get("trainedWords") or []),
"civitai_name": meta.get("name") or "",
"civitai_url": meta.get("civitai_url") or "",
"download_link": meta.get("source_url") or "",
"user_notes": meta.get("custom_notes") or "",
"fields_set_by_user": list(meta.get("user_fields") or []),
"description": _text(meta.get("description")),
"sha256": meta.get("hash") or "",
})
if meta.get("info_source") == "local":
detail["why_not_on_civitai"] = meta.get("unmatched_reason") or "unknown"
extra = []
if include_cover:
base = os.path.splitext(file_path)[0]
cover = next((base + suffix for suffix in PREVIEW_SUFFIXES + MEDIA_EXTENSIONS
if os.path.splitext(suffix)[1].lower() in IMAGE_TYPES and os.path.isfile(base + suffix)), None)
item = _image_content(cover) if cover else None
detail["cover"] = "attached" if item else "none"
extra = [item] if item else []
return detail, extra
def library_overview():
by_type, by_base = {}, {}
for folder_type, _idx, _base, file_path in _each_model():
by_type[folder_type] = by_type.get(folder_type, 0) + 1
base = get_metadata(file_path).get("baseModel") or "unknown"
by_base[base] = by_base.get(base, 0) + 1
summary = _summarize()
return {
"models_by_type": dict(sorted(by_type.items(), key=lambda item: -item[1])),
"models_by_base_model": dict(sorted(by_base.items(), key=lambda item: -item[1])[:20]),
"scan": {key: summary[key] for key in ("total", "matched", "unmatched", "new", "pending")},
"combos": len(_list_notebooks()),
"workflow_recipes": len(_list_recipes(get_recipes_dir())),
"saved_prompts": sum(1 for item in _list_materials(get_materials_dir()) if item.get("kind") == "prompt_plan"),
}
def scan_report():
summary = _summarize()
try:
with open(last_scan_path(), encoding="utf-8") as source:
last = json.load(source)
except (OSError, ValueError):
last = None
report = {
"counts": {key: summary[key] for key in ("total", "matched", "unmatched", "new", "pending")},
"meaning": "matched: found on Civitai; unmatched: base model inferred from the file (reason says why); "
"new: never scanned; pending: unmatched only because Civitai was not asked (offline or no connection).",
"unmatched_models": summary["unmatched_models"][:MAX_LIMIT],
"not_scanned_models": summary["new_models"][:MAX_LIMIT],
}
if isinstance(last, dict) and last.get("counts"):
report["last_scan"] = {"finished": _when(last.get("finished")), "kind": last.get("kind"),
"counts": last.get("counts"), "civitai_unreachable": bool(last.get("civitai_down")),
"models": (last.get("files") or [])[:30]}
return report
# ---------------------------------------------------------------- outputs
def search_images(query="", limit=20, page=1):
images = _gallery_listing(folder_paths.get_output_directory(), False, str(query or "").strip())
limit = _limit(limit)
try:
page = max(1, int(page))
except (TypeError, ValueError):
page = 1
kept = _kept_lookup()
chosen = images[(page - 1) * limit:page * limit]
return {
"total_matches": len(images), "page": page,
"images": [{"image": "/".join(part for part in (item["subfolder"], item["filename"]) if part),
"created": _when(item.get("mtime")),
**({"kept_as": kept[(item["subfolder"], item["filename"])]}
if (item["subfolder"], item["filename"]) in kept else {})}
for item in chosen],
}
def get_image_info(image, include_image=False):
rel, path = _resolve_image(image)
info = {"image": rel, "created": _when(os.path.getmtime(path))}
chunks = read_png_text(path) if path.lower().endswith(".png") else {}
try:
prompt = json.loads(chunks.get("prompt") or "null")
except ValueError:
prompt = None
if isinstance(prompt, dict):
nodes, models = [], set()
for node_id, node in list(prompt.items())[:80]:
if not isinstance(node, dict):
continue
inputs = {}
for key, value in (node.get("inputs") or {}).items():
if isinstance(value, str):
inputs[key] = _text(value, 2000) if len(value) > 2000 else value
if value.lower().endswith(MODEL_EXTENSIONS):
models.add(value.replace("\\", "/"))
elif isinstance(value, (int, float, bool)):
inputs[key] = value
nodes.append({"id": node_id, "type": node.get("class_type"), "inputs": inputs})
info.update(models_used=sorted(models), nodes=nodes)
elif chunks.get("parameters"):
info["parameters"] = _text(chunks["parameters"], 4000)
else:
info["note"] = "No generation settings are stored in this image."
kept = _kept_lookup().get(tuple(rel.rsplit("/", 1)) if "/" in rel else ("", rel))
if kept:
info["kept_as"] = kept
extra = []
if include_image:
item = _image_content(path)
info["image_attached"] = bool(item)
extra = [item] if item else []
return info, extra
def search_audio(query="", limit=20):
items, total, _more = _audio_page(folder_paths.get_output_directory(), 1, _limit(limit), str(query or "").lower().strip())
return {"total_matches": total, "audio": [{
"file": "/".join(part for part in (item["subfolder"], item["filename"]) if part),
"created": _when(item.get("mtime")),
"speech": (item.get("generation") or {}).get("speech") or "",
"voice_sample": (item.get("generation") or {}).get("sample") or "",
"seed": (item.get("generation") or {}).get("seed"),
} for item in items]}
# ---------------------------------------------------------------- what the user kept
def _model_name(model):
if isinstance(model, dict):
return model.get("filename") or model.get("name") or ""
return str(model or "")
def list_combos(query="", limit=20):
terms, found = _terms(query), []
for note in _list_notebooks():
data = note.get("data") or {}
if data.get("kind") == "nodes": # its own node structure: model and text slots by their names
structure = data.get("structure") or {}
values = data.get("values") or {}
slots = structure.get("slots") or []
models = [{"slot": s.get("label") or s.get("widget"), "folder": s.get("folder") or "",
"file": os.path.basename(str(values.get(s["id"]) or "").replace("\\", "/"))}
for s in slots if s.get("kind") == "model"]
texts = [{"slot": s.get("label") or s.get("widget"), "text": _text(values.get(s["id"]), 400)}
for s in slots if s.get("kind") == "text"]
combo = {"name": note.get("name"), "kind": "node structure", "models": models, "texts": texts,
"nodes": [n.get("name") or n.get("type") for n in structure.get("nodes") or []],
"main_model": models[0]["file"] if models else "", "prompt": texts[0]["text"] if texts else ""}
loras = [model["file"] for model in models[1:]] + [text["text"] for text in texts[1:]]
else:
loras = [_model_name(lora) for lora in data.get("loras") or []]
combo = {"name": note.get("name"), "base_model": data.get("baseModel") or "",
"main_model": _model_name(data.get("mainModel")), "loras": loras,
"prompt": _text(data.get("promptEn"), 800)}
if not _matches(terms, combo["name"], combo["main_model"], combo["prompt"], " ".join(loras)):
continue
found.append(combo)
return {"total_matches": len(found), "combos": found[:_limit(limit)]}
def list_workflows(query="", limit=20):
terms, found = _terms(query), []
for recipe in _list_recipes(get_recipes_dir()):
data = recipe.get("data") or {}
params = json.dumps(data.get("params") or {}, ensure_ascii=False)
if not _matches(terms, data.get("name"), " ".join(data.get("tags") or []), data.get("notes"), params):
continue
found.append({"name": data.get("name") or recipe["filename"], "tags": data.get("tags") or [],
"notes": _text(data.get("notes"), 500), "saved": _when((data.get("timestamp") or 0) / 1000),
"settings": params if len(params) <= MAX_TEXT else params[:MAX_TEXT] + "…"})
return {"total_matches": len(found), "workflows": found[:_limit(limit)]}
def list_saved_prompts(query="", limit=20):
terms, found = _terms(query), []
directory = get_materials_dir()
for summary in _list_materials(directory):
if summary.get("kind") != "prompt_plan" or summary.get("moved_to_recipe"):
continue
try:
plan = _read_material(os.path.join(directory, summary["filename"])).get("plan") or {}
except (OSError, ValueError, KeyError):
continue
prompt = {"name": summary.get("name"), "tags": summary.get("tags") or [],
"positive": _text(plan.get("positive"), 1200), "negative": _text(plan.get("negative"), 600)}
if _matches(terms, prompt["name"], " ".join(prompt["tags"]), prompt["positive"], prompt["negative"]):
found.append(prompt)
return {"total_matches": len(found), "prompts": found[:_limit(limit)]}
def recent_activity(limit=20, source=""):
entries = list_entries(_limit(limit), source if source in ("canvas", "file") else None)
return {"entries": [{"time": _when(entry.get("time")), "source": entry.get("source"), "action": entry.get("action"),
"target": entry.get("target") or "", "detail": entry.get("detail") or {}} for entry in entries]}
# ---------------------------------------------------------------- registry
def _schema(properties=None, required=()):
schema = {"type": "object", "properties": properties or {}, "additionalProperties": False}
if required:
schema["required"] = list(required)
return schema
_QUERY = {"type": "string", "description": "Words that must all appear (case-insensitive); empty lists everything."}
_LIMIT = {"type": "integer", "minimum": 1, "maximum": MAX_LIMIT, "default": 20}
def _tool(name, title, description, run, properties=None, required=()):
return name, {"run": run, "spec": {
"name": name, "title": title, "description": description,
"inputSchema": _schema(properties, required),
"annotations": {"title": title, "readOnlyHint": True, "destructiveHint": False,
"idempotentHint": True, "openWorldHint": False},
}}
TOOLS = dict([
_tool("library_overview", "Library overview",
"How many models of each type and base model the user has, how many are scanned, and how many "
"combos, workflow recipes and saved prompts they kept. A good first call.", library_overview),
_tool("search_models", "Search models",
"Find model files (checkpoints, LoRAs, VAEs…) by name, trigger word or base model. Returns model_id for get_model.",
search_models, {
"query": _QUERY,
"type": {"type": "string", "description": "ComfyUI models folder, e.g. checkpoints, loras, vae, diffusion_models."},
"base_model": {"type": "string", "description": "Part of the base model name, e.g. SDXL, Pony, Flux, SD 1.5."},
"source": {"type": "string", "enum": ["civitai", "inferred_from_file", "not_scanned"],
"description": "Where the model's information came from."},
"limit": _LIMIT}),
_tool("get_model", "Model details",
"Everything known about one model: Civitai name and link, trigger words, base model, description, "
"the user's own name and notes. include_cover attaches its cover picture.",
get_model, {"model_id": {"type": "string", "description": "From search_models."},
"include_cover": {"type": "boolean", "default": False}}, ["model_id"]),
_tool("scan_report", "Scan status",
"Which models are matched on Civitai, which are not and why, which were never scanned, and what the last scan did.",
scan_report),
_tool("search_images", "Search output images",
"Find images ComfyUI generated, newest first, by words in their prompt, model or settings (e.g. a LoRA "
"name or 'seed:123'). Returns image paths for get_image_info.",
search_images, {"query": _QUERY, "limit": _LIMIT, "page": {"type": "integer", "minimum": 1, "default": 1}}),
_tool("get_image_info", "Image generation settings",
"The nodes and settings that produced an output image (prompts, models, seed, sampler…) and whether the "
"user kept it as a recipe, combo or prompt. include_image attaches a 512 px copy.",
get_image_info, {"image": {"type": "string", "description": "From search_images, e.g. 'ComfyUI_00012_.png'."},
"include_image": {"type": "boolean", "default": False}}, ["image"]),
_tool("list_combos", "Combos",
"The user's combos (搭配): a main model, LoRAs and a prompt kept together, or (kind \"node "
"structure\") model nodes and text nodes saved from the canvas with their links, listed by slot.", list_combos,
{"query": _QUERY, "limit": _LIMIT}),
_tool("list_workflows", "Workflow recipes",
"The user's saved workflow recipes with tags, notes and their main settings.", list_workflows,
{"query": _QUERY, "limit": _LIMIT}),
_tool("list_saved_prompts", "Saved prompts",
"Prompts the user saved in the Prompt Studio, with positive and negative text.", list_saved_prompts,
{"query": _QUERY, "limit": _LIMIT}),
_tool("search_audio", "Generated audio",
"Speech the user generated (Anomalous TTS), newest first, with its text, voice and seed.", search_audio,
{"query": _QUERY, "limit": _LIMIT}),
_tool("recent_activity", "Activity log",
"What Anomalous changed recently, newest first: canvas edits it made and files it saved, scanned or deleted.",
recent_activity, {"limit": _LIMIT, "source": {"type": "string", "enum": ["canvas", "file"]}}),
])
def call_tool(tools, name, arguments, ctx=None):
"""An MCP tools/call result for tool `name` of `tools` (run in a worker thread). Tools marked
`context` get `ctx` ({base_url} of this server) first."""
tool = tools[name]
spec = tool["spec"]["inputSchema"]
unknown = set(arguments) - set(spec["properties"])
missing = [key for key in spec.get("required", []) if key not in arguments]
if unknown or missing:
return _failure(f"Unknown arguments {sorted(unknown)}" if unknown else f"Missing arguments {missing}")
try:
value = tool["run"](ctx, **arguments) if tool.get("context") else tool["run"](**arguments)
except ToolError as error:
return _failure(str(error))
except Exception as error: # noqa: BLE001 - an AI app gets a readable failure, never a dropped request
logging.getLogger(__name__).warning("Anomalous MCP tool %s failed", name, exc_info=True)
return _failure(f"{name} failed: {error}")
data, extra = value if isinstance(value, tuple) else (value, [])
return {"content": [{"type": "text", "text": json.dumps(data, ensure_ascii=False)}, *extra],
"structuredContent": data, "isError": False}
def _failure(message):
return {"content": [{"type": "text", "text": message}], "isError": True}
+428
View File
@@ -0,0 +1,428 @@
"""Model cover, thumbnail-cache, and generated-image lookup routes.
Card images are 512 px WebP copies of the covers (a video cover gets one of its first
frame, the poster), kept in the user folder so they survive ComfyUI restarts (ComfyUI
empties its temp folder on every start). Listing a folder queues the covers not made yet
for one background worker, so scrolling and the next visit find them ready.
"""
import asyncio
import collections
import hashlib
import json
import os
import struct
import tempfile
import threading
import time
import urllib.parse
from aiohttp import web
import folder_paths
from .path_utils import require_filename, resolve_folder_subdir, resolve_within
CARD_THUMBNAIL_EDGE = 512
CARD_THUMBNAIL_CACHE_LIMIT = 256 * 1024 * 1024
CARD_THUMBNAIL_STATIC_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.webp', '.avif'}
CARD_POSTER_VIDEO_EXTENSIONS = {'.mp4', '.webm', '.mov', '.avi', '.mkv'}
CARD_WARM_QUEUE_LIMIT = 512
_thumbnail_cleanup_lock = threading.Lock()
_thumbnail_generation_slots = threading.BoundedSemaphore(2)
_thumbnail_last_cleanup = 0.0
_thumbnail_created_since_cleanup = 0
def _thumbnail_cache_directory():
"""<user folder>/anomalous/cache/card_thumbnails; the system temp folder if there is none."""
try:
user_dir = folder_paths.get_user_directory()
except Exception:
user_dir = None
if isinstance(user_dir, (str, os.PathLike)):
cache_dir = os.path.join(user_dir, "anomalous", "cache", "card_thumbnails")
else:
cache_dir = os.path.join(tempfile.gettempdir(), "anomalous_model_browser", "card_thumbnails")
os.makedirs(cache_dir, exist_ok=True)
return cache_dir
def thumbnail_cache_usage():
"""(file count, bytes) of the card image cache, for the settings page."""
count = size = 0
try:
with os.scandir(_thumbnail_cache_directory()) as iterator:
for entry in iterator:
if entry.is_file() and entry.name.endswith('.webp'):
count += 1
try:
size += entry.stat().st_size
except OSError:
pass
except OSError:
pass
return count, size
def clear_thumbnail_cache():
"""Delete the derived card images (never a cover); they are made again when needed."""
removed = 0
try:
with os.scandir(_thumbnail_cache_directory()) as iterator:
for entry in iterator:
if entry.is_file() and entry.name.endswith('.webp'):
try:
os.remove(entry.path)
removed += 1
except OSError:
pass
except OSError:
pass
return removed
def _prune_thumbnail_cache(cache_dir, created=False):
"""Occasionally cap the derived thumbnail cache without touching source covers."""
global _thumbnail_last_cleanup, _thumbnail_created_since_cleanup
now = time.monotonic()
with _thumbnail_cleanup_lock:
if created:
_thumbnail_created_since_cleanup += 1
now = time.monotonic()
if (
now - _thumbnail_last_cleanup < 3600
and _thumbnail_created_since_cleanup < 64
):
return
_thumbnail_last_cleanup = now
_thumbnail_created_since_cleanup = 0
entries = []
total_size = 0
try:
with os.scandir(cache_dir) as iterator:
for entry in iterator:
if not entry.is_file() or not entry.name.endswith('.webp'):
continue
try:
stat = entry.stat()
except OSError:
continue
total_size += stat.st_size
entries.append((stat.st_mtime_ns, stat.st_size, entry.path))
except OSError:
return
if total_size <= CARD_THUMBNAIL_CACHE_LIMIT:
return
target_size = int(CARD_THUMBNAIL_CACHE_LIMIT * 0.8)
for _, size, path in sorted(entries):
try:
os.remove(path)
total_size -= size
except OSError:
pass
if total_size <= target_size:
break
def _cached_card_path(source_path, kind=''):
"""Where the card image of `source_path` is cached: keyed by the file's identity and edge."""
source_stat = os.stat(source_path)
cache_key = "\0".join((
os.path.realpath(source_path),
str(source_stat.st_size),
str(source_stat.st_mtime_ns),
str(getattr(source_stat, 'st_ctime_ns', 0)),
str(CARD_THUMBNAIL_EDGE),
*((kind,) if kind else ()),
))
digest = hashlib.sha256(cache_key.encode('utf-8', errors='surrogatepass')).hexdigest()
cache_dir = _thumbnail_cache_directory()
return cache_dir, os.path.join(cache_dir, f"{digest}.webp")
def _reuse_cached(cache_dir, cached_path):
if not os.path.isfile(cached_path):
return False
try:
os.utime(cached_path, None)
except OSError:
pass
_prune_thumbnail_cache(cache_dir)
return True
def _save_card_image(image, cache_dir, cached_path):
"""Shrink a PIL image to the card edge and store it as WebP (atomically)."""
from PIL import Image
resampling = getattr(Image, 'Resampling', Image).LANCZOS
image.thumbnail((CARD_THUMBNAIL_EDGE, CARD_THUMBNAIL_EDGE), resampling)
if image.mode not in ('RGB', 'RGBA'):
image = image.convert('RGBA' if 'transparency' in image.info else 'RGB')
temp_path = f"{cached_path}.{os.getpid()}.{threading.get_ident()}.tmp"
try:
image.save(temp_path, format='WEBP', quality=84, method=4)
os.replace(temp_path, cached_path)
finally:
if os.path.exists(temp_path):
try:
os.remove(temp_path)
except OSError:
pass
_prune_thumbnail_cache(cache_dir, created=True)
def _build_card_thumbnail_impl(source_path):
"""Return a cached 512px WebP card image, or the original on any safe fallback."""
try:
from PIL import Image, ImageOps
cache_dir, cached_path = _cached_card_path(source_path)
if _reuse_cached(cache_dir, cached_path):
return cached_path
with Image.open(source_path) as image:
if getattr(image, 'is_animated', False):
return source_path
image = ImageOps.exif_transpose(image)
if max(image.size) <= CARD_THUMBNAIL_EDGE:
return source_path
_save_card_image(image, cache_dir, cached_path)
return cached_path
except Exception:
return source_path
def _build_card_thumbnail(source_path):
# Bound simultaneous decodes so opening a large folder cannot monopolize CPU/RAM.
with _thumbnail_generation_slots:
return _build_card_thumbnail_impl(source_path)
def _build_video_poster_impl(source_path):
"""A cached 512 px WebP of a video cover's first frame, or None (no decoder, bad file)."""
try:
cache_dir, cached_path = _cached_card_path(source_path, 'poster')
if _reuse_cached(cache_dir, cached_path):
return cached_path
import av # PyAV ships with ComfyUI for its video nodes
with av.open(source_path) as container:
stream = next((s for s in container.streams if s.type == 'video'), None)
if stream is None:
return None
stream.thread_type = 'AUTO'
frame = next(container.decode(stream), None)
if frame is None:
return None
image = frame.to_image()
_save_card_image(image, cache_dir, cached_path)
return cached_path
except Exception:
return None
def _build_video_poster(source_path):
with _thumbnail_generation_slots:
return _build_video_poster_impl(source_path)
# ---------- background warm-up of card images ----------
_warm_queue = collections.deque()
_warm_pending = set()
_warm_lock = threading.Lock()
_warm_thread = None
def _warm_worker():
"""One thread, one decode at a time, a short rest between: never more than half the slots."""
global _warm_thread
while True:
with _warm_lock:
if not _warm_queue:
_warm_thread = None
return
path = _warm_queue.popleft()
_warm_pending.discard(path)
ext = os.path.splitext(path)[1].lower()
if ext in CARD_POSTER_VIDEO_EXTENSIONS:
_build_video_poster(path)
else:
_build_card_thumbnail(path)
time.sleep(0.02)
def queue_card_images(paths):
"""Make the card images of these covers in the background (bounded, deduplicated)."""
global _warm_thread
with _warm_lock:
for path in paths:
ext = os.path.splitext(path)[1].lower()
if ext not in CARD_THUMBNAIL_STATIC_EXTENSIONS and ext not in CARD_POSTER_VIDEO_EXTENSIONS:
continue
if path in _warm_pending or len(_warm_queue) >= CARD_WARM_QUEUE_LIMIT:
continue
_warm_pending.add(path)
_warm_queue.append(path)
if _warm_queue and _warm_thread is None:
_warm_thread = threading.Thread(target=_warm_worker, name="anomalous-card-images", daemon=True)
_warm_thread.start()
async def api_serve_image(request):
"""Dedicated image serving endpoint for model preview images."""
folder_type = request.query.get('type', 'checkpoints')
try:
path_idx = int(request.query.get('path_idx', 0))
except:
path_idx = 0
subfolder = request.query.get('subfolder', '')
filename = request.query.get('filename', '')
try:
filename = require_filename(filename)
_, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder)
file_path = resolve_within(target_dir, filename)
except (ValueError, KeyError):
return web.Response(status=400, text='Invalid request')
if not os.path.exists(file_path) or not os.path.isfile(file_path):
return web.Response(status=404, text='Image not found')
ext = os.path.splitext(filename)[1].lower()
content_types = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp',
'.gif': 'image/gif',
'.avif': 'image/avif',
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.mov': 'video/quicktime',
'.avi': 'video/x-msvideo',
'.mkv': 'video/x-matroska'
}
content_type = content_types.get(ext)
if content_type is None:
return web.Response(status=415, text='Unsupported media type')
served_path = file_path
variant = request.query.get('variant')
if variant == 'card' and ext in CARD_THUMBNAIL_STATIC_EXTENSIONS:
served_path = await asyncio.to_thread(_build_card_thumbnail, file_path)
if served_path != file_path:
content_type = 'image/webp'
elif variant == 'poster':
# The card's still image of a video cover; nothing (404) when it cannot be read.
poster = await asyncio.to_thread(_build_video_poster, file_path) if ext in CARD_POSTER_VIDEO_EXTENSIONS else None
if not poster:
return web.Response(status=404, text='No poster')
served_path, content_type = poster, 'image/webp'
headers = {'Content-Type': content_type}
if request.query.get('t'):
headers['Cache-Control'] = 'public, max-age=31536000, immutable'
return web.FileResponse(served_path, headers=headers)
async def api_card_cache(request):
"""GET /anomalous/card_cache - how many card images are cached and their size."""
count, size = await asyncio.to_thread(thumbnail_cache_usage)
return web.json_response({"count": count, "bytes": size, "limit": CARD_THUMBNAIL_CACHE_LIMIT})
async def api_clear_card_cache(request):
"""POST /anomalous/card_cache/clear - delete the derived card images (made again when shown)."""
removed = await asyncio.to_thread(clear_thumbnail_cache)
return web.json_response({"status": "ok", "removed": removed})
async def api_clear_cache(request):
try:
from .metadata import clear_metadata_cache
if hasattr(folder_paths, "filename_list_cache"):
folder_paths.filename_list_cache.clear()
if hasattr(folder_paths, "cache_helper") and hasattr(folder_paths.cache_helper, "clear"):
folder_paths.cache_helper.clear()
clear_metadata_cache()
return web.json_response({"status": "success"})
except Exception as e:
return web.json_response({"status": "error", "message": str(e)})
def read_png_text_fast(path):
try:
with open(path, 'rb') as f:
signature = f.read(8)
if signature != b'\x89PNG\r\n\x1a\n':
return None
while True:
length_bytes = f.read(4)
if not length_bytes: break
length = struct.unpack('>I', length_bytes)[0]
chunk_type = f.read(4)
if chunk_type == b'tEXt':
data = f.read(length)
keyword, text = data.split(b'\0', 1)
if keyword == b'prompt':
return text.decode('utf-8', errors='ignore')
else:
f.seek(length, 1)
f.seek(4, 1)
except Exception:
pass
return None
async def api_get_model_images(request):
model_name = request.rel_url.query.get('model_name', '')
if not model_name:
return web.json_response({'images': []})
base_target = os.path.basename(model_name).lower()
# We will search the output directory
output_dir = folder_paths.get_output_directory()
if not os.path.exists(output_dir):
return web.json_response({'images': []})
def find_images():
matched_images = []
for root, _, files in os.walk(output_dir):
for file in files:
if not file.lower().endswith('.png'):
continue
full_path = os.path.join(root, file)
prompt_text = read_png_text_fast(full_path)
if not prompt_text:
continue
try:
prompt_data = json.loads(prompt_text)
matched = any(
os.path.basename(v).lower() == base_target
for node in prompt_data.values()
if isinstance(node, dict) and 'class_type' in node
for v in node.get('inputs', {}).values()
if isinstance(v, str)
)
if not matched:
continue
rel_path = os.path.relpath(root, output_dir).replace('\\', '/')
if rel_path == '.':
rel_path = ''
url = f'/view?filename={urllib.parse.quote(file)}&type=output'
if rel_path:
url += f'&subfolder={urllib.parse.quote(rel_path)}'
matched_images.append({'url': url, 'mtime': os.path.getmtime(full_path)})
except (OSError, ValueError, TypeError, json.JSONDecodeError):
pass
matched_images.sort(key=lambda x: x['mtime'], reverse=True)
return matched_images
matched_images = await asyncio.to_thread(find_images)
return web.json_response({'images': matched_images})
+198 -178
View File
@@ -4,110 +4,102 @@ import json
import urllib.parse
import subprocess
import threading
import asyncio
import copy
from functools import lru_cache
from aiohttp import web
import asyncio
import copy
from functools import lru_cache
from aiohttp import web
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.
USER_FIELDS = {"custom_name": "custom_name", "custom_notes": "custom_notes", "source_url": "source_url"}
def _select_info_file(data, file_path):
"""Select the Civitai file entry that actually describes ``file_path``.
A model version may contain a checkpoint, text encoder, and VAE in the same
``files`` array. Taking the first SHA256 silently assigns the dependency's
hash to every local sidecar copied from that version. Physical byte size is
the strongest cheap discriminator available here and remains valid after a
local rename. Filenames are intentionally excluded: local renames are the
reason provenance recovery exists and cannot be identity evidence.
"""
entries = []
for file_info in data.get("files", []):
if not isinstance(file_info, dict):
continue
hashes = file_info.get("hashes", {})
sha256 = hashes.get("SHA256") if isinstance(hashes, dict) else None
if not sha256:
continue
size_bytes = None
try:
if file_info.get("sizeKB") is not None:
size_bytes = int(round(float(file_info["sizeKB"]) * 1024))
except (TypeError, ValueError, OverflowError):
pass
entries.append((file_info, str(sha256), size_bytes))
if not entries:
return None
def _read_safetensors_hash(file_path):
"""Extract hash from safetensors header metadata (O(1) fast read, no full-file SHA256)."""
try:
with open(file_path, "rb") as f:
header_size_bytes = f.read(8)
if len(header_size_bytes) < 8:
return None
header_size = struct.unpack('<Q', header_size_bytes)[0]
if header_size > 100 * 1024 * 1024:
return None
header_json_bytes = f.read(header_size)
header_str = header_json_bytes.decode('utf-8')
header_json = json.loads(header_str)
metadata = header_json.get('__metadata__', {})
if not metadata:
return None
if 'modelspec.hash.sha256' in metadata:
return metadata['modelspec.hash.sha256']
if 'modelspec.hash.blake3' in metadata:
return metadata['modelspec.hash.blake3']
except Exception:
pass
return None
@lru_cache(maxsize=4096)
def _extract_safetensors_hash_cached(file_path, signature):
return _read_safetensors_hash(file_path)
def _extract_safetensors_hash(file_path):
normalized_path = os.path.realpath(file_path)
return _extract_safetensors_hash_cached(normalized_path, _file_signature(normalized_path))
def _select_info_file(data, file_path):
"""Select the Civitai file entry that actually describes ``file_path``.
A model version may contain a checkpoint, text encoder, and VAE in the same
``files`` array. Taking the first SHA256 silently assigns the dependency's
hash to every local sidecar copied from that version. Physical byte size is
the strongest cheap discriminator available here and remains valid after a
local rename. Filenames are intentionally excluded: local renames are the
reason provenance recovery exists and cannot be identity evidence.
"""
entries = []
for file_info in data.get("files", []):
if not isinstance(file_info, dict):
continue
hashes = file_info.get("hashes", {})
sha256 = hashes.get("SHA256") if isinstance(hashes, dict) else None
if not sha256:
continue
size_bytes = None
try:
if file_info.get("sizeKB") is not None:
size_bytes = int(round(float(file_info["sizeKB"]) * 1024))
except (TypeError, ValueError, OverflowError):
pass
entries.append((file_info, str(sha256), size_bytes))
if not entries:
return None
try:
physical_size = os.path.getsize(file_path)
except OSError:
physical_size = None
if physical_size is not None:
size_matches = [entry for entry in entries if entry[2] == physical_size]
if len(size_matches) == 1:
return size_matches[0][0]
if len(size_matches) > 1:
return None
# Offline-generated metadata commonly contains one injected hash without
# Civitai size/name data. It is safe only when there is a single candidate.
if len(entries) == 1:
return entries[0][0]
return None
def _select_info_hash(data, file_path):
selected = _select_info_file(data, file_path)
if not selected:
return ""
hashes = selected.get("hashes", {})
return str(hashes.get("SHA256", "")) if isinstance(hashes, dict) else ""
def _read_metadata(file_path):
physical_size = os.path.getsize(file_path)
except OSError:
physical_size = None
if physical_size is not None:
size_matches = [entry for entry in entries if entry[2] == physical_size]
if len(size_matches) == 1:
return size_matches[0][0]
if len(size_matches) > 1:
return None
# Offline-generated metadata commonly contains one injected hash without
# Civitai size/name data. It is safe only when there is a single candidate.
if len(entries) == 1:
return entries[0][0]
return None
def _select_info_hash(data, file_path):
selected = _select_info_file(data, file_path)
if not selected:
return ""
hashes = selected.get("hashes", {})
return str(hashes.get("SHA256", "")) if isinstance(hashes, dict) else ""
def _positive_civitai_id(value):
"""Return a real Civitai identifier, excluding offline sentinel values."""
if isinstance(value, bool):
return None
try:
parsed = int(value)
except (TypeError, ValueError, OverflowError):
return None
return parsed if parsed > 0 else None
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 (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
def _read_metadata(file_path):
base_path = os.path.splitext(file_path)[0]
info_base = _info_base(file_path, base_path)
metadata = {
"name": os.path.basename(base_path),
"description": "",
@@ -115,12 +107,24 @@ def _read_metadata(file_path):
"trainedWords": [],
"baseModel": "",
"civitai_url": "",
"hash": "",
"custom_name": "",
"custom_notes": ""
"source_url": "",
"hash": "",
"hash_algorithm": "sha256",
"hash_scope": "file",
"hash_source": "",
"custom_name": "",
"custom_notes": "",
"version_name": "", # the Civitai version ("v2.0", "Illustrious"…), told apart on the card
"base_guess": "", # no base model known yet (not scanned): what the file's header tells
# "civitai" (matched), "local" (only what the file tells: no match, or offline) or "" (not scanned)
"info_source": "",
"unmatched_reason": "", # for "local": not_found | network | offline | "" (scanned before this was kept)
"user_fields": [],
"scan_choice": "", # the user's "skip" / "scan" (model_identity.scan_choice)
"not_drawing": False, # not scanned yet, and its header looks like no drawing model
}
info_files = [f"{base_path}.info", f"{base_path}.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:
@@ -130,7 +134,8 @@ def _read_metadata(file_path):
name = data.get("name", "")
if "model" in data and isinstance(data["model"], dict):
model_name = data["model"].get("name", "")
name = f"{model_name} - {name}".strip(' -')
# A model and version of one name (what the file alone tells) read once.
name = f"{model_name} - {name}" if model_name and name and model_name != name else model_name or name
description = data.get("description", "") or ""
if not description and "model" in data and isinstance(data["model"], dict):
@@ -139,10 +144,10 @@ def _read_metadata(file_path):
trained_words = data.get("trainedWords", [])
base_model = data.get("baseModel", "")
model_id = data.get("modelId", "")
version_id = data.get("id", "")
model_id = _positive_civitai_id(data.get("modelId"))
version_id = _positive_civitai_id(data.get("id"))
civitai_url = ""
if model_id:
if model_id is not None:
# Handle Civitai's new mature content policy
nsfw_level = data.get("nsfwLevel", 1)
is_nsfw = False
@@ -151,79 +156,94 @@ def _read_metadata(file_path):
domain = "civitai.red" if (nsfw_level > 1 or is_nsfw) else "civitai.com"
civitai_url = f"https://{domain}/models/{model_id}"
if version_id:
if version_id is not None:
civitai_url += f"?modelVersionId={version_id}"
selected_file = _select_info_file(data, file_path)
hash_val = ""
if selected_file:
hashes = selected_file.get("hashes", {})
if isinstance(hashes, dict):
hash_val = str(hashes.get("SHA256", ""))
if name: metadata["name"] = name
if description: metadata["description"] = description
if notes: metadata["notes"] = notes
if trained_words: metadata["trainedWords"] = trained_words
if base_model: metadata["baseModel"] = base_model
if civitai_url: metadata["civitai_url"] = civitai_url
if model_id: metadata["model_id"] = model_id
if version_id: metadata["version_id"] = version_id
if hash_val: metadata["hash"] = hash_val
if "anomalous_custom_name" in data and data["anomalous_custom_name"]: metadata["custom_name"] = data["anomalous_custom_name"]
if "anomalous_custom_notes" in data and data["anomalous_custom_notes"]: metadata["custom_notes"] = data["anomalous_custom_notes"]
except Exception:
pass
# Fallback: if no hash found from .info files, try extracting from safetensors header directly
if not metadata["hash"] and file_path.endswith('.safetensors'):
header_hash = _extract_safetensors_hash(file_path)
if header_hash:
metadata["hash"] = header_hash
return metadata
def _file_signature(file_path):
try:
stat = os.stat(file_path)
return (stat.st_size, stat.st_mtime_ns, stat.st_ctime_ns)
except OSError:
return None
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(f"{base_path}.civitai.info"),
)
@lru_cache(maxsize=4096)
def _get_metadata_cached(file_path, signature):
# ``signature`` is deliberately unused by the parser itself; it is part of
# the cache key and changes whenever the model or either metadata sidecar
# changes. The cache is bounded so removed/renamed models cannot grow it
# without limit.
return _read_metadata(file_path)
def get_metadata(file_path):
normalized_path = os.path.realpath(file_path)
signature = _metadata_signature(normalized_path)
# Metadata contains mutable lists. Never expose the cached object directly.
return copy.deepcopy(_get_metadata_cached(normalized_path, signature))
def clear_metadata_cache():
_get_metadata_cached.cache_clear()
_extract_safetensors_hash_cached.cache_clear()
def get_metadata_cache_info():
return _get_metadata_cached.cache_info()
selected_file = _select_info_file(data, file_path)
hash_val, hash_source = sidecar_file_hash(data, file_path, selected_file)
if hash_val:
metadata["hash_source"] = hash_source
if name: metadata["name"] = name
if description: metadata["description"] = description
if notes: metadata["notes"] = notes
if trained_words: metadata["trainedWords"] = trained_words
if base_model: metadata["baseModel"] = base_model
if civitai_url: metadata["civitai_url"] = civitai_url
if model_id is not None: metadata["model_id"] = model_id
if version_id is not None: metadata["version_id"] = version_id
if version_id is not None and isinstance(data.get("model"), dict) and isinstance(data.get("name"), str):
metadata["version_name"] = data["name"].strip()
if hash_val: metadata["hash"] = hash_val
if "anomalous_custom_name" in data and data["anomalous_custom_name"]: metadata["custom_name"] = data["anomalous_custom_name"]
if "anomalous_custom_notes" in data and data["anomalous_custom_notes"]: metadata["custom_notes"] = data["anomalous_custom_notes"]
if "anomalous_source_url" in data and data["anomalous_source_url"]: metadata["source_url"] = data["anomalous_source_url"]
except Exception:
pass
scan = sidecar_info(info_base)
if scan is not None:
metadata["info_source"] = "local" if is_unmatched(scan) else "civitai"
metadata["unmatched_reason"] = unmatched_reason(scan)
if scan is None:
found = inspect_file(file_path)
if not metadata["baseModel"]:
metadata["base_guess"] = found.get("base") or ""
# 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_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():
if isinstance(user.get(key), str):
metadata[field] = user[key]
metadata["user_fields"].append(field)
return metadata
def _file_signature(file_path):
try:
stat = os.stat(file_path)
return (stat.st_size, stat.st_mtime_ns, stat.st_ctime_ns)
except OSError:
return None
def _metadata_signature(file_path):
base_path = os.path.splitext(file_path)[0]
return (
_file_signature(file_path),
_file_signature(find(base_path, ".info") or f"{base_path}.info"),
_file_signature(f"{base_path}.civitai.info"),
_file_signature(find(base_path, USER_INFO_SUFFIX) or base_path + USER_INFO_SUFFIX),
)
@lru_cache(maxsize=4096)
def _get_metadata_cached(file_path, signature):
# ``signature`` is deliberately unused by the parser itself; it is part of
# the cache key and changes whenever the model or either metadata sidecar
# changes. The cache is bounded so removed/renamed models cannot grow it
# without limit.
return _read_metadata(file_path)
def get_metadata(file_path):
# As named, not resolved: a model that is a link has its covers and info beside the link.
normalized_path = os.path.abspath(file_path)
signature = _metadata_signature(normalized_path)
# Metadata contains mutable lists. Never expose the cached object directly.
return copy.deepcopy(_get_metadata_cached(normalized_path, signature))
def clear_metadata_cache():
_get_metadata_cached.cache_clear()
def get_metadata_cache_info():
return _get_metadata_cached.cache_info()
+593
View File
@@ -0,0 +1,593 @@
"""Model folder discovery, listing, search, and presentation lookup."""
import asyncio
import os
import urllib.parse
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 (
get_active_folder_types, get_active_model_roots,
get_active_physical_basenames, get_folder_view_mode,
)
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:
with os.scandir(target_dir) as iterator:
file_entries = {}
for entry in iterator:
try:
if entry.is_file():
file_entries[entry.name] = entry
except OSError:
continue
except OSError:
return {"models": [], "total": 0, "page": page, "limit": limit}
valid_files = sorted(
(name for name in file_entries if is_model_file(name)),
key=str.lower,
)
total = len(valid_files)
if limit > 0:
start = max(0, (page - 1) * limit)
sliced = valid_files[start:start + limit]
else:
sliced = valid_files
q_type = urllib.parse.quote(folder_type)
q_idx = str(path_idx)
q_sub = urllib.parse.quote(rel_subfolder.strip('/')) if rel_subfolder and rel_subfolder != '/' else ""
models = []
for filename in sliced:
file_path = os.path.join(target_dir, filename)
metadata = get_metadata(file_path)
base_name = os.path.splitext(filename)[0]
preview_file = next(
(base_name + suffix for suffix in PREVIEW_SUFFIXES + MEDIA_EXTENSIONS if base_name + suffix in file_entries),
None,
)
preview_url = ""
if preview_file:
q_file = urllib.parse.quote(preview_file)
try:
preview_version = file_entries[preview_file].stat().st_mtime_ns
except OSError:
preview_version = 0
preview_url = (
f"/anomalous/image?type={q_type}&path_idx={q_idx}&subfolder={q_sub}"
f"&filename={q_file}&t={preview_version}"
)
try:
model_stat = file_entries[filename].stat()
size_bytes = model_stat.st_size
size_mb = round(size_bytes / (1024 * 1024), 2)
except OSError:
size_mb = 0
size_bytes = 0
models.append({
"filename": filename,
"file_path": os.path.abspath(file_path),
"size_mb": size_mb,
"size_bytes": size_bytes,
"metadata": metadata,
"preview_url": preview_url,
"type": folder_type,
"path_idx": path_idx,
"subfolder": rel_subfolder,
})
return {"models": models, "total": total, "page": page, "limit": limit}
def _collect_folders():
mode = get_folder_view_mode()
result = []
seen_dirs = set()
if mode == "physical":
active_bns = get_active_physical_basenames()
all_paths_info = []
for t in folder_paths.folder_names_and_paths.keys():
try:
paths = folder_paths.get_folder_paths(t)
if not paths: continue
for path_idx, base_dir in enumerate(paths):
if not os.path.exists(base_dir): continue
real_path = os.path.realpath(base_dir)
if real_path in seen_dirs: continue
seen_dirs.add(real_path)
bn = os.path.basename(os.path.normpath(base_dir))
all_paths_info.append({
"t": t,
"path_idx": path_idx,
"base_dir": base_dir,
"bn": bn
})
except:
pass
for target_bn in active_bns:
matched = [p for p in all_paths_info if p["bn"] == target_bn]
if not matched: continue
for item in matched:
base_dir = item["base_dir"]
tree = _folder_tree(base_dir)
label = item["bn"]
if len(matched) > 1:
label += f" ({item['path_idx'] + 1})"
result.append({
"type": item["t"],
"path_idx": item["path_idx"],
"label": label,
"folders": tree
})
else:
types = get_active_folder_types()
for t in types:
try:
paths = folder_paths.get_folder_paths(t)
except Exception:
continue
if not paths:
continue
for path_idx, base_dir in enumerate(paths):
if not os.path.exists(base_dir):
continue
real_path = os.path.realpath(base_dir)
if real_path in seen_dirs:
continue
seen_dirs.add(real_path)
tree = _folder_tree(base_dir)
try:
folder_basename = os.path.basename(os.path.normpath(base_dir))
if not folder_basename:
folder_basename = t
except:
folder_basename = t
label = folder_basename
basenames = []
try:
basenames = [os.path.basename(os.path.normpath(p)) for p in paths]
except:
pass
if basenames.count(folder_basename) > 1:
label += f" ({path_idx + 1})"
result.append({
"type": t,
"path_idx": path_idx,
"label": label,
"folders": tree
})
return {"folders": result}
async def api_get_folders(request):
return web.json_response(await asyncio.to_thread(_collect_folders))
async def api_get_models(request):
folder_type = request.query.get('type', 'checkpoints')
subfolder = request.query.get('subfolder', '/')
page = int(request.query.get('page', 1))
limit = int(request.query.get('limit', 0))
try:
path_idx = int(request.query.get('path_idx', 0))
except:
path_idx = 0
try:
paths = folder_paths.get_folder_paths(folder_type)
except Exception:
return web.json_response({"models": [], "total": 0})
try:
base_dir, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder)
except (ValueError, KeyError):
return web.Response(status=400, text='Invalid subfolder')
rel_subfolder = "" if subfolder == '/' else subfolder.strip('/\\')
if not os.path.exists(target_dir):
return web.json_response({"models": [], "total": 0})
payload = await asyncio.to_thread(
_collect_folder_models,
target_dir,
folder_type,
path_idx,
rel_subfolder,
page,
limit,
)
return web.json_response(payload)
def _iter_search_models():
for folder_type in folder_paths.folder_names_and_paths.keys():
try:
paths = folder_paths.get_folder_paths(folder_type)
except Exception:
continue
for path_idx, base_dir in enumerate(paths):
if not os.path.isdir(base_dir):
continue
for root, _, files in os.walk(base_dir):
for filename in files:
if is_model_file(filename):
yield folder_type, path_idx, base_dir, os.path.join(root, filename)
def _find_model_sync(search):
normalized_search = search.replace('\\', '/').lower()
for folder_type, path_idx, base_dir, file_path in _iter_search_models():
normalized_path = file_path.replace(os.sep, '/').lower()
if normalized_search in os.path.basename(file_path).lower() or normalized_search in normalized_path:
return _model_info_for_path(folder_type, path_idx, base_dir, file_path)
for folder_type, path_idx, base_dir, file_path in _iter_search_models():
metadata = get_metadata(file_path)
searchable_names = (metadata.get('custom_name', ''), metadata.get('name', ''))
if any(normalized_search in str(name).lower() for name in searchable_names if name):
return _model_info_for_path(folder_type, path_idx, base_dir, file_path)
return None
async def api_find_model(request):
search = request.query.get('search', '').strip()
if not search:
return web.json_response({"status": "error", "message": "No search query provided"})
result = await asyncio.to_thread(_find_model_sync, search)
if not result:
return web.json_response({"status": "error", "message": "Model not found"})
return web.json_response({
"status": "success",
"model": result,
"type": result["type"],
"path_idx": result["path_idx"],
"subfolder": result["subfolder"],
})
# Prompt Notes ask for this base model to list models whose metadata names none.
UNLABELED_BASE_MODEL = "__unlabeled__"
def _normalised_base(value):
return str(value or "").strip().lower().replace(" ", "")
def _walk_model_files(target_types):
"""Yield (folder_type, path_idx, base_dir, root, filename, metadata) once per real file."""
seen_files = set()
for folder_type in target_types:
try:
paths = folder_paths.get_folder_paths(folder_type)
except Exception:
continue
for path_idx, base_dir in enumerate(paths or []):
if not os.path.exists(base_dir):
continue
for root, _, files in os.walk(base_dir):
for filename in files:
if not is_model_file(filename):
continue
file_path = os.path.join(root, filename)
real_path = os.path.realpath(file_path)
if real_path in seen_files:
continue
seen_files.add(real_path)
yield folder_type, path_idx, base_dir, root, filename, get_metadata(file_path)
def _base_matches(requested, metadata):
model_base = _normalised_base(metadata.get("baseModel"))
if requested == UNLABELED_BASE_MODEL:
return not model_base
wanted = _normalised_base(requested)
return bool(wanted and model_base and (wanted in model_base or model_base in wanted))
def _compatible_models_sync(base_model, target_types):
compatible_models = []
for folder_type, path_idx, base_dir, root, filename, meta in _walk_model_files(target_types):
if not _base_matches(base_model, meta):
continue
rel_subfolder = os.path.relpath(root, base_dir)
rel_subfolder = '/' if rel_subfolder == '.' else '/' + rel_subfolder.replace('\\', '/')
base_name = os.path.splitext(filename)[0]
preview_file = None
for ext in PREVIEW_SUFFIXES + MEDIA_EXTENSIONS:
if os.path.exists(os.path.join(root, base_name + ext)):
preview_file = base_name + ext
break
preview_url = ""
if preview_file:
q_sub = urllib.parse.quote(rel_subfolder.strip('/')) if rel_subfolder != '/' else ""
preview_url = (
f"/anomalous/image?type={urllib.parse.quote(folder_type)}&path_idx={path_idx}"
f"&subfolder={q_sub}&filename={urllib.parse.quote(preview_file)}"
)
try:
size_bytes = os.path.getsize(os.path.join(root, filename))
except OSError:
size_bytes = 0
compatible_models.append({
"type": folder_type,
"path_idx": path_idx,
"subfolder": rel_subfolder,
"filename": filename,
"size_mb": round(size_bytes / (1024 * 1024), 1),
"size_bytes": size_bytes,
"preview_url": preview_url,
"metadata": meta,
})
return compatible_models
async def api_compatible_models(request):
base_model = request.query.get('base_model', '')
if not base_model:
return web.json_response({"models": []})
target_types = [t.strip() for t in request.query.get('target_type', 'loras').split(',') if t.strip()]
models = await asyncio.to_thread(_compatible_models_sync, base_model, target_types)
return web.json_response({"models": models})
def _base_models_sync(target_types):
base_models = set()
unlabeled = False
for *_, meta in _walk_model_files(target_types):
label = str(meta.get("baseModel") or "").strip()
if label:
base_models.add(label)
else:
unlabeled = True
return sorted(base_models), unlabeled
async def api_base_models(request):
base_models, unlabeled = await asyncio.to_thread(_base_models_sync, get_active_folder_types())
return web.json_response({"base_models": base_models, "unlabeled": unlabeled})
def _model_info_for_path(folder_type, path_idx, base_dir, file_path):
root = os.path.dirname(file_path)
rel_subfolder = os.path.relpath(root, base_dir)
if rel_subfolder == '.':
rel_subfolder = '/'
else:
rel_subfolder = '/' + rel_subfolder.replace(os.sep, '/')
try:
size_bytes = os.path.getsize(file_path)
except OSError:
size_bytes = 0
return {
"type": folder_type,
"path_idx": path_idx,
"subfolder": rel_subfolder,
"filename": os.path.basename(file_path),
"file_path": os.path.abspath(file_path),
"size_bytes": size_bytes,
"size_mb": round(size_bytes / (1024 * 1024), 2),
"preview_url": _preview_url_for_model(folder_type, path_idx, base_dir, file_path),
"metadata": get_metadata(file_path),
}
def _allowed_folder_types(requested_types=None):
available = list(folder_paths.folder_names_and_paths.keys())
if requested_types is None:
return available
if not isinstance(requested_types, list):
return []
allowed = set(available)
return list(dict.fromkeys(
folder_type
for folder_type in requested_types
if isinstance(folder_type, str) and folder_type in allowed
))
def _resolve_paths_to_model_info_sync(paths, folder_types=None, exact_only=False):
requested = [
(path, path.replace('\\', '/').lower(), path.replace('\\', '/'))
for path in paths
if isinstance(path, str)
]
exact_results = {}
roots = []
for folder_type in _allowed_folder_types(folder_types):
try:
folder_dirs = folder_paths.get_folder_paths(folder_type)
except Exception:
continue
for path_idx, base_dir in enumerate(folder_dirs or []):
if not os.path.isdir(base_dir):
continue
real_base_dir = os.path.realpath(base_dir)
roots.append((folder_type, path_idx, real_base_dir))
for original, _, relative_path in requested:
try:
candidate = model_reference_within(real_base_dir, relative_path.replace('/', os.sep))
except ValueError:
continue
if os.path.isfile(candidate) and is_model_file(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:
return exact_results
wanted_relpaths = {normalized for _, normalized in unresolved}
wanted_basenames = {normalized.rsplit('/', 1)[-1] for _, normalized in unresolved}
rel_matches = {}
basename_matches = {}
for folder_type, path_idx, base_dir in roots:
for root, _, files in os.walk(base_dir):
for filename in files:
if not is_model_file(filename):
continue
rel_path = os.path.relpath(os.path.join(root, filename), base_dir).replace(os.sep, '/').lower()
basename = filename.lower()
if rel_path not in wanted_relpaths and basename not in wanted_basenames:
continue
candidate_info = _model_info_for_path(
folder_type,
path_idx,
base_dir,
os.path.join(root, filename),
)
if rel_path in wanted_relpaths:
rel_matches[rel_path] = candidate_info
if basename in wanted_basenames:
basename_matches[basename] = candidate_info
model_info = dict(exact_results)
for original, normalized in unresolved:
if normalized in rel_matches:
model_info[original] = rel_matches[normalized]
else:
basename = normalized.rsplit('/', 1)[-1]
if basename in basename_matches:
model_info[original] = basename_matches[basename]
return model_info
def _resolve_paths_to_previews_sync(paths, folder_types=None, exact_only=False):
model_info = _resolve_paths_to_model_info_sync(paths, folder_types, exact_only)
return {path: item.get("preview_url", "") for path, item in model_info.items()}
async def api_resolve_paths_to_previews(request):
try:
data = await request.json()
paths = data.get('paths', [])
folder_types = data.get('folder_types')
context_requests = data.get('context_requests', [])
exact_only = data.get('exact_only') is True
except:
return web.json_response({"previews": {}, "models": {}, "context_models": {}})
model_info = await asyncio.to_thread(_resolve_paths_to_model_info_sync, paths, folder_types, exact_only)
context_models = {}
if isinstance(context_requests, list):
for item in context_requests[:16]:
if not isinstance(item, dict):
continue
context_path = item.get('path')
if not isinstance(context_path, str) or not context_path:
continue
resolved = await asyncio.to_thread(
_resolve_paths_to_model_info_sync,
[context_path],
item.get('folder_types'),
item.get('exact_only') is True or exact_only,
)
if context_path in resolved:
context_key = item.get('key')
if not isinstance(context_key, str) or not context_key:
context_key = context_path
context_models[context_key] = resolved[context_path]
previews = {path: item.get("preview_url", "") for path, item in model_info.items()}
return web.json_response({
"previews": previews,
"models": model_info,
"context_models": context_models,
})
def _collect_all_scan_models(page, limit):
all_tuples = []
for model_root in get_active_model_roots():
t = model_root["type"]
path_idx = model_root["path_idx"]
base_dir = model_root["base_dir"]
for root, dirs, files in os.walk(base_dir):
for f in files:
if is_model_file(f):
all_tuples.append((t, path_idx, root, base_dir, f))
all_tuples.sort(key=lambda x: (x[0], x[4].lower()))
total = len(all_tuples)
if limit > 0:
start = (page - 1) * limit
end = start + limit
sliced = all_tuples[start:end]
else:
sliced = all_tuples
all_models = []
for t, path_idx, root, base_dir, f in sliced:
file_path = os.path.join(root, f)
rel_subfolder = os.path.relpath(root, base_dir)
if rel_subfolder == '.': rel_subfolder = ''
else: rel_subfolder = rel_subfolder.replace('\\', '/')
try:
size_bytes = os.path.getsize(file_path)
size_mb = round(size_bytes / (1024 * 1024), 2)
except:
size_bytes = 0; size_mb = 0
meta = get_metadata(file_path)
base_name = os.path.splitext(f)[0]
preview_file = None
for ext in PREVIEW_SUFFIXES + MEDIA_EXTENSIONS:
if os.path.exists(os.path.join(root, base_name + ext)):
preview_file = base_name + ext
break
preview_url = ""
if preview_file:
q_type = urllib.parse.quote(t)
q_idx = str(path_idx)
q_sub = urllib.parse.quote(rel_subfolder.strip('/')) if rel_subfolder and rel_subfolder != '/' else ""
q_file = urllib.parse.quote(preview_file)
mtime = _cache_token(os.path.join(root, preview_file))
preview_url = f"/anomalous/image?type={q_type}&path_idx={q_idx}&subfolder={q_sub}&filename={q_file}&t={mtime}"
all_models.append({
"type": t, "path_idx": path_idx, "subfolder": rel_subfolder,
"filename": f, "size_mb": size_mb, "size_bytes": size_bytes,
"preview_url": preview_url, "metadata": meta
})
return {"models": all_models, "total": total, "page": page, "limit": limit}
async def api_get_all_scan_models(request):
page = int(request.query.get('page', 1))
limit = int(request.query.get('limit', 0))
payload = await asyncio.to_thread(_collect_all_scan_models, page, limit)
return web.json_response(payload)
+23
View File
@@ -0,0 +1,23 @@
"""Shared model catalog, media, and recovery constants."""
MEDIA_EXTENSIONS = ('.png', '.jpg', '.jpeg', '.webp', '.gif', '.avif', '.mp4', '.webm', '.mov', '.avi')
# Model files the browser lists, counts and looks up: GGUF and .pth too (quantized
# diffusion models, upscalers). Compared without case: see is_model_file().
MODEL_EXTENSIONS = ('.safetensors', '.ckpt', '.pt', '.pth', '.bin', '.sft', '.gguf')
PREVIEW_SUFFIXES = tuple(f'.preview{ext}' for ext in MEDIA_EXTENSIONS)
CIVITAI_BACKUP_SUFFIXES = tuple(f'.civitai_bak{ext}' for ext in MEDIA_EXTENSIONS)
SIDECAR_SUFFIXES = (
'.info', '.civitai.info', '.anomalous.json', '.json', '.txt', '.yaml',
*MEDIA_EXTENSIONS,
*PREVIEW_SUFFIXES,
*CIVITAI_BACKUP_SUFFIXES,
)
RESOLVABLE_MODEL_TYPES = (
'checkpoints', 'loras', 'unet', 'diffusion_models', 'controlnet',
'vae', 'vae_approx', 'clip', 'text_encoders', 'clip_vision', 'upscale_models',
)
def is_model_file(name):
"""Whether a file name is a model file (MODEL_EXTENSIONS, any case)."""
return name.lower().endswith(MODEL_EXTENSIONS)
+444
View File
@@ -0,0 +1,444 @@
"""Downloading the models the open workflow is missing (Model Check's "Download").
POST /anomalous/download/lookup {items: [{key, hash, url, value, types}]} -> where each can come
from (download_sources.find_source) and the model folders of its type: [{index, path, free,
subfolders}].
POST /anomalous/download/start {download_url, type, root, rel, sha256, size, ...} -> {id}
GET /anomalous/download/status -> {jobs}
POST /anomalous/download/cancel {id}
GET/POST /anomalous/download/settings -> where downloads go ({place, folder}), kept in
user/anomalous/download_settings.json; imported models (model_import.py) follow the same folder.
One download runs at a time, into "<file>.part" next to where the file goes; an interrupted one
continues from there. The finished file is checked against the SHA-256 the workflow or the site
gives, and only then gets its name; a file that is already there is never replaced. The Civitai
key goes only to Civitai (not along a redirect to its file storage).
"""
import asyncio
import hashlib
import json
import os
import shutil
import threading
import time
import urllib.error
import urllib.request
import uuid
from aiohttp import web
import folder_paths
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,
)
from .path_utils import atomic_write_json, resolve_within
try:
from ..net_proxy import urlopen
except ImportError: # loaded outside the package (tests)
from net_proxy import urlopen
MODEL_EXTENSIONS = (".safetensors", ".ckpt", ".pt", ".pth", ".bin", ".sft", ".gguf")
CHUNK = 1024 * 1024
SPACE_MARGIN = 200 * 1024 * 1024
MAX_SUBFOLDERS = 300
KEEP_FINISHED = 50
# hf_mirror None: not chosen yet (the page takes the mirror for a Chinese interface).
DEFAULT_SETTINGS = {"place": "workflow", "folder": "{base}", "hf_mirror": None}
_lock = threading.Lock()
_jobs = {} # id -> job dict, in the order started
_queue = []
_worker = None
class DownloadError(Exception):
def __init__(self, code, message=""):
super().__init__(message or code)
self.code = code
async def _body(request):
"""The request's JSON object, or {} when it is not one (each route then refuses it)."""
try:
data = await request.json()
except ValueError:
return {}
return data if isinstance(data, dict) else {}
# --- settings ---------------------------------------------------------------------------
def settings_path():
return os.path.join(os.path.dirname(log_path()), "download_settings.json")
def read_settings():
settings = dict(DEFAULT_SETTINGS)
try:
with open(settings_path(), encoding="utf-8") as source:
saved = json.load(source)
if saved.get("place") in ("workflow", "folder"):
settings["place"] = saved["place"]
if isinstance(saved.get("folder"), str):
settings["folder"] = saved["folder"]
if isinstance(saved.get("hf_mirror"), bool):
settings["hf_mirror"] = saved["hf_mirror"]
except (OSError, ValueError, AttributeError):
pass
return settings
def clean_folder(folder):
"""A relative folder ('' = the type's own folder), '/' separated; ValueError if it climbs out."""
parts = [part.strip() for part in str(folder or "").replace("\\", "/").split("/") if part.strip()]
for part in parts:
if part in (".", "..") or ":" in part or any(char in part for char in '<>"|?*'):
raise ValueError("Invalid folder")
return "/".join(parts)
async def api_settings(request):
if request.method == "POST":
data = await _body(request)
settings = read_settings()
if data.get("place") in ("workflow", "folder"):
settings["place"] = data["place"]
if isinstance(data.get("hf_mirror"), bool):
settings["hf_mirror"] = data["hf_mirror"]
if "folder" in data:
try:
# {base} is filled with the model's base model when a download starts.
settings["folder"] = clean_folder(data.get("folder"))
except ValueError:
return web.json_response({"error": "Invalid folder"}, status=400)
atomic_write_json(settings_path(), settings)
return web.json_response(settings)
return web.json_response(read_settings())
# --- lookup -----------------------------------------------------------------------------
def folder_type_for(types):
known = getattr(folder_paths, "folder_names_and_paths", {})
for name in types or []:
if isinstance(name, str) and name in known:
return name
return ""
def _subfolders(root):
found = []
for current, dirs, _files in os.walk(root):
dirs[:] = sorted(name for name in dirs if not name.startswith("."))
depth = os.path.relpath(current, root).count(os.sep)
for name in dirs:
found.append(os.path.relpath(os.path.join(current, name), root).replace(os.sep, "/"))
if len(found) >= MAX_SUBFOLDERS:
return found
if depth >= 1:
dirs[:] = []
return found
def roots_of(folder_type):
"""The model folders of a type: [{index, path, exists, free, subfolders}]."""
roots = []
for index, path in enumerate(folder_paths.get_folder_paths(folder_type) or []):
exists = os.path.isdir(path)
probe = path if exists else os.path.dirname(path)
try:
free = shutil.disk_usage(probe).free if os.path.isdir(probe) else 0
except OSError:
free = 0
roots.append({"index": index, "path": path, "exists": exists, "free": free,
"subfolders": _subfolders(path) if exists else []})
return roots
def lookup_item(item, hf_mirror=False, down=None):
"""Where one item can come from; `down` (kept across one lookup) names the sites that
stopped answering, so the next items skip only those."""
file_hash = str(item.get("hash") or "").strip()
name = os.path.basename(str(item.get("value") or "").replace("\\", "/"))
found = find_source(file_hash, str(item.get("url") or "").strip(), name, hf_mirror,
folder_type_for(item.get("types")), down)
if found.get("found") and file_hash and found.get("sha256") and len(file_hash) >= 10 \
and all(c in "0123456789abcdefABCDEF" for c in file_hash) and not same_file(file_hash, found["sha256"]):
# The link names another file than the one the workflow was saved with.
return {"found": False, "reason": "different_file", "page": found.get("page", "")}
return found
async def api_lookup(request):
"""Items already looked up (`known`) only get their type and folders. Once a site does not
answer, the rest do not ask it (offline: no wait per model); the other sites still are."""
data = await _body(request)
items, hf_mirror = data.get("items"), data.get("hf_mirror") is True
if not isinstance(items, list):
return web.json_response({"error": "items must be a list"}, status=400)
results, types, down = [], {}, set()
for item in items[:100]:
if not isinstance(item, dict):
continue
folder_type = folder_type_for(item.get("types"))
if item.get("known"):
result = {"found": False, "reason": "known"}
else:
result = await asyncio.to_thread(lookup_item, item, hf_mirror, down)
result.update(key=item.get("key"), type=folder_type)
if folder_type and folder_type not in types:
types[folder_type] = await asyncio.to_thread(roots_of, folder_type)
results.append(result)
# 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 --------------------------------------------------------------------------
def _public(job):
public = {key: job[key] for key in ("id", "key", "state", "error", "received", "total", "rel", "type",
"root", "value", "file", "source", "verified")}
public["host"] = host_of(job["url"])
return public
def plan_job(data):
"""A queued job from a start request, or ValueError / DownloadError saying why not."""
url = str(data.get("download_url") or "")
if not allowed_download_url(url):
raise DownloadError("bad_source")
folder_type = folder_type_for([data.get("type")])
if not folder_type:
raise DownloadError("bad_type")
paths = folder_paths.get_folder_paths(folder_type) or []
root_index = data.get("root", 0)
if not isinstance(root_index, int) or not 0 <= root_index < len(paths):
raise DownloadError("bad_root")
rel = clean_folder(data.get("rel"))
if not rel.lower().endswith(MODEL_EXTENSIONS):
raise DownloadError("bad_name")
root = os.path.realpath(paths[root_index])
dest = resolve_within(root, *rel.split("/"))
if os.path.exists(dest):
raise DownloadError("exists")
size = int(data.get("size") or 0)
probe = root
while probe and not os.path.isdir(probe):
probe = os.path.dirname(probe)
if size and probe and shutil.disk_usage(probe).free < size + SPACE_MARGIN:
raise DownloadError("no_space")
sha256 = str(data.get("sha256") or "").lower()
with _lock:
if any(job["dest"] == dest and job["state"] in ("queued", "running", "verifying") for job in _jobs.values()):
raise DownloadError("already")
return {
"id": uuid.uuid4().hex[:12], "key": str(data.get("key") or ""), "state": "queued", "error": "",
"received": 0, "total": size, "rel": rel, "type": folder_type, "root": root_index,
"value": str(data.get("value") or ""), "file": os.path.basename(dest), "dest": dest, "url": url,
"sha256": sha256 if SHA256.match(sha256) else "", "expected": str(data.get("hash") or "").lower(),
"source": str(data.get("source") or ""), "page": str(data.get("page") or ""),
"verified": False, "cancel": False, "finished": 0,
}
def _open(job, start):
headers = {"User-Agent": USER_AGENT}
if start:
headers["Range"] = f"bytes={start}-"
request = urllib.request.Request(job["url"], headers=headers)
key = _load_api_key() if is_civitai(job["url"]) else None
if key: # not carried along the redirect to the file storage
request.add_unredirected_header("Authorization", f"Bearer {key}")
try:
return urlopen(request, timeout=60)
except urllib.error.HTTPError as error:
if error.code in (401, 403):
raise DownloadError("needs_key" if is_civitai(job["url"]) and not key else "forbidden") from error
if error.code == 404:
raise DownloadError("gone") from error
if error.code == 416 and start:
raise DownloadError("restart") from error
raise DownloadError("network", f"HTTP {error.code}") from error
except (urllib.error.URLError, OSError) as error:
raise DownloadError("network", str(error)) from error
def _hash_existing(path, digest):
with open(path, "rb") as source:
for chunk in iter(lambda: source.read(CHUNK), b""):
digest.update(chunk)
def _fetch(job):
part = job["dest"] + ".part"
os.makedirs(os.path.dirname(job["dest"]), exist_ok=True)
digest = hashlib.sha256()
start = os.path.getsize(part) if os.path.isfile(part) else 0
try:
response = _open(job, start)
except DownloadError as error:
if error.code != "restart":
raise
start, response = 0, _open(job, 0)
with response:
if start and response.status != 206:
start = 0 # the site sends the whole file again
if "text/html" in str(response.headers.get("Content-Type", "")).lower():
raise DownloadError("not_a_file") # a login or error page, not the model
if start:
_hash_existing(part, digest)
length = int(response.headers.get("Content-Length") or 0)
job["total"] = start + length if length else job["total"]
job["received"] = start
with open(part, "ab" if start else "wb") as target:
while True:
if job["cancel"]:
raise DownloadError("cancelled")
chunk = response.read(CHUNK)
if not chunk:
break
target.write(chunk)
digest.update(chunk)
job["received"] += len(chunk)
return part, digest.hexdigest()
def _finish(job, part, sha256):
job["state"] = "verifying"
if job["total"] and job["received"] != job["total"]:
raise DownloadError("network", "incomplete")
expected = job["sha256"] or (job["expected"] if len(job["expected"]) >= 10 else "")
if expected and not same_file(expected, sha256):
os.remove(part) # our own unfinished download, not a file of the user's
raise DownloadError("hash_mismatch")
job["verified"] = bool(expected)
if os.path.exists(job["dest"]):
raise DownloadError("exists")
os.replace(part, job["dest"])
if host_of(job["url"]) in ("huggingface.co", "hf-mirror.com"):
try: # the model card's example image as cover, its words as notes (never over the user's)
job["card"] = hf_card.enrich(job["dest"], job["url"])
except Exception as error:
print(f"[Anomalous Browser] Download: the Hugging Face model card was not read: {error}")
forget_file_lists()
def forget_file_lists():
"""ComfyUI's file lists and the browser's model metadata see a file that just arrived
(a download, an import)."""
for cache in (getattr(folder_paths, "filename_list_cache", None), getattr(folder_paths, "cache_helper", None)):
try:
cache.clear()
except Exception:
pass
try:
from .metadata import clear_metadata_cache
clear_metadata_cache()
except Exception as error:
print(f"[Anomalous Browser] Could not clear the model cache: {error}")
def _record(job, sha256):
add_entry("file", "model_download", job["rel"], {"download": {
"type": job["type"], "path_idx": job["root"], "rel": job["rel"], "file": job["file"],
"size": job["received"], "sha256": sha256, "verified": job["verified"],
"source": job["source"], "page": job["page"], "value": job["value"], "card": job.get("card"),
}})
def run_job(job):
job["state"] = "running"
try:
part, sha256 = _fetch(job)
_finish(job, part, sha256)
job["state"] = "done"
try:
_record(job, sha256)
except Exception as error:
print(f"[Anomalous Browser] Download: could not record it in the activity log: {error}")
except DownloadError as error:
job["state"] = "cancelled" if error.code == "cancelled" else "failed"
job["error"] = error.code
if error.code == "cancelled" and os.path.isfile(job["dest"] + ".part"):
os.remove(job["dest"] + ".part") # our own unfinished download
if error.code not in ("cancelled",):
print(f"[Anomalous Browser] Download of {job['rel']} failed: {error}")
except Exception as error:
job["state"], job["error"] = "failed", "unknown"
print(f"[Anomalous Browser] Download of {job['rel']} failed: {error}")
job["finished"] = time.time()
def _work():
global _worker
while True:
with _lock:
job = next((item for item in _queue if item["state"] == "queued"), None)
if job is None:
_queue.clear()
_worker = None
return
_queue.remove(job)
run_job(job)
def enqueue(job):
global _worker
with _lock:
_jobs[job["id"]] = job
_queue.append(job)
finished = [item for item in _jobs.values() if item["finished"]]
for old in finished[:-KEEP_FINISHED]:
_jobs.pop(old["id"], None)
if _worker is None:
_worker = threading.Thread(target=_work, daemon=True, name="anomalous-download")
_worker.start()
async def api_start(request):
data = await _body(request)
if not data:
return web.json_response({"error": "bad_request"}, status=400)
try:
job = await asyncio.to_thread(plan_job, data)
except DownloadError as error:
return web.json_response({"error": error.code}, status=409 if error.code in ("exists", "already") else 400)
except (ValueError, TypeError) as error:
return web.json_response({"error": "bad_path", "message": str(error)}, status=400)
enqueue(job)
return web.json_response(_public(job))
async def api_status(request):
with _lock:
jobs = [_public(job) for job in _jobs.values()]
return web.json_response({"jobs": jobs})
async def api_cancel(request):
data = await _body(request)
with _lock:
job = _jobs.get(str(data.get("id") or ""))
if not job:
return web.json_response({"error": "unknown"}, status=404)
if job["state"] == "queued":
job["state"], job["error"], job["finished"] = "cancelled", "cancelled", time.time()
if job in _queue:
_queue.remove(job)
else:
job["cancel"] = True
return web.json_response(_public(job))
def register_routes(app):
app.router.add_post("/anomalous/download/lookup", api_lookup)
app.router.add_post("/anomalous/download/start", api_start)
app.router.add_get("/anomalous/download/status", api_status)
app.router.add_post("/anomalous/download/cancel", api_cancel)
app.router.add_get("/anomalous/download/settings", api_settings)
app.router.add_post("/anomalous/download/settings", api_settings)
+125
View File
@@ -0,0 +1,125 @@
"""Shows a model file in this computer's file manager: Explorer or Finder with the file
selected, the folder on Linux; or, without a file name, opens a models folder (a type's root
or a subfolder). The request names it the way the browser lists it (type, folder index,
subfolder, file name), never a path, and local_guard lets only this computer ask (a POST)."""
import os
import subprocess
import sys
import threading
import time
from aiohttp import web
from .utils import model_file_within, resolve_folder_subdir
def _explorer_windows():
"""Handles of the open Explorer folder windows (window class CabinetWClass)."""
import ctypes
from ctypes import wintypes
found = []
callback_type = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
def collect(hwnd, _):
name = ctypes.create_unicode_buffer(64)
ctypes.windll.user32.GetClassNameW(hwnd, name, 64)
if name.value == "CabinetWClass":
found.append(hwnd)
return True
ctypes.windll.user32.EnumWindows(callback_type(collect), 0)
return found
def _window_title(hwnd):
import ctypes
title = ctypes.create_unicode_buffer(512)
ctypes.windll.user32.GetWindowTextW(hwnd, title, 512)
return title.value
def _to_front(hwnd):
"""Windows keeps a background program (ComfyUI here) from raising a window; borrowing the
foreground window's input queue for a moment lets it."""
import ctypes
user32, kernel32 = ctypes.windll.user32, ctypes.windll.kernel32
foreground = user32.GetForegroundWindow()
theirs = user32.GetWindowThreadProcessId(foreground, None)
mine = kernel32.GetCurrentThreadId()
attached = theirs and theirs != mine and user32.AttachThreadInput(mine, theirs, True)
try:
user32.ShowWindow(hwnd, 9 if user32.IsIconic(hwnd) else 5) # SW_RESTORE / SW_SHOW
user32.BringWindowToTop(hwnd)
user32.SetForegroundWindow(hwnd)
finally:
if attached:
user32.AttachThreadInput(mine, theirs, False)
def _raise_explorer(folder, before):
"""Waits up to 3 s for the window showing `folder` (a new one, or the open one Explorer
reused) and brings it to the front."""
names = (os.path.basename(folder).lower(), os.path.normcase(folder).lower())
def shows_folder(hwnd):
# The folder's name (or full path), then " - File Explorer" in the system's language.
title = _window_title(hwnd).lower()
return any(title == name or title.startswith(name + " - ") for name in names)
for _ in range(30):
time.sleep(0.1)
windows = _explorer_windows()
fresh = [hwnd for hwnd in windows if hwnd not in before]
match = fresh or [hwnd for hwnd in windows if shows_folder(hwnd)]
if match:
_to_front(match[0])
return
def _reveal(path):
if sys.platform == "win32":
before = set(_explorer_windows())
subprocess.Popen(f'explorer /select,"{path}"')
threading.Thread(target=_raise_explorer, args=(os.path.dirname(path), before), daemon=True).start()
elif sys.platform == "darwin":
subprocess.Popen(["open", "-R", path])
else:
subprocess.Popen(["xdg-open", os.path.dirname(path)])
def _open_folder(folder):
if sys.platform == "win32":
before = set(_explorer_windows())
subprocess.Popen(f'explorer "{folder}"')
threading.Thread(target=_raise_explorer, args=(folder, before), daemon=True).start()
elif sys.platform == "darwin":
subprocess.Popen(["open", folder])
else:
subprocess.Popen(["xdg-open", folder])
async def api_open_model_folder(request):
"""{type, path_idx, subfolder, filename}: Explorer with the model selected; without a
filename, the folder (type root or subfolder) itself."""
try:
data = await request.json()
except ValueError:
data = None
if not isinstance(data, dict):
return web.json_response({"status": "error", "message": "Invalid request body"}, status=400)
try:
_, folder = resolve_folder_subdir(data.get("type", ""), int(data.get("path_idx", 0)), data.get("subfolder", "/"))
filename = data.get("filename")
path = model_file_within(folder, filename) if filename else None
except (ValueError, KeyError, TypeError):
return web.json_response({"status": "error", "message": "Invalid request parameters"}, status=400)
if not os.path.isfile(path) if path else not os.path.isdir(folder):
return web.json_response({"status": "error", "message": "Not found"}, status=404)
try:
_reveal(path) if path else _open_folder(folder)
except OSError as error:
return web.json_response({"status": "error", "message": str(error)}, status=500)
return web.json_response({"status": "success"})
+101
View File
@@ -0,0 +1,101 @@
"""What a model file's header says (model_kind.inspect_file: kind, base model, format), kept on
disk so each file version is read once, also across restarts. With many models, or models on a
network drive (a NAS, a cloud server's model disk), reading every header again each time the
scan page or a models folder opens takes minutes; the header only changes with the file.
Kept in user/anomalous/model_heads.json by the file's real path, with its size and time: a
changed file is read again. At most MAX_ENTRIES files, the longest unused dropped first, and
written a few seconds after the last change (one write for a whole folder). Only this plugin
reads it; it is a cache, not data to back up. It is kept on disk once the plugin's routes are
set up (keep_on_disk); before that (tests, tools) it lives in memory only."""
import os
import threading
import time
from .activity_log import log_path
from .path_utils import atomic_write_json
try:
from ..model_identity import read_json
from ..model_kind import inspect_file
except ImportError: # loaded outside the package (tests)
from model_identity import read_json
from model_kind import inspect_file
MAX_ENTRIES = 50000 # about 6 MB on disk; a library has far fewer models
MAX_BYTES = 64 * 1024 * 1024 # a cache file larger than this is not read (nor written)
SAVE_AFTER_S = 5
FIELDS = ("kind", "base", "sure", "format")
_lock = threading.Lock()
_heads = None # {real path: [size, mtime_ns, last used (days), kind, base, sure, format]}
_timer = None
_on_disk = False
def keep_on_disk():
"""From now on the cache is read from and written to cache_path() (the plugin's start)."""
global _on_disk
_on_disk = True
def cache_path():
return os.path.join(os.path.dirname(log_path()), "model_heads.json")
def _load():
global _heads
if _heads is None:
try:
data = read_json(cache_path()) if _on_disk and os.path.getsize(cache_path()) <= MAX_BYTES else None
except OSError:
data = None
_heads = {key: value for key, value in (data or {}).items()
if isinstance(key, str) and isinstance(value, list) and len(value) == 3 + len(FIELDS)}
return _heads
def save():
"""Writes the cache now (also what the timer does)."""
global _timer
with _lock:
_timer = None
heads = _load()
if len(heads) > MAX_ENTRIES:
for key in sorted(heads, key=lambda name: heads[name][2])[:len(heads) - MAX_ENTRIES]:
del heads[key]
snapshot = dict(heads)
try:
atomic_write_json(cache_path(), snapshot, max_bytes=MAX_BYTES)
except (OSError, ValueError) as error:
print(f"[Anomalous Browser] Model header cache not saved: {error}")
def _save_soon():
global _timer
if _on_disk and _timer is None:
_timer = threading.Timer(SAVE_AFTER_S, save)
_timer.daemon = True
_timer.start()
def inspect(path):
"""model_kind.inspect_file(path), from the cache while the file is unchanged."""
try:
stat = os.stat(path)
except OSError:
return inspect_file(path)
real, today = os.path.realpath(path), int(time.time() // 86400)
with _lock:
known = _load().get(real)
if known and known[0] == stat.st_size and known[1] == stat.st_mtime_ns:
if known[2] != today:
known[2] = today
_save_soon()
return dict(zip(FIELDS, known[3:]))
found = inspect_file(path)
with _lock:
_load()[real] = [stat.st_size, stat.st_mtime_ns, today, *(found.get(field) for field in FIELDS)]
_save_soon()
return found
+399
View File
@@ -0,0 +1,399 @@
"""Putting a model file the user drops on the models page into the right models folder.
A browser gives a dropped file's name, size, time and bytes, never its path. So:
GET /anomalous/import/folders -> {types: {type: roots}, settings}: the folders a model can go to
(model_download.roots_of) and the download settings, whose folder rule ({base}) imports share.
POST /anomalous/import/inspect?name&size&mtime body: the file's first bytes (its header)
-> what it is (model_kind.classify), files of the same name already there, and, when this
computer's Downloads or Desktop holds that very file (same name, size and time; asked only
by the computer ComfyUI runs on), a `token` for it.
POST /anomalous/import/identify {token} -> its SHA-256, what Civitai knows of it (base model,
model and version name) and a scanned model with the same SHA-256, if any.
POST /anomalous/import/place {token, type, root, rel, keep} -> moves that file there (a copy
when `keep`); same drive = a rename, another drive = copy, then the original goes.
PUT /anomalous/import/upload?type&root&rel&size body: the whole file -> written there.
identify, place and upload answer only the computer ComfyUI runs on, unless remote changes
are allowed (local_guard.may_change).
A file already at the destination is never replaced: the new one gets " (2)". Each import is
one activity log entry (model_import).
"""
import asyncio
import os
import shutil
import threading
import time
import uuid
from aiohttp import web
import folder_paths
try:
from ..model_identity import file_sha256
from ..model_kind import classify, inspect_file, parse_gguf, parse_safetensors
except ImportError: # loaded outside the package (tests)
from model_identity import file_sha256
from model_kind import classify, inspect_file, parse_gguf, parse_safetensors
from .activity_log import add_entry
from .download_sources import SourceUnreachable, civitai_by_hash
from .local_guard import is_local as _is_local, may_change
from .model_download import (
CHUNK, MODEL_EXTENSIONS, SPACE_MARGIN, clean_folder, folder_type_for, forget_file_lists, read_settings, roots_of,
)
from .path_utils import resolve_within
KINDS = ("checkpoints", "loras", "vae", "text_encoders", "diffusion_models", "controlnet", "embeddings",
"clip_vision", "upscale_models", "ipadapter")
SEARCH_DEPTH = 2
SEARCH_LIMIT = 20000
TIME_SLACK_MS = 2000
KEEP_TOKENS = 200
_FOLDER_IDS = { # Windows known folders: Downloads, Desktop
"downloads": "{374DE290-123F-4565-9164-39C4925E467B}",
"desktop": "{B4BFCC3A-DB2C-424C-B029-7FE99A87C641}",
}
_lock = threading.Lock()
_tokens = {} # token -> {path, name, size}
class Refused(Exception):
"""An import that cannot be done; `code` says why (the page has a sentence for each)."""
def __init__(self, code):
super().__init__(code)
self.code = code
def _error(code, status=400):
return web.json_response({"error": code}, status=status)
# --- where the dropped file is on this computer --------------------------------------------
def _known_folder(name):
if os.name == "nt":
try:
import ctypes
from ctypes import wintypes
folder_id = uuid.UUID(_FOLDER_IDS[name]).bytes_le
path = ctypes.c_wchar_p()
shell = ctypes.windll.shell32
shell.SHGetKnownFolderPath.argtypes = [ctypes.c_char_p, wintypes.DWORD, wintypes.HANDLE, ctypes.POINTER(ctypes.c_wchar_p)]
if shell.SHGetKnownFolderPath(folder_id, 0, None, ctypes.byref(path)) == 0:
found = path.value
ctypes.windll.ole32.CoTaskMemFree(path)
if found and os.path.isdir(found):
return found
except Exception:
pass
fallback = os.path.expanduser("~/" + name.capitalize())
return fallback if os.path.isdir(fallback) else ""
def search_folders():
"""[(label, path)] where dropped files usually come from: Downloads, then Desktop."""
found = []
for label in ("downloads", "desktop"):
path = _known_folder(label)
if path and all(os.path.realpath(path) != os.path.realpath(other) for _, other in found):
found.append((label, path))
return found
def find_original(name, size, mtime_ms, folders=None):
"""(label, path) of the file with this name, size and time in `folders` (and their
subfolders two deep), else None."""
wanted = name.lower()
seen = 0
for label, base in folders if folders is not None else search_folders():
for current, dirs, files in os.walk(base):
depth = 0 if current == base else os.path.relpath(current, base).count(os.sep) + 1
dirs[:] = sorted(item for item in dirs if not item.startswith(".")) if depth < SEARCH_DEPTH else []
for file in files:
seen += 1
if seen > SEARCH_LIMIT:
return None
if file.lower() != wanted:
continue
path = os.path.join(current, file)
try:
stat = os.stat(path)
except OSError:
continue
if stat.st_size == size and (not mtime_ms or abs(stat.st_mtime * 1000 - mtime_ms) <= TIME_SLACK_MS):
return label, path
return None
def _remember(path, name, size):
token = uuid.uuid4().hex
with _lock:
_tokens[token] = {"path": path, "name": name, "size": size}
while len(_tokens) > KEEP_TOKENS:
_tokens.pop(next(iter(_tokens)))
return token
def _original(token):
with _lock:
item = _tokens.get(str(token or ""))
if not item or not os.path.isfile(item["path"]) or os.path.getsize(item["path"]) != item["size"]:
raise Refused("gone")
return item
# --- what it is ----------------------------------------------------------------------------
def import_types():
known = getattr(folder_paths, "folder_names_and_paths", {})
return [kind for kind in KINDS if kind in known]
def classify_head(name, head):
"""classify() from a file's first bytes, by its extension."""
lower = name.lower()
if lower.endswith((".safetensors", ".sft")):
return classify("safetensors", parse_safetensors(head))
if lower.endswith(".gguf"):
return classify("gguf", parse_gguf(head))
return classify(None, None)
def same_name(kind, name):
"""Files of this name already in the type's folders: [{root, rel, size}]."""
if not kind:
return []
wanted, found = name.lower(), []
for index, base in enumerate(folder_paths.get_folder_paths(kind) or []):
if not os.path.isdir(base):
continue
for current, _dirs, files in os.walk(base):
for file in files:
if file.lower() == wanted:
path = os.path.join(current, file)
found.append({"root": index, "rel": os.path.relpath(path, base).replace(os.sep, "/"),
"size": os.path.getsize(path)})
return found[:10]
def inspect(name, size, mtime_ms, head, local):
found = find_original(name, size, mtime_ms) if local else None
if found:
result = inspect_file(found[1])
result.pop("format", None)
result.update(token=_remember(found[1], name, size), where=found[0])
else:
result = classify_head(name, head)
if result["kind"] not in import_types():
result.update(kind="", sure=False)
result["same_name"] = same_name(result["kind"], name)
return result
def _same_hash(sha256):
"""Where a scanned model with this SHA-256 is ("sub/name.safetensors"), else None."""
from .model_resolution import collect_model_hash_index
names = [key for key, value in collect_model_hash_index().items() if str(value.get("hash") or "").lower() == sha256]
return next((key for key in names if "/" in key), names[0] if names else None)
def identify(token):
"""SHA-256, Civitai's record, and an already scanned copy, of a found file."""
item = _original(token)
sha256 = file_sha256(item["path"])
result = {"sha256": sha256, "civitai": None, "reason": "", "duplicate": _same_hash(sha256)}
try:
found = civitai_by_hash(sha256)
if found:
result["civitai"] = {key: found.get(key, "") for key in ("base_model", "model_name", "version_name", "page")}
else:
result["reason"] = "not_found"
except SourceUnreachable:
result["reason"] = "network"
return result
# --- putting it there ----------------------------------------------------------------------
def free_path(path):
"""`path`, or "name (2).ext", "name (3).ext"… when it is taken."""
if not os.path.exists(path) and not os.path.exists(path + ".part"):
return path
stem, ext = os.path.splitext(path)
number = 2
while os.path.exists(f"{stem} ({number}){ext}") or os.path.exists(f"{stem} ({number}){ext}.part"):
number += 1
return f"{stem} ({number}){ext}"
def destination(data, size=0):
"""(type, root index, root path, dest) for a place/upload request; Refused if refused."""
folder_type = folder_type_for([data.get("type")])
if folder_type not in import_types():
raise Refused("bad_type")
paths = folder_paths.get_folder_paths(folder_type) or []
try:
root_index = int(data.get("root", 0))
except (TypeError, ValueError):
raise Refused("bad_root")
if not 0 <= root_index < len(paths):
raise Refused("bad_root")
try:
rel = clean_folder(data.get("rel"))
root = os.path.realpath(paths[root_index])
dest = resolve_within(root, *rel.split("/"))
except ValueError:
raise Refused("bad_path")
if not rel.lower().endswith(MODEL_EXTENSIONS):
raise Refused("bad_name")
probe = root
while probe and not os.path.isdir(probe):
probe = os.path.dirname(probe)
if size and probe and shutil.disk_usage(probe).free < size + SPACE_MARGIN:
raise Refused("no_space")
os.makedirs(os.path.dirname(dest), exist_ok=True)
return folder_type, root_index, root, free_path(dest)
def _same_drive(first, second):
"""Whether `first` can be renamed to `second`; a link counts where the link is (moving
it moves the link, not the model disk behind it)."""
try:
return os.lstat(first).st_dev == os.stat(os.path.dirname(second)).st_dev
except OSError:
return False
def move_into(source, dest, keep):
"""Moves (or with `keep` copies) `source` to `dest`; a copy goes through "<dest>.part"."""
if not keep and _same_drive(source, dest):
os.replace(source, dest)
return
part = dest + ".part"
try:
shutil.copyfile(source, part)
if os.path.getsize(part) != os.path.getsize(source):
raise Refused("incomplete")
os.replace(part, dest)
except BaseException:
if os.path.exists(part):
os.remove(part) # our own unfinished copy
raise
if not keep:
os.remove(source) # moved: the whole file is at dest now
def _finish(folder_type, root_index, root, dest, how, size):
forget_file_lists()
rel = os.path.relpath(dest, root).replace(os.sep, "/")
add_entry("file", "model_import", rel, {"import": {
"type": folder_type, "path_idx": root_index, "rel": rel, "file": os.path.basename(dest), "size": size, "how": how,
}})
return {"type": folder_type, "root": root_index, "rel": rel, "file": os.path.basename(dest)}
def place(data):
item = _original(data.get("token"))
keep = data.get("keep") is True
folder_type, root_index, root, dest = destination(data, item["size"] if keep else 0)
move_into(item["path"], dest, keep)
if not keep:
with _lock:
_tokens.pop(str(data.get("token")), None)
return _finish(folder_type, root_index, root, dest, "copy" if keep else "move", item["size"])
# --- routes --------------------------------------------------------------------------------
async def api_folders(request):
types = {}
for kind in import_types():
types[kind] = await asyncio.to_thread(roots_of, kind)
return web.json_response({"types": types, "settings": read_settings()})
async def api_inspect(request):
name = os.path.basename(str(request.query.get("name") or "").replace("\\", "/"))
if not name.lower().endswith(MODEL_EXTENSIONS):
return _error("not_model")
try:
size = int(request.query.get("size") or 0)
mtime_ms = float(request.query.get("mtime") or 0)
except ValueError:
return _error("bad_request")
head = await request.read()
result = await asyncio.to_thread(inspect, name, size, mtime_ms, head, _is_local(request))
return web.json_response(result)
async def api_identify(request):
if not may_change(request):
return _error("not_local", 403)
try:
data = await request.json()
return web.json_response(await asyncio.to_thread(identify, data.get("token")))
except Refused as error:
return _error(error.code, 404)
except (ValueError, AttributeError):
return _error("bad_request")
async def api_place(request):
if not may_change(request):
return _error("not_local", 403)
try:
data = await request.json()
return web.json_response(await asyncio.to_thread(place, data))
except Refused as error:
return _error(error.code, 404 if error.code == "gone" else 400)
except (ValueError, AttributeError):
return _error("bad_request")
except OSError as error:
print(f"[Anomalous Browser] Import: could not put the file in place: {error}")
return _error("io", 500)
async def api_upload(request):
if not may_change(request):
return _error("not_local", 403)
try:
size = int(request.query.get("size") or 0)
folder_type, root_index, root, dest = await asyncio.to_thread(destination, dict(request.query), size)
except Refused as error:
return _error(error.code)
except ValueError:
return _error("bad_request")
part = dest + ".part"
received = 0
try:
target = await asyncio.to_thread(open, part, "wb")
try:
async for chunk in request.content.iter_chunked(CHUNK):
await asyncio.to_thread(target.write, chunk)
received += len(chunk)
finally:
await asyncio.to_thread(target.close)
if size and received != size:
raise Refused("incomplete")
dest = free_path(dest) if os.path.exists(dest) else dest
await asyncio.to_thread(os.replace, part, dest)
except BaseException as error:
if os.path.exists(part):
os.remove(part) # our own unfinished upload
if isinstance(error, Refused):
return _error(error.code)
if isinstance(error, OSError):
print(f"[Anomalous Browser] Import: upload failed: {error}")
return _error("io", 500)
raise
return web.json_response(await asyncio.to_thread(_finish, folder_type, root_index, root, dest, "upload", received))
def register_routes(app):
app.router.add_get("/anomalous/import/folders", api_folders)
app.router.add_post("/anomalous/import/inspect", api_inspect)
app.router.add_post("/anomalous/import/identify", api_identify)
app.router.add_post("/anomalous/import/place", api_place)
app.router.add_put("/anomalous/import/upload", api_upload)
+187
View File
@@ -0,0 +1,187 @@
"""Model preview lookup and bounded custom-cover operations."""
import asyncio
import os
import shutil
import urllib.parse
from aiohttp import web
import folder_paths
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
def _cache_token(file_path):
try:
return os.stat(file_path).st_mtime_ns
except OSError:
return 0
async def _handle_custom_cover(target_dir, filename, save_func, source_ext='.png'):
base_name = os.path.splitext(filename)[0]
# Always save custom covers with a .preview.[ext] suffix so standard nodes recognize them as covers.
if source_ext.startswith('.preview.'):
preview_ext = source_ext
else:
preview_ext = f".preview{source_ext}"
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:
move_to_trash(*[path for path in older if path != dest_path])
except OSError as e:
print(f"[Anomalous Browser] Old cover kept: {e}")
await save_func(dest_path)
async def api_set_custom_cover(request):
try:
data = await request.json()
folder_type = data.get('type', 'checkpoints')
subfolder = data.get('subfolder', '/')
filename = data.get('filename', '')
source_image = data.get('source_image', '')
try: path_idx = int(data.get('path_idx', 0))
except: path_idx = 0
try:
filename = require_filename(filename)
_, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder)
output_dir = folder_paths.get_output_directory()
src_path = resolve_within(output_dir, source_image)
except (ValueError, KeyError):
return web.json_response({"status": "error", "message": "Invalid request parameters"}, status=400)
if not os.path.exists(src_path):
return web.json_response({"status": "error", "message": "Source image not found in output directory"})
source_ext = os.path.splitext(src_path)[1].lower()
if source_ext not in {'.png', '.jpg', '.jpeg', '.webp', '.gif', '.avif', '.mp4', '.webm', '.mov', '.avi'}:
return web.json_response({"status": "error", "message": "Unsupported cover format"}, status=415)
async def save_copy(dest_path):
import shutil
import asyncio
await asyncio.to_thread(shutil.copy2, src_path, dest_path)
await _handle_custom_cover(target_dir, filename, save_copy, source_ext)
return web.json_response({"status": "success"})
except Exception as e:
return web.json_response({"status": "error", "message": str(e)})
async def api_upload_custom_cover(request):
try:
data = await request.post()
folder_type = data.get('type', 'checkpoints')
subfolder = data.get('subfolder', '/')
filename = data.get('filename', '')
try: path_idx = int(data.get('path_idx', 0))
except: path_idx = 0
image_field = data.get('image')
try:
filename = require_filename(filename)
_, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder)
except (ValueError, KeyError):
return web.json_response({"status": "error", "message": "Invalid request parameters"}, status=400)
if image_field is None:
return web.json_response({"status": "error", "message": "Image is required"}, status=400)
image_data = image_field.file.read()
if len(image_data) > 100 * 1024 * 1024:
return web.json_response({"status": "error", "message": "Cover file is too large"}, status=413)
upload_filename = image_field.filename
source_ext = os.path.splitext(upload_filename)[1].lower()
if source_ext not in {'.png', '.jpg', '.jpeg', '.webp', '.gif', '.avif', '.mp4', '.webm', '.mov', '.avi'}:
return web.json_response({"status": "error", "message": "Unsupported cover format"}, status=415)
async def save_upload(dest_path):
def write_file():
with open(dest_path, 'wb') as f:
f.write(image_data)
import asyncio
await asyncio.to_thread(write_file)
await _handle_custom_cover(target_dir, filename, save_upload, source_ext)
return web.json_response({"status": "success"})
except Exception as e:
return web.json_response({"status": "error", "message": str(e)})
def _preview_url_for_model(folder_type, path_idx, base_dir, file_path):
root = os.path.dirname(file_path)
filename = os.path.basename(file_path)
base_name = os.path.splitext(filename)[0]
preview_file = next(
(
base_name + suffix
for suffix in PREVIEW_SUFFIXES + MEDIA_EXTENSIONS
if os.path.isfile(os.path.join(root, base_name + suffix))
),
None,
)
if not preview_file:
return ""
return _media_url(folder_type, path_idx, base_dir, os.path.join(root, preview_file))
def _media_url(folder_type, path_idx, base_dir, media_path):
"""The /anomalous/image URL of a file beside a model, with a token that changes with it."""
rel_subfolder = os.path.relpath(os.path.dirname(media_path), base_dir)
if rel_subfolder == '.':
rel_subfolder = '/'
q_type = urllib.parse.quote(folder_type)
q_idx = str(path_idx)
q_sub = urllib.parse.quote(rel_subfolder)
q_file = urllib.parse.quote(os.path.basename(media_path))
version = _cache_token(media_path)
return f"/anomalous/image?type={q_type}&path_idx={q_idx}&subfolder={q_sub}&filename={q_file}&t={version}"
def _model_covers(folder_type, path_idx, base_dir, file_path):
"""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]
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)), "")
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}
async def api_model_covers(request):
"""GET /anomalous/model_covers?type&path_idx&subfolder&filename - for the model editor: the
Civitai cover it can bring back, and whether the one shown is the user's."""
try:
folder_type = request.query.get('type', '')
path_idx = int(request.query.get('path_idx', 0))
filename = require_filename(request.query.get('filename', ''))
base_dir, target_dir = resolve_folder_subdir(folder_type, path_idx, request.query.get('subfolder', '/'))
file_path = model_file_within(target_dir, filename)
except (ValueError, KeyError, TypeError):
return web.json_response({"error": "Invalid model location"}, status=400)
if not os.path.isfile(file_path):
return web.json_response({"error": "Model not found"}, status=404)
return web.json_response(await asyncio.to_thread(_model_covers, folder_type, path_idx, base_dir, file_path))
+273
View File
@@ -0,0 +1,273 @@
"""Model metadata, physical rename, deletion, and sidecar lifecycle."""
import json
import os
import re
import shutil
from aiohttp import web
try:
from ..model_policies import is_physical_rename_protected
except ImportError:
from model_policies import is_physical_rename_protected
from .model_constants import (
CIVITAI_BACKUP_SUFFIXES, MEDIA_EXTENSIONS, MODEL_EXTENSIONS,
PREVIEW_SUFFIXES, SIDECAR_SUFFIXES,
)
from .activity_log import ACTIVITY_DETAIL
from .metadata import get_metadata
from .model_catalog import _resolve_paths_to_model_info_sync
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):
for suffix in suffixes:
candidate = f"{base_path}{suffix}"
if os.path.isfile(candidate):
return candidate, suffix
return None, None
def _reset_model_cover(base_path):
"""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}"
for suffix in PREVIEW_SUFFIXES
if os.path.isfile(f"{base_path}{suffix}")
]
if backup_path:
media_ext = backup_suffix[len('.civitai_bak'):]
restored_path = f"{base_path}.preview{media_ext}"
temp_path = f"{restored_path}.anomalous_tmp"
try:
# Copy first. If the backup cannot be read, the active custom cover
# remains untouched. os.replace then makes the actual restore atomic.
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):
os.remove(temp_path)
except OSError:
pass
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)
except Exception as exc:
return False, 'restore_failed', trash_failure(exc)
return True, 'original_cover', None
if preview_paths:
# There is no recoverable source. Keep the current cover instead of
# turning a harmless Reset click into irreversible image loss.
return False, 'preserved_current', 'No Civitai backup or original cover exists.'
return True, 'no_cover', None
async def api_delete_model(request):
try:
data = await request.json()
except ValueError:
data = None
if not isinstance(data, dict):
return web.json_response({"status": "error", "message": "Invalid request body"}, status=400)
try:
folder_type = data.get('type', 'checkpoints')
subfolder = data.get('subfolder', '/')
filename = data.get('filename', '')
try:
path_idx = int(data.get('path_idx', 0))
except:
path_idx = 0
try:
filename = require_filename(filename)
_, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder)
model_path = model_file_within(target_dir, filename)
except (ValueError, KeyError):
return web.json_response({"status": "error", "message": "Invalid request parameters"}, status=400)
if not os.path.exists(model_path):
return web.json_response({"status": "error", "message": "Model file not found"})
base_name = os.path.splitext(filename)[0]
# Sidecars are keyed by stem, not by the main model extension. If a
# second real model shares this stem, preserve the shared sidecars for
# the survivor instead of treating that model as cleanup debris.
shared_stem_in_use = any(
os.path.isfile(os.path.join(target_dir, base_name + model_ext))
for model_ext in MODEL_EXTENSIONS if base_name + model_ext != filename
)
sidecars = [] if shared_stem_in_use else [
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}
# 前端期待的成功状态是 "success" 而不是 "ok"
return web.json_response({
"status": "success",
"deleted": deleted_files,
"sidecars_preserved": shared_stem_in_use,
})
except Exception as e:
import traceback
traceback.print_exc()
return web.json_response({"status": "error", "message": str(e)}, status=500)
def _is_web_link(text):
return not text or (len(text) <= 2048 and re.fullmatch(r"https?://[^\s/]+\.\S+", text, re.IGNORECASE) is not None)
async def api_update_metadata(request):
try:
data = await request.json()
folder_type = data.get('type', 'checkpoints')
subfolder = data.get('subfolder', '/')
raw_filename = data.get('filename', '')
custom_name = data.get('custom_name', '')
custom_notes = data.get('custom_notes', '')
custom_source_url = data.get('custom_source_url', None)
physical_rename_requested = data.get('physical_rename', False)
try: path_idx = int(data.get('path_idx', 0))
except: path_idx = 0
# Normalize filename and subfolder if directory separators are included
extracted_filename = raw_filename
if isinstance(raw_filename, str) and ('/' in raw_filename or '\\' in raw_filename):
if subfolder in (None, '', '/'):
extracted_sub = os.path.dirname(raw_filename).replace('\\', '/')
if extracted_sub:
subfolder = '/' + extracted_sub.strip('/')
extracted_filename = os.path.basename(raw_filename)
file_path = None
target_dir = None
filename = extracted_filename
try:
filename = require_filename(extracted_filename)
_, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder)
file_path = model_file_within(target_dir, filename)
except (ValueError, KeyError):
pass
if not file_path or not os.path.exists(file_path):
# Fallback: attempt to locate model across known model directories
search_keys = [k for k in [raw_filename, extracted_filename] if k and isinstance(k, str)]
fallback_resolved = _resolve_paths_to_model_info_sync(search_keys)
resolved_info = fallback_resolved.get(raw_filename) or fallback_resolved.get(extracted_filename)
if resolved_info:
resolved_path = resolved_info.get("file_path")
if resolved_path and os.path.exists(resolved_path):
file_path = resolved_path
target_dir = os.path.dirname(file_path)
folder_type = resolved_info.get("type", folder_type)
path_idx = resolved_info.get("path_idx", path_idx)
filename = resolved_info.get("filename", filename)
if not file_path or not os.path.exists(file_path):
return web.json_response({"status": "error", "message": "Model not found"})
# A source link is opened from the page: a web address or nothing.
if custom_source_url is not None and not _is_web_link(str(custom_source_url).strip()):
return web.json_response({"status": "error", "message": "The source link must be an http(s) address."}, status=400)
physical_rename_skipped = physical_rename_requested and is_physical_rename_protected(
folder_type=folder_type,
folder_path=target_dir,
)
physical_rename = physical_rename_requested and not physical_rename_skipped
base_name = os.path.splitext(file_path)[0]
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 = 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}
before = get_metadata(file_path) # what was shown, for the activity log
if 'custom_name' in data:
user_data["custom_name"] = str(custom_name)
if 'custom_notes' in data:
user_data["custom_notes"] = str(custom_notes)
if custom_source_url is not None:
user_data["source_url"] = str(custom_source_url).strip()
reset_cover = data.get('reset_cover', False)
cover_reset = None
cover_reset_source = None
cover_reset_warning = None
if reset_cover:
cover_reset, cover_reset_source, cover_reset_warning = _reset_model_cover(base_name)
atomic_write_json(user_file, user_data)
new_filename = filename
if physical_rename and custom_name:
import re
safe_name = re.sub(r'[<>:"/\\|?*]', '_', custom_name).strip(' .')
if not safe_name:
return web.json_response({"status": "error", "message": "The physical filename cannot be empty."}, status=400)
new_file_path = os.path.join(target_dir, f"{safe_name}{model_ext}")
if new_file_path != file_path and not os.path.exists(new_file_path):
os.rename(file_path, new_file_path)
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:
return web.json_response({"status": "error", "message": "A file with the target physical name already exists."})
sent = [key for key in ("custom_name", "custom_notes") if key in data]
sent += ["source_url"] if custom_source_url is not None else []
fields = [{"field": key, "before": before.get(key) or "", "after": user_data[key]}
for key in sent if user_data[key] != (before.get(key) or "")]
if new_filename != filename:
fields.append({"field": "filename", "before": filename, "after": new_filename})
request[ACTIVITY_DETAIL] = {"fields": fields}
response_data = {
"status": "success",
"new_filename": new_filename,
"physical_rename_skipped": physical_rename_skipped,
}
if reset_cover:
response_data.update({
"cover_reset": cover_reset,
"cover_reset_source": cover_reset_source,
"cover_reset_warning": cover_reset_warning,
})
return web.json_response(response_data)
except Exception as e:
return web.json_response({"status": "error", "message": str(e)})
+273
View File
@@ -0,0 +1,273 @@
"""Models in the wrong models folder, and identical copies (the models page's Tidy view,
ui_model_tidy.js).
GET /anomalous/placement/check[?deep=1] -> {misplaced, duplicates, unchecked}
misplaced: models whose header (model_kind.py, only when sure) names another type than
the folder they are in, with where they belong (the same subfolder in a folder of that
type, on the same drive when there is one) and whether they still load where they are.
duplicates: files with the same SHA-256 — known from scans, else read now for files of
the same size (small ones always, large ones with deep). unchecked: the same-size large
files a deep check would still read.
POST /anomalous/placement/move {type, root, rel, to_type, to_root, to_rel} -> moves a model
and its sidecars (covers, info, the user's notes); never over a file (" (2)"). Only for
the computer ComfyUI runs on, unless remote changes are allowed (local_guard.may_change).
Removing a copy goes through /anomalous/delete_model (Recycle Bin). Headers and computed
SHA-256s are kept in memory per file (path, size, time).
"""
import asyncio
import os
import threading
import urllib.parse
from aiohttp import web
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
from .model_constants import MEDIA_EXTENSIONS, PREVIEW_SUFFIXES, SIDECAR_SUFFIXES
from .model_download import MODEL_EXTENSIONS, clean_folder, forget_file_lists
from .model_import import Refused, destination, import_types, move_into
from .path_utils import model_reference_within
# (folder type, what the file is): it loads there all the same.
WORKS_ANYWAY = {("diffusion_models", "checkpoints")}
# Same-size files up to this are read at once (LoRAs, embeddings); larger ones on "Compare".
AUTO_HASH = 256 * 1024 * 1024
CACHE_LIMIT = 5000
_lock = threading.Lock()
_heads = {} # path -> (size, mtime_ns, inspect_file result)
_hashes = {} # path -> (size, mtime_ns, sha256)
def _cached(store, path, stat, compute):
key = (stat.st_size, stat.st_mtime_ns)
with _lock:
hit = store.get(path)
if hit and hit[:2] == key:
return hit[2]
value = compute(path)
with _lock:
if len(store) >= CACHE_LIMIT:
store.clear()
store[path] = (*key, value)
return value
def library():
"""Every model file once: [{type, root, rel, path, size, dev}] (a folder listed under two
types counts for the first)."""
seen, files = set(), []
for folder_type in import_types():
for index, base in enumerate(folder_paths.get_folder_paths(folder_type) or []):
if not os.path.isdir(base):
continue
for current, dirs, names in os.walk(base):
dirs[:] = sorted(name for name in dirs if not name.startswith("."))
for name in sorted(names):
if not name.lower().endswith(MODEL_EXTENSIONS):
continue
path = os.path.join(current, name)
real = os.path.realpath(path)
if real in seen:
continue
seen.add(real)
try:
stat = os.stat(path)
except OSError:
continue
files.append({"type": folder_type, "root": index, "rel": os.path.relpath(path, base).replace(os.sep, "/"),
"path": path, "size": stat.st_size, "dev": stat.st_dev, "stat": stat})
return files
def _card(item):
"""What the page shows of a file: its name (the user's, else Civitai's, else the file's),
Civitai version and cover URL."""
try:
meta = get_metadata(item["path"])
except Exception:
meta = {}
folder, name = os.path.split(item["path"])
stem = os.path.splitext(name)[0]
cover = next((stem + suffix for suffix in PREVIEW_SUFFIXES + MEDIA_EXTENSIONS
if os.path.isfile(os.path.join(folder, stem + suffix))), "")
sub = item["rel"].rpartition("/")[0]
url = ""
if cover:
url = (f"/anomalous/image?type={urllib.parse.quote(item['type'])}&path_idx={item['root']}"
f"&subfolder={urllib.parse.quote(sub)}&filename={urllib.parse.quote(cover)}")
version = meta.get("version_name") or ""
name = meta.get("custom_name") or ""
if not name and meta.get("model_id"): # Civitai's "Model - Version": the model's part
full = meta.get("name") or ""
name = full[:-len(" - " + version)] if version and full.endswith(" - " + version) else full
return {"name": name or stem, "version": version, "preview_url": url}
def _home_for(item, kind):
"""Where a file of `kind` goes: its own subfolder and name, in a folder of that type,
preferably on its drive (a move is then a rename)."""
roots = []
for index, base in enumerate(folder_paths.get_folder_paths(kind) or []):
if os.path.isdir(base):
roots.append((index, os.stat(base).st_dev))
if not roots:
return None
index = next((index for index, dev in roots if dev == item["dev"]), roots[0][0])
return {"type": kind, "root": index, "rel": item["rel"]}
def misplaced(files):
found = []
for item in files:
result = _cached(_heads, item["path"], item["stat"], inspect_file)
kind = result.get("kind")
if not kind or not result.get("sure") or kind == item["type"] or kind not in import_types():
continue
home = _home_for(item, kind)
if home:
found.append({"type": item["type"], "root": item["root"], "rel": item["rel"], "size": item["size"],
"kind": kind, "base": result.get("base", ""), "works": (item["type"], kind) in WORKS_ANYWAY,
"to": home, **_card(item)})
return found
def _known_hash(item):
try:
value = str(get_metadata(item["path"]).get("hash") or "").lower()
except Exception:
value = ""
if len(value) == 64:
return value
with _lock:
hit = _hashes.get(item["path"])
return hit[2] if hit and hit[:2] == (item["stat"].st_size, item["stat"].st_mtime_ns) else ""
def _copy(item):
"""One of identical files: where it is, how it shows, whether its folder fits what it is
(the copy to keep, when only one does)."""
kind = _cached(_heads, item["path"], item["stat"], inspect_file).get("kind")
return {"type": item["type"], "root": item["root"], "rel": item["rel"],
"fits": not kind or kind == item["type"], **_card(item)}
def duplicates(files, deep=False):
"""(groups of identical files, {groups, bytes} still to read for a deep check)."""
by_size = {}
for item in files:
if item["size"]:
by_size.setdefault(item["size"], []).append(item)
groups, unchecked = {}, {"groups": 0, "bytes": 0}
for size, items in by_size.items():
if len(items) < 2:
continue
unknown = [item for item in items if not _known_hash(item)]
read = deep or size <= AUTO_HASH
if unknown and not read:
unchecked["groups"] += 1
unchecked["bytes"] += size * len(unknown)
for item in items:
sha = _known_hash(item)
if not sha and read:
sha = _cached(_hashes, item["path"], item["stat"], file_sha256)
if sha:
groups.setdefault(sha, []).append(item)
found = [{"sha256": sha, "size": items[0]["size"], "files": [_copy(item) for item in items]}
for sha, items in groups.items() if len(items) > 1]
return found, unchecked
def check(deep=False):
files = library()
found, unchecked = duplicates(files, deep)
return {"misplaced": misplaced(files), "duplicates": found, "unchecked": unchecked, "checked": len(files)}
def _sidecars(path):
"""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 existing(os.path.join(folder, stem), SIDECAR_SUFFIXES)
def move(data):
folder_type = data.get("type")
if folder_type not in import_types():
raise Refused("bad_type")
paths = folder_paths.get_folder_paths(folder_type) or []
try:
index = int(data.get("root", 0))
base = os.path.realpath(paths[index])
rel = clean_folder(data.get("rel"))
source = model_reference_within(base, rel.replace("/", os.sep))
except (ValueError, TypeError, IndexError):
raise Refused("bad_path")
if not os.path.isfile(source) or not rel.lower().endswith(MODEL_EXTENSIONS):
raise Refused("gone")
to_type, to_root, root_path, dest = destination(
{"type": data.get("to_type"), "root": data.get("to_root", 0), "rel": data.get("to_rel")}, os.path.getsize(source))
sidecars = _sidecars(source)
move_into(source, dest, keep=False)
moved, left = [os.path.basename(dest)], []
dest_stem = os.path.splitext(dest)[0]
for path, suffix in sidecars:
target = follow(path, dest_stem, suffix)
try:
if os.path.exists(target):
raise FileExistsError(target)
move_into(path, target, keep=False)
moved.append(os.path.basename(target))
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": {
"from": {"type": folder_type, "path_idx": index, "rel": rel},
"to": {"type": to_type, "path_idx": to_root, "rel": to_rel}, "files": moved, "left": left,
}})
return {"type": to_type, "root": to_root, "rel": to_rel, "left": left}
async def api_check(request):
deep = request.query.get("deep") == "1"
return web.json_response(await asyncio.to_thread(check, deep))
async def api_move(request):
if not may_change(request):
return web.json_response({"error": "not_local"}, status=403)
try:
data = await request.json()
if not isinstance(data, dict):
raise ValueError
return web.json_response(await asyncio.to_thread(move, data))
except Refused as error:
return web.json_response({"error": error.code}, status=404 if error.code == "gone" else 400)
except ValueError:
return web.json_response({"error": "bad_request"}, status=400)
except OSError as error:
print(f"[Anomalous Browser] Tidy: could not move the model: {error}")
return web.json_response({"error": "io"}, status=500)
def register_routes(app):
app.router.add_get("/anomalous/placement/check", api_check)
app.router.add_post("/anomalous/placement/move", api_move)
+333
View File
@@ -0,0 +1,333 @@
"""Cryptographic and size-based missing-model resolution."""
import asyncio
import json
import os
import threading
from aiohttp import web
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:
from model_policies import requires_hash_for_model_recovery
from .metadata import get_metadata
from .model_constants import MODEL_EXTENSIONS, RESOLVABLE_MODEL_TYPES
from .utils import atomic_write_json
def _parse_resolution_types(expected_types_raw):
if not expected_types_raw:
return RESOLVABLE_MODEL_TYPES
requested = tuple(dict.fromkeys(
value.strip() for value in str(expected_types_raw).split(',') if value.strip()
))
if not requested or any(value not in RESOLVABLE_MODEL_TYPES for value in requested):
raise ValueError("Invalid model type")
return requested
def _collect_resolution_candidates(types):
candidates = []
seen_realpaths = set()
for folder_type in types:
try:
paths = folder_paths.get_folder_paths(folder_type)
except Exception:
continue
for base_dir in paths or []:
if not os.path.exists(base_dir):
continue
for root, _, files in os.walk(base_dir):
for filename in files:
if not filename.lower().endswith(MODEL_EXTENSIONS):
continue
file_path = os.path.join(root, filename)
real_path = os.path.realpath(file_path)
if real_path in seen_realpaths:
continue
try:
file_size = os.path.getsize(file_path)
except OSError:
continue
seen_realpaths.add(real_path)
candidates.append({
"type": folder_type,
"filename": os.path.relpath(file_path, base_dir).replace('\\', '/'),
"path": file_path,
"size": file_size,
})
return candidates
def _candidate_hashes(candidate):
if "hashes" in candidate:
return candidate["hashes"]
values = set()
metadata = candidate.get("metadata")
if metadata is None:
metadata = get_metadata(candidate["path"])
candidate["metadata"] = metadata
meta_hash = normalise_sha256(metadata.get("hash", ""))
if meta_hash:
values.add(str(meta_hash).upper())
candidate["hashes"] = values
return values
def _resolved_payload(candidate, **details):
payload = {
"found": True,
"type": candidate["type"],
"filename": candidate["filename"],
}
payload.update(details)
return payload
def _size_candidate_payload(candidate, require_hash=False):
"""Expose one in-category size match for explicit user confirmation only."""
return {
"found": False,
"confirmation_required": True,
"matched_by_size": True,
"hash_required": bool(require_hash),
"type": candidate["type"],
"filename": candidate["filename"],
"size": candidate["size"],
}
def _compute_and_save_fallback_info(file_path, file_hash):
try:
base_path = os.path.splitext(file_path)[0]
civitai_info_path = base_path + ".civitai.info"
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:
filename = os.path.basename(file_path)
inferred_base = infer_base_model_from_header(file_path) if file_path.lower().endswith('.safetensors') else ""
if inferred_base == 'Unknown':
inferred_base = ""
info_data = {
"id": -1,
"modelId": -1,
"name": os.path.splitext(filename)[0],
"baseModel": inferred_base,
"description": "<p>Automatically inferred by Anomalous Local Engine.</p>",
"model": {
"name": os.path.splitext(filename)[0],
"type": "LORA" if "lora" in file_path.lower() else "Checkpoint"
},
"files": [{"hashes": {"SHA256": file_hash.lower()}}]
}
info_data["anomalous_file_identity"] = computed_file_identity(file_path, file_hash)
atomic_write_json(info_path or target(base_path, ".info"), info_data)
except Exception:
pass
def _resolve_from_candidates(candidates, target_hash="", target_size=None, filename_query="", require_hash=False):
target_hash = str(target_hash or "").strip().upper()
# A saved filename/path is not identity evidence. It is intentionally
# ignored here; exact path lookup for previews lives in the bounded
# resolve_paths_to_previews endpoint and does not activate a model match.
has_target_hash = bool(target_hash and target_hash != "UNKNOWN")
size_matches = [
candidate for candidate in candidates
if target_size is not None and candidate["size"] == target_size
]
if has_target_hash and target_size is not None:
combined_matches = [candidate for candidate in size_matches if target_hash in _candidate_hashes(candidate)]
if len(combined_matches) == 1:
return _resolved_payload(combined_matches[0], matched_by_hash=True, matched_by_size=True)
if len(combined_matches) > 1:
return {"found": False, "ambiguous": True}
# If no combined match found from static cache, but there are size-matching candidates without hash metadata,
# dynamically compute their hash on-demand to test against target_hash
unhashed_size_matches = [c for c in size_matches if not _candidate_hashes(c)]
if unhashed_size_matches:
for candidate in unhashed_size_matches:
try:
computed_hash = file_sha256(candidate["path"]).upper()
candidate.setdefault("hashes", set()).add(computed_hash)
if computed_hash == target_hash:
_compute_and_save_fallback_info(candidate["path"], computed_hash)
except Exception:
pass
dynamic_matches = [c for c in size_matches if target_hash in c.get("hashes", set())]
if len(dynamic_matches) == 1:
return _resolved_payload(dynamic_matches[0], matched_by_hash=True, matched_by_size=True)
if len(dynamic_matches) > 1:
return {"found": False, "ambiguous": True}
hash_matches = [candidate for candidate in candidates if target_hash in _candidate_hashes(candidate)]
if hash_matches or size_matches:
return {"found": False, "identity_conflict": True}
return {"found": False}
if has_target_hash:
hash_matches = [candidate for candidate in candidates if target_hash in _candidate_hashes(candidate)]
if len(hash_matches) == 1:
return _resolved_payload(hash_matches[0], matched_by_hash=True)
if len(hash_matches) > 1:
return {"found": False, "ambiguous": True}
if target_size is not None:
if len(size_matches) == 1:
return _size_candidate_payload(size_matches[0], require_hash=require_hash)
if len(size_matches) > 1:
return {"found": False, "ambiguous": True}
if require_hash:
return {"found": False, "hash_required": True}
return {"found": False}
async def api_resolve_hash(request):
target_hash = request.query.get("hash", "").strip().upper()
size_str = request.query.get("size", "").strip()
filename_query = request.query.get("filename", "").strip()
target_size = int(size_str) if size_str.isdigit() else None
if not target_hash and target_size is None and not filename_query:
return web.json_response({"found": False})
try:
types = _parse_resolution_types(request.query.get("type", "").strip())
except ValueError as exc:
return web.json_response({"found": False, "error": str(exc)}, status=400)
def resolve_one():
candidates = _collect_resolution_candidates(types)
return _resolve_from_candidates(
candidates,
target_hash,
target_size,
filename_query,
require_hash=requires_hash_for_model_recovery(types),
)
return web.json_response(await asyncio.to_thread(resolve_one))
async def api_resolve_hash_batch(request):
try:
data = await request.json()
items = data.get("items", [])
except Exception:
return web.json_response({"error": "Invalid JSON body"}, status=400)
if not isinstance(items, list) or len(items) > 256:
return web.json_response({"error": "items must be a list with at most 256 entries"}, status=400)
parsed_items = []
try:
for index, item in enumerate(items):
if not isinstance(item, dict):
raise ValueError("Invalid batch item")
size_value = item.get("size")
size_string = str(size_value).strip() if size_value is not None else ""
parsed_items.append({
"key": str(item.get("key", index)),
"hash": str(item.get("hash", "")).strip().upper(),
"size": int(size_string) if size_string.isdigit() else None,
"types": _parse_resolution_types(str(item.get("type", "")).strip()),
})
except (TypeError, ValueError) as exc:
return web.json_response({"error": str(exc)}, status=400)
def resolve_batch():
candidate_groups = {}
results = []
for item in parsed_items:
types = item["types"]
if types not in candidate_groups:
candidate_groups[types] = _collect_resolution_candidates(types)
result = _resolve_from_candidates(
candidate_groups[types],
item["hash"],
item["size"],
require_hash=requires_hash_for_model_recovery(types),
)
results.append({"key": item["key"], "result": result})
return results
return web.json_response({"results": await asyncio.to_thread(resolve_batch)})
def collect_model_hash_index():
"""Scanned models keyed by relative path and basename -> {hash, size, url}.
Ambiguous keys (same name, different hash) are dropped. Shared by the
all-hashes endpoint and the output gallery's hash search.
"""
hashes = {}
ambiguous_keys = set()
def add_hash(key, value):
if key in ambiguous_keys:
return
existing = hashes.get(key)
if existing is None or existing == value:
hashes[key] = value
else:
hashes.pop(key, None)
ambiguous_keys.add(key)
types = RESOLVABLE_MODEL_TYPES
seen_dirs = set()
for t in types:
try:
paths = folder_paths.get_folder_paths(t)
if not paths: continue
for base_dir in paths:
if base_dir in seen_dirs: continue
seen_dirs.add(base_dir)
if not os.path.exists(base_dir): continue
for root, dirs, files in os.walk(base_dir):
for file in files:
if file.lower().endswith(MODEL_EXTENSIONS):
file_path = os.path.join(root, file)
try:
size_bytes = os.path.getsize(file_path)
except Exception:
size_bytes = 0
meta = get_metadata(file_path)
hash_val = ""
if meta and meta.get("hash"):
hash_val = meta["hash"]
model_url = ""
if meta:
model_url = meta.get("source_url") or meta.get("civitai_url") or ""
rel_path = os.path.relpath(file_path, base_dir)
if rel_path.startswith('.\\') or rel_path.startswith('./'):
rel_path = rel_path[2:]
rel_path = rel_path.replace('\\', '/')
basename = os.path.basename(file_path)
val = {"hash": hash_val, "size": size_bytes, "url": model_url}
add_hash(rel_path, val)
add_hash(basename, val)
except Exception:
pass
return hashes
async def api_get_all_hashes(request):
"""
Returns a dictionary of all scanned models with their hash and size.
Keyed by both relative path and basename for maximum frontend resilience.
"""
hashes = await asyncio.to_thread(collect_model_hash_index)
return web.json_response(hashes)
+54
View File
@@ -0,0 +1,54 @@
"""One model type as a single list: a models folder and all its subfolders (the models page's type chips).
Listing a type also queues its covers' card images for the background (media_routes).
"""
import asyncio
import os
import urllib.parse
from aiohttp import web
import folder_paths
from .model_catalog import _collect_folder_models
from .media_routes import queue_card_images
def _collect_type_models(folder_type, path_idx):
"""Every model under one registered models folder, folder by folder in name order."""
try:
base_dir = folder_paths.get_folder_paths(folder_type)[path_idx]
except Exception:
return []
if not os.path.isdir(base_dir):
return []
models = []
for root, dirs, _files in os.walk(base_dir):
dirs.sort(key=str.lower)
rel = os.path.relpath(root, base_dir).replace('\\', '/')
models += _collect_folder_models(root, folder_type, path_idx, '' if rel == '.' else rel, 1, 0)["models"]
return models
async def api_get_type_models(request):
"""GET /anomalous/type_models?type&path_idx - {models, total}; each model names its own subfolder."""
try:
path_idx = int(request.query.get('path_idx', 0))
except ValueError:
path_idx = -1
if path_idx < 0:
return web.json_response({"status": "error", "message": "Invalid path_idx"}, status=400)
models = await asyncio.to_thread(_collect_type_models, request.query.get('type', ''), path_idx)
queue_card_images(_cover_paths(models))
return web.json_response({"models": models, "total": len(models)})
def _cover_paths(models):
"""The cover file of each listed model: it sits beside the model, named in its preview URL."""
paths = []
for model in models:
query = urllib.parse.urlparse(model.get("preview_url") or "").query
name = urllib.parse.parse_qs(query).get("filename", [""])[0]
if name and model.get("file_path"):
paths.append(os.path.join(os.path.dirname(model["file_path"]), name))
return paths
+365
View File
@@ -0,0 +1,365 @@
"""When each model and node pack here was last used (the models page's Tidy tab, "Not used lately").
GET /anomalous/usage/check -> {models, packs, seen, now, ledger_since}
Evidence, read only when the user asks:
- the run record (usage_ledger.py): every run since it started, kept or not, and every
workflow opened on the page (the open tabs are noted just before a check);
- the outputs from before that: PNG images (their prompt names model files and node types;
image_search.cached_record) and FLAC / Ogg sounds (audio_metadata), dated by the file;
- the saved ComfyUI workflows, recipes, combos and parameter sets (any string naming a model
file, any node type, any SHA-256).
A model counts as used when its path, its file name (two files of one name: both count, the
safe side) or its scanned SHA-256 appears.
Nothing new is judged: a model or pack counts as idle since its last use or since it came
here (`added`: the file's or folder's creation date), whichever is later.
models: idle for 30 days or more, with {type, root, rel, size, name, version, preview_url,
last_used (seconds or null), outputs, runs, opened, saved (saved files naming it), added}.
packs: the node packs in custom_nodes but this plugin and ComfyUI Manager, {name, nodes,
last_used, outputs, runs, opened, saved, added, web (it adds to ComfyUI's page too), feeds (packs
whose nodes take a kind of thing only its nodes make), words (what to search the Manager
for), kept (the user said they still use it)}; a pack of no nodes only works on the page or
the server, nothing here can tell.
seen: {outputs, saved, capped} what was read (capped: some outputs or files were not).
ledger_since: when the run record started (null: nothing run since).
POST /anomalous/usage/keep {pack, kept} -> {kept: [names]}
What no record can show (a pack used only through its panels or buttons) the user says:
"I still use it", kept in user/anomalous/usage_kept.json ({packs: {name: since}}); only a
pack ComfyUI loaded can be marked, any name unmarked.
What none of this can show (a workflow from before the record, dropped in and never saved) is
said on the page; nothing here deletes or disables anything.
"""
import asyncio
import json
import os
import re
import threading
import time
import folder_paths
from aiohttp import web
from . import usage_ledger
from .audio_metadata import read_audio_tags
from .backup import part_dirs
from .image_search import MODEL_FILE, cached_record
from .model_placement import _card, _known_hash, library
from .path_utils import atomic_write_json
RECENT_SECONDS = 30 * 24 * 3600 # used or added within this: not listed
MAX_IMAGES = 100_000 # outputs read at most, images and sounds
MAX_SAVED = 20_000
MAX_JSON_BYTES = 20 * 1024 * 1024
SAVED_PARTS = ("comfy_workflows", "recipes", "combos", "parameters")
SOUNDS = (".flac", ".ogg", ".opus")
SHA256 = re.compile(r"^[0-9a-fA-F]{64}$")
PLUGIN_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
NOT_LISTED = {os.path.basename(PLUGIN_DIR).lower(), "comfyui-manager"} # the tools doing the tidying
FEEDS_SHOWN = 3
# Words left out of what the Manager is searched for: its search wants all words, in order, in
# one of a pack's title, author or description, and pack folders carry these where titles do not.
SEARCH_NOISE = {"comfyui", "comfy", "custom", "customnodes", "node", "nodes", "pack", "main", "master"}
_kept_lock = threading.Lock()
def _refs(value):
ref = str(value).replace("\\", "/").strip().lower()
return {ref, ref.rsplit("/", 1)[-1]}
def _arrived(stat, changed=True):
"""When a file or folder came here: its creation date, or its last change when that is later
(`changed`; a copied file keeps its old change date, a folder's changes with every update)."""
born = getattr(stat, "st_birthtime", None) or stat.st_ctime
return max(born, stat.st_mtime) if changed else born
class Evidence:
"""Per model reference / hash / node type: how many outputs, runs, openings and saved files,
and the latest time."""
def __init__(self):
self.outputs, self.runs, self.opened, self.saved, self.last = {}, {}, {}, {}, {}
self.seen = {"outputs": 0, "saved": 0, "capped": False}
def add(self, keys, when=None):
for key in keys:
if when is None:
self.saved[key] = self.saved.get(key, 0) + 1
else:
self.outputs[key] = self.outputs.get(key, 0) + 1
self.last[key] = max(self.last.get(key, 0), when)
def add_noted(self, counts, key, when, count):
"""A run record entry; `counts` is self.runs or self.opened."""
counts[key] = counts.get(key, 0) + count
self.last[key] = max(self.last.get(key, 0), when)
def of(self, keys):
keys = [key for key in keys if key]
last = max((self.last.get(key, 0) for key in keys), default=0)
return {"last_used": last or None,
**{kind: max((counts.get(key, 0) for key in keys), default=0)
for kind, counts in (("outputs", self.outputs), ("runs", self.runs), ("opened", self.opened), ("saved", self.saved))}}
def _walk_json(value, found):
"""Model references ('m:'), SHA-256s ('h:') and node types ('t:') anywhere in saved JSON."""
stack = [value]
while stack:
item = stack.pop()
if isinstance(item, dict):
for key in ("type", "class_type"):
if isinstance(item.get(key), str) and item[key]:
found.add("t:" + item[key])
stack.extend(item.values())
elif isinstance(item, list):
stack.extend(item)
elif isinstance(item, str):
if MODEL_FILE.search(item):
found.update("m:" + ref for ref in _refs(item))
elif SHA256.match(item):
found.add("h:" + item.upper())
def _read_saved(evidence):
dirs = part_dirs()
for part in SAVED_PARTS:
base = dirs.get(part)
if not base or not os.path.isdir(base):
continue
for current, folders, names in os.walk(base):
folders[:] = [name for name in folders if not name.startswith(".")]
for name in names:
if not name.lower().endswith(".json"):
continue
if evidence.seen["saved"] >= MAX_SAVED:
evidence.seen["capped"] = True
return
path = os.path.join(current, name)
try:
if os.path.getsize(path) > MAX_JSON_BYTES:
continue
with open(path, encoding="utf-8") as f:
data = json.load(f)
except (OSError, ValueError):
continue
found = set()
_walk_json(data, found)
evidence.seen["saved"] += 1
evidence.add(found)
def _sound_keys(path):
"""What a sound's embedded prompt names, as evidence keys; empty without one."""
try:
prompt = json.loads(read_audio_tags(path).get("prompt") or "null")
except (OSError, ValueError):
return set()
if not isinstance(prompt, dict):
return set()
types, models = usage_ledger.prompt_uses(prompt)
return {"t:" + node_type for node_type in types} | {"m:" + ref for model in models for ref in _refs(model)}
def _read_outputs(evidence):
base = folder_paths.get_output_directory()
for current, folders, names in os.walk(base):
folders[:] = [name for name in folders if not name.startswith(".")]
for name in names:
lower = name.lower()
if not lower.endswith((".png",) + SOUNDS):
continue
if evidence.seen["outputs"] >= MAX_IMAGES:
evidence.seen["capped"] = True
return
path = os.path.join(current, name)
try:
when = os.stat(path).st_mtime
except OSError:
continue
if lower.endswith(SOUNDS):
keys = _sound_keys(path)
else:
record = cached_record(path, when)
if not record:
continue
keys = {"m:" + ref for ref in record["models"]} | {"h:" + sha for sha in record["hashes"]}
keys |= {"t:" + node_type for node_type in record.get("types", ())}
if not keys:
continue
evidence.seen["outputs"] += 1
evidence.add(keys, when)
def _read_ledger(evidence):
"""Every run and opened workflow since the record started; its start, or None."""
ledger = usage_ledger.read()
for names, counts in ((ledger, evidence.runs), (ledger["opened"], evidence.opened)):
for node_type, (when, count) in names["types"].items():
evidence.add_noted(counts, "t:" + node_type, when, count)
for model, (when, count) in names["models"].items():
for ref in _refs(model):
evidence.add_noted(counts, "m:" + ref, when, count)
return ledger["started"]
def _io_types(cls):
"""(kinds of thing a node takes, kinds it makes); combo lists and '*' left out."""
taken, made = set(), set()
try:
inputs = cls.INPUT_TYPES() or {}
for group in ("required", "optional"):
for spec in (inputs.get(group) or {}).values():
kind = spec[0] if isinstance(spec, (list, tuple)) and spec else spec
if isinstance(kind, str):
taken.add(kind)
except Exception: # noqa: BLE001 - a pack's own code; it only means "takes nothing known"
pass
try:
made = {kind for kind in (cls.RETURN_TYPES or ()) if isinstance(kind, str)}
except Exception: # noqa: BLE001
pass
return taken - {"*"}, made - {"*"}
def _search_words(name):
words = [word for word in re.split(r"[\s_.\-]+", name.lower()) if word and word not in SEARCH_NOISE]
return " ".join(words) or name.lower()
def _pack_dirs():
"""{name: {name, path, dir, types: []}} for the node packs ComfyUI loaded from custom_nodes."""
import nodes # ComfyUI's registry, filled once ComfyUI has loaded the packs
roots = []
try:
roots = [os.path.normcase(os.path.realpath(root)) for root in folder_paths.get_folder_paths("custom_nodes") or []]
except Exception: # noqa: BLE001
pass
packs = {}
for module, directory in (getattr(nodes, "LOADED_MODULE_DIRS", {}) or {}).items():
real = os.path.normcase(os.path.realpath(directory))
name = os.path.splitext(os.path.basename(module))[0]
if real in roots: # a single .py file in custom_nodes
packs[name] = {"name": name, "path": os.path.join(directory, name + ".py"), "dir": None, "types": []}
elif os.path.normcase(os.path.realpath(os.path.dirname(directory))) in roots:
packs[name] = {"name": name, "path": directory, "dir": real, "types": []}
return packs
def _packs():
"""[{name, path, dir, types, web, feeds}] for the node packs ComfyUI loaded from custom_nodes."""
import nodes
packs = _pack_dirs()
io, core = {}, set()
for node_type, cls in (getattr(nodes, "NODE_CLASS_MAPPINGS", {}) or {}).items():
module = str(getattr(cls, "RELATIVE_PYTHON_MODULE", "") or "")
taken, made = _io_types(cls)
pack = module.split(".", 2)[1] if module.startswith("custom_nodes.") else None
if pack in packs:
packs[pack]["types"].append(node_type)
io.setdefault(pack, [set(), set()])
io[pack][0] |= taken
io[pack][1] |= made
elif pack is None:
core |= taken | made
web_dirs = [os.path.normcase(os.path.realpath(path)) for path in (getattr(nodes, "EXTENSION_WEB_DIRS", {}) or {}).values()]
for name, pack in packs.items():
pack["web"] = bool(pack["dir"]) and any(path == pack["dir"] or path.startswith(pack["dir"] + os.sep) for path in web_dirs)
own = io.get(name, [set(), set()])[1] - core
pack["feeds"] = sorted((other for other, (taken, _) in io.items() if other != name and own & taken), key=str.lower)
return [pack for name, pack in packs.items() if name.lower() not in NOT_LISTED]
def kept_path():
return os.path.join(os.path.dirname(usage_ledger.ledger_path()), "usage_kept.json")
def _kept():
"""{pack name: since} the user still uses."""
try:
with open(kept_path(), encoding="utf-8") as f:
data = json.load(f)
except (OSError, ValueError):
return {}
packs = data.get("packs") if isinstance(data, dict) else None
return packs if isinstance(packs, dict) else {}
def keep(name, kept):
"""Marks pack `name` as still used (`kept`) or not; the names marked. KeyError: not a loaded pack."""
with _kept_lock:
packs = _kept()
if kept:
if name not in _pack_dirs() or name.lower() in NOT_LISTED:
raise KeyError(name)
packs[name] = time.time()
else:
packs.pop(name, None)
atomic_write_json(kept_path(), {"packs": packs})
return sorted(packs)
def check(now=None):
now = time.time() if now is None else now
evidence = Evidence()
ledger_since = _read_ledger(evidence)
_read_saved(evidence)
_read_outputs(evidence)
models = []
for item in library():
keys = ["m:" + ref for ref in _refs(item["rel"])]
sha = _known_hash(item)
if sha:
keys.append("h:" + sha.upper())
use = evidence.of(keys)
added = _arrived(item["stat"]) if item.get("stat") else None
if now - max(use["last_used"] or 0, added or 0) < RECENT_SECONDS:
continue
models.append({"type": item["type"], "root": item["root"], "rel": item["rel"], "size": item["size"],
"added": added, **_card(item), **use})
packs, kept = [], _kept()
for pack in sorted(_packs(), key=lambda entry: entry["name"].lower()):
uses = [evidence.of(["t:" + node_type]) for node_type in pack["types"]]
try:
added = _arrived(os.stat(pack["path"]), changed=False)
except OSError:
added = None
packs.append({"name": pack["name"], "nodes": len(pack["types"]), "added": added, "web": pack["web"],
"feeds": pack["feeds"][:FEEDS_SHOWN], "words": _search_words(pack["name"]), "kept": pack["name"] in kept,
"last_used": max((use["last_used"] or 0 for use in uses), default=0) or None,
**{kind: max((use[kind] for use in uses), default=0) for kind in ("outputs", "runs", "opened", "saved")}})
return {"models": models, "packs": packs, "seen": evidence.seen, "now": now, "ledger_since": ledger_since}
async def api_check(request):
return web.json_response(await asyncio.to_thread(check))
async def api_keep(request):
try:
data = await request.json()
except ValueError:
data = None
name, kept = (data.get("pack"), data.get("kept")) if isinstance(data, dict) else (None, None)
if not isinstance(name, str) or not name or not isinstance(kept, bool):
return web.json_response({"error": "bad_request"}, status=400)
try:
return web.json_response({"kept": await asyncio.to_thread(keep, name, kept)})
except KeyError:
return web.json_response({"error": "unknown_pack"}, status=404)
except (OSError, ValueError):
return web.json_response({"error": "write_failed"}, status=500)
def register_routes(app):
app.router.add_get("/anomalous/usage/check", api_check)
app.router.add_post("/anomalous/usage/keep", api_keep)
+27 -1289
View File
File diff suppressed because it is too large Load Diff
+62
View File
@@ -0,0 +1,62 @@
"""GET /anomalous/civitai_check: whether this computer reaches Civitai now, through the proxy in
Settings -> Network when one is set (net_proxy.py); for Settings' "Test" button and the first-run
setup. One small request with the key, if one is set, so a key Civitai refuses is told apart;
when civitai.com does not answer, Civitai's other address (civitai.red) is asked too, and the
answer says which one answered (`host`). It goes online, so it answers this computer only
(local_guard LOCAL_GETS)."""
import asyncio
import time
import urllib.error
import urllib.request
from aiohttp import web
from .download_sources import civitai_headers
try:
from ..civitai_client import API_HOSTS, answered
from ..net_proxy import configured, failure, urlopen
except ImportError: # loaded outside the package (tests)
from civitai_client import API_HOSTS, answered
from net_proxy import configured, failure, urlopen
PROBE_PATH = "/api/v1/models?limit=1"
TIMEOUT = 8
def check_civitai():
"""{reachable, key ('set' | 'none' | 'refused'), ms, error, failure (net_proxy.failure), proxy,
host (the address that answered, or '')}."""
headers = civitai_headers()
key = "set" if "Authorization" in headers else "none"
started = time.monotonic()
def answer(reachable, key, error="", why="", host=""):
return {"reachable": reachable, "key": key, "ms": round((time.monotonic() - started) * 1000),
"error": error, "failure": why, "proxy": configured(), "host": host}
first = None
for host in API_HOSTS:
try:
with urlopen(urllib.request.Request(f"https://{host}{PROBE_PATH}", headers=headers), timeout=TIMEOUT) as response:
response.read(1024)
answered(host)
return answer(True, key, host=host)
except urllib.error.HTTPError as error:
# Civitai answered: the network is fine; 401/403 with a key means the key is refused.
answered(host)
if error.code in (401, 403) and key == "set":
return answer(True, "refused", f"HTTP {error.code}", failure(error), host)
return answer(error.code < 500, key, f"HTTP {error.code}", failure(error), host)
except (urllib.error.URLError, OSError, ValueError) as error:
reason = getattr(error, "reason", None) or error
first = first or answer(False, key, str(reason)[:200], failure(error))
return first
async def api_civitai_check(request):
return web.json_response(await asyncio.to_thread(check_civitai))
def register_routes(app):
app.router.add_get("/anomalous/civitai_check", api_civitai_check)
+65
View File
@@ -0,0 +1,65 @@
"""Saving one canvas node's current values as a parameter material (the current-node panel's "save")."""
import asyncio
import hashlib
import json
import time
import uuid
from aiohttp import web
from . import materials
from .material_schema import MAX_MATERIAL_NAME_LENGTH, _material_summary, _normalise_material_tags
from .notebooks import MAX_NOTEBOOK_BYTES
def _node_material(payload):
node = payload.get("node")
if not isinstance(node, dict):
raise ValueError("Invalid node")
node_type = node.get("type")
if not isinstance(node_type, str) or not node_type.strip() or len(node_type) > 200:
raise ValueError("Invalid node type")
values = node.get("widgets_values")
if not isinstance(values, list) or not values:
raise ValueError("Invalid node values")
encoded = json.dumps([node_type, values], ensure_ascii=False, allow_nan=False, sort_keys=True)
if len(encoded.encode("utf-8")) > MAX_NOTEBOOK_BYTES:
raise ValueError("Node values are too large")
title = node.get("title") if isinstance(node.get("title"), str) else ""
title = title.strip()[:200] or node_type
name = payload.get("name") or title
if not isinstance(name, str) or not (name := name.strip()) or len(name) > MAX_MATERIAL_NAME_LENGTH:
raise ValueError("Invalid material name")
return {
"schema_version": materials.MATERIAL_SCHEMA_VERSION,
"id": uuid.uuid4().hex,
"kind": "node_parameter_selection",
"name": name,
"tags": _normalise_material_tags(payload.get("tags", [])),
"timestamp": int(time.time() * 1000),
"source": {
"type": "canvas_node",
"parameter_name": title,
"parameter_signature": hashlib.sha256(encoded.encode("utf-8")).hexdigest(),
},
"workflow": {"nodes": [{"id": 1, "type": node_type, "title": title, "widgets_values": values}]},
"selection": {"scope": "nodes", "node_ids": [1]},
"capabilities": ["apply_node_parameters"],
}
async def api_save_node_material(request):
try:
material = _node_material(await request.json())
except (AttributeError, TypeError, ValueError):
return web.json_response({"status": "error", "message": "Invalid node material"}, status=400)
filename = f"material_{int(time.time())}_{material['id']}.json"
try:
duplicate = await asyncio.to_thread(
materials._persist_parameter_material, materials.get_materials_dir(), filename, material, False)
except (OSError, ValueError):
return web.json_response({"status": "error", "message": "Could not save node material"}, status=500)
if duplicate:
return web.json_response({"status": "duplicate", "filename": duplicate["filename"], "name": duplicate["name"]}, status=409)
return web.json_response({"status": "success", "filename": filename, "material": _material_summary(filename, material)})
+128 -65
View File
@@ -1,77 +1,140 @@
import os
import sys
import json
import urllib.parse
import subprocess
import threading
import asyncio
import hashlib
import json
import logging
import os
import threading
from aiohttp import web
import folder_paths
import struct
from .utils import require_filename, resolve_within
from .trash import move_to_trash
from .utils import atomic_write_json, require_filename, resolve_within
MAX_NOTEBOOK_BYTES = 2 * 1024 * 1024
LEGACY_NOTEBOOKS_DIR = os.path.join(os.path.dirname(__file__), "notebooks")
_notebooks_lock = threading.RLock()
def _read_notebook(path):
if os.path.getsize(path) > MAX_NOTEBOOK_BYTES:
raise ValueError("Notebook is too large")
with open(path, encoding="utf-8") as source:
data = json.load(source)
if not isinstance(data, dict):
raise ValueError("Invalid notebook")
return data
def _migrate_notebooks(directory):
marker = resolve_within(directory, ".legacy_imported.json")
if os.path.exists(marker):
return
skipped = []
if os.path.isdir(LEGACY_NOTEBOOKS_DIR):
for filename in sorted(os.listdir(LEGACY_NOTEBOOKS_DIR)):
if not filename.endswith(".json") or filename.startswith("."):
continue
try:
source = resolve_within(LEGACY_NOTEBOOKS_DIR, require_filename(filename))
data = _read_notebook(source)
except (OSError, ValueError):
skipped.append(filename)
logging.warning("Anomalous: could not migrate notebook %s; original preserved", filename)
continue
target = resolve_within(directory, filename)
if os.path.exists(target):
try:
if _read_notebook(target) == data:
continue
except (OSError, ValueError):
pass
digest = hashlib.sha256(json.dumps(data, sort_keys=True).encode()).hexdigest()[:12]
recovered = f"{os.path.splitext(filename)[0][:100]}_legacy_{digest}.json"
target = resolve_within(directory, recovered)
if os.path.exists(target):
if _read_notebook(target) != data:
raise ValueError("Conflicting migrated notebook")
continue
atomic_write_json(target, data, MAX_NOTEBOOK_BYTES)
atomic_write_json(marker, {"version": 1, "skipped": skipped})
def get_notebooks_dir():
user_dir = folder_paths.get_user_directory()
directory = os.path.join(user_dir, "workflows", "anomalous_notebooks")
os.makedirs(directory, exist_ok=True)
with _notebooks_lock:
_migrate_notebooks(directory)
return directory
def _list_notebooks():
with _notebooks_lock:
directory = get_notebooks_dir()
notebooks = []
for filename in sorted(os.listdir(directory)):
if not filename.endswith(".json") or filename.startswith("."):
continue
try:
data = _read_notebook(resolve_within(directory, filename))
notebooks.append({"filename": filename, "name": data.get("name", filename[:-5]), "data": data})
except (OSError, ValueError):
continue
return notebooks
async def api_get_notebooks(request):
script_dir = os.path.dirname(os.path.abspath(__file__))
nb_dir = os.path.join(script_dir, "notebooks")
if not os.path.exists(nb_dir):
os.makedirs(nb_dir)
notebooks = []
for f in os.listdir(nb_dir):
if f.endswith('.json'):
try:
with open(os.path.join(nb_dir, f), 'r', encoding='utf-8') as file:
data = json.load(file)
notebooks.append({
"filename": f,
"name": data.get("name", f.replace('.json', '')),
"data": data
})
except Exception:
pass
return web.json_response({"notebooks": notebooks})
try:
notebooks = await asyncio.to_thread(_list_notebooks)
return web.json_response({"notebooks": notebooks})
except (OSError, ValueError):
return web.json_response({"status": "error", "message": "Could not load notebooks"}, status=500)
def _save_notebook(filename, data):
with _notebooks_lock:
atomic_write_json(resolve_within(get_notebooks_dir(), filename), data, MAX_NOTEBOOK_BYTES)
async def api_save_notebook(request):
try:
data = await request.json()
filename = data.get("filename", "")
if not filename.endswith('.json'):
filename += '.json'
try:
filename = require_filename(filename)
except ValueError:
return web.json_response({"status": "error", "message": "Invalid filename"}, status=400)
script_dir = os.path.dirname(os.path.abspath(__file__))
nb_dir = os.path.join(script_dir, "notebooks")
if not os.path.exists(nb_dir):
os.makedirs(nb_dir)
file_path = resolve_within(nb_dir, filename)
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(data.get("data", {}), f, indent=4, ensure_ascii=False)
payload = await request.json()
filename = require_filename(payload.get("filename", ""))
if filename.startswith("."):
raise ValueError("Invalid notebook filename")
if not filename.endswith(".json"):
filename += ".json"
data = payload.get("data")
if not isinstance(data, dict):
raise ValueError("Invalid notebook")
await asyncio.to_thread(_save_notebook, filename, data)
return web.json_response({"status": "success"})
except Exception as e:
return web.json_response({"status": "error", "message": str(e)})
except (AttributeError, TypeError, ValueError):
return web.json_response({"status": "error", "message": "Invalid notebook"}, status=400)
except OSError:
return web.json_response({"status": "error", "message": "Could not save notebook"}, status=500)
def _delete_notebook(filename):
with _notebooks_lock:
path = resolve_within(get_notebooks_dir(), filename)
if not os.path.isfile(path):
raise FileNotFoundError(path)
move_to_trash(path)
async def api_delete_notebook(request):
try:
data = await request.json()
filename = data.get("filename", "")
try:
filename = require_filename(filename)
except ValueError:
return web.json_response({"status": "error", "message": "Invalid filename"}, status=400)
script_dir = os.path.dirname(os.path.abspath(__file__))
nb_dir = os.path.join(script_dir, "notebooks")
file_path = resolve_within(nb_dir, filename)
if os.path.exists(file_path):
os.remove(file_path)
return web.json_response({"status": "success"})
return web.json_response({"status": "error", "message": "File not found"})
except Exception as e:
return web.json_response({"status": "error", "message": str(e)})
payload = await request.json()
filename = require_filename(payload.get("filename", ""))
if not filename.endswith(".json") or filename.startswith("."):
raise ValueError("Invalid notebook filename")
await asyncio.to_thread(_delete_notebook, filename)
return web.json_response({"status": "success"})
except (AttributeError, TypeError, ValueError):
return web.json_response({"status": "error", "message": "Invalid notebook"}, status=400)
except FileNotFoundError:
return web.json_response({"status": "error", "message": "Notebook not found"}, status=404)
except OSError:
return web.json_response({"status": "error", "message": "Could not delete notebook"}, status=500)
+107 -3
View File
@@ -8,7 +8,11 @@ import re
from aiohttp import web
import folder_paths
from .utils import require_filename, resolve_within
from .recipes import MAX_RECIPE_BYTES, _parameter_signature, _parameter_gallery_images, get_recipes_dir, _read_recipe
from .recipe_constants import MAX_RECIPE_BYTES
from .recipe_images import _parameter_gallery_images, _pictures_by_signature
from .recipe_store import get_recipes_dir, _read_recipe
from .workflow_schema import _parameter_signature
from .trash import move_to_trash
def get_parameters_dir():
# Store parameter notebooks in the user directory
@@ -46,6 +50,42 @@ async def api_get_parameters(request):
return web.json_response({"notebooks": notebooks})
MAX_COUNTED_NOTEBOOKS = 5000
def _parameter_set_signatures(parameters_dir):
"""Each recipe's parameter sets by their values' signature: {recipe filename: [signature]},
so the page can leave out the set saved with the recipe itself. Files over the save limit,
unreadable ones and any past the first MAX_COUNTED_NOTEBOOKS are skipped."""
sets = {}
try:
filenames = sorted(name for name in os.listdir(parameters_dir) if name.endswith(".json"))
except OSError:
return sets
for filename in filenames[:MAX_COUNTED_NOTEBOOKS]:
try:
file_path = resolve_within(parameters_dir, filename)
if os.path.getsize(file_path) > MAX_RECIPE_BYTES:
continue
with open(file_path, "r", encoding="utf-8") as f:
data = json.load(f)
except (OSError, ValueError):
continue
if not isinstance(data, dict):
continue
recipe_filename = data.get("recipe_filename")
signature = data.get("parameter_signature")
value = signature.get("value") if isinstance(signature, dict) else None
if isinstance(recipe_filename, str) and recipe_filename:
sets.setdefault(recipe_filename, []).append(value if isinstance(value, str) else "")
return sets
async def api_get_parameter_set_signatures(request):
sets = await asyncio.to_thread(_parameter_set_signatures, get_parameters_dir())
return web.json_response({"sets": sets})
_parameter_notebooks_cache = None
_parameter_notebooks_cache_time = 0
@@ -148,10 +188,12 @@ async def api_get_parameters_by_type(request):
if recipe_fn not in grouped:
grouped[recipe_fn] = []
signature = (nb.get("data", {}).get("parameter_signature") or {})
grouped[recipe_fn].append({
"filename": nb.get("filename"),
"name": nb.get("name"),
"timestamp": nb.get("timestamp"),
"signature": signature.get("value") if isinstance(signature, dict) else "",
"nodes": matched_nodes
})
@@ -292,14 +334,76 @@ async def api_delete_parameter(request):
try:
parameters_dir = get_parameters_dir()
file_path = resolve_within(parameters_dir, filename)
if os.path.exists(file_path):
os.remove(file_path)
move_to_trash(file_path)
_invalidate_parameter_notebooks_cache()
except OSError:
return web.json_response({"status": "error", "message": "Could not delete parameter notebook"}, status=500)
return web.json_response({"status": "success"})
async def api_rename_parameter(request):
try:
data = await request.json()
filename = require_filename(data.get("filename", ""))
if not filename.endswith(".json"):
raise ValueError("Invalid filename")
new_name = str(data.get("name", "")).strip()
if not new_name:
raise ValueError("Name cannot be empty")
if len(new_name) > 200:
new_name = new_name[:200]
except (ValueError, AttributeError, json.JSONDecodeError):
return web.json_response({"status": "error", "message": "Invalid parameter data"}, status=400)
try:
parameters_dir = get_parameters_dir()
file_path = resolve_within(parameters_dir, filename)
if not os.path.exists(file_path):
return web.json_response({"status": "error", "message": "Parameter notebook not found"}, status=404)
with open(file_path, "r", encoding="utf-8") as f:
nb_data = json.load(f)
if not isinstance(nb_data, dict):
return web.json_response({"status": "error", "message": "Invalid parameter file format"}, status=400)
nb_data["name"] = new_name
nb_data["timestamp"] = int(time.time() * 1000)
fd, temp_path = tempfile.mkstemp(prefix=".parameter-", suffix=".tmp", dir=parameters_dir)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(nb_data, f, ensure_ascii=False, separators=(",", ":"))
f.flush()
os.fsync(f.fileno())
os.replace(temp_path, file_path)
finally:
if os.path.exists(temp_path):
os.remove(temp_path)
_invalidate_parameter_notebooks_cache()
except OSError:
return web.json_response({"status": "error", "message": "Could not update parameter notebook"}, status=500)
return web.json_response({"status": "success", "filename": filename, "name": new_name})
MAX_PICTURE_SIGNATURES = 64
async def api_get_parameter_pictures(request):
"""GET /anomalous/parameter_pictures?signatures=a,b - for Current node: how many recent
output pictures were made with each parameter set's values, and the newest, in one pass."""
signatures = {value.lower() for value in request.query.get("signatures", "").split(",") if value}
if not signatures or len(signatures) > MAX_PICTURE_SIGNATURES \
or any(not re.fullmatch(r"[0-9a-f]{64}", value) for value in signatures):
return web.json_response({"status": "error", "message": "Invalid signatures"}, status=400)
try:
pictures, scanned = await asyncio.to_thread(_pictures_by_signature, signatures)
except OSError:
return web.json_response({"status": "error", "message": "Could not read the pictures"}, status=500)
return web.json_response({"status": "success", "pictures": pictures, "scanned": scanned})
async def api_get_parameter_gallery(request):
"""Find recent output PNGs whose embedded parameters match one parameter notebook."""
try:
+103
View File
@@ -0,0 +1,103 @@
"""Filesystem containment and atomic JSON helpers."""
import json
import os
import tempfile
import folder_paths
from .recipe_constants import MODEL_FILE_SUFFIXES
def resolve_within(base_dir, *parts):
"""Resolve a path and require it to stay inside base_dir, including through symlinks."""
base_real = os.path.realpath(base_dir)
candidate = os.path.realpath(os.path.join(base_real, *parts))
try:
if os.path.commonpath([base_real, candidate]) != base_real:
raise ValueError("Path escapes the configured directory")
except ValueError:
raise ValueError("Path escapes the configured directory")
return candidate
def _model_roots():
for folder_type in getattr(folder_paths, "folder_names_and_paths", {}):
try:
paths = folder_paths.get_folder_paths(folder_type)
except Exception:
continue
for path_index, base_dir in enumerate(paths or []):
if os.path.isdir(base_dir):
yield folder_type, path_index, os.path.realpath(base_dir)
def model_file_within(directory, filename):
"""The model file `filename` (a bare name) in `directory`, which was resolved inside its
models folder already, as named: a model that is a link stays the link (on a cloud server
the models folder links into a shared, read-only model disk). Its covers and info are the
ones beside the link, edits are written there and a delete moves the link; only the folder
part must stay inside the models folder, and the name cannot leave it."""
return os.path.join(directory, require_filename(filename))
def model_reference_within(base_dir, relative):
"""model_file_within for a path relative to the models folder `base_dir` (subfolders and
a file name): the subfolders must stay inside it, through links too."""
head, name = os.path.split(relative)
return model_file_within(resolve_within(base_dir, head), name)
def _resolve_exact_model_reference(saved_value):
"""Resolve one saved model value without recursive scanning or hashing."""
if not isinstance(saved_value, str) or not saved_value.strip():
return None
relative_value = saved_value.replace("/", os.sep)
for folder_type, path_index, base_dir in _model_roots():
try:
candidate = model_reference_within(base_dir, relative_value)
except ValueError:
continue
if not os.path.isfile(candidate) or not candidate.lower().endswith(MODEL_FILE_SUFFIXES):
continue
return {
"path": candidate,
"folder_type": folder_type,
"path_index": path_index,
}
return None
def get_folder_root(folder_type, path_idx=0):
paths = folder_paths.get_folder_paths(folder_type)
if not paths or not isinstance(path_idx, int) or path_idx < 0 or path_idx >= len(paths):
raise ValueError("Invalid folder path")
return os.path.realpath(paths[path_idx])
def resolve_folder_subdir(folder_type, path_idx=0, subfolder='/'):
base_dir = get_folder_root(folder_type, path_idx)
relative = "" if subfolder in (None, "", "/") else str(subfolder).strip("/\\")
return base_dir, resolve_within(base_dir, relative)
def require_filename(filename):
if not isinstance(filename, str) or filename in ("", ".", "..") or os.path.basename(filename) != filename:
raise ValueError("Invalid filename")
return filename
def atomic_write_json(path, value, max_bytes=12 * 1024 * 1024):
encoded = json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
if len(encoded) > max_bytes:
raise ValueError("JSON record is too large")
fd, temporary = tempfile.mkstemp(prefix=".write-", suffix=".tmp", dir=os.path.dirname(path))
try:
with os.fdopen(fd, "wb") as output:
output.write(encoded)
output.flush()
os.fsync(output.fileno())
os.replace(temporary, path)
finally:
if os.path.exists(temporary):
os.remove(temporary)
+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 [])})
+57
View File
@@ -0,0 +1,57 @@
"""Shared bounded constants for Workflow Recipe domains."""
import re
MAX_NAME_LENGTH = 120
MAX_TAGS = 20
MAX_TAG_LENGTH = 60
MAX_NOTES_LENGTH = 3000
MAX_MODEL_NOTE_LENGTH = 1000
MAX_FOCUS_PARAMS = 12
MAX_FOCUS_NOTE_LENGTH = 120
MAX_FOCUS_NAME_LENGTH = 200
MAX_THUMBNAIL_LENGTH = 1_500_000
MAX_SOURCE_SUBFOLDER_LENGTH = 500
MAX_RECIPE_BYTES = 12 * 1024 * 1024
MAX_HISTORY_VERSIONS = 20
MAX_PREVIEW_SNAPSHOTS = 12
MAX_PREVIEW_SNAPSHOT_BYTES = 96 * 1024
MAX_PREVIEW_SNAPSHOT_TOTAL_BYTES = 1_250_000
MAX_PREVIEW_SOURCE_BYTES = 20 * 1024 * 1024
MAX_WORKFLOW_NODES = 5_000
MAX_WORKFLOW_LINKS = 30_000
MAX_WORKFLOW_GROUPS = 2_000
MAX_WIDGET_VALUES_PER_NODE = 2_048
MAX_RECIPE_GALLERY_SCAN = 200
MAX_RECIPE_GALLERY_RESULTS = 200
MAX_EMBEDDED_WORKFLOW_BYTES = 3 * 1024 * 1024
MAX_RECIPE_COVER_SOURCE_BYTES = 64 * 1024 * 1024
MAX_RECIPE_COVER_BYTES = 256 * 1024
SAFE_THUMBNAIL_PREFIXES = (
"data:image/jpeg;base64,",
"data:image/png;base64,",
"data:image/webp;base64,",
)
MODEL_FILE_SUFFIXES = (".safetensors", ".ckpt", ".pt", ".bin", ".sft")
STATIC_PREVIEW_EXTENSIONS = (".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif")
STATIC_PREVIEW_SUFFIXES = tuple(f".preview{extension}" for extension in STATIC_PREVIEW_EXTENSIONS)
SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$", re.IGNORECASE)
VOLATILE_WORKFLOW_KEYS = frozenset({
"seed",
"noise_seed",
"random_seed",
"variation_seed",
"batch_size",
"batch_index",
"batch_num",
"last_seed",
})
VERIFIABLE_RECIPE_MODEL_CATEGORIES = frozenset({
"checkpoint",
"lora",
"unet",
"controlnet",
"vae",
"text_encoder",
"clip_vision",
})
+349
View File
@@ -0,0 +1,349 @@
"""Contained Recipe image assets and bounded output-gallery inspection."""
from io import BytesIO
from functools import lru_cache
import hashlib
import heapq
import json
import os
import folder_paths
from PIL import Image
from .path_utils import _resolve_exact_model_reference
from .recipe_constants import *
from .utils import require_filename, resolve_within
from .workflow_schema import _node_type, _parameter_signature, _workflow_node_signature
def _recipe_assets_dir(recipes_dir, filename, create=False):
stem = os.path.splitext(require_filename(filename))[0]
assets_dir = resolve_within(recipes_dir, ".assets", stem)
if create:
os.makedirs(assets_dir, exist_ok=True)
return assets_dir
def _preview_source_path(saved_value):
resolved = _resolve_exact_model_reference(saved_value)
if not resolved:
return None
base_path = os.path.splitext(resolved["path"])[0]
for suffix in STATIC_PREVIEW_SUFFIXES + STATIC_PREVIEW_EXTENSIONS:
candidate = f"{base_path}{suffix}"
if os.path.isfile(candidate):
return candidate
return None
def _thumbnail_webp_bytes(source_path):
try:
if os.path.getsize(source_path) > MAX_PREVIEW_SOURCE_BYTES:
return None
with Image.open(source_path) as image:
image.thumbnail((320, 320))
if image.mode not in ("RGB", "RGBA"):
image = image.convert("RGBA" if "A" in image.getbands() else "RGB")
width, height = image.size
for quality in (76, 62, 48):
output = BytesIO()
image.save(output, format="WEBP", quality=quality, method=4)
data = output.getvalue()
if len(data) <= MAX_PREVIEW_SNAPSHOT_BYTES:
return data, width, height
except (OSError, ValueError):
return None
return None
def _recipe_cover_webp_bytes(source_path):
"""Create a portable recipe-cover asset without retaining the full output image."""
try:
if os.path.getsize(source_path) > MAX_RECIPE_COVER_SOURCE_BYTES:
return None
with Image.open(source_path) as image:
image.thumbnail((640, 640))
if image.mode not in ("RGB", "RGBA"):
image = image.convert("RGBA" if "A" in image.getbands() else "RGB")
width, height = image.size
for quality in (84, 72, 60, 48):
output = BytesIO()
image.save(output, format="WEBP", quality=quality, method=4)
data = output.getvalue()
if len(data) <= MAX_RECIPE_COVER_BYTES:
return data, width, height
except (OSError, ValueError):
return None
return None
def _output_source_path(source_image):
output_dir = folder_paths.get_output_directory()
target_dir = resolve_within(output_dir, source_image.get("subfolder", ""))
source_path = resolve_within(target_dir, source_image["filename"])
if not os.path.isfile(source_path):
raise FileNotFoundError
return source_path
def _store_recipe_gallery_cover(recipes_dir, filename, source_image):
source_path = _output_source_path(source_image)
cover = _recipe_cover_webp_bytes(source_path)
if not cover:
raise ValueError("Could not create recipe cover")
data, width, height = cover
asset_id = f"cover-{hashlib.sha256(data).hexdigest()}.webp"
asset_path = resolve_within(_recipe_assets_dir(recipes_dir, filename, create=True), asset_id)
if not os.path.exists(asset_path):
with open(asset_path, "wb") as asset_file:
asset_file.write(data)
return {
"asset_id": asset_id,
"media_type": "image/webp",
"width": width,
"height": height,
}
def _decode_embedded_json(value):
if isinstance(value, bytes):
if len(value) > MAX_EMBEDDED_WORKFLOW_BYTES:
return None
value = value.decode("utf-8")
if not isinstance(value, str) or len(value.encode("utf-8")) > MAX_EMBEDDED_WORKFLOW_BYTES:
return None
parsed = json.loads(value)
return parsed if isinstance(parsed, dict) else None
def _embedded_workflow_payload(image_path):
"""Read bounded workflow/prompt metadata from one PNG without loading pixels."""
try:
with Image.open(image_path) as image:
return {
"workflow": _decode_embedded_json(image.info.get("workflow")),
"prompt": _decode_embedded_json(image.info.get("prompt")),
}
except (OSError, UnicodeDecodeError, ValueError, json.JSONDecodeError):
return None
def _embedded_workflow_node_signature(image_path):
payload = _embedded_workflow_payload(image_path)
if not payload:
return None
workflow = payload.get("workflow") or payload.get("prompt")
if not workflow:
return None
return _workflow_node_signature(workflow)["value"]
def _embedded_parameter_signature(image_path):
payload = _embedded_workflow_payload(image_path)
if not payload:
return None
workflow = payload.get("workflow") or payload.get("prompt")
if not workflow:
return None
return _parameter_signature(workflow)["value"]
def _recent_output_pngs(output_dir, limit=MAX_RECIPE_GALLERY_SCAN):
"""Return a bounded newest-first PNG list without trusting request paths."""
newest = []
try:
for root, _, files in os.walk(output_dir):
for name in files:
if not name.lower().endswith(".png"):
continue
path = os.path.join(root, name)
try:
stat = os.stat(path)
except OSError:
continue
candidate = (stat.st_mtime_ns, path)
if len(newest) < limit:
heapq.heappush(newest, candidate)
elif candidate > newest[0]:
heapq.heapreplace(newest, candidate)
except OSError:
return []
return sorted(newest, reverse=True)
def _recipe_gallery_images(node_signature):
output_dir = folder_paths.get_output_directory()
if not os.path.isdir(output_dir):
return [], 0
matches = []
scanned = 0
for mtime_ns, image_path in _recent_output_pngs(output_dir):
scanned += 1
if _embedded_workflow_node_signature(image_path) != node_signature:
continue
relative_dir = os.path.relpath(os.path.dirname(image_path), output_dir)
subfolder = "" if relative_dir == "." else relative_dir.replace("\\", "/")
matches.append({
"filename": os.path.basename(image_path),
"subfolder": subfolder,
"type": "output",
"mtime": mtime_ns // 1_000_000,
})
if len(matches) >= MAX_RECIPE_GALLERY_RESULTS:
break
return matches, scanned
@lru_cache(maxsize=4096)
def _picture_parameter_signature(image_path, mtime_ns):
"""A picture's parameter signature, read once per version of the file."""
return _embedded_parameter_signature(image_path)
def _pictures_by_signature(signatures):
"""({signature: {"count", "newest": {filename, subfolder}}}, pictures read) for the recent
output pictures made with each of `signatures`, newest first, in one pass."""
output_dir = folder_paths.get_output_directory()
found = {}
if not os.path.isdir(output_dir):
return found, 0
scanned = 0
for mtime_ns, image_path in _recent_output_pngs(output_dir):
scanned += 1
signature = _picture_parameter_signature(image_path, mtime_ns)
if signature not in signatures:
continue
entry = found.setdefault(signature, {"count": 0, "newest": None})
entry["count"] += 1
if entry["newest"] is None:
relative_dir = os.path.relpath(os.path.dirname(image_path), output_dir)
entry["newest"] = {"filename": os.path.basename(image_path),
"subfolder": "" if relative_dir == "." else relative_dir.replace("\\", "/")}
return found, scanned
def _parameter_gallery_images(parameter_signature):
output_dir = folder_paths.get_output_directory()
if not os.path.isdir(output_dir):
return [], 0
matches = []
scanned = 0
for mtime_ns, image_path in _recent_output_pngs(output_dir):
scanned += 1
if _picture_parameter_signature(image_path, mtime_ns) != parameter_signature:
continue
relative_dir = os.path.relpath(os.path.dirname(image_path), output_dir)
subfolder = "" if relative_dir == "." else relative_dir.replace("\\", "/")
matches.append({
"filename": os.path.basename(image_path),
"subfolder": subfolder,
"type": "output",
"mtime": mtime_ns // 1_000_000,
})
if len(matches) >= MAX_RECIPE_GALLERY_RESULTS:
break
return matches, scanned
def _bounded_gallery_value(value, depth=0):
"""Keep image-embedded parameter details useful without echoing huge metadata."""
if depth > 5:
return "…"
if isinstance(value, str):
return value if len(value) <= 2000 else f"{value[:1997]}..."
if isinstance(value, (int, float, bool)) or value is None:
return value
if isinstance(value, list):
return [_bounded_gallery_value(item, depth + 1) for item in value[:64]]
if isinstance(value, dict):
return {
str(key): _bounded_gallery_value(nested, depth + 1)
for key, nested in list(value.items())[:96]
}
return str(value)[:2000]
def _gallery_parameter_records(workflow):
if not isinstance(workflow, dict):
return []
nodes = workflow.get("nodes")
is_ui_workflow = isinstance(nodes, list)
values = nodes if is_ui_workflow else workflow.values()
records = []
occurrence = {}
for node in values:
if not isinstance(node, dict):
continue
node_type = _node_type(node) or str(node.get("class_type") or "").strip()
if not node_type:
continue
occurrence[node_type] = occurrence.get(node_type, 0) + 1
if is_ui_workflow:
parameters = {"widgets": _bounded_gallery_value(node.get("widgets_values", []))}
node_id = node.get("id")
else:
parameters = {"inputs": _bounded_gallery_value(node.get("inputs", {}))}
node_id = None
records.append({
"type": node_type,
"index": occurrence[node_type],
"node_id": node_id,
"parameters": parameters,
})
return records
def _gallery_parameter_diff(recipe_workflow, image_payload):
recipe_records = _gallery_parameter_records(recipe_workflow)
embedded_workflow = image_payload.get("workflow") or image_payload.get("prompt") if image_payload else None
image_records = _gallery_parameter_records(embedded_workflow)
recipe_map = {(item["type"], item["index"]): item for item in recipe_records}
image_map = {(item["type"], item["index"]): item for item in image_records}
changes = []
for key in sorted(set(recipe_map) | set(image_map), key=lambda item: (item[0].casefold(), item[1])):
recipe_item = recipe_map.get(key)
image_item = image_map.get(key)
if recipe_item is None or image_item is None or recipe_item["parameters"] != image_item["parameters"]:
changes.append({
"type": key[0],
"index": key[1],
"recipe": recipe_item["parameters"] if recipe_item else None,
"image": image_item["parameters"] if image_item else None,
})
return {
"recipe_nodes": recipe_records,
"image_nodes": image_records,
"changes": changes,
"comparable": bool(image_records) and all(
isinstance(item.get("workflow"), dict) for item in [image_payload or {}]
),
}
def _attach_preview_snapshots(recipe, recipes_dir, filename, references):
if not recipe.get("presentation", {}).get("save_model_preview_snapshots"):
return
assets_dir = _recipe_assets_dir(recipes_dir, filename, create=True)
total_bytes = 0
for reference in references[:MAX_PREVIEW_SNAPSHOTS]:
source_path = _preview_source_path(reference.get("saved_value"))
if not source_path:
continue
thumbnail = _thumbnail_webp_bytes(source_path)
if not thumbnail:
continue
data, width, height = thumbnail
if total_bytes + len(data) > MAX_PREVIEW_SNAPSHOT_TOTAL_BYTES:
break
asset_id = f"{hashlib.sha256(data).hexdigest()}.webp"
asset_path = resolve_within(assets_dir, asset_id)
if not os.path.exists(asset_path):
with open(asset_path, "wb") as asset_file:
asset_file.write(data)
total_bytes += len(data)
reference["preview"] = {
"snapshot_asset_id": asset_id,
"media_type": "image/webp",
"width": width,
"height": height,
"captured_at": recipe["timestamp"],
}
+65 -71
View File
@@ -1,4 +1,7 @@
"""Bounded import/export for self-contained Workflow Recipe packages."""
"""Recipe packages (配方包): one Workflow Recipe as a .zip to give to someone else — the recipe,
its cover, its models' thumbnails and, when asked, its versions — and taking one in as a new
recipe card. Bounded: entry names, count, sizes, compression ratio and checksums are checked
before anything is written; an import never replaces a recipe (a taken name gets " (2)")."""
import hashlib
import io
@@ -9,13 +12,14 @@ import shutil
import stat
import tempfile
import time
import urllib.parse
import uuid
import zipfile
from aiohttp import web
from . import recipes as recipe_store
from .recipes import get_recipes_dir
from . import recipe_images, recipe_schema, recipe_store
from .recipe_store import get_recipes_dir
from .utils import require_filename, resolve_within
@@ -130,6 +134,18 @@ def _cover_asset_id(recipe):
return require_filename(asset_id)
def _picture_summary(recipe):
"""What a package shows the receiver: (has a cover, how many model thumbnails). The cover
is usually the small image inside recipe.json (`thumbnail`), not an asset file."""
thumbnail = recipe.get("thumbnail")
has_cover = bool(_cover_asset_id(recipe)) or (
isinstance(thumbnail, str) and thumbnail.lower().startswith(recipe_schema.SAFE_THUMBNAIL_PREFIXES))
references = recipe.get("params", {}).get("model_references", [])
models = {_asset_id_from_reference(item) for item in references if isinstance(item, dict)} if isinstance(references, list) else set()
models.discard(None)
return has_cover, len(models)
def _validate_manifest(manifest, archive, names):
if not isinstance(manifest, dict) or manifest.get("package_version") != PACKAGE_VERSION:
raise ValueError("Unsupported recipe package version")
@@ -155,12 +171,14 @@ def _validate_manifest(manifest, archive, names):
raise ValueError("Package checksum mismatch")
def _sanitize_recipe_for_export(recipe, include_snapshots=True, include_identity=True):
def _sanitize_recipe_for_export(recipe, include_snapshots=True, include_identity=True, include_model_notes=True):
value = json.loads(json.dumps(recipe, ensure_ascii=False))
# A source_image points into the exporting machine's output directory and
# is not portable package data. The bounded thumbnail remains the cover.
value["source_image"] = None
for reference in value.get("params", {}).get("model_references", []):
if not include_model_notes:
reference.pop("user_note", None)
if not include_snapshots:
reference.pop("preview", None)
if not include_identity:
@@ -206,10 +224,11 @@ def _recipe_history_files(recipes_dir, filename):
def _build_export(raw_recipe, recipes_dir, filename, options):
include_snapshots = options.get("include_snapshots") is True
include_snapshots = options.get("include_snapshots", True) is True
include_history = options.get("include_history") is True
include_identity = options.get("include_identity", True) is True
recipe = _sanitize_recipe_for_export(raw_recipe, include_snapshots, include_identity)
include_model_notes = options.get("include_model_notes", True) is True
recipe = _sanitize_recipe_for_export(raw_recipe, include_snapshots, include_identity, include_model_notes)
history_recipes = []
if include_history:
for history_name in _recipe_history_files(recipes_dir, filename):
@@ -219,7 +238,12 @@ def _build_export(raw_recipe, recipes_dir, filename, options):
if len(data) > MAX_ENTRY_BYTES:
raise ValueError("Historical recipe is too large")
history_recipe = _parse_json(data, "historical recipe")
history_recipes.append((history_name, _sanitize_recipe_for_export(history_recipe, include_snapshots, include_identity)))
history_recipes.append((history_name, _sanitize_recipe_for_export(
history_recipe,
include_snapshots,
include_identity,
include_model_notes,
)))
asset_ids = _referenced_asset_ids(recipe, include_snapshots)
for _, history_recipe in history_recipes:
asset_ids.update(_referenced_asset_ids(history_recipe, include_snapshots))
@@ -230,7 +254,7 @@ def _build_export(raw_recipe, recipes_dir, filename, options):
_add_zip_entry(archive, entries, "recipe.json", recipe_bytes, "application/json")
if asset_ids:
assets_dir = recipe_store._recipe_assets_dir(recipes_dir, filename)
assets_dir = recipe_images._recipe_assets_dir(recipes_dir, filename)
for asset_id in sorted(asset_ids):
asset_path = resolve_within(assets_dir, asset_id)
if not os.path.isfile(asset_path):
@@ -254,6 +278,7 @@ def _build_export(raw_recipe, recipes_dir, filename, options):
"include_snapshots": include_snapshots,
"include_history": include_history,
"include_identity": include_identity,
"include_model_notes": include_model_notes,
},
"entries": entries,
}
@@ -284,14 +309,14 @@ def _inspect_package(raw):
manifest = _parse_json(_read_zip_entry(archive, "manifest.json"), "manifest")
_validate_manifest(manifest, archive, names)
recipe = _parse_json(_read_zip_entry(archive, "recipe.json"), "recipe")
recipe_store._normalise_recipe(recipe)
recipe_schema._normalise_recipe(recipe)
history_names = [name for name in names if name.startswith("history/")]
if len(history_names) > MAX_HISTORY_ENTRIES:
raise ValueError("Too many history entries")
history_recipes = []
for name in history_names:
history = _parse_json(_read_zip_entry(archive, name), "historical recipe")
recipe_store._normalise_recipe(history)
recipe_schema._normalise_recipe(history)
history_recipes.append(history)
_validate_recipe_assets(archive, [recipe, *history_recipes], names)
return {
@@ -359,42 +384,26 @@ def _write_staged_json(path, value):
def _commit_import(record, payload):
"""Writes the inspected package as a new recipe (never over one): (filename, name)."""
recipes_dir = get_recipes_dir()
package_recipe = json.loads(json.dumps(record["recipe"], ensure_ascii=False))
collision = payload.get("collision", "rename")
name_override = payload.get("name")
if name_override is not None:
if not isinstance(name_override, str) or not name_override.strip():
raise ValueError("Invalid imported recipe name")
package_recipe["name"] = name_override.strip()
package_recipe["name"] = _unique_name(package_recipe.get("name", ""), _existing_recipe_names(recipes_dir))
existing_names = _existing_recipe_names(recipes_dir)
target_filename = None
if collision == "replace":
target_filename = require_filename(payload.get("target_filename", ""))
if not target_filename.endswith(".json"):
raise ValueError("Invalid replacement target")
if not os.path.isfile(resolve_within(recipes_dir, target_filename)):
raise FileNotFoundError
elif collision != "rename":
raise ValueError("Unsupported collision choice")
else:
package_recipe["name"] = _unique_name(package_recipe.get("name", ""), existing_names)
normalized = recipe_store._normalise_recipe(package_recipe)
normalized = recipe_schema._normalise_recipe(package_recipe)
normalized["presentation"]["imported"] = True
filename = target_filename or f"recipe_{int(time.time())}_{uuid.uuid4().hex[:8]}.json"
normalized = recipe_store._enrich_recipe(normalized)
filename = f"recipe_{int(time.time())}_{uuid.uuid4().hex[:8]}.json"
normalized = recipe_schema._enrich_recipe(normalized)
staging = tempfile.mkdtemp(prefix=".recipe-import-", dir=recipes_dir)
final_path = resolve_within(recipes_dir, filename)
final_assets = recipe_store._recipe_assets_dir(recipes_dir, filename)
final_assets = recipe_images._recipe_assets_dir(recipes_dir, filename)
final_history = recipe_store._history_dir(recipes_dir, filename)
backup_recipe = None
backup_assets = None
backup_history = None
installed_assets = False
installed_history = False
installed = []
try:
archive, _ = _validate_zip(record["raw"])
with archive:
@@ -406,47 +415,32 @@ def _commit_import(record, payload):
for name in record["history_names"]:
history = _parse_json(_read_zip_entry(archive, name), "historical recipe")
_write_staged_json(os.path.join(history_stage, require_filename(name.split("/", 1)[1])), history)
if collision == "replace":
backup_recipe = os.path.join(staging, "old-recipe.json")
os.replace(final_path, backup_recipe)
if os.path.isdir(final_assets):
backup_assets = os.path.join(staging, "old-assets")
shutil.move(final_assets, backup_assets)
if record["history_names"] and os.path.isdir(final_history):
backup_history = os.path.join(staging, "old-history")
shutil.move(final_history, backup_history)
os.replace(os.path.join(staging, "recipe.json"), final_path)
# The images first, the recipe last: a recipe card never shows without its pictures.
staged_assets = os.path.join(staging, "assets")
if os.path.isdir(staged_assets):
if os.listdir(staged_assets):
os.makedirs(os.path.dirname(final_assets), exist_ok=True)
os.replace(staged_assets, final_assets)
installed_assets = True
installed.append(final_assets)
if record["history_names"]:
history_stage = os.path.join(staging, "history")
if os.path.isdir(final_history):
shutil.rmtree(final_history)
os.replace(history_stage, final_history)
installed_history = True
os.makedirs(os.path.dirname(final_history), exist_ok=True)
os.replace(os.path.join(staging, "history"), final_history)
installed.append(final_history)
os.replace(os.path.join(staging, "recipe.json"), final_path)
except Exception:
if collision == "replace" and backup_recipe and os.path.isfile(backup_recipe):
if installed_assets and os.path.isdir(final_assets):
shutil.rmtree(final_assets, ignore_errors=True)
if installed_history and os.path.isdir(final_history):
shutil.rmtree(final_history, ignore_errors=True)
if os.path.isfile(final_path):
os.remove(final_path)
os.replace(backup_recipe, final_path)
if backup_assets and os.path.isdir(backup_assets):
shutil.move(backup_assets, final_assets)
if backup_history and os.path.isdir(backup_history):
shutil.move(backup_history, final_history)
for path in installed: # this import's own new folders; nothing of the user's
shutil.rmtree(path, ignore_errors=True)
raise
finally:
shutil.rmtree(staging, ignore_errors=True)
return filename, normalized.get("name", "")
def _download_name(recipe, filename):
"""The .zip's name for the browser: the recipe's name, safe on every system."""
name = "".join("_" if char in '<>:"/\\|?*' or ord(char) < 32 else char for char in str(recipe.get("name") or "")).strip(" .")
return f"{name or os.path.splitext(filename)[0]}.anomalous-recipe.zip"
async def api_export_recipe_package(request):
try:
payload = await request.json()
@@ -462,11 +456,10 @@ async def api_export_recipe_package(request):
return web.json_response({"status": "error", "message": "Recipe not found"}, status=404)
except OSError:
return web.json_response({"status": "error", "message": "Could not export recipe"}, status=500)
return web.Response(
body=package,
content_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{os.path.splitext(filename)[0]}.anomalous-recipe.zip"'},
)
name = _download_name(recipe, filename)
return web.Response(body=package, content_type="application/zip", headers={
"Content-Disposition": f"attachment; filename=\"recipe.anomalous-recipe.zip\"; filename*=UTF-8''{urllib.parse.quote(name)}",
})
async def api_import_recipe_package_inspect(request):
@@ -483,8 +476,11 @@ async def api_import_recipe_package_inspect(request):
"token": token,
"recipe": {"name": report["recipe"].get("name", ""), "tags": report["recipe"].get("tags", [])},
"asset_count": len(report["asset_names"]),
"has_cover": _picture_summary(report["recipe"])[0],
"model_pictures": _picture_summary(report["recipe"])[1],
"history_count": len(report["history_names"]),
"existing_names": _existing_recipe_names(get_recipes_dir()),
"imported_name": _unique_name(report["recipe"].get("name", ""), _existing_recipe_names(get_recipes_dir())),
})
@@ -500,8 +496,6 @@ async def api_import_recipe_package_commit(request):
filename, name = await asyncio.to_thread(_commit_import, record, payload)
except (AttributeError, TypeError, ValueError, json.JSONDecodeError):
return web.json_response({"status": "error", "message": "Could not commit recipe package"}, status=400)
except FileNotFoundError:
return web.json_response({"status": "error", "message": "Replacement recipe not found"}, status=404)
except OSError:
return web.json_response({"status": "error", "message": "Could not commit recipe package"}, status=500)
return web.json_response({"status": "success", "filename": filename, "name": name})
+439
View File
@@ -0,0 +1,439 @@
"""Workflow Recipe record normalization, model references, and enrichment."""
import hashlib
import json
import os
import re
import time
import folder_paths
from .metadata import get_metadata
from .recipe_constants import *
from .path_utils import _model_roots, _resolve_exact_model_reference
from .recipe_images import _attach_preview_snapshots
from .utils import require_filename, resolve_within
from .workflow_schema import (
_node_title, _node_type, _parameter_signature, _validate_workflow,
_widget_values, _workflow_fingerprint,
)
# All-in-one loaders with a verified serialized widget layout. Must stay in sync
# with ALL_IN_ONE_LOADER_SPECS in web/modules/recipe_identity.js.
ALL_IN_ONE_LOADER_SPECS = {
# ComfyUI-Easy-Use: ckpt_name, vae_name, clip_skip, lora_name, ...
"easy a1111loader": ((0, "checkpoint", "ckpt_name"), (1, "vae", "vae_name"), (3, "lora", "lora_name")),
# ComfyUI-Easy-Use: ckpt_name, config_name, vae_name, clip_skip, lora_name, ...
"easy fullloader": ((0, "checkpoint", "ckpt_name"), (2, "vae", "vae_name"), (4, "lora", "lora_name")),
# efficiency-nodes-comfyui: ckpt_name, vae_name, clip_skip, lora_name, ...
"efficient loader": ((0, "checkpoint", "ckpt_name"), (1, "vae", "vae_name"), (3, "lora", "lora_name")),
}
PLACEHOLDER_MODEL_VALUES = {"none", "baked vae"}
POWER_LORA_TYPE = "power lora loader (rgthree)"
EASY_LORA_STACK_TYPE = "easy lorastack"
EASY_STACK_ROWS = 10
def _model_reference_specs(node):
"""Known loader adapters only; arbitrary third-party widgets stay parameters."""
node_type = _node_type(node)
lowered = node_type.lower().strip()
specs = []
if lowered in ALL_IN_ONE_LOADER_SPECS:
specs.extend(ALL_IN_ONE_LOADER_SPECS[lowered])
elif re.search(r"checkpointloader(simple)?$", lowered):
specs.append((0, "checkpoint", "checkpoint"))
elif lowered.endswith("unetloader"):
specs.append((0, "unet", "unet"))
elif re.search(r"loraloader", lowered):
specs.append((0, "lora", "lora"))
elif lowered.endswith("vaeloader"):
specs.append((0, "vae", "vae"))
elif lowered.endswith("clipvisionloader"):
specs.append((0, "clip_vision", "clip_vision"))
elif lowered.endswith("controlnetloader"):
specs.append((0, "controlnet", "controlnet"))
elif re.search(r"(?:^|[^a-z])(?:dual|triple)?cliploader$", lowered):
specs.append((0, "text_encoder", "clip"))
if "dualclip" in lowered or "tripleclip" in lowered:
specs.append((1, "text_encoder", "clip"))
if "tripleclip" in lowered:
specs.append((2, "text_encoder", "clip"))
return specs
def _model_reference_values(node):
"""(widget_index, category, widget_name, saved_value) for every model a saved node names: known
loaders by position, and two stack nodes by their rows: rgthree's Power Lora Loader (a row is a
dict {on, lora, strength}; switched-off rows load nothing) and Easy-Use's LoRA stack
(lora_N_name at 3 + 4*(N-1), up to num_loras). Must stay in sync with nodeModelValues in
web/modules/recipe_identity.js."""
values = _widget_values(node)
lowered = _node_type(node).lower().strip()
if lowered == POWER_LORA_TYPE:
rows = [(index, value) for index, value in enumerate(values) if isinstance(value, dict) and "lora" in value]
return [(index, "lora", f"lora_{row}", value.get("lora"))
for row, (index, value) in enumerate(rows, 1) if value.get("on") is not False]
if lowered == EASY_LORA_STACK_TYPE:
try:
count = max(0, min(int(values[2]), EASY_STACK_ROWS))
except (IndexError, TypeError, ValueError):
count = 0
return [(3 + 4 * row, "lora", f"lora_{row + 1}_name", values[3 + 4 * row] if 3 + 4 * row < len(values) else None)
for row in range(count)]
return [(index, category, name, values[index] if index < len(values) else None)
for index, category, name in _model_reference_specs(node)]
def _identity_for_reference(saved_value):
resolved = _resolve_exact_model_reference(saved_value)
if not resolved:
return {"status": "unavailable"}, None
identity = {"status": "unverified", "provenance": "local cached metadata"}
origin = None
try:
identity["size"] = os.path.getsize(resolved["path"])
except OSError:
pass
try:
metadata = get_metadata(resolved["path"])
candidate_hash = str(metadata.get("hash") or "").strip()
if SHA256_PATTERN.fullmatch(candidate_hash):
identity["status"] = "verified"
identity["sha256"] = candidate_hash.lower()
identity["provenance"] = metadata.get("hash_source") or "sidecar file SHA-256"
civitai_url = metadata.get("civitai_url")
if civitai_url:
origin = {
"provider": "civitai",
"model_name": metadata.get("name") or "",
"model_url": civitai_url,
"model_id": metadata.get("model_id") or "",
"version_id": metadata.get("version_id") or ""
}
except Exception:
pass
return identity, origin
def _computed_identity_for_reference(saved_value):
"""Compute an exact SHA-256 only after an explicit save-time opt-in."""
resolved = _resolve_exact_model_reference(saved_value)
if not resolved:
return {"status": "unavailable"}
digest = hashlib.sha256()
try:
with open(resolved["path"], "rb") as model_file:
for block in iter(lambda: model_file.read(4 * 1024 * 1024), b""):
digest.update(block)
return {
"status": "verified",
"sha256": digest.hexdigest(),
"size": os.path.getsize(resolved["path"]),
"provenance": "computed during recipe save",
}
except OSError:
return {"status": "unverified"}
def _workflow_identity_for_reference(workflow, node_id, saved_value):
extra = workflow.get("extra") if isinstance(workflow, dict) else None
hashes = extra.get("anomalous_hashes") if isinstance(extra, dict) else None
if not isinstance(hashes, dict) or not isinstance(saved_value, str):
return None
normalized = saved_value.replace("\\", "/")
windows_path = saved_value.replace("/", "\\")
keys = (
f"{node_id}_{saved_value}",
f"{node_id}_{normalized}",
f"{node_id}_{windows_path}",
saved_value,
normalized,
windows_path,
)
record = next((hashes[key] for key in keys if key in hashes), None)
candidate_hash = record if isinstance(record, str) else record.get("hash") if isinstance(record, dict) else None
if not isinstance(candidate_hash, str) or not SHA256_PATTERN.fullmatch(candidate_hash.strip()):
return None
identity = {
"status": "verified",
"sha256": candidate_hash.strip().lower(),
"provenance": "workflow snapshot",
}
if isinstance(record, dict):
try:
identity["size"] = int(record.get("size"))
except (TypeError, ValueError):
pass
return identity
def _build_model_references(recipe, verify_identities=False):
workflow = recipe.get("workflow") if isinstance(recipe, dict) else None
params = recipe.get("params") if isinstance(recipe, dict) else None
base_model = params.get("baseModel") if isinstance(params, dict) else None
references = []
for node in workflow.get("nodes", []) if isinstance(workflow, dict) else []:
if not isinstance(node, dict):
continue
for widget_index, category, widget_name, saved_value in _model_reference_values(node):
if not isinstance(saved_value, str) or not saved_value.strip():
continue
if saved_value.strip().lower() in PLACEHOLDER_MODEL_VALUES:
continue
identity_result = _identity_for_reference(saved_value)
if isinstance(identity_result, tuple):
identity, origin = (identity_result + (None,))[:2]
else:
# Keep compatibility with older callers/tests that provide the
# pre-origin helper contract and return identity only.
identity, origin = identity_result, None
workflow_identity = _workflow_identity_for_reference(workflow, node.get("id"), saved_value)
if workflow_identity:
identity = workflow_identity
elif (
verify_identities
and category in VERIFIABLE_RECIPE_MODEL_CATEGORIES
and identity.get("status") != "verified"
):
identity = _computed_identity_for_reference(saved_value)
ref_dict = {
"node_id": node.get("id"),
"node_type": _node_type(node) or "Unknown",
"node_title": _node_title(node),
"widget_index": widget_index,
"widget_name": widget_name,
"saved_value": saved_value,
"category": category,
"base_model": base_model,
"identity": identity,
}
if origin:
ref_dict["origin"] = origin
references.append(ref_dict)
return references
def _model_reference_key(reference):
if not isinstance(reference, dict):
return None
return (
reference.get("node_id"),
reference.get("widget_index"),
reference.get("category"),
reference.get("saved_value"),
)
def _preserve_model_reference_fields(previous_references, references, preserve_identity=True):
"""Carry package/history presentation and identity without trusting names."""
previous = {
_model_reference_key(reference): reference
for reference in previous_references or []
if _model_reference_key(reference) is not None
and isinstance(reference, dict)
}
for reference in references:
prior = previous.get(_model_reference_key(reference))
if prior is None:
continue
for field in ("identity", "origin", "preview"):
if field == "preview" or (field in ("identity", "origin") and preserve_identity):
if isinstance(prior.get(field), dict):
reference[field] = json.loads(json.dumps(prior[field], ensure_ascii=False))
user_note = prior.get("user_note")
if isinstance(user_note, str) and user_note.strip():
reference["user_note"] = user_note.strip()
def _enrich_recipe(
recipe,
recipes_dir=None,
filename=None,
recapture_previews=True,
refresh_identities=False,
verify_identities=False,
):
recipe["workflow_fingerprint"] = _workflow_fingerprint(recipe["workflow"])
# Keep the exact parameter-match identity alongside the structural
# fingerprint. The detail panel can use this value for result discovery
# without asking the browser to recreate or upload a legacy notebook.
recipe["parameter_signature"] = _parameter_signature(recipe["workflow"])
params = dict(recipe.get("params") or {})
previous_references = params.get("model_references", [])
references = _build_model_references(recipe, verify_identities=verify_identities)
_preserve_model_reference_fields(
previous_references,
references,
preserve_identity=not (refresh_identities or verify_identities),
)
if recipes_dir and filename and recapture_previews:
_attach_preview_snapshots(recipe, recipes_dir, filename, references)
params["model_references"] = references
recipe["params"] = params
if recipe.get("workflow_scope") not in {"partial", "complete"}:
recipe["workflow_scope"] = "complete"
recipe["schema_version"] = 7
encoded = json.dumps(recipe, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
if len(encoded) > MAX_RECIPE_BYTES:
raise ValueError("Recipe is too large")
return recipe
def _normalise_source_image(value):
if value is None:
return None
if not isinstance(value, dict) or value.get("type") != "output":
raise ValueError("Invalid source image")
filename = require_filename(value.get("filename", ""))
subfolder = value.get("subfolder", "")
if not isinstance(subfolder, str) or len(subfolder) > MAX_SOURCE_SUBFOLDER_LENGTH:
raise ValueError("Invalid source image")
output_dir = folder_paths.get_output_directory()
resolve_within(output_dir, subfolder)
return {
"filename": filename,
"subfolder": subfolder,
"type": "output",
}
def _normalise_focus_params(entries):
"""A workflow's focus values (重点参数): [{node, type, widget, note}], all short text; anything else is refused."""
if not isinstance(entries, list) or len(entries) > MAX_FOCUS_PARAMS:
raise ValueError("Invalid recipe focus values")
kept = []
seen = set()
for entry in entries:
if not isinstance(entry, dict):
raise ValueError("Invalid recipe focus value")
node, node_type, widget, note = (entry.get(key, "") for key in ("node", "type", "widget", "note"))
if not all(isinstance(value, str) for value in (node, node_type, widget, note)):
raise ValueError("Invalid recipe focus value")
if (
not node or not widget or len(node) > 32
or max(len(node_type), len(widget)) > MAX_FOCUS_NAME_LENGTH
or len(note) > MAX_FOCUS_NOTE_LENGTH
):
raise ValueError("Invalid recipe focus value")
if (node, widget) in seen:
continue
seen.add((node, widget))
kept.append({"node": node, "type": node_type, "widget": widget, "note": note.strip()})
return kept
def _normalise_recipe(payload):
if not isinstance(payload, dict):
raise ValueError("Invalid recipe")
name = payload.get("name", "")
if not isinstance(name, str) or not (name := name.strip()) or len(name) > MAX_NAME_LENGTH:
raise ValueError("Invalid recipe name")
raw_tags = payload.get("tags", [])
if not isinstance(raw_tags, list):
raise ValueError("Invalid recipe tags")
tags = []
for tag in raw_tags:
if not isinstance(tag, str):
raise ValueError("Invalid recipe tag")
tag = tag.strip()
if not tag or len(tag) > MAX_TAG_LENGTH:
continue
if tag not in tags:
tags.append(tag)
if len(tags) >= MAX_TAGS:
break
notes = payload.get("notes", "")
if not isinstance(notes, str) or len(notes) > MAX_NOTES_LENGTH:
raise ValueError("Invalid recipe notes")
params = payload.get("params", {})
workflow = payload.get("workflow")
if not isinstance(params, dict) or not isinstance(workflow, dict):
raise ValueError("Invalid recipe workflow")
model_references = params.get("model_references")
if model_references is not None:
if not isinstance(model_references, list):
raise ValueError("Invalid recipe model references")
for reference in model_references:
if not isinstance(reference, dict):
raise ValueError("Invalid recipe model reference")
user_note = reference.get("user_note")
if user_note is None:
continue
if not isinstance(user_note, str) or len(user_note) > MAX_MODEL_NOTE_LENGTH:
raise ValueError("Invalid recipe model note")
if user_note.strip():
reference["user_note"] = user_note.strip()
else:
reference.pop("user_note", None)
if "focusParams" in params:
params["focusParams"] = _normalise_focus_params(params["focusParams"])
_validate_workflow(workflow)
workflow_scope = payload.get("workflow_scope", "complete")
if workflow_scope not in {"partial", "complete"}:
raise ValueError("Invalid recipe workflow scope")
thumbnail = payload.get("thumbnail")
if thumbnail is not None:
if (
not isinstance(thumbnail, str)
or len(thumbnail) > MAX_THUMBNAIL_LENGTH
or not thumbnail.lower().startswith(SAFE_THUMBNAIL_PREFIXES)
):
thumbnail = None
presentation = payload.get("presentation", {})
if presentation is None:
presentation = {}
if not isinstance(presentation, dict):
raise ValueError("Invalid recipe presentation")
save_model_preview_snapshots = presentation.get("save_model_preview_snapshots", True)
if not isinstance(save_model_preview_snapshots, bool):
raise ValueError("Invalid recipe presentation")
cover_asset_id = presentation.get("cover_asset_id")
if cover_asset_id is not None:
cover_asset_id = require_filename(cover_asset_id)
if not cover_asset_id.startswith("cover-") or not cover_asset_id.endswith(".webp"):
raise ValueError("Invalid recipe presentation")
recipe = {
"schema_version": 1,
"name": name,
"tags": tags,
"notes": notes.strip(),
"params": params,
"workflow": workflow,
"workflow_scope": workflow_scope,
"thumbnail": thumbnail,
"source_image": _normalise_source_image(payload.get("source_image")),
"presentation": {
"save_model_preview_snapshots": save_model_preview_snapshots,
**({"cover_asset_id": cover_asset_id} if cover_asset_id else {}),
},
"timestamp": int(time.time() * 1000),
}
encoded = json.dumps(recipe, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
if len(encoded) > MAX_RECIPE_BYTES:
raise ValueError("Recipe is too large")
return recipe
def _updated_recipe(payload, existing):
recipe = _normalise_recipe(payload)
existing_presentation = existing.get("presentation") if isinstance(existing.get("presentation"), dict) else {}
existing_cover = existing_presentation.get("cover_asset_id")
source_unchanged = recipe.get("source_image") == existing.get("source_image")
if existing_cover and source_unchanged and not recipe["presentation"].get("cover_asset_id"):
recipe["presentation"]["cover_asset_id"] = existing_cover
created_timestamp = existing.get("created_timestamp", existing.get("timestamp"))
if isinstance(created_timestamp, int):
recipe["created_timestamp"] = created_timestamp
recipe["updated_timestamp"] = recipe["timestamp"]
return recipe
+139
View File
@@ -0,0 +1,139 @@
"""User-owned Workflow Recipe records and bounded history persistence."""
import json
import os
import shutil
import tempfile
import time
import uuid
import folder_paths
from .recipe_constants import MAX_HISTORY_VERSIONS
from .recipe_images import _recipe_assets_dir
from .trash import move_to_trash
from .utils import require_filename, resolve_within
def get_recipes_dir():
"""Return the user-owned recipe directory, creating it if needed."""
user_dir = (
folder_paths.get_user_directory()
if hasattr(folder_paths, "get_user_directory")
else os.path.join(folder_paths.base_path, "user", "default")
)
recipes_dir = os.path.join(user_dir, "workflows", "anomalous_recipes")
os.makedirs(recipes_dir, exist_ok=True)
return os.path.realpath(recipes_dir)
def _read_recipe(path):
with open(path, "r", encoding="utf-8") as recipe_file:
return json.load(recipe_file)
def _list_recipes(recipes_dir):
recipes = []
try:
with os.scandir(recipes_dir) as entries:
for entry in entries:
if not entry.is_file() or not entry.name.endswith(".json"):
continue
try:
filename = require_filename(entry.name)
data = _read_recipe(resolve_within(recipes_dir, filename))
if not isinstance(data, dict):
continue
# The graph can be much larger than all card data combined.
summary = {key: value for key, value in data.items() if key != "workflow"}
recipes.append({"filename": filename, "data": summary})
except (OSError, ValueError, json.JSONDecodeError):
continue
except OSError:
return []
recipes.sort(key=lambda item: item["data"].get("timestamp", 0), reverse=True)
return recipes
def _write_recipe(recipes_dir, filename, recipe):
path = resolve_within(recipes_dir, filename)
fd, temp_path = tempfile.mkstemp(prefix=".recipe-", suffix=".tmp", dir=recipes_dir)
try:
with os.fdopen(fd, "w", encoding="utf-8") as recipe_file:
json.dump(recipe, recipe_file, ensure_ascii=False, separators=(",", ":"))
recipe_file.flush()
os.fsync(recipe_file.fileno())
os.replace(temp_path, path)
finally:
if os.path.exists(temp_path):
try:
os.remove(temp_path)
except OSError:
pass
def _history_dir(recipes_dir, filename, create=False):
"""Return the contained, user-data-only history directory for one recipe."""
stem = os.path.splitext(require_filename(filename))[0]
history_dir = resolve_within(recipes_dir, ".history", stem)
if create:
os.makedirs(history_dir, exist_ok=True)
return history_dir
def _archive_recipe(recipes_dir, filename, recipe):
"""Atomically retain a bounded pre-update snapshot before replacing a recipe."""
history_dir = _history_dir(recipes_dir, filename, create=True)
version_name = f"version_{int(time.time() * 1000)}_{uuid.uuid4().hex[:8]}.json"
_write_recipe(history_dir, version_name, recipe)
entries = []
with os.scandir(history_dir) as scan:
for entry in scan:
if entry.is_file() and entry.name.endswith(".json"):
entries.append(entry)
entries.sort(key=lambda entry: entry.stat().st_mtime_ns, reverse=True)
for entry in entries[MAX_HISTORY_VERSIONS:]:
try:
os.remove(resolve_within(history_dir, entry.name))
except OSError:
pass
def _list_recipe_history(recipes_dir, filename):
history_dir = _history_dir(recipes_dir, filename)
versions = []
try:
with os.scandir(history_dir) as entries:
for entry in entries:
if not entry.is_file() or not entry.name.endswith(".json"):
continue
try:
version = require_filename(entry.name)
data = _read_recipe(resolve_within(history_dir, version))
if not isinstance(data, dict):
continue
versions.append({
"version": version,
"timestamp": data.get("timestamp", 0),
"name": data.get("name", ""),
"workflow_fingerprint": data.get("workflow_fingerprint"),
"model_reference_count": len(
(data.get("params") or {}).get("model_references", [])
if isinstance(data.get("params"), dict)
else []
),
})
except (OSError, ValueError, json.JSONDecodeError):
continue
except OSError:
return []
versions.sort(key=lambda item: item["timestamp"], reverse=True)
return versions
def _delete_recipe_with_history(recipes_dir, filename):
"""Moves an explicitly selected recipe, its history and its images to the Recycle Bin."""
path = resolve_within(recipes_dir, filename)
if not os.path.isfile(path):
raise FileNotFoundError(path)
move_to_trash(path, _history_dir(recipes_dir, filename), _recipe_assets_dir(recipes_dir, filename))
+116 -989
View File
File diff suppressed because it is too large Load Diff
+74
View File
@@ -0,0 +1,74 @@
"""The user's choice to leave a model out of scans, or keep it in: POST /anomalous/scan_choice,
kept in each model's <model>.anomalous.json ("scan"; model_identity.scan_choice reads it). The
scan page asks it about new models that look like no drawing model; a model's details change it."""
import asyncio
import os
from aiohttp import web
from .local_guard import may_change
from .path_utils import atomic_write_json, resolve_folder_subdir
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
def _set_scan_choice(targets, choice):
"""Writes the user's scan choice into each picked model's <model>.anomalous.json ("scan":
"skip" or "scan"; "" removes it). `targets`: [{type, path_idx, subfolder, files}] as a scan
takes them. Returns (models changed, file names left as they were: not found, not a
.safetensors, or an unreadable .anomalous.json)."""
if choice not in ("skip", "scan", "") or not isinstance(targets, list):
raise ValueError("Invalid scan choice")
picked = []
for target in targets:
names = target.get("files") if isinstance(target, dict) else None
if not isinstance(names, list) or not all(isinstance(name, str) for name in names):
raise ValueError("each target needs a list of file names")
_, target_dir = resolve_folder_subdir(target.get("type"), int(target.get("path_idx", 0)), target.get("subfolder") or "/")
picked += [(target_dir, name) for name in names]
if len(picked) > MAX_CHOICE_FILES:
raise ValueError("Too many models")
changed, failed = 0, []
for target_dir, name in picked:
try:
path = model_file_within(target_dir, name)
except ValueError:
failed.append(name)
continue
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)
continue
user = user or {"format": 1}
if choice:
user["scan"] = choice
else:
user.pop("scan", None)
atomic_write_json(user_file, user)
changed += 1
return changed, failed
async def api_set_scan_choice(request):
"""POST /anomalous/scan_choice {targets, choice} - leave models out of scans ("skip"), keep
them in ("scan"), or forget the choice (""). Only from this computer (local_guard.may_change)."""
if not may_change(request):
return web.json_response({"status": "error", "message": "Only from this computer"}, status=403)
try:
data = await request.json()
changed, failed = await asyncio.to_thread(_set_scan_choice, data.get("targets"), data.get("choice", ""))
except (AttributeError, KeyError, TypeError, ValueError):
return web.json_response({"status": "error", "message": "Invalid scan choice"}, status=400)
except OSError:
return web.json_response({"status": "error", "message": "Could not save the choice"}, status=500)
return web.json_response({"status": "success", "changed": changed, "failed": failed})
+169
View File
@@ -0,0 +1,169 @@
"""What a scan did, model by model.
The scraper appends one JSON line per model it changed to a report file (--report-file),
plus events: "civitai_down" when Civitai stopped answering, "done" with the count of models
left as they were. When a scan ends, ScanJob turns the lines of all its folders into one
result: kept as the last scan (GET /anomalous/last_scan, shown on the scan page) and recorded
in the activity log, with each model located by type, path index and relative path so the
browser can open it (GET /anomalous/scan_model).
"""
import asyncio
import json
import os
import time
from aiohttp import web
import folder_paths
from .activity_log import MAX_CHANGES, add_entry, log_path
from .folder_types import get_active_folder_types
from .model_catalog import _model_info_for_path
from .path_utils import atomic_write_json, resolve_folder_subdir
MAX_FILES = 2000 # models kept in the last scan's result
FILE_FIELDS = ("status", "reason", "name", "base", "cover", "error")
def last_scan_path():
return os.path.join(os.path.dirname(log_path()), "last_scan.json")
def locate_root(directory):
"""(type, path_idx) of the models folder that is `directory`, shown types first; None if none."""
target = os.path.normcase(os.path.realpath(directory))
active = get_active_folder_types()
types = active + [name for name in folder_paths.folder_names_and_paths if name not in active]
for folder_type in types:
try:
paths = folder_paths.get_folder_paths(folder_type) or []
except Exception:
continue
for path_idx, path in enumerate(paths):
if os.path.normcase(os.path.realpath(path)) == target:
return folder_type, path_idx
return None
def read_report(path):
"""(model entries, events) from a report file; both empty when there is none."""
entries, events = [], []
try:
with open(path, encoding="utf-8") as source:
for line in source:
try:
item = json.loads(line)
except ValueError:
continue
if isinstance(item, dict):
(events if "event" in item else entries).append(item)
except OSError:
pass
return entries, events
class ScanJob:
"""One scan from start to end: what to call it, its options, and its folders' reports."""
def __init__(self, kind, data):
self.kind = kind # "all" | "picked" | "one"
self.options = {key: bool(data.get(key)) for key in ("offline_only", "retry_unmatched", "force_overwrite")}
self.started = time.time()
self.files = []
self.unchanged = 0
self.civitai_down = False
self.civitai_error = "" # why Civitai could not be reached (net_proxy.failure), the first one
self.errors = []
def add_folder(self, report_file, base_dir, located=None):
"""Reads (and removes) one scraper run's report. `located`: (type, path_idx) of base_dir."""
located = located or locate_root(base_dir) or ("", 0)
entries, events = read_report(report_file)
try:
os.remove(report_file)
except OSError:
pass
for event in events:
if event.get("event") == "done":
self.unchanged += int(event.get("unchanged") or 0)
elif event.get("event") == "civitai_down":
self.civitai_down = True
self.civitai_error = self.civitai_error or str(event.get("reason") or "")[:40]
for entry in entries:
path = entry.get("renamed_to") if entry.get("renamed_to") and entry.get("status") != "failed" else entry.get("file")
if not isinstance(path, str):
continue
item = {key: entry[key] for key in FILE_FIELDS if entry.get(key)}
item.update(type=located[0], path_idx=located[1], filename=os.path.basename(path),
rel=os.path.relpath(path, base_dir).replace(os.sep, "/"))
if entry.get("renamed_to") and item["status"] != "failed":
item["renamed_from"] = os.path.basename(entry.get("file") or "")
if entry.get("duplicate_of"):
item["duplicate"] = True
self.files.append(item)
def add_error(self, message):
self.errors.append(str(message)[:300])
def result(self):
files = self.files
counts = {
"matched": sum(item["status"] == "matched" for item in files),
"inferred": sum(item["status"] == "inferred" for item in files),
"failed": sum(item["status"] == "failed" for item in files),
"covers": sum(item.get("cover") == "new" for item in files),
"renamed": sum(bool(item.get("renamed_from")) for item in files),
"unchanged": self.unchanged,
}
return {
"kind": self.kind, "options": self.options, "started": self.started,
"finished": time.time(), "counts": counts, "civitai_down": self.civitai_down,
"civitai_error": self.civitai_error,
"errors": self.errors[:10], "files": files[:MAX_FILES],
}
def finish(self):
"""Keeps the result as the last scan and records it in the activity log. Never raises:
the scan itself is done, the record of it is a convenience."""
result = self.result()
try:
atomic_write_json(last_scan_path(), result)
except Exception as error: # noqa: BLE001
print(f"[Anomalous Browser] Could not keep the scan result: {error}")
try:
target = result["files"][0]["filename"] if self.kind == "one" and result["files"] else ""
detail = {"scan": {key: result[key] for key in ("kind", "options", "counts", "civitai_down", "civitai_error", "errors")},
"files": result["files"][:MAX_CHANGES], "total": len(self.files),
"seconds": round(result["finished"] - result["started"])}
add_entry("file", "scan_done", target, detail)
except Exception as error: # noqa: BLE001
print(f"[Anomalous Browser] Could not record the scan in the activity log: {error}")
return result
async def api_last_scan(request):
"""GET /anomalous/last_scan - the last scan's result, or {} before the first one."""
def read():
try:
with open(last_scan_path(), encoding="utf-8") as source:
data = json.load(source)
return data if isinstance(data, dict) else {}
except (OSError, ValueError):
return {}
return web.json_response(await asyncio.to_thread(read))
async def api_scan_model(request):
"""GET /anomalous/scan_model?type&path_idx&rel - one model as the grid lists it, to open it."""
rel = request.query.get("rel", "").replace("\\", "/").strip("/")
subfolder, filename = os.path.split(rel)
try:
path_idx = int(request.query.get("path_idx", 0))
base_dir, target_dir = resolve_folder_subdir(request.query.get("type", ""), path_idx, "/" + subfolder)
except (ValueError, KeyError, TypeError):
return web.json_response({"error": "Invalid model location"}, status=400)
file_path = os.path.join(target_dir, filename)
if not filename or filename in (".", "..") or not os.path.isfile(file_path):
return web.json_response({"error": "Model not found"}, status=404)
model = await asyncio.to_thread(_model_info_for_path, request.query.get("type"), path_idx, base_dir, file_path)
return web.json_response({"model": model})
+262
View File
@@ -0,0 +1,262 @@
"""What the scan page shows before a scan: how many models are matched on Civitai, unmatched or
new, and which ones the last two are (located like the scan report's models, so the page can
open them); also the model files a scan never reads (other formats than .safetensors), so the
page can say why they are not there, the models the user set to "don't scan", and the new ones
whose header looks like no drawing model (a voice model, say), for the page to ask about. The
components a drawing model loads (VAE, text encoders…) are counted and listed on their own too,
since many are not on Civitai and that is no fault; and some covers of the scanned models, for
the scan card's band of pictures. And for the scan page's picker, every model
a scan reads with its name, size, cover and state.
Working the summary out reads every model folder, which takes a while with many models or on
a network drive. So the last one is kept (in memory, and in user/anomalous/scan_summary.json
for after a restart) and handed out at once (`?quick=1`) while a fresh one is worked out;
requests that come meanwhile wait for that same one instead of reading the folders again. The
models found on Civitai are kept with it and listed on request (GET /anomalous/scan_matched),
so the page's summary stays small."""
import asyncio
import os
import time
from concurrent.futures import ThreadPoolExecutor
from aiohttp import web
from .activity_log import log_path
from .folder_types import get_active_scan_paths
from .metadata import get_metadata
from .path_utils import atomic_write_json
from .model_media import _preview_url_for_model
from .scan_report import locate_root
try:
from ..model_identity import is_unmatched, read_json, scan_choice, sidecar_info, unmatched_reason
except ImportError: # loaded outside the package (tests)
from model_identity import is_unmatched, read_json, scan_choice, sidecar_info, unmatched_reason
MAX_LISTED = 2000 # models listed per kind; the counts stay exact
MAX_PICKABLE = 5000 # models the picker lists; its total stays exact
COVER_SAMPLE = 48 # covers the scan card's band gets (it shows as many as fit)
# Model files in other formats: listed as "not scanned" (scans read only .safetensors).
OTHER_MODEL_EXTENSIONS = (".ckpt", ".pt", ".pth", ".bin", ".gguf", ".onnx", ".sft", ".engine")
# Folders of the parts a drawing model loads: many are not on Civitai, and that is no fault.
COMPONENT_TYPES = ("vae", "clip", "text_encoders", "clip_vision", "upscale_models")
# What the picker needs of a model's metadata: its card badge (model_source.js) and its name.
PICKER_FIELDS = ("info_source", "unmatched_reason", "scan_choice", "not_drawing", "baseModel", "base_guess", "custom_name")
# New models' headers are read this many at a time: on a network drive (a NAS, a cloud server's
# model disk) each read waits on the network, and a thousand one after another take minutes.
# They are read through get_metadata, whose cache keeps them until the file changes (the page
# opened again reads none, and the picker after it finds them there).
HEAD_READERS = 16
MAX_SAVED_BYTES = 16 * 1024 * 1024 # the kept summary; MAX_LISTED keeps it far smaller
_last = {} # the last summary worked out ("summary", "at") and its models found on Civitai ("matched")
_working = None # the summary being worked out (an asyncio future), shared by the requests meanwhile
def _walk():
"""Each model file in the active scan folders once, as ((type, path_idx), base_dir, path,
other): the .safetensors files scans read, and (`other`) model files in other formats outside
hidden folders (tools keep their working files there)."""
seen = set()
for base_dir in get_active_scan_paths():
located = locate_root(base_dir) or ("", 0)
for root, _dirs, files in os.walk(base_dir):
hidden = any(part.startswith(".") for part in os.path.relpath(root, base_dir).split(os.sep) if part != ".")
for filename in files:
other = filename.lower().endswith(OTHER_MODEL_EXTENSIONS)
if not filename.endswith(".safetensors") and not (other and not hidden):
continue
path = os.path.join(root, filename)
real = os.path.realpath(path)
if real in seen:
continue
seen.add(real)
yield located, base_dir, path, other
def _looks_not_drawing(path):
"""Whether the header of a new model reads but names no drawing model kind."""
return bool(get_metadata(path).get("not_drawing"))
def _found_item(item, info):
"""A model found on Civitai, for the list of them: where it is, Civitai's name, its base model."""
model = info.get("model") if isinstance(info.get("model"), dict) else {}
names = [str(model.get("name") or ""), str(info.get("name") or "")]
return {**item, "name": " · ".join(name for name in names if name), "base": str(info.get("baseModel") or "")}
def _summarize(with_matched=False):
"""Counts the files the scanner handles (.safetensors in the active scan folders), each once.
`pending`: unmatched models Civitai was never asked about (offline, or unreachable then),
which the next online scan looks up. `skipped`: model files in other formats. `excluded`:
models set to "don't scan", not in the other counts. A new model not chosen about whose
header reads but names no drawing model kind carries `not_drawing` (counted too); only new
models' headers are read. Models in a component folder carry `component` (counted too) and
are listed again, matched ones as well, in `component_models` with their `state`
(matched | unmatched | new); `components` counts them. `covers`: see `_covers`."""
total = matched = 0
unmatched, new, skipped, excluded, components, scanned, to_read, found = [], [], [], [], [], [], [], []
for located, base_dir, path, other in _walk():
item = {"type": located[0], "path_idx": located[1], "filename": os.path.basename(path),
"rel": os.path.relpath(path, base_dir).replace(os.sep, "/")}
if other:
skipped.append(item)
continue
base = os.path.splitext(path)[0]
choice = scan_choice(base)
if choice == "skip":
excluded.append(item)
continue
total += 1
info = sidecar_info(base)
if located[0] in COMPONENT_TYPES:
item["component"] = True
components.append(item)
elif info is not None:
scanned.append((located, base_dir, path))
if info is not None and not is_unmatched(info):
matched += 1
item["state"] = "matched"
if with_matched and not item.get("component"):
found.append(_found_item(item, info))
continue
if info is None:
if not choice:
to_read.append((item, path))
item["state"] = "new"
new.append(item)
else:
item.update(state="unmatched", reason=unmatched_reason(info), base=info.get("baseModel") or "")
unmatched.append(item)
if to_read:
with ThreadPoolExecutor(max_workers=HEAD_READERS) as pool:
for (item, _path), not_drawing in zip(to_read, pool.map(_looks_not_drawing, [path for _item, path in to_read])):
if not_drawing:
item["not_drawing"] = True
return {
**({"matched_models": found} if with_matched else {}),
"total": total, "matched": matched, "unmatched": len(unmatched), "new": len(new),
"pending": sum(item["reason"] in ("offline", "network") for item in unmatched),
"damaged": sum(item["reason"] == "damaged" for item in unmatched),
"not_drawing": sum(bool(item.get("not_drawing")) for item in new),
"excluded": len(excluded), "excluded_models": excluded[:MAX_LISTED],
"unmatched_models": unmatched[:MAX_LISTED], "new_models": new[:MAX_LISTED],
"skipped": len(skipped), "skipped_models": skipped[:MAX_LISTED],
"components": {
"total": len(components),
**{state: sum(item["state"] == state for item in components) for state in ("matched", "unmatched", "new")},
"pending": sum(item.get("reason") in ("offline", "network") for item in components),
},
"component_models": components[:MAX_LISTED],
"covers": _covers(scanned),
}
def _modified(entry):
try:
return os.path.getmtime(entry[2])
except OSError:
return 0
def _covers(scanned):
"""Up to COVER_SAMPLE covers of the scanned models (components left out, they seldom have
one), the newest model files first, as {name, preview_url}: the scan card's band of pictures."""
covers = []
for located, base_dir, path in sorted(scanned, key=_modified, reverse=True):
url = _preview_url_for_model(located[0], located[1], base_dir, path)
if url:
covers.append({"name": os.path.splitext(os.path.basename(path))[0], "preview_url": url})
if len(covers) >= COVER_SAMPLE:
break
return covers
def _pickable():
"""Every model a scan reads (the models set to "don't scan" too), located like the summary's,
with its size, cover and what its card badge needs; `roots`: the name of each models folder
they are in and of the folder holding it, to tell two folders of one type apart."""
models, roots, total = [], {}, 0
for located, base_dir, path, other in _walk():
if other:
continue
total += 1
if len(models) >= MAX_PICKABLE:
continue
key = f"{located[0]}|{located[1]}"
if key not in roots:
roots[key] = {"type": located[0], "path_idx": located[1], "name": os.path.basename(os.path.normpath(base_dir)),
"parent": os.path.basename(os.path.dirname(os.path.normpath(base_dir)))}
meta = get_metadata(path)
try:
size = os.path.getsize(path)
except OSError:
size = 0
models.append({
"type": located[0], "path_idx": located[1], "filename": os.path.basename(path),
"rel": os.path.relpath(path, base_dir).replace(os.sep, "/"), "size": size,
"preview_url": _preview_url_for_model(located[0], located[1], base_dir, path),
"metadata": {field: meta.get(field) for field in PICKER_FIELDS},
})
return {"models": models, "total": total, "roots": list(roots.values())}
def _summary_path():
return os.path.join(os.path.dirname(log_path()), "scan_summary.json")
def _work_out():
"""A fresh summary, kept as the last one (and on disk when it changed)."""
summary = _summarize(with_matched=True)
matched = summary.pop("matched_models")
changed = summary != _last.get("summary")
_last.update(summary=summary, matched=matched[:MAX_PICKABLE], matched_total=len(matched), at=time.time())
if changed:
try:
atomic_write_json(_summary_path(), {"at": _last["at"], "summary": summary}, max_bytes=MAX_SAVED_BYTES)
except (OSError, ValueError) as error:
print(f"[Anomalous Browser] Scan summary not kept: {error}")
return summary
async def _fresh():
global _working
if _working is None or _working.done():
_working = asyncio.ensure_future(asyncio.to_thread(_work_out))
return await asyncio.shield(_working)
def _kept():
"""The last summary ({"at", "summary"}), from memory or from disk; None before the first."""
if _last.get("summary"):
return {"at": _last["at"], "summary": _last["summary"]}
try:
if os.path.getsize(_summary_path()) > MAX_SAVED_BYTES:
return None
except OSError:
return None
data = read_json(_summary_path())
return data if data and isinstance(data.get("summary"), dict) else None
async def api_scan_summary(request):
"""GET /anomalous/scan_summary - counts, and the unmatched and new models. `?quick=1`: the
last one at once ({"at", "summary"}, or {} before the first), for the page to show while it
asks for a fresh one."""
if request.query.get("quick"):
return web.json_response(await asyncio.to_thread(_kept) or {})
return web.json_response(await _fresh())
async def api_scan_matched(request):
"""GET /anomalous/scan_matched - the models found on Civitai (components apart), as the last
summary saw them: {"models": [{type, path_idx, filename, rel, name, base}], "total"}."""
if "matched" not in _last:
await _fresh()
return web.json_response({"models": _last.get("matched") or [], "total": _last.get("matched_total", 0)})
async def api_scan_pickable(request):
"""GET /anomalous/scan_pickable - the models the scan settings' picker lists."""
return web.json_response(await asyncio.to_thread(_pickable))
+413 -340
View File
@@ -5,10 +5,247 @@ import urllib.parse
import subprocess
import threading
import asyncio
import ctypes
from ctypes import wintypes
import time
import uuid
from aiohttp import web
import folder_paths
import struct
from .utils import get_active_folder_types, get_active_scan_paths, resolve_folder_subdir
from .folder_types import get_active_scan_paths
from .path_utils import resolve_folder_subdir
from .scan_report import ScanJob, locate_root
try:
from ..model_policies import is_physical_rename_protected
from ..net_proxy import child_env
except ImportError:
from model_policies import is_physical_rename_protected
from net_proxy import child_env
PLUGIN_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SCRAPER_PATH = os.path.join(PLUGIN_DIR, "scraper.py")
SCAN_SESSION_ID = uuid.uuid4().hex
SCAN_MARKER_LOCK = threading.Lock()
ACTIVE_SCAN_MARKERS = set()
SCAN_RUNTIME_STATE = {}
# Request field -> scraper switch, with the field's default. Both scan routes read these.
_SCRAPER_FLAGS = (
("offline_only", "--offline-only", False),
("virtual_rename", "--virtual-rename", False),
("force_overwrite", "--force-overwrite", False),
("retry_unmatched", "--retry-unmatched", False),
("skip_media", "--skip-media", False),
)
def _scraper_flags(data, skip_rename_default, physical_allowed):
"""The scraper's command-line switches for one scan request."""
flags = [flag for field, flag, default in _SCRAPER_FLAGS if data.get(field, default)]
if data.get("skip_rename", skip_rename_default):
flags.append("--skip-rename")
if data.get("physical_rename", False) and physical_allowed:
flags.append("--physical-rename")
if not data.get("use_local_metadata", True):
flags.append("--skip-local-metadata")
return flags
def _clear_folder_caches():
"""ComfyUI lists model folders from caches; a scan may have renamed files."""
for cache in (getattr(folder_paths, "filename_list_cache", None), getattr(folder_paths, "cache_helper", None)):
try:
cache.clear()
except Exception:
pass
def _read_json_file(path):
try:
with open(path, 'r', encoding='utf-8') as f:
value = json.load(f)
return value if isinstance(value, dict) else None
except (OSError, ValueError, TypeError):
return None
def _remove_file(path):
try:
os.remove(path)
except FileNotFoundError:
return True
except OSError:
return False
return True
def _pid_is_running(pid):
try:
pid = int(pid)
except (TypeError, ValueError):
return False
if pid <= 0:
return False
if pid == os.getpid():
return True
if os.name == 'nt':
kernel32 = ctypes.WinDLL('kernel32', use_last_error=True)
open_process = kernel32.OpenProcess
open_process.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
open_process.restype = wintypes.HANDLE
get_exit_code = kernel32.GetExitCodeProcess
get_exit_code.argtypes = (wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD))
get_exit_code.restype = wintypes.BOOL
close_handle = kernel32.CloseHandle
close_handle.argtypes = (wintypes.HANDLE,)
close_handle.restype = wintypes.BOOL
process = open_process(0x1000, False, pid)
if not process:
return False
try:
exit_code = wintypes.DWORD()
return bool(get_exit_code(process, ctypes.byref(exit_code))) and exit_code.value == 259
finally:
close_handle(process)
try:
os.kill(pid, 0)
return True
except PermissionError:
return True
except OSError:
return False
def _write_marker(marker_file, marker):
temp_file = f"{marker_file}.{os.getpid()}.{threading.get_ident()}.tmp"
with open(temp_file, 'w', encoding='utf-8') as f:
json.dump(marker, f)
os.replace(temp_file, marker_file)
def _marker_owner_is_running(marker):
if not marker:
return False
if marker.get("session_id") == SCAN_SESSION_ID:
return marker.get("marker_file") in ACTIVE_SCAN_MARKERS
worker_pid = marker.get("worker_pid")
if worker_pid:
return _pid_is_running(worker_pid)
return _pid_is_running(marker.get("owner_pid"))
def _cleanup_scan_artifacts(marker_file, artifact_files=()):
_remove_file(marker_file)
for path in artifact_files:
_remove_file(path)
def _claim_scan_marker(marker_file, kind, artifact_files=()):
recovered = False
with SCAN_MARKER_LOCK:
if marker_file in ACTIVE_SCAN_MARKERS:
return False, False
if os.path.exists(marker_file):
marker = _read_json_file(marker_file)
if _marker_owner_is_running(marker):
return False, False
_cleanup_scan_artifacts(marker_file, artifact_files)
recovered = True
marker = {
"version": 2,
"job_id": uuid.uuid4().hex,
"kind": kind,
"session_id": SCAN_SESSION_ID,
"owner_pid": os.getpid(),
"worker_pid": 0,
"started_at": time.time(),
"marker_file": marker_file,
}
try:
with open(marker_file, 'x', encoding='utf-8') as f:
json.dump(marker, f)
except FileExistsError:
return False, recovered
ACTIVE_SCAN_MARKERS.add(marker_file)
SCAN_RUNTIME_STATE[marker_file] = {
"phase": "preparing",
"error": "",
"recovered": recovered,
}
return True, recovered
def _update_scan_marker(marker_file, **values):
with SCAN_MARKER_LOCK:
if marker_file not in ACTIVE_SCAN_MARKERS:
return
marker = _read_json_file(marker_file) or {}
marker.update(values)
marker["marker_file"] = marker_file
_write_marker(marker_file, marker)
def _update_scan_state(marker_file, **values):
with SCAN_MARKER_LOCK:
if marker_file in ACTIVE_SCAN_MARKERS:
SCAN_RUNTIME_STATE.setdefault(marker_file, {}).update(values)
def _release_scan_marker(marker_file, artifact_files=()):
with SCAN_MARKER_LOCK:
ACTIVE_SCAN_MARKERS.discard(marker_file)
SCAN_RUNTIME_STATE.pop(marker_file, None)
_cleanup_scan_artifacts(marker_file, artifact_files)
def _scan_marker_status(marker_file, artifact_files=()):
with SCAN_MARKER_LOCK:
if marker_file in ACTIVE_SCAN_MARKERS:
return True, False, _read_json_file(marker_file) or {}
if not os.path.exists(marker_file):
return False, False, {}
marker = _read_json_file(marker_file)
if _marker_owner_is_running(marker):
return True, False, marker or {}
_cleanup_scan_artifacts(marker_file, artifact_files)
return False, True, marker or {}
def _scan_status_payload(marker_file, progress_file, artifact_files=()):
scanning, interrupted, marker = _scan_marker_status(marker_file, artifact_files)
data = {
"scanning": scanning,
"interrupted": interrupted,
"job_id": marker.get("job_id", ""),
"phase": "idle",
"total": 0,
"current": 0,
"filename": "",
}
if scanning:
with SCAN_MARKER_LOCK:
data.update(SCAN_RUNTIME_STATE.get(marker_file, {}))
progress = _read_json_file(progress_file)
if progress:
data.update(progress)
return data
def _protected_type_for_path(folder_path):
"""Resolve protected registered aliases for a global physical-folder scan."""
target = os.path.realpath(folder_path)
for folder_type in folder_paths.folder_names_and_paths.keys():
if not is_physical_rename_protected(folder_type=folder_type):
continue
try:
if any(os.path.realpath(path) == target for path in folder_paths.get_folder_paths(folder_type)):
return folder_type
except Exception:
continue
return ""
async def api_scan_status(request):
folder_type = request.query.get('type', 'checkpoints')
@@ -21,19 +258,20 @@ async def api_scan_status(request):
paths = folder_paths.get_folder_paths(folder_type)
except Exception:
return web.json_response({"scanning": False})
if not paths or path_idx < 0 or path_idx >= len(paths):
return web.json_response({"scanning": False})
try:
base_dir, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder)
except ValueError:
return web.json_response({"scanning": False})
if not paths or path_idx < 0 or path_idx >= len(paths):
return web.json_response({"scanning": False})
try:
base_dir, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder)
except ValueError:
return web.json_response({"scanning": False})
marker_file = os.path.join(target_dir, '.scan_in_progress')
progress_file = os.path.join(target_dir, '.scan_progress.json')
targets_file = os.path.join(target_dir, '.scan_targets.json')
result_file = os.path.join(target_dir, '.scan_result.json')
scanning = os.path.exists(marker_file)
data = {"scanning": scanning}
if not scanning and os.path.exists(result_file):
data = _scan_status_payload(marker_file, progress_file, (progress_file, targets_file))
if not data["scanning"] and os.path.exists(result_file):
try:
with open(result_file, 'r', encoding='utf-8') as f:
data["result"] = __import__('json').load(f)
@@ -44,361 +282,196 @@ async def api_scan_status(request):
return web.json_response(data)
async def api_scan_folder(request):
"""Launches the scraper in the background for a specific directory."""
"""Launches the scraper in the background for one folder (a model card's scan)."""
folder_type = request.query.get('type', 'checkpoints')
subfolder = request.query.get('subfolder', '/')
try:
path_idx = int(request.query.get('path_idx', 0))
except:
except (TypeError, ValueError):
path_idx = 0
try:
base_dir, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder)
except (ValueError, KeyError):
return web.json_response({"status": "error", "message": "Invalid folder type"})
try:
base_dir, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder)
except (ValueError, KeyError):
return web.json_response({"status": "error", "message": "Invalid folder type"})
if not os.path.exists(target_dir):
return web.json_response({"status": "error", "message": "Directory does not exist"})
# Get the scraper path relative to this script
plugin_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
scraper_path = os.path.join(plugin_dir, "scraper.py")
if not os.path.exists(scraper_path):
if not os.path.exists(SCRAPER_PATH):
return web.json_response({"status": "error", "message": "scraper.py not found in extension directory"})
print(f"[Anomalous Browser] Starting background scan for: {target_dir}")
try:
data = await request.json()
except Exception:
data = {}
offline_only = data.get("offline_only", False)
skip_rename = data.get("skip_rename", False)
virtual_rename = data.get("virtual_rename", False)
physical_rename = data.get("physical_rename", False)
force_overwrite = data.get("force_overwrite", False)
except ValueError:
data = None
if not isinstance(data, dict):
return web.json_response({"status": "error", "message": "Invalid request body"}, status=400)
flags = _scraper_flags(data, False, not is_physical_rename_protected(
folder_type=folder_type,
folder_path=target_dir,
))
target_files_list = data.get("target_files", [])
if not target_files_list:
target_files_str = request.query.get('target_files', '')
target_files_list = [f.strip() for f in target_files_str.split(',')] if target_files_str else []
try:
marker_file = os.path.join(target_dir, '.scan_in_progress')
try:
with open(marker_file, 'x') as f:
f.write('1')
except FileExistsError:
return web.json_response({"status": "error", "message": "Scan already in progress"}, status=409)
if target_files_list:
targets_file = os.path.join(target_dir, '.scan_targets.json')
with open(targets_file, 'w', encoding='utf-8') as f:
__import__('json').dump(target_files_list, f)
def run_bg():
try:
cmd = [sys.executable, scraper_path, target_dir]
if offline_only:
cmd.append("--offline-only")
if skip_rename:
cmd.append("--skip-rename")
if virtual_rename:
cmd.append("--virtual-rename")
if physical_rename:
cmd.append("--physical-rename")
if force_overwrite:
cmd.append("--force-overwrite")
result = subprocess.run(
cmd,
cwd=plugin_dir
)
if result.returncode != 0:
result_file = os.path.join(target_dir, '.scan_result.json')
with open(result_file, 'w', encoding='utf-8') as f:
json.dump({"success": 0, "fail": 1, "error": f"Scanner exited with code {result.returncode}"}, f)
claimed = False
try:
marker_file = os.path.join(target_dir, '.scan_in_progress')
progress_file = os.path.join(target_dir, '.scan_progress.json')
targets_file = os.path.join(target_dir, '.scan_targets.json')
report_file = os.path.join(target_dir, '.scan_report.jsonl')
artifacts = (progress_file, targets_file, report_file)
claimed, recovered = _claim_scan_marker(marker_file, "folder", artifacts)
if not claimed:
return web.json_response({"status": "error", "message": "Scan already in progress"}, status=409)
if target_files_list:
with open(targets_file, 'w', encoding='utf-8') as f:
json.dump(target_files_list, f)
job = ScanJob("one" if len(target_files_list) == 1 else "picked", data)
def run_bg():
try:
cmd = [sys.executable, SCRAPER_PATH, target_dir, "--folder-type", folder_type, *flags]
cmd.extend(["--progress-file", progress_file, "--report-file", report_file])
process = subprocess.Popen(cmd, cwd=PLUGIN_DIR, env=child_env())
_update_scan_marker(marker_file, worker_pid=process.pid)
return_code = process.wait()
job.add_folder(report_file, base_dir, (folder_type, path_idx))
if return_code != 0:
job.add_error(f"Scanner exited with code {return_code}")
result_file = os.path.join(target_dir, '.scan_result.json')
with open(result_file, 'w', encoding='utf-8') as f:
json.dump({"success": 0, "fail": 1, "error": f"Scanner exited with code {return_code}"}, f)
finally:
if hasattr(folder_paths, "filename_list_cache"):
try: folder_paths.filename_list_cache.clear()
except: pass
if hasattr(folder_paths, "cache_helper") and hasattr(folder_paths.cache_helper, "clear"):
try: folder_paths.cache_helper.clear()
except: pass
if os.path.exists(marker_file):
try: os.remove(marker_file)
except: pass
_clear_folder_caches()
job.finish() # before the marker goes: whoever sees the scan end finds its result
_release_scan_marker(marker_file, artifacts)
threading.Thread(target=run_bg, daemon=True).start()
return web.json_response({"status": "ok", "message": "Scan started in background. Check console for details."})
except Exception as e:
if 'marker_file' in locals() and os.path.exists(marker_file):
try: os.remove(marker_file)
except OSError: pass
return web.json_response({"status": "error", "message": str(e)})
return web.json_response({
"status": "ok",
"message": "Scan started in background. Check console for details.",
"recovered": recovered,
})
except Exception as e:
if claimed:
_release_scan_marker(marker_file, (progress_file, targets_file))
return web.json_response({"status": "error", "message": str(e)})
def _global_scan_plan(data):
"""The folders a scan-page scan runs through: [(scan dir, (type, path_idx) or None, base dir,
file names or None, folder type for the rename policy)]. `data["targets"]`, when given, is
the picked models: [{type, path_idx, subfolder, files}]; otherwise every active folder."""
targets = data.get("targets")
if not targets:
plan = []
for base_dir in get_active_scan_paths():
if os.path.exists(base_dir):
plan.append((base_dir, locate_root(base_dir), base_dir, None, _protected_type_for_path(base_dir)))
return plan
if not isinstance(targets, list):
raise ValueError("targets must be a list")
plan = []
for target in targets:
files = target.get("files") if isinstance(target, dict) else None
if not isinstance(files, list) or not all(isinstance(name, str) for name in files):
raise ValueError("each target needs a list of file names")
path_idx = int(target.get("path_idx", 0))
base_dir, target_dir = resolve_folder_subdir(target.get("type"), path_idx, target.get("subfolder") or "/")
if files and os.path.isdir(target_dir):
plan.append((target_dir, (target["type"], path_idx), base_dir, files, target["type"]))
return plan
async def api_scan_all(request):
plugin_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
scraper_path = os.path.join(plugin_dir, "scraper.py")
marker_file = os.path.join(plugin_dir, '.global_scan_in_progress')
"""Scans every active models folder, or the picked models (`targets`), one folder after another."""
marker_file = os.path.join(PLUGIN_DIR, '.global_scan_in_progress')
progress_file = os.path.join(PLUGIN_DIR, '.global_scan_progress.json')
targets_file = os.path.join(PLUGIN_DIR, '.global_scan_targets.json')
report_file = os.path.join(PLUGIN_DIR, '.global_scan_report.jsonl')
artifacts = (progress_file, targets_file, report_file)
try:
data = await request.json()
except Exception:
data = {}
offline_only = data.get("offline_only", False)
use_local_metadata = data.get("use_local_metadata", True)
skip_rename = data.get("skip_rename", True)
virtual_rename = data.get("virtual_rename", False)
physical_rename = data.get("physical_rename", False)
force_overwrite = data.get("force_overwrite", False)
skip_media = data.get("skip_media", False)
try:
try:
with open(marker_file, 'x') as f:
f.write('1')
except FileExistsError:
return web.json_response({"status": "error", "message": "Global scan already in progress"}, status=409)
except ValueError:
data = None
if not isinstance(data, dict):
return web.json_response({"status": "error", "message": "Invalid request body"}, status=400)
try:
plan = _global_scan_plan(data) if data.get("targets") else None
except (ValueError, KeyError, TypeError) as e:
return web.json_response({"status": "error", "message": f"Invalid targets: {e}"}, status=400)
picked = sum(len(files) for _dir, _loc, _base, files, _type in plan or ())
job = ScanJob("all" if plan is None else "one" if picked == 1 else "picked", data)
claimed = False
try:
claimed, recovered = _claim_scan_marker(marker_file, "global", artifacts)
if not claimed:
return web.json_response({"status": "error", "message": "Global scan already in progress"}, status=409)
def run_global_bg():
try:
paths_to_scan = get_active_scan_paths()
for base_dir in paths_to_scan:
if not os.path.exists(base_dir): continue
folders = plan if plan is not None else _global_scan_plan(data)
_update_scan_state(marker_file, phase="preparing", folder_total=len(folders), folder_current=0, folder="")
for folder_index, (scan_dir, located, base_dir, files, policy_type) in enumerate(folders, 1):
try:
print(f"[Anomalous Browser] Global scan processing: {base_dir}")
cmd = [sys.executable, scraper_path, base_dir]
if offline_only:
cmd.append("--offline-only")
if skip_rename:
cmd.append("--skip-rename")
if virtual_rename:
cmd.append("--virtual-rename")
if physical_rename:
cmd.append("--physical-rename")
if force_overwrite:
cmd.append("--force-overwrite")
if skip_media:
cmd.append("--skip-media")
if not use_local_metadata:
cmd.append("--skip-local-metadata")
result = subprocess.run(
cmd,
cwd=plugin_dir
)
if result.returncode != 0:
print(f"[Anomalous Browser] Scanner exited with code {result.returncode}: {base_dir}")
print(f"[Anomalous Browser] Global scan processing: {scan_dir}")
_remove_file(progress_file)
_update_scan_state(
marker_file,
phase="enumerating",
folder_total=len(folders),
folder_current=folder_index,
folder=os.path.basename(os.path.normpath(scan_dir)) or scan_dir,
error="",
)
cmd = [sys.executable, SCRAPER_PATH, scan_dir]
if policy_type:
cmd.extend(["--folder-type", policy_type])
cmd.extend(_scraper_flags(data, True, not is_physical_rename_protected(
folder_type=policy_type,
folder_path=scan_dir,
)))
if files is not None:
with open(targets_file, 'w', encoding='utf-8') as f:
json.dump(files, f)
cmd.extend(["--targets-file", targets_file])
if job.civitai_down: # an earlier folder found Civitai unreachable
cmd.append("--civitai-down")
cmd.extend(["--progress-file", progress_file, "--report-file", report_file])
process = subprocess.Popen(cmd, cwd=PLUGIN_DIR, env=child_env())
_update_scan_marker(marker_file, worker_pid=process.pid)
return_code = process.wait()
_update_scan_marker(marker_file, worker_pid=0)
job.add_folder(report_file, base_dir, located)
if return_code != 0:
message = f"Scanner exited with code {return_code}: {scan_dir}"
_update_scan_state(marker_file, error=message)
job.add_error(message)
print(f"[Anomalous Browser] {message}")
except Exception as e:
print(f"[Anomalous Browser] Global scan error on {base_dir}: {e}")
_update_scan_state(marker_file, error=str(e))
job.add_error(f"{scan_dir}: {e}")
print(f"[Anomalous Browser] Global scan error on {scan_dir}: {e}")
finally:
if hasattr(folder_paths, "filename_list_cache"):
try: folder_paths.filename_list_cache.clear()
except: pass
if hasattr(folder_paths, "cache_helper") and hasattr(folder_paths.cache_helper, "clear"):
try: folder_paths.cache_helper.clear()
except: pass
if os.path.exists(marker_file):
try: os.remove(marker_file)
except: pass
_clear_folder_caches()
job.finish() # before the marker goes: whoever sees the scan end finds its result
_release_scan_marker(marker_file, artifacts)
threading.Thread(target=run_global_bg, daemon=True).start()
return web.json_response({"status": "ok", "message": "Global scan started"})
except Exception as e:
if os.path.exists(marker_file):
try: os.remove(marker_file)
except OSError: pass
return web.json_response({"status": "error", "message": str(e)})
return web.json_response({"status": "ok", "message": "Global scan started", "recovered": recovered})
except Exception as e:
if claimed:
_release_scan_marker(marker_file, artifacts)
return web.json_response({"status": "error", "message": str(e)})
async def api_global_scan_status(request):
plugin_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
marker_file = os.path.join(plugin_dir, '.global_scan_in_progress')
return web.json_response({"scanning": os.path.exists(marker_file)})
async def api_clean_civitai_info(request):
try:
paths_to_clean = get_active_scan_paths()
deleted_count = 0
for base_dir in paths_to_clean:
if not os.path.exists(base_dir): continue
for root, dirs, files in os.walk(base_dir):
for file in files:
if file.endswith('.civitai.info'):
file_path = os.path.join(root, file)
try:
os.remove(file_path)
deleted_count += 1
except Exception as e:
pass
return web.json_response({"status": "success", "count": deleted_count})
except Exception as e:
return web.json_response({"status": "error", "message": str(e)}, status=500)
GLOBAL_SCAN_STATE = {
"scanning": False,
"total": 0,
"current": 0,
"filename": "",
"error": ""
}
GLOBAL_SCAN_LOCK = threading.Lock()
async def api_scan_missing_models_status(request):
return web.json_response(GLOBAL_SCAN_STATE)
async def api_scan_missing_models(request):
import sys
import threading
import traceback
plugin_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if plugin_dir not in sys.path:
sys.path.insert(0, plugin_dir)
try:
from scraper import extract_safetensors_hash, calculate_sha256, fetch_civitai_info, infer_base_model_from_header
except ImportError:
return web.json_response({"status": "error", "message": "Failed to load scraper module"})
with GLOBAL_SCAN_LOCK:
if GLOBAL_SCAN_STATE["scanning"]:
return web.json_response({"status": "error", "message": "Scan already in progress"}, status=409)
GLOBAL_SCAN_STATE["scanning"] = True
GLOBAL_SCAN_STATE["total"] = 0
GLOBAL_SCAN_STATE["current"] = 0
GLOBAL_SCAN_STATE["filename"] = "Initializing..."
GLOBAL_SCAN_STATE["error"] = ""
try:
data = await request.json()
except Exception:
data = {}
force_overwrite = data.get("force_overwrite", False)
def run_deep_scan():
try:
paths_to_scan = get_active_scan_paths()
files_to_scan = []
for base_dir in paths_to_scan:
if not os.path.exists(base_dir): continue
for root, dirs, files in os.walk(base_dir):
for file in files:
if not file.endswith('.safetensors'):
continue
file_path = os.path.join(root, file)
base_path = os.path.splitext(file_path)[0]
info_path = base_path + ".info"
civitai_info_path = base_path + ".civitai.info"
# Check if a valid info file exists
has_valid_info = False
actual_info = info_path if os.path.exists(info_path) else (civitai_info_path if os.path.exists(civitai_info_path) else None)
if force_overwrite:
actual_info = None
if actual_info:
try:
with open(actual_info, 'r', encoding='utf-8') as f:
data = json.load(f)
for fi in data.get("files", []):
if isinstance(fi, dict) and "hashes" in fi and "SHA256" in fi["hashes"]:
if fi["hashes"]["SHA256"]:
has_valid_info = True
break
except Exception:
pass
if not has_valid_info:
files_to_scan.append(file_path)
GLOBAL_SCAN_STATE["total"] = len(files_to_scan)
for idx, file_path in enumerate(files_to_scan):
filename = os.path.basename(file_path)
GLOBAL_SCAN_STATE["current"] = idx + 1
GLOBAL_SCAN_STATE["filename"] = filename
try:
civitai_data = None
file_hash = None
# Fallback 1: Try header hash on Civitai
header_hash = extract_safetensors_hash(file_path)
if header_hash:
civitai_data = fetch_civitai_info(header_hash)
if civitai_data:
file_hash = header_hash
# Fallback 2: If header hash fails, compute full SHA256
if not civitai_data:
full_hash = calculate_sha256(file_path)
civitai_data = fetch_civitai_info(full_hash)
file_hash = full_hash
# Fallback 3: Local Offline Inference
if not civitai_data:
inferred_base = infer_base_model_from_header(file_path)
if inferred_base == 'Unknown':
inferred_base = ""
if not file_hash:
file_hash = header_hash or calculate_sha256(file_path)
civitai_data = {
"id": -1,
"modelId": -1,
"name": os.path.splitext(filename)[0],
"baseModel": inferred_base,
"description": "<p>Automatically inferred by Anomalous Local Engine.</p>",
"model": {
"name": os.path.splitext(filename)[0],
"type": "LORA" if "lora" in file_path.lower() else "Checkpoint"
},
"files": [{"hashes": {"SHA256": file_hash}}]
}
# Ensure SHA256 is explicitly injected into the info data
if civitai_data:
if not civitai_data.get("files"):
civitai_data["files"] = [{"hashes": {"SHA256": file_hash}}]
elif isinstance(civitai_data["files"][0], dict):
if "hashes" not in civitai_data["files"][0]:
civitai_data["files"][0]["hashes"] = {}
civitai_data["files"][0]["hashes"]["SHA256"] = file_hash
# Save info file
info_path = os.path.splitext(file_path)[0] + ".info"
with open(info_path, 'w', encoding='utf-8') as f:
json.dump(civitai_data, f, ensure_ascii=True, indent=4)
except Exception as e:
GLOBAL_SCAN_STATE["error"] = f"{filename}: {e}"
except Exception as e:
GLOBAL_SCAN_STATE["error"] = str(e)
traceback.print_exc()
finally:
GLOBAL_SCAN_STATE["scanning"] = False
GLOBAL_SCAN_STATE["filename"] = ""
# Clear caches
if hasattr(folder_paths, "filename_list_cache"):
folder_paths.filename_list_cache.clear()
if hasattr(folder_paths, "cache_helper") and hasattr(folder_paths.cache_helper, "clear"):
folder_paths.cache_helper.clear()
try:
threading.Thread(target=run_deep_scan, daemon=True).start()
except Exception as e:
GLOBAL_SCAN_STATE["scanning"] = False
GLOBAL_SCAN_STATE["error"] = str(e)
return web.json_response({"status": "error", "message": str(e)}, status=500)
return web.json_response({"status": "success", "message": "Deep scan started in background"})
marker_file = os.path.join(PLUGIN_DIR, '.global_scan_in_progress')
progress_file = os.path.join(PLUGIN_DIR, '.global_scan_progress.json')
return web.json_response(_scan_status_payload(marker_file, progress_file, (progress_file,)))
+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)
+160
View File
@@ -0,0 +1,160 @@
"""Prompt translation (POST /anomalous/translate): DeepL when the user saved a key in Settings
-> Translation, else Google's free endpoint, else MyMemory. When DeepL was asked and failed, the
answer says why (`deepl_error`) while another provider still translates."""
import asyncio
import json
import os
import urllib.error
import urllib.parse
import urllib.request
from aiohttp import web
try:
from ..net_proxy import urlopen
except ImportError: # loaded outside the package (tests)
from net_proxy import urlopen
_DEEPL_TARGETS = {
"zh": "ZH-HANS", "zh-CN": "ZH-HANS", "zh-TW": "ZH-HANT", "en": "EN-US", "ja": "JA", "ko": "KO",
"fr": "FR", "de": "DE", "es": "ES", "ru": "RU", "pt": "PT-BR",
}
def _translate_with_deepl(text, tl, deepl_key):
"""None for a target DeepL does not take: the other providers are asked instead."""
d_tl = _DEEPL_TARGETS.get(tl)
if not d_tl:
return None
# A Free account's key ends in ":fx". The key goes in the Authorization header: DeepL no
# longer takes it as an auth_key parameter (2025).
url = "https://api-free.deepl.com/v2/translate" if deepl_key.endswith(":fx") else "https://api.deepl.com/v2/translate"
payload = json.dumps({"text": [text], "target_lang": d_tl}).encode("utf-8")
req = urllib.request.Request(url, data=payload, method="POST", headers={
"Authorization": f"DeepL-Auth-Key {deepl_key}",
"Content-Type": "application/json",
})
with urlopen(req, timeout=8) as resp:
result = json.loads(resp.read().decode('utf-8'))
return result["translations"][0]["text"]
def _deepl_error(error):
"""Why DeepL did not translate, for Settings: bad_key, quota, busy, or the error's own words."""
if isinstance(error, urllib.error.HTTPError):
if error.code in (401, 403):
return "bad_key"
if error.code == 456:
return "quota"
if error.code == 429:
return "busy"
return f"HTTP {error.code}"
return str(error) or type(error).__name__
def _deepl_key():
"""The DeepL key saved in Settings -> Translation (config.json), or ""."""
config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json")
try:
with open(config_path, 'r', encoding='utf-8') as f:
key = json.load(f).get("DEEPL_API_KEY", "")
except (OSError, ValueError, AttributeError):
return ""
return key.strip() if isinstance(key, str) else ""
def _translate_with_google(text, tl):
clients = ["dict-chrome-ex", "gtx"]
last_err = None
target_lang = "zh-CN" if tl in ("zh", "zh-CN") else tl
for client in clients:
try:
url = f"https://translate.googleapis.com/translate_a/single?client={client}&sl=auto&tl={target_lang}&dt=t&q={urllib.parse.quote(text)}"
req = urllib.request.Request(url, headers={
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
})
with urlopen(req, timeout=5) as resp:
result = json.loads(resp.read().decode('utf-8'))
if result and isinstance(result, list) and len(result) > 0 and isinstance(result[0], list):
translated_text = "".join([part[0] for part in result[0] if part and len(part) > 0 and part[0]])
if translated_text:
return translated_text
except Exception as e:
last_err = e
continue
if last_err:
raise last_err
raise RuntimeError("Google translate returned empty result")
def _translate_with_mymemory(text, tl):
has_cn = any('\u4e00' <= char <= '\u9fa5' for char in text)
# Text that is not plain English and not Chinese (kana, hangul, accents...) is left to its detection.
sl = 'zh-CN' if has_cn else ('en' if text.isascii() else 'Autodetect')
pair_tl = 'zh-CN' if tl in ('zh', 'zh-CN') else tl
url = f"https://api.mymemory.translated.net/get?q={urllib.parse.quote(text)}&langpair={sl}|{pair_tl}"
req = urllib.request.Request(url, headers={
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
})
with urlopen(req, timeout=5) as resp:
data = json.loads(resp.read().decode('utf-8'))
res = data.get("responseData", {}).get("translatedText")
if res and res != text:
return res
for m in data.get("matches", []):
t_match = m.get("translation")
if t_match and t_match != text:
return t_match
if res:
return res
raise RuntimeError("MyMemory returned no translation")
async def api_translate(request):
try:
data = await request.json()
except ValueError:
data = None
if not isinstance(data, dict):
return web.json_response({"status": "error", "message": "Invalid request body"}, status=400)
text = str(data.get("text", "")).strip()
try:
tl = str(data.get("target_lang", "zh-CN")).strip()
if not text:
return web.json_response({"translated": "", "status": "success"})
# 1. DeepL, when a key is saved
deepl_key = _deepl_key()
deepl_error = None
if deepl_key:
try:
translated = await asyncio.to_thread(_translate_with_deepl, text, tl, deepl_key)
if translated:
return web.json_response({"translated": translated, "status": "success", "engine": "deepl"})
except Exception as e:
deepl_error = _deepl_error(e)
print(f"[Anomalous] DeepL translate failed: {deepl_error}")
why = {"deepl_error": deepl_error} if deepl_error else {}
# 2. Try Google Translate (client=dict-chrome-ex)
try:
translated = await asyncio.to_thread(_translate_with_google, text, tl)
if translated:
return web.json_response({"translated": translated, "status": "success", "engine": "google", **why})
except Exception as e:
print(f"[Anomalous] Google translate failed: {e}")
# 3. Fallback to MyMemory
try:
translated = await asyncio.to_thread(_translate_with_mymemory, text, tl)
if translated:
return web.json_response({"translated": translated, "status": "success", "engine": "mymemory", **why})
except Exception as e:
print(f"[Anomalous] MyMemory translate failed: {e}")
raise e
except Exception as e:
print(f"[Anomalous] All translation providers failed: {e}")
return web.json_response({"translated": text, "error": str(e), "status": "error"})
+19
View File
@@ -0,0 +1,19 @@
"""The API's way to delete the user's files: recycle_bin.py (shared with the scanner)."""
try:
from ..recycle_bin import TrashUnavailable, move_to_trash
except ImportError: # loaded outside the package (tests)
from recycle_bin import TrashUnavailable, move_to_trash
__all__ = ["TrashUnavailable", "move_to_trash", "trash_failure"]
def trash_failure(exc):
"""What to tell the user when moving to the Recycle Bin did not happen."""
if isinstance(exc, TrashUnavailable):
return ("这个位置没有回收站(U 盘或网络盘),为了安全没有删除,请在文件管理器里手动删除。"
" / No Recycle Bin here (USB or network drive); nothing was deleted, please delete it in your file manager.")
if getattr(exc, "errno", None) == 32 or getattr(exc, "winerror", None) == 32 or "being used" in str(exc):
return ("文件正在被使用(可能 ComfyUI 已经加载了它),没有删除。请先卸载模型或重启 ComfyUI。"
" / The file is in use (ComfyUI may have it loaded); nothing was deleted.")
return f"没有删除 / Not deleted: {exc}"
+96
View File
@@ -0,0 +1,96 @@
"""Version and updates of the companion node pack Anomalous_TTS (GPT-SoVITS Settings).
Only a git checkout of the official repository is touched, found among the
custom_nodes folders by its node class and checked by its `origin` remote. The
rules are those of the plugin's own version panel (api/version_manager.py):
published releases are fetched only when asked, a switch checks out a
published release tag, refuses over modified tracked files and can be undone.
"""
import asyncio
import os
from aiohttp import web
import folder_paths
from . import version_manager as vm
OFFICIAL = ("demongatanjieu", "anomalous_tts")
NODE_CLASS = "AnomalousTTS_CharacterSpeech"
STATE_FILE = os.path.join(vm.PLUGIN_DIR, ".anomalous_tts_version.local.json")
READ_LIMIT = 200_000
def _node_folders():
"""custom_nodes folders whose nodes.py defines the Anomalous_TTS node (disabled ones left out)."""
for base in folder_paths.get_folder_paths("custom_nodes"):
try:
names = sorted(os.listdir(base))
except OSError:
continue
for name in names:
folder = os.path.join(base, name)
if name.endswith(".disabled") or not os.path.exists(os.path.join(folder, ".git")):
continue
try:
with open(os.path.join(folder, "nodes.py"), encoding="utf-8", errors="replace") as handle:
if NODE_CLASS in handle.read(READ_LIMIT):
yield folder
except OSError:
continue
def find_checkout():
"""The installed Anomalous_TTS as a version_manager.Checkout, or None (not installed, not a git
clone, or cloned from somewhere else)."""
for folder in _node_folders():
repo = vm.Checkout(folder, STATE_FILE)
owner_repo = vm._github_repo(vm._git_out("remote", "get-url", "origin", repo=repo))
if owner_repo and tuple(part.lower() for part in owner_repo) == OFFICIAL:
return repo
return None
def _checkout():
repo = find_checkout()
if repo is None:
raise vm.VersionError("tts_missing", "Anomalous_TTS is not installed as a git clone of its GitHub repository")
return repo
def state():
repo = find_checkout()
return {"found": False} if repo is None else {"found": True, "state": vm.current_state(repo)}
async def api_get_state(request):
"""GET /anomalous/tts_version - Installed Anomalous_TTS version (local, no network)."""
try:
return web.json_response({"success": True, **(await asyncio.to_thread(state))})
except vm.VersionError as error:
return vm.error_response(error)
async def api_get_releases(request):
"""GET /anomalous/tts_version/releases?refresh=1 - Its published releases (network, only when asked)."""
refresh = request.query.get("refresh") == "1"
return await vm.run(lambda: vm.list_releases(refresh, _checkout()))
async def api_update(request):
"""POST /anomalous/tts_version/update {tag} - Check out a published release."""
tag = str((await vm.json_body(request)).get("tag", ""))
return await vm.run(lambda: vm.switch_to(tag, _checkout()))
async def api_undo(request):
"""POST /anomalous/tts_version/undo - Back to where the last update came from."""
return await vm.run(lambda: vm.undo_last_switch(_checkout()))
def register_routes(app):
app.router.add_get('/anomalous/tts_version', api_get_state)
app.router.add_get('/anomalous/tts_version/releases', api_get_releases)
app.router.add_post('/anomalous/tts_version/update', api_update)
app.router.add_post('/anomalous/tts_version/undo', api_undo)
+173
View File
@@ -0,0 +1,173 @@
"""What each run used and each opened workflow held, kept from the day this started (the Tidy
tab's "Not used lately").
ComfyUI keeps no lasting note of which nodes and models a run used: its history is gone after a
restart and its log names neither. So every prompt queued here passes `note_prompt` (ComfyUI's
on_prompt hook) on its way in, whether it makes an image, a sound, a video or nothing kept, and
the page posts what a workflow holds when it is opened (POST /anomalous/usage/opened {types,
models}, usage_notes.js; this computer only). Node types and model files go into
user/anomalous/usage_ledger.json with when each was last used and how often:
{started, types: {type: [last, runs]}, models: {ref: [last, runs]},
opened: {types: {type: [last, times]}, models: {ref: [last, times]}}}.
It stays on this computer, is saved a few seconds after a note, and noting never stops a run.
"""
import json
import logging
import os
import threading
import time
import folder_paths
from aiohttp import web
from .image_search import MODEL_FILE
from .path_utils import atomic_write_json
SAVE_DELAY = 5.0 # seconds after a note: several in a row are saved once
MAX_NAMES = 20_000 # per kind; the longest unused go first
MAX_POSTED = 5_000 # names one opened workflow may post, per kind
MAX_NAME_CHARS = 512
_lock = threading.Lock()
_ledger = None
_timer = None
def ledger_path():
user_dir = folder_paths.get_user_directory() if hasattr(folder_paths, "get_user_directory") else None
if not user_dir:
user_dir = os.path.join(folder_paths.base_path, "user")
directory = os.path.join(user_dir, "anomalous")
os.makedirs(directory, exist_ok=True)
return os.path.join(directory, "usage_ledger.json")
def _names(data):
"""{type or ref: [last, count]} from the saved file, {} when it is not one."""
return data if isinstance(data, dict) else {}
def _load():
global _ledger
if _ledger is None:
data = {}
try:
with open(ledger_path(), encoding="utf-8") as f:
data = json.load(f)
except (OSError, ValueError):
pass
if not isinstance(data, dict):
data = {}
opened = data.get("opened") if isinstance(data.get("opened"), dict) else {}
_ledger = {
"started": data.get("started") if isinstance(data.get("started"), (int, float)) else None,
"types": _names(data.get("types")),
"models": _names(data.get("models")),
"opened": {"types": _names(opened.get("types")), "models": _names(opened.get("models"))},
}
return _ledger
def _copy(ledger):
return {"started": ledger["started"], "types": dict(ledger["types"]), "models": dict(ledger["models"]),
"opened": {kind: dict(ledger["opened"][kind]) for kind in ("types", "models")}}
def _save():
global _timer
with _lock:
_timer = None
ledger = _load()
for names in (ledger, ledger["opened"]):
for kind in ("types", "models"):
if len(names[kind]) > MAX_NAMES:
names[kind] = dict(sorted(names[kind].items(), key=lambda entry: entry[1][0], reverse=True)[:MAX_NAMES])
snapshot = _copy(ledger)
try:
atomic_write_json(ledger_path(), snapshot)
except (OSError, ValueError) as error:
logging.warning("[Anomalous Browser] Could not save the usage record: %s", error)
def prompt_uses(prompt):
"""(node types, model file references) a queued prompt names."""
types, models = set(), set()
for node in prompt.values():
if not isinstance(node, dict):
continue
if isinstance(node.get("class_type"), str) and node["class_type"]:
types.add(node["class_type"])
for value in (node.get("inputs") or {}).values():
if isinstance(value, dict) and isinstance(value.get("lora"), str):
value = value["lora"] # a row of rgthree's Power Lora Loader
if isinstance(value, str) and MODEL_FILE.search(value):
models.add(value.replace("\\", "/").strip().lower())
return types, models
def _note(types, models, when, opened):
"""Counts `types` and `models` as used at `when`, among the runs or the opened workflows."""
global _timer
with _lock:
ledger = _load()
if ledger["started"] is None:
ledger["started"] = when
names = ledger["opened"] if opened else ledger
for kind, found in (("types", types), ("models", models)):
for name in found:
count = names[kind].get(name, [0, 0])[1]
names[kind][name] = [when, count + 1]
if _timer is None:
_timer = threading.Timer(SAVE_DELAY, _save)
_timer.daemon = True
_timer.start()
def note(prompt, when=None):
"""Note what `prompt` (ComfyUI's API prompt) uses, as of `when`; saved shortly after."""
if not isinstance(prompt, dict):
return
types, models = prompt_uses(prompt)
_note(types, models, time.time() if when is None else when, opened=False)
def note_prompt(json_data):
"""ComfyUI's on_prompt hook: notes the run and hands the request on unchanged."""
try:
note((json_data or {}).get("prompt"))
except Exception as error: # noqa: BLE001 - a note must never stop a run
logging.warning("[Anomalous Browser] Could not note a run's nodes and models: %s", error)
return json_data
def _posted(values):
"""The names of a posted list: strings of a sane length, at most MAX_POSTED; None when not a list."""
if not isinstance(values, list):
return None
return {value for value in values[:MAX_POSTED] if isinstance(value, str) and 0 < len(value) <= MAX_NAME_CHARS}
async def api_opened(request):
try:
data = await request.json()
except ValueError:
data = None
if not isinstance(data, dict):
return web.json_response({"error": "bad_request"}, status=400)
types, models = _posted(data.get("types")), _posted(data.get("models"))
if types is None or models is None:
return web.json_response({"error": "bad_request"}, status=400)
models = {model.replace("\\", "/").strip().lower() for model in models if MODEL_FILE.search(model)}
_note(types, models, time.time(), opened=True)
return web.json_response({"noted": len(types) + len(models)})
def read():
"""The record as noted so far (a copy), shaped as the module docstring says."""
with _lock:
return _copy(_load())
def register_routes(app):
app.router.add_post("/anomalous/usage/opened", api_opened)
+21 -600
View File
@@ -1,610 +1,31 @@
import os
import sys
import json
import urllib.parse
import urllib.request
import subprocess
import threading
import asyncio
from aiohttp import web
import folder_paths
import struct
import hashlib
import tempfile
import time
"""Compatibility exports for focused backend utility modules."""
import os
from . import gallery_routes as _gallery
from .folder_types import *
from .media_routes import *
from .path_utils import *
from .translation_routes import *
CARD_THUMBNAIL_EDGE = 512
CARD_THUMBNAIL_CACHE_LIMIT = 256 * 1024 * 1024
CARD_THUMBNAIL_STATIC_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.webp', '.avif'}
_thumbnail_cleanup_lock = threading.Lock()
_thumbnail_generation_slots = threading.BoundedSemaphore(2)
_thumbnail_last_cleanup = 0.0
_thumbnail_created_since_cleanup = 0
_gallery_snapshot = _gallery._gallery_snapshot
_gallery_collect_impl = _gallery._collect_gallery_images
_gallery_images_impl = _gallery._gallery_images
def resolve_within(base_dir, *parts):
"""Resolve a path and require it to stay inside base_dir, including through symlinks."""
base_real = os.path.realpath(base_dir)
candidate = os.path.realpath(os.path.join(base_real, *parts))
try:
if os.path.commonpath([base_real, candidate]) != base_real:
raise ValueError("Path escapes the configured directory")
except ValueError:
raise ValueError("Path escapes the configured directory")
return candidate
def _collect_gallery_images(output_dir):
return _gallery_collect_impl(output_dir)
def get_folder_root(folder_type, path_idx=0):
paths = folder_paths.get_folder_paths(folder_type)
if not paths or not isinstance(path_idx, int) or path_idx < 0 or path_idx >= len(paths):
raise ValueError("Invalid folder path")
return os.path.realpath(paths[path_idx])
def _gallery_images(output_dir, refresh=False):
_gallery._collect_gallery_images = _collect_gallery_images
return _gallery_images_impl(output_dir, refresh)
def resolve_folder_subdir(folder_type, path_idx=0, subfolder='/'):
base_dir = get_folder_root(folder_type, path_idx)
relative = "" if subfolder in (None, "", "/") else str(subfolder).strip("/\\")
return base_dir, resolve_within(base_dir, relative)
def _invalidate_gallery_snapshot():
return _gallery._invalidate_gallery_snapshot()
def require_filename(filename):
if not isinstance(filename, str) or not filename or os.path.basename(filename) != filename:
raise ValueError("Invalid filename")
return filename
def _thumbnail_cache_directory():
try:
base_dir = folder_paths.get_temp_directory()
except Exception:
base_dir = None
if not isinstance(base_dir, (str, os.PathLike)):
base_dir = tempfile.gettempdir()
cache_dir = os.path.join(base_dir, "anomalous_model_browser", "card_thumbnails")
os.makedirs(cache_dir, exist_ok=True)
return cache_dir
def _prune_thumbnail_cache(cache_dir, created=False):
"""Occasionally cap the derived thumbnail cache without touching source covers."""
global _thumbnail_last_cleanup, _thumbnail_created_since_cleanup
now = time.monotonic()
with _thumbnail_cleanup_lock:
if created:
_thumbnail_created_since_cleanup += 1
now = time.monotonic()
if (
now - _thumbnail_last_cleanup < 3600
and _thumbnail_created_since_cleanup < 64
):
return
_thumbnail_last_cleanup = now
_thumbnail_created_since_cleanup = 0
entries = []
total_size = 0
try:
with os.scandir(cache_dir) as iterator:
for entry in iterator:
if not entry.is_file() or not entry.name.endswith('.webp'):
continue
try:
stat = entry.stat()
except OSError:
continue
total_size += stat.st_size
entries.append((stat.st_mtime_ns, stat.st_size, entry.path))
except OSError:
return
if total_size <= CARD_THUMBNAIL_CACHE_LIMIT:
return
target_size = int(CARD_THUMBNAIL_CACHE_LIMIT * 0.8)
for _, size, path in sorted(entries):
try:
os.remove(path)
total_size -= size
except OSError:
pass
if total_size <= target_size:
break
def _build_card_thumbnail_impl(source_path):
"""Return a cached 512px WebP card image, or the original on any safe fallback."""
try:
from PIL import Image, ImageOps
source_stat = os.stat(source_path)
cache_key = "\0".join((
os.path.realpath(source_path),
str(source_stat.st_size),
str(source_stat.st_mtime_ns),
str(getattr(source_stat, 'st_ctime_ns', 0)),
str(CARD_THUMBNAIL_EDGE),
))
digest = hashlib.sha256(cache_key.encode('utf-8', errors='surrogatepass')).hexdigest()
cache_dir = _thumbnail_cache_directory()
cached_path = os.path.join(cache_dir, f"{digest}.webp")
if os.path.isfile(cached_path):
try:
os.utime(cached_path, None)
except OSError:
pass
_prune_thumbnail_cache(cache_dir)
return cached_path
with Image.open(source_path) as image:
if getattr(image, 'is_animated', False):
return source_path
image = ImageOps.exif_transpose(image)
if max(image.size) <= CARD_THUMBNAIL_EDGE:
return source_path
resampling = getattr(Image, 'Resampling', Image).LANCZOS
image.thumbnail((CARD_THUMBNAIL_EDGE, CARD_THUMBNAIL_EDGE), resampling)
if image.mode not in ('RGB', 'RGBA'):
image = image.convert('RGBA' if 'transparency' in image.info else 'RGB')
temp_path = (
f"{cached_path}.{os.getpid()}.{threading.get_ident()}.tmp"
)
try:
image.save(temp_path, format='WEBP', quality=84, method=4)
os.replace(temp_path, cached_path)
finally:
if os.path.exists(temp_path):
try:
os.remove(temp_path)
except OSError:
pass
_prune_thumbnail_cache(cache_dir, created=True)
return cached_path
except Exception:
return source_path
def _build_card_thumbnail(source_path):
# Bound simultaneous decodes so opening a large folder cannot monopolize CPU/RAM.
with _thumbnail_generation_slots:
return _build_card_thumbnail_impl(source_path)
async def api_serve_image(request):
"""Dedicated image serving endpoint for model preview images."""
folder_type = request.query.get('type', 'checkpoints')
try:
path_idx = int(request.query.get('path_idx', 0))
except:
path_idx = 0
subfolder = request.query.get('subfolder', '')
filename = request.query.get('filename', '')
try:
filename = require_filename(filename)
_, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder)
file_path = resolve_within(target_dir, filename)
except (ValueError, KeyError):
return web.Response(status=400, text='Invalid request')
if not os.path.exists(file_path) or not os.path.isfile(file_path):
return web.Response(status=404, text='Image not found')
ext = os.path.splitext(filename)[1].lower()
content_types = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp',
'.gif': 'image/gif',
'.avif': 'image/avif',
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.mov': 'video/quicktime',
'.avi': 'video/x-msvideo'
}
content_type = content_types.get(ext)
if content_type is None:
return web.Response(status=415, text='Unsupported media type')
served_path = file_path
if (
request.query.get('variant') == 'card'
and ext in CARD_THUMBNAIL_STATIC_EXTENSIONS
):
served_path = await asyncio.to_thread(_build_card_thumbnail, file_path)
if served_path != file_path:
content_type = 'image/webp'
headers = {'Content-Type': content_type}
if request.query.get('t'):
headers['Cache-Control'] = 'public, max-age=31536000, immutable'
return web.FileResponse(served_path, headers=headers)
async def api_clear_cache(request):
try:
from .metadata import clear_metadata_cache
if hasattr(folder_paths, "filename_list_cache"):
folder_paths.filename_list_cache.clear()
if hasattr(folder_paths, "cache_helper") and hasattr(folder_paths.cache_helper, "clear"):
folder_paths.cache_helper.clear()
clear_metadata_cache()
return web.json_response({"status": "success"})
except Exception as e:
return web.json_response({"status": "error", "message": str(e)})
async def api_translate(request):
try:
data = await request.json()
text = data.get("text", "")
tl = data.get("target_lang", "zh-CN")
if not text:
return web.json_response({"translated": ""})
# Try DeepL if API key exists
script_dir = os.path.dirname(os.path.abspath(__file__))
config_path = os.path.join(script_dir, "config.json")
deepl_key = ""
if os.path.exists(config_path):
try:
with open(config_path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
deepl_key = cfg.get("DEEPL_API_KEY", "").strip()
except:
pass
if deepl_key:
deepl_map = { "zh-CN": "ZH", "en": "EN", "ja": "JA", "ko": "KO", "fr": "FR", "de": "DE", "es": "ES", "ru": "RU" }
d_tl = deepl_map.get(tl, "EN")
url = "https://api-free.deepl.com/v2/translate" if ":fx" in deepl_key else "https://api.deepl.com/v2/translate"
payload = urllib.parse.urlencode({
"auth_key": deepl_key,
"text": text,
"target_lang": d_tl
}).encode('utf-8')
req = urllib.request.Request(url, data=payload)
with urllib.request.urlopen(req, timeout=5) as resp:
result = json.loads(resp.read().decode('utf-8'))
return web.json_response({"translated": result["translations"][0]["text"]})
else:
# Fallback to Google Translate (free, no key)
url = f"https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl={tl}&dt=t&q={urllib.parse.quote(text)}"
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
with urllib.request.urlopen(req, timeout=5) as resp:
result = json.loads(resp.read().decode('utf-8'))
# Google Translate returns a list of fragments
translated_text = "".join([part[0] for part in result[0]])
return web.json_response({"translated": translated_text})
except Exception as e:
print(f"Translate Error: {e}")
return web.json_response({"translated": text, "error": str(e)})
async def api_get_gallery_images(request):
try:
output_dir = folder_paths.get_output_directory()
if not os.path.exists(output_dir):
return web.json_response({"images": [], "total": 0, "page": 1, "pages": 0})
page = max(1, int(request.query.get('page', 1)))
limit = min(200, max(1, int(request.query.get('limit', 50))))
valid_exts = {'.png', '.jpg', '.jpeg', '.webp', '.gif'}
def collect_images():
images = []
for root, dirs, files in os.walk(output_dir):
for f in files:
ext = os.path.splitext(f)[1].lower()
if ext in valid_exts:
rel_path = os.path.relpath(root, output_dir)
subfolder = "" if rel_path == "." else rel_path.replace('\\', '/')
full_path = os.path.join(root, f)
try:
mtime = os.path.getmtime(full_path)
except OSError:
mtime = 0
images.append({
"filename": f,
"subfolder": subfolder,
"type": "output",
"mtime": mtime
})
images.sort(key=lambda x: x['mtime'], reverse=True)
return images
images = await asyncio.to_thread(collect_images)
total = len(images)
start_idx = (page - 1) * limit
end_idx = start_idx + limit
paginated_images = images[start_idx:end_idx]
return web.json_response({
"images": paginated_images,
"total": total,
"page": page,
"pages": (total + limit - 1) // limit if limit > 0 else 0
})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
async def api_delete_gallery_image(request):
try:
data = await request.json()
filename = data.get("filename")
subfolder = data.get("subfolder", "")
output_dir = folder_paths.get_output_directory()
try:
filename = require_filename(filename)
target_dir = resolve_within(output_dir, subfolder)
file_path = resolve_within(target_dir, filename)
except ValueError:
return web.json_response({"status": "error", "message": "Invalid parameters"}, status=400)
if os.path.exists(file_path):
os.remove(file_path)
return web.json_response({"status": "success"})
else:
return web.json_response({"status": "error", "message": "File not found"})
except Exception as e:
return web.json_response({"status": "error", "message": str(e)}, status=500)
def read_png_text_fast(path):
try:
with open(path, 'rb') as f:
signature = f.read(8)
if signature != b'\x89PNG\r\n\x1a\n':
return None
while True:
length_bytes = f.read(4)
if not length_bytes: break
length = struct.unpack('>I', length_bytes)[0]
chunk_type = f.read(4)
if chunk_type == b'tEXt':
data = f.read(length)
keyword, text = data.split(b'\0', 1)
if keyword == b'prompt':
return text.decode('utf-8', errors='ignore')
else:
f.seek(length, 1)
f.seek(4, 1)
except Exception:
pass
return None
async def api_get_model_images(request):
model_name = request.rel_url.query.get('model_name', '')
if not model_name:
return web.json_response({'images': []})
base_target = os.path.basename(model_name).lower()
# We will search the output directory
output_dir = folder_paths.get_output_directory()
if not os.path.exists(output_dir):
return web.json_response({'images': []})
def find_images():
matched_images = []
for root, _, files in os.walk(output_dir):
for file in files:
if not file.lower().endswith('.png'):
continue
full_path = os.path.join(root, file)
prompt_text = read_png_text_fast(full_path)
if not prompt_text:
continue
try:
prompt_data = json.loads(prompt_text)
matched = any(
os.path.basename(v).lower() == base_target
for node in prompt_data.values()
if isinstance(node, dict) and 'class_type' in node
for v in node.get('inputs', {}).values()
if isinstance(v, str)
)
if not matched:
continue
rel_path = os.path.relpath(root, output_dir).replace('\\', '/')
if rel_path == '.':
rel_path = ''
url = f'/view?filename={urllib.parse.quote(file)}&type=output'
if rel_path:
url += f'&subfolder={urllib.parse.quote(rel_path)}'
matched_images.append({'url': url, 'mtime': os.path.getmtime(full_path)})
except (OSError, ValueError, TypeError, json.JSONDecodeError):
pass
matched_images.sort(key=lambda x: x['mtime'], reverse=True)
return matched_images
matched_images = await asyncio.to_thread(find_images)
return web.json_response({'images': matched_images})
def get_folder_view_mode():
script_dir = os.path.dirname(os.path.abspath(__file__))
config_path = os.path.join(script_dir, "config.json")
try:
if os.path.exists(config_path):
with open(config_path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
return cfg.get("folder_view_mode", "abstract")
except:
pass
return "abstract"
def get_all_physical_basenames():
basenames = set()
for t in folder_paths.folder_names_and_paths.keys():
try:
paths = folder_paths.get_folder_paths(t)
if not paths: continue
for p in paths:
bn = os.path.basename(os.path.normpath(p))
if bn: basenames.add(bn)
except:
pass
return list(basenames)
def get_active_physical_basenames():
script_dir = os.path.dirname(os.path.abspath(__file__))
config_path = os.path.join(script_dir, "config.json")
all_bns = get_all_physical_basenames()
active = []
configured = set()
try:
if os.path.exists(config_path):
with open(config_path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
pfc = cfg.get("physical_folders_config")
if pfc and isinstance(pfc, list):
for item in pfc:
bn = item.get("type")
if bn in all_bns:
configured.add(bn)
if item.get("visible", True):
active.append(bn)
for bn in all_bns:
if bn not in configured:
active.append(bn)
return active
except:
pass
return all_bns
def get_active_scan_paths():
mode = get_folder_view_mode()
paths = set()
if mode == "physical":
active_bns = set(get_active_physical_basenames())
for t in folder_paths.folder_names_and_paths.keys():
try:
ps = folder_paths.get_folder_paths(t)
if not ps: continue
for p in ps:
if not os.path.exists(p): continue
bn = os.path.basename(os.path.normpath(p))
if bn in active_bns:
paths.add(os.path.realpath(p))
except:
pass
else:
active_types = get_active_folder_types()
for t in active_types:
try:
ps = folder_paths.get_folder_paths(t)
if not ps: continue
for p in ps:
if not os.path.exists(p): continue
paths.add(os.path.realpath(p))
except:
pass
return list(paths)
def get_active_folder_types():
script_dir = os.path.dirname(os.path.abspath(__file__))
config_path = os.path.join(script_dir, "config.json")
all_types = list(folder_paths.folder_names_and_paths.keys())
default_types = ['checkpoints', 'loras', 'diffusion_models', 'controlnet', 'vae']
try:
if os.path.exists(config_path):
with open(config_path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
ftc = cfg.get("folder_types_config")
if ftc and isinstance(ftc, list):
active = []
configured_types = set()
for item in ftc:
t = item.get("type")
if t in all_types:
configured_types.add(t)
if item.get("visible", True):
active.append(t)
# For any newly registered types not in config, do not show them by default
# unless they are in default_types
for t in all_types:
if t not in configured_types and t in default_types:
active.append(t)
return active
except Exception:
pass
# Fallback if no config exists
active = [t for t in default_types if t in all_types]
return active
async def api_get_all_folder_types(request):
mode = get_folder_view_mode()
script_dir = os.path.dirname(os.path.abspath(__file__))
config_path = os.path.join(script_dir, "config.json")
result = []
if mode == "physical":
all_bns = get_all_physical_basenames()
configured = set()
try:
if os.path.exists(config_path):
with open(config_path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
pfc = cfg.get("physical_folders_config")
if pfc and isinstance(pfc, list):
for item in pfc:
bn = item.get("type")
if bn in all_bns:
configured.add(bn)
result.append({
"type": bn,
"visible": item.get("visible", True)
})
except:
pass
default_physical_types = ['checkpoints', 'loras', 'unet', 'diffusion_models', 'controlnet', 'vae']
for bn in all_bns:
if bn not in configured:
result.append({
"type": bn,
"visible": bn in default_physical_types
})
else:
# Abstract mode
all_types = list(folder_paths.folder_names_and_paths.keys())
default_types = ['checkpoints', 'loras', 'diffusion_models', 'controlnet', 'vae']
configured_types = set()
try:
if os.path.exists(config_path):
with open(config_path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
ftc = cfg.get("folder_types_config")
if ftc and isinstance(ftc, list):
for item in ftc:
t = item.get("type")
if t in all_types:
configured_types.add(t)
result.append({
"type": t,
"visible": item.get("visible", True)
})
except:
pass
for t in all_types:
if t not in configured_types:
result.append({
"type": t,
"visible": t in default_types
})
return web.json_response({"folder_types": result, "folder_view_mode": mode})
api_get_gallery_images = _gallery.api_get_gallery_images
api_delete_gallery_image = _gallery.api_delete_gallery_image
+403
View File
@@ -0,0 +1,403 @@
"""Plugin version display, published-release listing and version switching.
The plugin is normally installed as a git clone (ComfyUI Manager), so the
installed version is read from git and switching checks out a published
release tag. Nothing runs automatically: every network call starts from a
button in the UI. Switching refuses when tracked files have local changes, and
only tags that are published releases (or at least exist on the remote) can be
checked out.
Every function acts on the plugin itself unless given a `Checkout`: the
companion node pack Anomalous_TTS is updated the same way (api/tts_version.py).
"""
import asyncio
import json
import os
import re
import shutil
import subprocess
import threading
import time
import urllib.request
from aiohttp import web
try:
from ..net_proxy import urlopen
except ImportError: # loaded outside the package (tests)
from net_proxy import urlopen
PLUGIN_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
STATE_FILE = os.path.join(PLUGIN_DIR, ".anomalous_version.local.json")
# A version whose tree contains this file has the version panel; older ones do not.
SWITCHER_MARKER = "api/version_manager.py"
TAG_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._\-]{0,99}$")
GITHUB_REMOTE = re.compile(r"github\.com[:/]+([^/]+)/([^/]+?)(?:\.git)?/?$", re.IGNORECASE)
RELEASES_CACHE_SECONDS = 300
NETWORK_TIMEOUT = 10
FETCH_TIMEOUT = 180
_lock = threading.Lock()
_releases_cache = {"expires": 0, "data": None}
class Checkout:
"""Another git checkout to manage: its folder, where its last switch is noted, and the file
that marks versions with a version panel (None: no such notion)."""
def __init__(self, root, state_file, marker=None):
self.root = root
self.state_file = state_file
self.marker = marker
self.cache = {"expires": 0, "data": None}
def _root(repo):
return repo.root if repo else PLUGIN_DIR
class VersionError(Exception):
"""A user-facing failure with a stable code for the UI."""
def __init__(self, code, message="", **details):
super().__init__(message or code)
self.code = code
self.details = details
# ---------- git helpers ----------
def _git_executable():
found = shutil.which("git")
if not found:
raise VersionError("git_missing", "git executable not found")
return found
def _git(*args, timeout=30, check=True, repo=None):
kwargs = {}
if os.name == "nt":
kwargs["creationflags"] = getattr(subprocess, "CREATE_NO_WINDOW", 0)
env = dict(os.environ, GIT_TERMINAL_PROMPT="0")
try:
result = subprocess.run(
[_git_executable(), "-C", _root(repo), *args],
capture_output=True, text=True, encoding="utf-8", errors="replace",
timeout=timeout, env=env, **kwargs,
)
except subprocess.TimeoutExpired:
raise VersionError("git_timeout", f"git {' '.join(args[:2])} timed out")
if check and result.returncode != 0:
raise VersionError("git_failed", (result.stderr or result.stdout).strip()[-500:])
return result
def _git_out(*args, **kwargs):
result = _git(*args, check=False, **kwargs)
return result.stdout.strip() if result.returncode == 0 else ""
def version_key(tag):
"""Sortable key for tags such as v1.4.2, v1.57-beta, v1.58.0-rc1 (stable after pre-release)."""
match = re.match(r"^v?(\d+(?:\.\d+)*)(.*)$", str(tag or ""))
if not match:
return ((), 0, str(tag))
numbers = tuple(int(part) for part in match.group(1).split("."))
suffix = match.group(2).lstrip("-_.")
return (numbers, 1 if not suffix else 0, suffix)
def current_state(repo=None):
"""Local only (no network): what is installed and whether it can be switched."""
if not os.path.isdir(os.path.join(_root(repo), ".git")) and _git_out("rev-parse", "--is-inside-work-tree", repo=repo) != "true":
return {"is_git": False}
commit = _git_out("rev-parse", "HEAD", repo=repo)
branch = _git_out("symbolic-ref", "--short", "-q", "HEAD", repo=repo) or None
exact_tag = _git_out("describe", "--tags", "--exact-match", "HEAD", repo=repo) or None
described = _git_out("describe", "--tags", "--always", repo=repo)
base_tag = _git_out("describe", "--tags", "--abbrev=0", repo=repo) or None
# Porcelain lines are "XY path"; keep the leading status column (no strip before slicing).
status = _git("status", "--porcelain", "--untracked-files=no", check=False, repo=repo).stdout
changed = [line[3:] for line in status.splitlines() if line.strip()]
return {
"is_git": True,
"commit": commit[:12],
"branch": branch,
"tag": exact_tag,
"base_tag": base_tag,
"label": exact_tag or described or commit[:12],
"dirty": bool(changed),
"changed_files": changed[:20],
"remote": _git_out("remote", "get-url", "origin", repo=repo) or None,
"previous": _read_state(repo).get("previous"),
}
# ---------- releases ----------
def _github_repo(remote_url):
match = GITHUB_REMOTE.search(remote_url or "")
return (match.group(1), match.group(2)) if match else None
def _fetch_github_releases(owner, repo):
url = f"https://api.github.com/repos/{owner}/{repo}/releases?per_page=50"
request = urllib.request.Request(url, headers={
"Accept": "application/vnd.github+json",
"User-Agent": "Anomalous-Model-Browser",
})
with urlopen(request, timeout=NETWORK_TIMEOUT) as response:
payload = json.loads(response.read().decode("utf-8"))
releases = []
for item in payload if isinstance(payload, list) else []:
if item.get("draft") or not item.get("tag_name"):
continue
releases.append({
"tag": item["tag_name"],
"name": item.get("name") or item["tag_name"],
"published_at": item.get("published_at"),
"prerelease": bool(item.get("prerelease")),
"notes": (item.get("body") or "")[:8000],
"url": item.get("html_url"),
})
return releases
def _remote_tags(repo=None):
output = _git_out("ls-remote", "--tags", "--refs", "origin", timeout=NETWORK_TIMEOUT * 3, repo=repo)
tags = [line.split("refs/tags/", 1)[1] for line in output.splitlines() if "refs/tags/" in line]
return [{"tag": tag, "name": tag, "published_at": None, "prerelease": False, "notes": "", "url": None} for tag in tags]
def _remote_tag_at(commit, repo=None):
"""The remote tag on `commit`, for a checkout whose own tags were never fetched (a clone made
before the release, then updated by a pull of its branch)."""
output = _git_out("ls-remote", "--tags", "origin", timeout=NETWORK_TIMEOUT * 3, repo=repo)
for line in output.splitlines():
sha, _, ref = line.partition("\t")
if commit and sha.startswith(commit) and ref.startswith("refs/tags/"):
return ref[len("refs/tags/"):].removesuffix("^{}")
return None
def list_releases(force=False, repo=None):
"""Published releases, newest first. GitHub API first, `git ls-remote` (uses git's proxy) as fallback."""
state = current_state(repo)
if not state.get("is_git"):
raise VersionError("not_git", "The plugin is not a git checkout")
cache = repo.cache if repo else _releases_cache
now = time.monotonic()
if not force and cache["data"] and now < cache["expires"]:
releases, source = cache["data"]
else:
releases, source = None, None
github = _github_repo(state.get("remote"))
if github:
try:
releases, source = _fetch_github_releases(*github), "github"
except Exception:
releases = None
if releases is None:
releases, source = _remote_tags(repo), "git"
if not releases:
raise VersionError("offline", "Could not reach GitHub or the git remote")
releases.sort(key=lambda item: (item["published_at"] or "", version_key(item["tag"])), reverse=True)
if source == "git":
releases.sort(key=lambda item: version_key(item["tag"]), reverse=True)
cache.update(expires=now + RELEASES_CACHE_SECONDS, data=(releases, source))
if not state.get("tag") and not state.get("dirty"):
state["tag"] = _remote_tag_at(state.get("commit"), repo)
current_key = version_key(state.get("tag") or state.get("base_tag"))
stable = [item for item in releases if not item["prerelease"]]
latest = stable[0]["tag"] if stable else None
result = []
for item in releases:
key = version_key(item["tag"])
entry = dict(item)
entry["current"] = item["tag"] == state.get("tag")
entry["latest"] = item["tag"] == latest
entry["direction"] = "same" if entry["current"] else ("newer" if key > current_key else "older")
result.append(entry)
return {"state": state, "releases": result, "latest": latest, "source": source}
# ---------- switching ----------
def _read_state(repo=None):
try:
with open(repo.state_file if repo else STATE_FILE, "r", encoding="utf-8") as handle:
data = json.load(handle)
return data if isinstance(data, dict) else {}
except (OSError, ValueError):
return {}
def _write_state(data, repo=None):
path = repo.state_file if repo else STATE_FILE
temporary = path + ".tmp"
with open(temporary, "w", encoding="utf-8") as handle:
json.dump(data, handle, ensure_ascii=False, indent=2)
os.replace(temporary, path)
def _require_switchable(repo=None):
state = current_state(repo)
if not state.get("is_git"):
raise VersionError("not_git", "The plugin is not a git checkout")
if state["dirty"]:
raise VersionError("dirty", "Tracked files have local changes", files=state["changed_files"])
return state
def _known_tag(tag, repo=None):
if not TAG_NAME.match(str(tag or "")):
raise VersionError("bad_tag", "Invalid version name")
tags = {item["tag"] for item in list_releases(repo=repo)["releases"]}
if tag not in tags:
raise VersionError("unknown_tag", f"{tag} is not a published version")
return tag
def _fetch_tag(tag, repo=None):
_git("fetch", "--no-tags", "--force", "origin", f"+refs/tags/{tag}:refs/tags/{tag}", timeout=FETCH_TIMEOUT, repo=repo)
def preview_switch(tag, repo=None):
"""Fetch the target and report what switching would mean, without changing anything."""
state = _require_switchable(repo)
_known_tag(tag, repo)
_fetch_tag(tag, repo)
marker = repo.marker if repo else SWITCHER_MARKER
has_switcher = not marker or _git("cat-file", "-e", f"refs/tags/{tag}:{marker}", check=False, repo=repo).returncode == 0
target_key = version_key(tag)
current_key = version_key(state.get("tag") or state.get("base_tag"))
return {
"tag": tag,
"direction": "same" if tag == state.get("tag") else ("newer" if target_key > current_key else "older"),
"has_switcher": has_switcher,
"from": state["label"],
"from_branch": state["branch"],
}
def switch_to(tag, repo=None):
with _lock:
state = _require_switchable(repo)
_known_tag(tag, repo)
_fetch_tag(tag, repo)
previous = {"commit": _git_out("rev-parse", "HEAD", repo=repo), "branch": state["branch"], "label": state["label"]}
_git("-c", "advice.detachedHead=false", "checkout", "--quiet", "--detach", f"refs/tags/{tag}", repo=repo)
_write_state({"previous": previous, "switched_at": time.time()}, repo)
return {"state": current_state(repo), "restart_required": True}
def _default_branch():
ref = _git_out("symbolic-ref", "--short", "refs/remotes/origin/HEAD")
if ref.startswith("origin/"):
return ref.split("/", 1)[1]
for name in ("main", "master"):
if _git("ls-remote", "--exit-code", "--heads", "origin", name, check=False, timeout=NETWORK_TIMEOUT * 3).returncode == 0:
return name
raise VersionError("no_default_branch", "Could not determine the default branch")
def return_to_latest():
"""Back to the default branch at its newest commit; the same path ComfyUI Manager's update takes."""
with _lock:
state = _require_switchable()
branch = _default_branch()
_git("fetch", "--no-tags", "origin", f"+refs/heads/{branch}:refs/remotes/origin/{branch}", timeout=FETCH_TIMEOUT)
previous = {"commit": _git_out("rev-parse", "HEAD"), "branch": state["branch"], "label": state["label"]}
if _git("rev-parse", "--verify", "--quiet", f"refs/heads/{branch}", check=False).returncode == 0:
_git("checkout", "--quiet", branch)
else:
_git("checkout", "--quiet", "-b", branch, f"origin/{branch}")
merge = _git("merge", "--ff-only", "--quiet", f"origin/{branch}", check=False)
if merge.returncode != 0:
raise VersionError("diverged", f"Local {branch} has commits that are not on the remote")
_write_state({"previous": previous, "switched_at": time.time()})
return {"state": current_state(), "restart_required": True}
def undo_last_switch(repo=None):
with _lock:
previous = _read_state(repo).get("previous")
if not previous or not previous.get("commit"):
raise VersionError("no_previous", "There is no switch to undo")
state = _require_switchable(repo)
here = {"commit": _git_out("rev-parse", "HEAD", repo=repo), "branch": state["branch"], "label": state["label"]}
if previous.get("branch"):
# Going back to a branch means its current tip, even if it moved since (e.g. a Manager update).
_git("checkout", "--quiet", previous["branch"], repo=repo)
else:
_git("-c", "advice.detachedHead=false", "checkout", "--quiet", "--detach", previous["commit"], repo=repo)
_write_state({"previous": here, "switched_at": time.time()}, repo)
return {"state": current_state(repo), "restart_required": True}
# ---------- routes ----------
def error_response(error):
status = 409 if error.code in {"dirty", "diverged"} else 400
if error.code in {"offline", "git_timeout", "git_failed"}:
status = 502
return web.json_response({"success": False, "code": error.code, "error": str(error), **error.details}, status=status)
async def run(handler, *args):
try:
return web.json_response({"success": True, **(await asyncio.to_thread(handler, *args))})
except VersionError as error:
return error_response(error)
async def json_body(request):
try:
data = await request.json()
except Exception:
return {}
return data if isinstance(data, dict) else {}
async def api_get_version(request):
"""GET /anomalous/version - Installed version (local, no network)."""
try:
return web.json_response({"success": True, "state": await asyncio.to_thread(current_state)})
except VersionError as error:
return error_response(error)
async def api_get_releases(request):
"""GET /anomalous/version/releases?refresh=1 - Published releases (network, only when asked)."""
return await run(list_releases, request.query.get("refresh") == "1")
async def api_preview_switch(request):
"""POST /anomalous/version/preview {tag}"""
return await run(preview_switch, str((await json_body(request)).get("tag", "")))
async def api_switch_version(request):
"""POST /anomalous/version/switch {tag}"""
return await run(switch_to, str((await json_body(request)).get("tag", "")))
async def api_return_to_latest(request):
"""POST /anomalous/version/latest"""
return await run(return_to_latest)
async def api_undo_switch(request):
"""POST /anomalous/version/undo"""
return await run(undo_last_switch)
def register_routes(app):
app.router.add_get('/anomalous/version', api_get_version)
app.router.add_get('/anomalous/version/releases', api_get_releases)
app.router.add_post('/anomalous/version/preview', api_preview_switch)
app.router.add_post('/anomalous/version/switch', api_switch_version)
app.router.add_post('/anomalous/version/latest', api_return_to_latest)
app.router.add_post('/anomalous/version/undo', api_undo_switch)
+219
View File
@@ -0,0 +1,219 @@
"""Pure Workflow Recipe graph validation and fingerprinting."""
import hashlib
import json
import re
from .recipe_constants import *
def _normalise_workflow_key(value):
return re.sub(r"[^a-z0-9]+", "_", str(value).strip().lower()).strip("_")
def _volatile_widget_indexes(node):
"""Known serialized LiteGraph widget positions for native sampler seeds."""
node_type = _node_type(node).lower()
if node_type == "ksampler":
return (0,)
if node_type == "ksampleradvanced":
return (1,)
return ()
def _clean_volatile_params(value):
"""Produce a stable graph view while retaining all generation-defining data."""
if isinstance(value, list):
return [_clean_volatile_params(item) for item in value]
if not isinstance(value, dict):
return value
cleaned = {}
for key, nested_value in value.items():
if _normalise_workflow_key(key) in VOLATILE_WORKFLOW_KEYS:
continue
cleaned[str(key)] = _clean_volatile_params(nested_value)
widgets = cleaned.get("widgets_values")
if isinstance(widgets, list):
for index in _volatile_widget_indexes(value):
if 0 <= index < len(widgets):
widgets[index] = "__anomalous_volatile_seed__"
return cleaned
def _workflow_fingerprint(workflow):
"""Hash the workflow structure after removing known run-volatile values."""
canonical = json.dumps(
_clean_volatile_params(workflow),
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
return {
"algorithm": "sha256-structural-v1",
"value": hashlib.sha256(canonical).hexdigest(),
}
def _parameter_signature(workflow):
"""Hash node types and parameter values for UI workflows or API prompts."""
if not isinstance(workflow, dict):
return {"algorithm": "sha256-params-v1", "value": ""}
nodes = workflow.get("nodes")
is_ui_workflow = isinstance(nodes, list)
if not is_ui_workflow:
nodes = list(workflow.values())
cleaned_nodes = []
for node in nodes:
if not isinstance(node, dict):
continue
if is_ui_workflow:
parameters = {"widgets": _clean_volatile_params(node.get("widgets_values") or [])}
widgets = parameters["widgets"]
if isinstance(widgets, list):
for index in _volatile_widget_indexes(node):
if 0 <= index < len(widgets):
widgets[index] = "__anomalous_volatile_seed__"
else:
parameters = {"inputs": _clean_volatile_params(node.get("inputs") or {})}
cleaned_nodes.append({
"type": _node_type(node),
**parameters,
})
cleaned_nodes.sort(key=lambda x: (x["type"], json.dumps(x, sort_keys=True, ensure_ascii=False)))
canonical = json.dumps(
cleaned_nodes,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
return {
"algorithm": "sha256-params-v1",
"value": hashlib.sha256(canonical).hexdigest(),
}
def _workflow_node_types(workflow):
"""Extract only node class names from either UI workflow or API prompt data."""
if not isinstance(workflow, dict):
return []
nodes = workflow.get("nodes")
if isinstance(nodes, list):
values = nodes
else:
values = workflow.values()
result = []
for node in values:
if not isinstance(node, dict):
continue
node_type = _node_type(node) or str(node.get("class_type") or "").strip()
if node_type:
result.append(node_type)
return sorted(result, key=lambda value: value.casefold())
def _workflow_node_signature(workflow):
"""Stable node-composition signature used for tolerant result discovery."""
canonical = json.dumps(
_workflow_node_types(workflow),
ensure_ascii=False,
separators=(",", ":"),
).encode("utf-8")
return {
"algorithm": "sha256-node-types-v1",
"value": hashlib.sha256(canonical).hexdigest(),
}
def _node_type(node):
return str(node.get("type") or node.get("class_type") or "").strip()
def _node_title(node):
meta = node.get("_meta") if isinstance(node.get("_meta"), dict) else {}
return str(meta.get("title") or node.get("title") or _node_type(node) or "Unknown node").strip()
def _widget_values(node):
values = node.get("widgets_values")
return values if isinstance(values, list) else []
def _workflow_node_key(value):
if isinstance(value, bool) or not isinstance(value, (int, str)):
raise ValueError("Invalid workflow node id")
value = str(value).strip()
if not value:
raise ValueError("Invalid workflow node id")
return value
def _workflow_link_records(workflow):
links = workflow.get("links", [])
if links is None:
return []
if isinstance(links, list):
return links
if isinstance(links, dict):
return list(links.values())
raise ValueError("Invalid workflow links")
def _validate_workflow(workflow):
"""Reject malformed graph topology before it reaches user recipe storage."""
if not isinstance(workflow, dict):
raise ValueError("Invalid recipe workflow")
nodes = workflow.get("nodes")
if not isinstance(nodes, list) or len(nodes) > MAX_WORKFLOW_NODES:
raise ValueError("Invalid workflow nodes")
node_ids = set()
for node in nodes:
if not isinstance(node, dict):
raise ValueError("Invalid workflow node")
node_key = _workflow_node_key(node.get("id"))
if node_key in node_ids:
raise ValueError("Duplicate workflow node id")
node_ids.add(node_key)
values = node.get("widgets_values")
if values is not None and (not isinstance(values, list) or len(values) > MAX_WIDGET_VALUES_PER_NODE):
raise ValueError("Invalid workflow widget values")
groups = workflow.get("groups", [])
if groups is not None and (not isinstance(groups, list) or len(groups) > MAX_WORKFLOW_GROUPS):
raise ValueError("Invalid workflow groups")
links = _workflow_link_records(workflow)
if len(links) > MAX_WORKFLOW_LINKS:
raise ValueError("Too many workflow links")
for link in links:
if isinstance(link, list) and len(link) >= 5:
origin_id, target_id = link[1], link[3]
elif isinstance(link, dict):
origin_id, target_id = link.get("origin_id"), link.get("target_id")
else:
raise ValueError("Invalid workflow link")
if _workflow_node_key(origin_id) not in node_ids or _workflow_node_key(target_id) not in node_ids:
raise ValueError("Dangling workflow link")
def _recipe_receipt(recipe, filename):
"""Return a small, user-visible confirmation for the accepted graph."""
workflow = recipe.get("workflow") if isinstance(recipe, dict) else {}
params = recipe.get("params") if isinstance(recipe, dict) else {}
return {
"filename": filename,
"node_count": len(workflow.get("nodes", [])) if isinstance(workflow, dict) else 0,
"link_count": len(_workflow_link_records(workflow)) if isinstance(workflow, dict) else 0,
"group_count": len(workflow.get("groups", [])) if isinstance(workflow.get("groups", []), list) else 0,
"parameter_node_count": len(params.get("nodes", [])) if isinstance(params, dict) and isinstance(params.get("nodes", []), list) else 0,
"pinned_count": len(params.get("pinned", [])) if isinstance(params, dict) and isinstance(params.get("pinned", []), list) else 0,
"workflow_fingerprint": recipe.get("workflow_fingerprint") if isinstance(recipe, dict) else None,
}
+141
View File
@@ -0,0 +1,141 @@
"""Civitai over the network, for the scanner: a model version by file hash, a model page's
description, and preview media. The API key comes from the plugin's config.json.
fetch_civitai_info tells the two failures apart: None means Civitai answered that it does not
know the file; CivitaiUnreachable means it could not be asked (no connection, timeouts, errors),
and carries why (net_proxy.failure). Requests go through Settings' proxy (net_proxy.py).
Civitai answers its API on two addresses, civitai.com and civitai.red. Some networks reach only
the second (mainland China's, a cloud server there), so when one does not answer the other is
asked, and the one that answered is asked first next time (api_hosts). The pictures stay on
image.civitai.com either way.
"""
import json
import os
import time
import urllib.error
import urllib.request
from typing import Dict, Optional
try:
from .net_proxy import failure, urlopen
except ImportError: # the scan process runs this as a top-level module
from net_proxy import failure, urlopen
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
API_HOSTS = ("civitai.com", "civitai.red")
_answered = [API_HOSTS[0]] # the address that answered last
def api_hosts():
"""Civitai's API addresses, the one that answered last first."""
return [_answered[0]] + [host for host in API_HOSTS if host != _answered[0]]
def answered(host):
"""Notes that `host` answered (with data or a 404), so it is asked first from now on."""
_answered[0] = host
class CivitaiUnreachable(Exception):
"""Civitai did not answer (network, timeout or server error) after the retries; args[0] says
why ('timeout', 'refused', 'http:503' …)."""
def _load_api_key():
plugin_dir = os.path.dirname(os.path.abspath(__file__))
for config_path in (os.path.join(plugin_dir, "api", "config.json"), os.path.join(plugin_dir, "config.json")):
if not os.path.exists(config_path):
continue
try:
with open(config_path, 'r', encoding='utf-8') as f:
key = json.load(f).get("CIVITAI_API_KEY", "")
if isinstance(key, str) and key.strip():
return key.strip()
except Exception as e:
print(f"[-] 读取 config.json 失败: {e}")
return None
CIVITAI_API_KEY = _load_api_key()
def _headers():
headers = {"User-Agent": USER_AGENT}
if CIVITAI_API_KEY:
headers["Authorization"] = f"Bearer {CIVITAI_API_KEY}"
return headers
def fetch_civitai_info(file_hash: str, max_retries: int = 3) -> Optional[Dict]:
"""Civitai's record of the model version with this file hash; None when Civitai does not
know the file (404). Raises CivitaiUnreachable when it could not be asked. Each try asks
the other address too when the first does not answer at all."""
why = "other"
for attempt in range(max_retries):
for host in api_hosts():
url = f"https://{host}/api/v1/model-versions/by-hash/{file_hash}"
try:
with urlopen(urllib.request.Request(url, headers=_headers()), timeout=15) as response:
data = json.loads(response.read().decode('utf-8'))
answered(host)
return data
except urllib.error.HTTPError as e:
if e.code == 404:
answered(host)
print(f"\033[93m[Skip] 模型 Hash {file_hash} 未在 Civitai 找到 (404)。\033[0m")
return None
why = failure(e)
print(f"[-] 请求异常,状态码: {e.code} (尝试 {attempt+1}/{max_retries})")
break # Civitai answered with an error: the other address is the same Civitai
except urllib.error.URLError as e:
why = failure(e)
print(f"[-] {host} 网络请求超时或异常: {e.reason} (尝试 {attempt+1}/{max_retries})")
except Exception as e:
why = failure(e)
print(f"[-] {host} 未知异常: {e} (尝试 {attempt+1}/{max_retries})")
if attempt < max_retries - 1:
time.sleep(2)
print(f"\033[93m[Skip] 模型 Hash {file_hash} 网络重试失败。\033[0m")
raise CivitaiUnreachable(why)
def fetch_model_description(model_id) -> str:
"""The model page's description (the version record only has the version's); '' when unavailable."""
try:
request = urllib.request.Request(f"https://{api_hosts()[0]}/api/v1/models/{model_id}", headers=_headers())
with urlopen(request, timeout=10) as response:
return json.loads(response.read().decode('utf-8')).get("description") or ""
except Exception as e:
print(f"[-] 获取模型主页详细说明失败: {e}")
return ""
def download_media(url: str, base_path: str, max_retries: int = 3):
"""Downloads an image or video to base_path + the extension its type calls for; the path, or None."""
for attempt in range(max_retries):
try:
with urlopen(urllib.request.Request(url, headers=_headers()), timeout=15) as response:
content_type = response.headers.get("Content-Type", "").lower()
ext = ".png"
if "video/mp4" in content_type: ext = ".mp4"
elif "video/webm" in content_type: ext = ".webm"
elif "image/jpeg" in content_type: ext = ".jpg"
elif "image/webp" in content_type: ext = ".webp"
elif url.endswith(".mp4"): ext = ".mp4"
final_path = base_path + ext
with open(final_path, 'wb') as f:
while True:
chunk = response.read(8192)
if not chunk:
break
f.write(chunk)
return final_path
except urllib.error.HTTPError as e:
print(f"[-] 媒体下载失败,状态码: {e.code} (尝试 {attempt+1}/{max_retries})")
except urllib.error.URLError as e:
print(f"[-] 媒体下载网络异常: {e.reason} (尝试 {attempt+1}/{max_retries})")
except Exception:
pass
return None
+24
View File
@@ -0,0 +1,24 @@
# Architecture Topic Guide
Start with the repository-root [`ARCHITECTURE.md`](../../ARCHITECTURE.md), then
open only the topic needed for the current task.
- [`backend.md`](backend.md): Python routes, filesystem safety, persistence,
scan state, model sidecars, and backend performance.
- [`frontend.md`](frontend.md): extension bootstrap, UI ownership, localization,
media lifecycle, panel state, and graph mutations.
- [`recipes.md`](recipes.md): Workflow Recipes, packages, galleries, Parameter
Notebooks, prompt roles, and recipe-to-model transitions.
- [`material-library.md`](material-library.md): saved prompts and node values
(material files), keeping an output image, and dragging prompts onto the canvas.
- [`recipe-model-lifecycle.md`](recipe-model-lifecycle.md): authoritative model
fields, Hash synchronization, local matching, partial append, and note privacy.
- [`model-resolution.md`](model-resolution.md): model provenance, Model Check,
hash injection, model scanning, and recovery policy.
- [`../decisions/README.md`](../decisions/README.md): concise records of product
and architectural decisions that still explain current behavior.
These documents describe current contracts rather than implementation history.
For exact changes, use Git history. For user-visible releases, use
`CHANGELOG.md`. For recurring implementation traps, use
[`lessons.md`](lessons.md).
+484
View File
@@ -0,0 +1,484 @@
# Backend Architecture
Read this document for changes to `api/`, `scraper.py`, model files and sidecars,
recipe persistence, or filesystem-facing routes.
## Runtime and route boundary
`__init__.py` exposes the frontend directory and imports the modular route
package. `api/__init__.py` registers `aiohttp` routes, all prefixed with
`/anomalous/`. Changes to Python modules require a full ComfyUI restart; a
frontend reload alone does not replace registered handlers or module state.
Route registration uses explicit module references, so moving a handler between
modules cannot silently drop, duplicate, or rename an endpoint.
The backend owns filesystem authority. A browser-supplied path, filename,
category, output reference, recipe asset, or archive member is untrusted until
validated. Use the shared helpers in `api/path_utils.py` (also re-exported by the
small `api/utils.py` compatibility facade):
- `resolve_folder_subdir()` for configured model-folder boundaries;
- `resolve_within()` for containment below an owned root;
- `require_filename()` for single-file identifiers (never `.` or `..`);
- `model_file_within()` / `model_reference_within()` for a model file in a resolved folder.
A model file can be a link: on a cloud server (AutoDL) every model in the models folder links
into the platform's shared, read-only model disk, under a name like `6b4679…` without an
extension. Resolving the file would leave the models folder and lose its covers and info, which
are written beside the link. So the folder part is resolved and must stay inside its root
(through links too), and the file is taken as named: `model_file_within` is the folder joined
with a bare `require_filename` name, `get_metadata` reads the sidecars beside the path it is
given (`abspath`, not `realpath`), and edits, renames and deletes act on the link, never on the
disk behind it. Only when a link has no info beside it are the info files beside the file it
links to read (`_info_base`): another tool (A1111's Civitai Helper) may have left them there. Nothing in the plugin writes into a model file. Tidy's moves and the
import's `move_into` tell a rename from a copy by where the link is (`lstat`), so moving a
linked model renames the link instead of copying the model disk's file onto the server.
Never rely only on `..` rejection. Absolute Windows paths, UNC paths, alternate
separators, device paths, and symlinks can bypass naïve string checks. Media
routes also enforce an explicit extension allowlist.
Configured model scope belongs to `api.folder_types`. Category mode uses
`get_active_folder_types()`; folder-manager-facing library enumeration uses
`get_active_model_roots()` so physical mode can retain the exact registered
`type`, `path_idx`, and real root for every enabled directory. Real roots are
deduplicated without merging same-named files from different roots. Do not
hardcode a walk across `checkpoints`, `loras`, or another category. A directory
disabled in `config.json` must cause no walk or metadata I/O.
`/anomalous/all_scan_models` follows that current folder-manager mode and keeps
its paginated response plus `limit=0` full-list behavior. Its local source-link
inventory recognizes GGUF and PTH case-insensitively in addition to the existing
list formats. This route-level inventory does not expand global model-format,
scanner, metadata-parser, recovery, or cloud-identification support.
## Requests from other devices
ComfyUI started with `--listen` lets other devices on the network open the page. They may
look; every change under `/anomalous/` answers only the computer ComfyUI runs on. One
middleware (`api/local_guard.py`, registered first in `setup_routes`) decides it for all routes:
a request other than GET must be local unless its path is in `OPEN_POSTS` (reads sent as POST:
`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
HTTP 403 with `code: local_only` and a bilingual message. `GET /anomalous/access` tells the page
whether it is local; `remote_view.js` then shows a "view only" chip in the header.
ComfyUI on a rented cloud server is only ever opened from another device. An empty
`allow_remote_changes` file in the plugin folder, made on the server and read once at start
(`REMOTE_CHANGES`), makes `local_guard.may_change` true for every request: the middleware and
the routes that check for themselves (import, placement, scan choice, recipe focus) use
`may_change`; `is_local` stays for what needs the browser on this computer (import's search of
this computer's Downloads) and for MCP. `GET /anomalous/access` answers `{local, changes}`, and
for another device also `command`: the line that makes the file (or, when it is there, removes
it), with this server's real path, which the chip's dialog offers to copy; it only runs where
someone pastes it, in the server's terminal. The chip reads "remote changes on" while the file
is there. Usage and the risk: `docs/guides/cloud-server.md`.
## Storage ownership
Runtime UI settings and newly stored API keys live in `api/config.json`.
`scraper.py` reads it first and may fall back to the legacy root `config.json`.
Secrets are never persisted to browser `localStorage`.
Workflow Recipes live below the active ComfyUI user directory in
`workflows/anomalous_recipes`; Parameter Notebooks live in
`workflows/anomalous_parameters`. They are user data, not repository assets.
Writes validate their bounded schema and use atomic replacement.
Backend modules follow route/domain/storage ownership. Model discovery,
identity recovery, metadata mutation, and cover media live in
`model_catalog.py`, `model_resolution.py`, `model_metadata.py`, and
`model_media.py`. Recipe graph rules, recipe shaping, image processing, and
persistence/history live in `workflow_schema.py`, `recipe_schema.py`,
`recipe_images.py`, and `recipe_store.py`; `recipes.py` is the HTTP facade.
Material shaping, private assets, and the single persistence lock/summary cache
live in `material_schema.py`, `material_assets.py`, and `material_store.py`;
`materials.py` owns request/response mapping and compatibility entry points;
`node_material.py` saves one canvas node's values as a `node_parameter_selection`
material (deduplicated by node type and values like other parameter materials).
The former mixed utility routes are separated: `media_routes.py` owns card
thumbnails and model/output media lookups, `gallery_routes.py` owns the bounded
output snapshot and deletion (searched through `image_search.py`, which reads
PNG text chunks only and caches per-image records by mtime), `translation_routes.py`
owns provider fallback, `folder_types.py` owns configured visibility and scan scope, and `audio_catalog.py`
owns generated audio: the output-audio history, temp Preview Audio results and
their copy into `output/audio/`, deletion, and streaming from the output and temp
folders only. Character voices are the Anomalous_TTS node's data and never pass
through this plugin's backend.
`version_manager.py` runs git against the plugin checkout only (no shell, no
prompts, `CREATE_NO_WINDOW` on Windows) and accepts only tags that appear in the
published release list. It refuses switches while tracked files are modified,
never rewrites a local branch that has diverged from the remote, and reports
stable error codes (`dirty`, `diverged`, `offline`, `unknown_tag`, ...) that the UI
localizes. Switching changes code only; a ComfyUI restart applies it.
Offline inference sidecars use non-positive Civitai IDs as sentinels. Metadata
normalization must not expose those values as release-page URLs or resolved
model/version identities; only positive IDs may form a Civitai source link.
Combos (搭配, formerly Prompt Notes) use `workflows/anomalous_notebooks`. First access copies legacy
`api/notebooks` records without deleting originals or overwriting current notes.
Conflicts receive a deterministic recovered filename. A completion marker makes
the copy retryable after write failure and prevents deleted notes reappearing.
Invalid legacy records remain untouched and are listed in the migration marker.
Notebook I/O runs in a worker under its persistence lock; the UI queues snapshots
and reports failed saves instead of showing success. `api.path_utils.atomic_write_json`
owns bounded, flushed temporary writes followed by atomic file replacement.
The recipe card endpoint returns lightweight metadata. Full graphs and history
are fetched only for detail, edit, compare, restore, export, or another operation
that needs them. Successful save, update, and restore responses include a compact
integrity receipt containing graph counts and the persisted workflow fingerprint.
Package import is inspect-then-commit. `recipe_packages.py` accepts only bounded
ZIP packages with a manifest, recipe JSON, declared contained WebP assets, and
optional bounded history. It rejects traversal, symlinks, undeclared members,
archive bombs, and checksum failures; stages all content before the final rename;
and restores the previous recipe set if replacement commit fails. Packages never
install code or dependencies.
## Event-loop and scan-state rules
Potentially large disk work does not run on the `aiohttp` event loop. Recursive
walks, bulk metadata parsing, full-file hashing, and similar operations use
`asyncio.to_thread()` or the established worker process/thread path. Reading a
small bounded safetensors header is acceptable; reading a whole multi-gigabyte
model to discover metadata is not.
Background work claims state before launching. Folder scans use
`.scan_in_progress`; global scans use `.global_scan_in_progress`.
Marker files are versioned JSON records containing backend session, owner PID,
worker PID, and job ID. Status checks validate ownership and process liveness;
file existence alone is not proof that a scan is active.
A marker owned by a dead process, or a legacy plain-text marker, represents an
interrupted job. Its marker, progress file, and pending selection may be removed
so a restart does not permanently lock scanning. A still-running orphan worker
remains locked until that recorded process exits.
`scraper.py --progress-file` atomically publishes enumeration state, selected
file count, current index, and filename. Status responses merge worker progress
with parent-owned folder progress. The frontend can reconstruct this state by
polling after its UI has been reopened.
`scraper.py --report-file` appends one JSON line per model it changed (`matched`,
`inferred` with its reason, `failed` with the error, renames and covers) and events
(`civitai_down` with `reason`, net_proxy.py's name for the failure, and `done` with the
unchanged count; the result carries the first reason as `civitai_error`). The scan process
gets Settings' proxy as HTTP(S)_PROXY (`net_proxy.child_env`). Civitai answers its API on
civitai.com and civitai.red, and some networks (a cloud server in mainland China) reach only the
second: `civitai_client.api_hosts` gives both, the one that answered last first, and every API
request (the scan, import's and Model Check's lookups, `civitai_check`, which names the `host`)
asks the other when the first does not answer at all; an HTTP error is Civitai's own answer and
is not asked again. The pictures stay on image.civitai.com, so after `COVERS_DOWN_AFTER` (2)
covers in a row that would not download the scan fetches no more covers (`covers_down`). One model's error is reported and
the scan goes on. `ScanJob` in `api/scan_report.py` reads each folder's report when its
worker exits and, before the scan's marker is released, writes the scan's result to
`user/anomalous/last_scan.json` and the activity log, so whoever sees the scan end can
read its result. The scan page's picked or listed models go to `/anomalous/scan_all` as
`targets` (`--targets-file` per folder), so one scan has one result.
An unmatched model's `.info` (`id` -1) keeps why: `anomalous_unmatched_reason` is
`not_found` (Civitai answered 404), `network` (no answer after the retries),
`offline`, or `damaged`: Civitai had no record and the safetensors header does not read
whole (`model_identity.safetensors_damage`: too short, a header longer than the file, not
JSON, or a tensor ending past the file's end; only the header is read). A damaged header
changes the file's hash, so the file is to be downloaded again; the scan summary counts these
(`damaged`). Online scans look up `network` and `offline` models again; `not_found` and
`damaged` ones wait for "look up again". After `CIVITAI_DOWN_AFTER` models in a row without an answer
a scan stops asking (`--civitai-down` carries this to the next folders) and infers the
rest from the files, instead of waiting for every timeout.
A model can be left out of scans: `"scan": "skip"` in its `<model>.anomalous.json` (the
user's own layer, which scans never write; `model_identity.scan_choice`). A scan of a whole
folder skips it and the scan summary counts it apart (`excluded`), not in the other counts;
picked by hand (`targets`) it is scanned all the same. `"scan": "scan"` records that the user
was asked and kept it. Nothing decides this alone: the summary only marks new models whose
safetensors header reads but names no drawing model kind (`model_kind.inspect_file`, kind "",
a voice model say) as `not_drawing`, and the scan page asks. Those headers are read
`HEAD_READERS` (16) at a time, since on a network drive each read waits on the network (1041
new models on a cloud server's model disk took 79 s one after another), through
`get_metadata`, which reads headers through `api/model_heads.py`: each file's answer is kept
by real path with its size and time in user/anomalous/model_heads.json (at most 50,000 files,
the longest unused dropped first, written a few seconds after the last change, and only once
`setup_routes` called `keep_on_disk`, so tests stay in memory), so a header is read once per
file version, also after a restart. The last summary is kept too (`_last`, and
user/anomalous/scan_summary.json when it changed): `?quick=1` hands it out at once for the page
to show while a fresh one is worked out, and requests meanwhile await that same count
(`_fresh`). The models found on Civitai are kept with it, components apart, and
`GET /anomalous/scan_matched` lists them (file, Civitai's name, base model; at most 5000), so
the summary the page reads each time stays small. `POST /anomalous/scan_choice`
(`api/scan_choice.py`, this computer only) takes the picked models as scan `targets`, at most
2000, and writes only `.safetensors` files' layers that are readable.
The summary also counts the models in component folders (`COMPONENT_TYPES`: VAE, text
encoders, CLIP, CLIP Vision, upscalers) as `components` and lists them, found ones too, in
`component_models` with their `state`; they stay in the other counts, and their list items carry
`component`, so the page can show them apart. Folder type decides, not the file's header.
`covers` holds up to 48 covers of scanned models, components left out, newest model file first
(`{name, preview_url}`), for the pictures on top of the scan card.
The model editor writes through `POST /anomalous/update_metadata` (only the fields it sends:
`custom_name`, `custom_notes`, `custom_source_url`, which must be empty or an http(s) address,
else 400 and nothing changes) into the user layer, and covers through `set_custom_cover` /
`upload_custom_cover` first. `GET /anomalous/model_covers?type&path_idx&subfolder&filename`
(`api/model_media.py`, read-only) tells it Civitai's kept image (`<model>.civitai_bak.*`, as an
image URL) and whether the cover shown is the user's, so it can offer Civitai's back.
Current node's saved values: `GET /anomalous/parameters/by_node_type` gives each recipe set's
file and values' `signature`; `GET /anomalous/parameter_pictures?signatures=a,b`
(`api/parameters.py`, at most 64 signatures, 64 hex characters each) counts, in one pass over
the newest output pictures (`recipe_images._recent_output_pngs`), how many were made with each
set's values and names the newest. A picture's signature is read once per file version
(`lru_cache` of 4096 entries keyed by path and modification time), which the workflow page's
`parameter_gallery` shares.
The scan page's picker reads `GET /anomalous/scan_pickable` (`api/scan_summary.py`): the
same files the summary counts (the scan folders' `.safetensors`, each once), the "don't scan"
ones included, up to 5000 with the exact `total`. Each carries its locator (`type`,
`path_idx`, `rel`), size, cover URL and only the metadata fields its badge needs, not the
descriptions. Its picks go to `/anomalous/scan_all` as `targets`, like a list page's.
## MCP endpoint
`POST /anomalous/mcp` (`api/mcp_server.py`) answers one JSON-RPC message per POST with
one JSON object; GET and DELETE get 405, and nothing is sent unasked. A request whose
`params._meta` carries `io.modelcontextprotocol/protocolVersion` is served as 2026-07-28:
the `MCP-Protocol-Version`, `Mcp-Method` and (for `tools/call`) `Mcp-Name` headers must
match the body, or 400 with -32020; an unknown version gets 400 with -32022 and the
supported list. `initialize` selects the legacy shape (2025-03-26 to 2025-11-25); no
session id is minted, so both eras are stateless. Unknown methods are -32601 (404 for
modern requests).
The peer must be loopback with no proxy header (`Forwarded`, `X-Forwarded-*`,
`X-Real-IP`), and the Host and any Origin must name a loopback host; anything else is
403. ComfyUI's own origin middleware also applies.
Tools (`api/mcp_tools.py`) only read and run in worker threads, four at a time. Model ids
are `type:path_idx:relative/path` and images are paths under the output folder; both are
resolved with `resolve_folder_subdir` / `resolve_within`, so an id cannot leave its root.
A tool's own failure (bad id, missing file, unexpected error) is a result with
`isError: true`, not a protocol error. Pictures attached on request are the gallery's
512 px thumbnails (at most 1.5 MB).
Acting tools (`api/mcp_actions.py`) never delete or rename files. Canvas actions go
through `mcp_bridge.ask_page`: it sends `{id, action, args}` as the `anomalous.mcp`
websocket event to every open page and waits (30 s, 90 s for model checks; at most 20
waiting). The first page to POST `{id, claim: true}` to `/anomalous/mcp/bridge` gets it
(hidden tabs wait 1.5 s so a visible one wins) and posts `{id, outcome}` back. No page,
no answer in time, or the page's refusal becomes a tool error. Scans and speech call this
server's own `/anomalous/scan_all`, `/anomalous/download/status`, `/anomalous_tts/characters`, `/prompt` and
`/history` over loopback, so they run exactly as from the UI (speech waits up to two
minutes for the file). Downloads go through the page (`model_download.js` downloadMissing /
downloadFrom), so the placement rules and the put-in after a download are the UI's own. Canvas changes are posted to the activity log with `via: "mcp"`.
## Backups
`api/backup.py` packs the user's data into one .zip (`POST /anomalous/backup/export`, then
`GET /anomalous/backup/file`) in the temp folder (the newest three are kept): the stores
under `stores/<part>/` (recipes, combos, materials, parameters, and `comfy_workflows` =
`user/default/workflows` when chosen; the notebooks' legacy-migration marker stays out),
`settings/config.json` without `CIVITAI_API_KEY` plus `download_settings.json`, and for each
model with a user layer or a user cover `models/<id>/user.json` and `cover<suffix>` (and,
unless left out, its scan file `scan<.info|.civitai.info>`; Civitai's cover only when asked,
stored once as `civitai<.civitai_bak suffix>`), listed
in `manifest.json` with type, relative path, size and SHA-256 (from its sidecars; none is
computed). The manifest's `library` lists every model the same way plus its download page;
inspecting a backup returns the ones this computer lacks, which the frontend looks up and
downloads with the model download routes (into the same folder and path). A cover is the user's when it is not byte-identical to `<model>.civitai_bak.*`,
as in the scanner.
`POST /anomalous/backup/inspect` receives the .zip into the temp folder, refuses anything
that is not this plugin's backup, too large, or has an unsafe member path, and reports per
store how many files are new, the same or different, and how many models it finds here.
`POST /anomalous/backup/apply` then writes only the chosen parts: new files are added, the
same skipped, different ones replaced only with `replace` (the file here goes to the
Recycle Bin first). Models are found by SHA-256, else by models folder, path and equal
size. A scan file is written where the model has none (replaced only with `replace`);
Civitai's cover where it has no cover at all, with its `civitai_bak` copy so it stays
Civitai's. Their user layer is merged field by field (empty fields filled; `replace` takes the
backup's, the old file to the Recycle Bin); the backup's cover replaces a Civitai cover
(whose `civitai_bak` copy stays) but not another user cover unless `replace`. Settings
are written only when chosen, keeping this computer's Civitai key. Export and import are
activity entries (`backup_export`, `backup_import`).
## Model downloads
`api/model_download.py` serves Model Check's downloads. `POST /anomalous/download/lookup`
asks `api/download_sources.py` where each missing model comes from: Civitai by the
workflow's hash (the version's file with that SHA-256 or AutoV2), else a link the
workflow carries — a Civitai link naming a model version (the file of that name, else
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; 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
workflow's hash is refused (`different_file`). A site that stops answering is skipped
for the rest of the lookup (`down`), the others are still asked, so Civitai failing in
mainland China does not stop Hugging Face through its mirror. It also returns each type's model folders with free
space and subfolders (two levels).
`POST /anomalous/download/start` takes the source, type, root index and a relative path
and refuses: a non-https link or one off Civitai, Hugging Face, hf-mirror or GitHub; a
path leaving the root (`resolve_within`) or not ending in a model extension; a file that
exists; not enough space. Jobs run one at a time in a worker thread into
`<file>.part`; a new start for the same file continues it with a Range request. The
Civitai key is sent to Civitai only, as an unredirected header, so it never reaches the
file storage the download redirects to. An HTML answer (a login page) fails as
`not_a_file`, 401/403 as `needs_key` or `forbidden`. The finished file must match the
source's SHA-256 (else the workflow's hash) or it is removed as `hash_mismatch`; then it
is renamed into place, ComfyUI's file-list caches are cleared and the activity log gets
a `model_download` entry. A file from Hugging Face first gets its repository's model card
(`api/hf_card.py`, `/api/models/<repo>` on the same host): the first example image (else
the repository's first picture) as `<model>.preview.<ext>` when the model has no cover, and
its base model, trigger words (`instance_prompt`) and example prompts as notes plus the
repository as link in the user layer, only where those are empty. `GET /anomalous/download/status` lists the jobs of this run,
`POST /anomalous/download/cancel` stops one (its `.part` goes). The default place is
`user/anomalous/download_settings.json` (`{place: workflow | folder, folder, hf_mirror}`).
With `hf_mirror` (the lookup's flag; the page sends the setting, or for a Chinese interface
true while it is unset) Hugging Face files are looked up and fetched on hf-mirror.com, and
without it on huggingface.co, whichever host the link names. The mirror sends networks
outside China back to huggingface.co; the lookup follows such a redirect once for the size
and SHA-256.
## Model import
`api/model_import.py` takes model files the user drops on the models page. A browser gives a
dropped file's name, size, time and bytes but never its path, so `POST
/anomalous/import/inspect` gets the name, size and time with the file's header bytes, and,
asked from this computer (`local_guard.is_local`), looks for the same file (name, size, time
within 2 s) in the Downloads and Desktop known folders and two subfolder levels below. A
found file gets an in-memory token; the client never sends a path. What the file is comes
from `model_kind.py`: the safetensors header's tensor names, shapes and kohya/modelspec
metadata, or a GGUF's `general.architecture` and tensor names; `.ckpt/.pt/.pth/.bin` are never
opened (the user picks the type). Types without a folder in this ComfyUI are not offered.
Files of the same name already in that type's folders are listed. `POST
/anomalous/import/identify` hashes a found file in a thread and asks Civitai by SHA-256 (base
model, model and version name) and the scanned-model hash index for an identical copy.
`POST /anomalous/import/place` (local only) moves the found file into `{type, root, rel}`:
a rename on the same drive, else a copy through `<dest>.part` checked by size, then the
original is removed (or kept with `keep`). `PUT /anomalous/import/upload` (local only) streams the request
body (not limited by ComfyUI's upload size) into `<dest>.part` and renames it when the size
matches. Both check the destination like downloads (`resolve_within`, model extension, free
space) and never replace a file: a taken name becomes `name (2).ext`. A failed copy or
upload removes only its own `.part`. Each import clears the file-list caches and adds a
`model_import` activity entry; the page then scans the new files. The destination the page
proposes follows the download settings' folder (`{base}` = the base model's folder).
## Tidy check
`api/model_placement.py` serves the models page's Tidy view. `GET /anomalous/placement/check`
walks every import type's folders once (a folder listed under two types counts for the first;
real paths deduplicated) and reads each header through `model_kind.inspect_file`, kept in
memory per path, size and time. A file whose header names another type than its folder, only
when `sure`, is misplaced; it goes to the same relative path in a folder of that type, on the
same drive when one exists. `works` marks a whole checkpoint in the diffusion models folder,
which still loads there. Identical files are grouped by SHA-256: the scan's (metadata) hash, or
one computed earlier this run; files of one size whose hash is unknown are only counted
(`unchecked`) unless `deep=1` reads them. `POST /anomalous/placement/move` (local only) moves the model with
`model_import.destination` and `move_into` (never over a file: " (2)") and then its sidecars
(`SIDECAR_SUFFIXES`; none when another model in the folder shares the stem), reports those that
could not follow (`left`), clears the file-list caches and logs `model_move`. Removing a copy is
`/anomalous/delete_model` (Recycle Bin, sidecars along).
## Metadata and cache behavior
The output gallery keeps one ordered directory snapshot for at most ten seconds
and 50,000 images. Page requests reuse it; explicit refresh and image deletion
invalidate it immediately. Larger inventories are returned normally but not cached.
Metadata and embedded safetensors-header hashes may be cached only in a bounded
cache. The key includes the model's real path and the physical `size`, `mtime_ns`,
and `ctime_ns` signatures of the model and relevant sidecars. Return independent
copies; never expose cached mutable dictionaries or lists.
Folder listing inventories a directory once with `os.scandir()` and preserves
preview priority: `.preview.*` before bare same-stem media. Avoid an `exists()`
sequence for every candidate when the directory inventory already has the
answer.
Preview URLs use the selected preview file's stable nanosecond modification time
as the cache version. Do not append `Date.now()` or random request tokens during
ordinary listing. A changed file must change its URL; an unchanged file should
remain browser-cacheable.
Preview lookup tries a contained exact relative path first and recursively walks
the library only for unresolved basename fallbacks. This locates presentation
for a model value already supplied by ComfyUI; it is not Model Check discovery.
Balanced grid thumbnails are derived, longest-edge 512 px WebP files in
`<user folder>/anomalous/cache/card_thumbnails` (not ComfyUI's temp folder, which
ComfyUI empties on every start). The cache is keyed by source real path and
physical signature and capped at 256 MiB with oldest-accessed eviction; the
settings page reads its size (`GET /anomalous/card_cache`) and empties it
(`POST /anomalous/card_cache/clear`). A video cover's card image is its first frame
(`variant=poster`, decoded with PyAV, which ComfyUI ships); the card shows it until
the video plays. Listing a model type queues the covers without a card image for one
background thread, one decode at a time, at most 512 waiting. The original mode
serves source covers, and detail views always use originals. Derived media never
modifies or sits beside a user's cover; unsupported, animated, or failed inputs
fall back to the original (a video without a readable frame has no poster).
## Model sidecar and cover lifecycle
Sidecar handling is non-negotiable because it can destroy user metadata or
media.
- 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.
- Physical rename moves every recognized sidecar to the new model stem. Model
deletion may clean sidecars only after the main model is successfully deleted.
- Foundation components—`vae`, `vae_approx`, `clip`, `text_encoders`, and
`clip_vision`—are never physical-rename targets. UI and all backend entry
points enforce the same denial.
- Model files are `MODEL_EXTENSIONS` in `api/model_constants.py` (`.safetensors`,
`.ckpt`, `.pt`, `.pth`, `.bin`, `.sft`, `.gguf`); listing, counting, search and
lookups test names with `is_model_file()`, without case. They are never
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 (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.
## Backend implementation rules
- Prefer standard-library and existing ComfyUI facilities; missing optional
dependencies must fail as a localized capability, not break plugin import.
- Use `os.path`, `pathlib`, `os.sep`, or normalization helpers for paths. Avoid
hand-written backslash replacement.
- Update an existing `.civitai.info` dictionary rather than overwriting it with
a one-field object.
- Large input collections use a JSON `POST` body. Do not place them in a query
string or pass them as command-line arguments on Windows.
- `tests/` holds the plugin's tests (how to run them: `docs/guides/testing.md`,
`tools/run_tests.mjs`). Runtime code never imports it.
+436
View File
@@ -0,0 +1,436 @@
# Frontend Architecture
Read this document for changes under `web/`, browser entry modes, panel state,
localization, media, or explicit canvas mutations.
## Bootstrap and module ownership
Share and import is the Workflows page's ⇅ (`ui_share.js`). Its share code
(`share_code.js`, no DOM) is AMB2: the workflow's lean form — node id, type, widget values,
position to 10 px, and only what differs from a fresh node of the type (`LiteGraph.createNode`
serialized as the template; a node pack's node always keeps its ports, so its links survive on
a computer still lacking the pack), links by slot, groups, the `anomalous_*` extras — deflated
and spelled as Chinese characters (14 bits each, U+4E00..U+8DFF, a first byte marking the
padding) or base64url. Encoding rebuilds the workflow from the lean form and compares
(`sameWorkflow`); subgraphs, native reroutes or any difference make the code hold the whole
workflow. AMB0/AMB1 codes still decode. The same dialog imports a pasted code or workflow JSON,
a dropped or chosen workflow file or image (`app.handleFile`), or a backup .zip
(`ui_backup.js`). It is independent of the paused Recipe package import/export.
ComfyUI loads JavaScript in the extension `WEB_DIRECTORY` as ES modules.
`web/main.js` registers `Anomalous.ModelBrowser` and coordinates host hooks.
`browser.js` defines the browser class and binds extracted feature methods;
`browser_entry.js` owns the single browser instance and all entry presentation,
including `window.anomalous_open_voice(character)`, which the Anomalous_TTS node's
character menu calls to open one character's voice card (`browser.openVoice`; the
node pack documents it in its `docs/INTERFACE.md` §6);
`interface_settings.js` owns locale and theme preferences. A syntax error or duplicate
top-level declaration in any imported module can prevent registration and make
the entire entry disappear. For affected modules, validate syntax and module linking
via Node's experimental VM modules (`vm.SourceTextModule`) and verify
the real ComfyUI runtime creates the configured entry. Floating trigger styling in
the ordered stylesheet bundle rooted at `web/styles.css` uses dynamic `1em` SVG
scaling and flex centering to guarantee
consistent visual presentation across all configured trigger sizes. The unsaved
default position is defined in CSS outside the sidebar dock; `browser_entry.js`
drags with pointer capture and only persists real drags, and `entry_controls.js`
validates and clamps saved coordinates (`clampFloatingTriggerPosition`). Resetting
the position clears the saved coordinates so the CSS default applies again.
Web modules are imported by plain relative paths. Do not add `?v=` query strings to
module imports: a different URL is a second module instance with its own state.
Freshness comes from the `Cache-Control: no-cache` header on this plugin's files.
Major UI panels live in `web/modules/ui_*.js`. Shared browser state remains on
the `AnomalousBrowser` instance. Pure parsing, normalization, comparison, and
transaction helpers remain in focused modules rather than acquiring DOM state.
`ui_sidebar.js` assembles the browser window and places the model folder column
(`ui_folder_list.js`, the current type's folders only); the left
icon rail (`ui_shell_rail.js`), page navigation and the list column's open state
(`ui_shell_nav.js`), the home page (`ui_home.js`) and the window frame
(`ui_shell_frame.js`) are their own modules. Every page change goes through
`owner.goTo(page)`. `ui_settings_hub.js` owns the display preferences behind
`owner.displayPrefs`, which the settings page (`ui_settings_page.js`) changes,
while `ui_rail_tools.js` owns the rail's tool buttons.
The Prompt Studio shortcut (its command and setting ids still say "materials") is a
native ComfyUI command/keybinding; a deferred window-key fallback invokes the same
command path only when the host did not open the studio, so handled shortcuts are not executed twice.
`ui_browser_navigation.js` owns shared panel hiding, recoverable detail cleanup,
and workspace return. `ui_scan_page.js` owns the scan page and `scan_runner.js` scan launch and polling (an online scan first goes through `scan_connection.js`: Civitai reachable, or the user picks scanning from the files),
`ui_folder_manager.js` is the settings page's Model folders view (grouping mode,
visibility and order, saved on each change). Public entry functions remain
browser-instance methods so existing actions share current browser state.
Update-guide content and UI lifecycle are separate modules. The header and Help
open it only on explicit user action; browser close disposes it. Read
[update-guide.md](update-guide.md) before changing guide IDs, steps, persistence,
or sidebar icon/label transitions. Do not embed guide logic in the main/sidebar
bootstrap or reuse its content version as a feature flag.
The main surfaces are:
- Sidebar shell: navigation and persistent scan controls; focused child modules
own the scan page, folder manager, and help dialog.
- Grid and model detail: model browsing plus coordinated detail display in
`ui_detail.js` (its head with the actions in `model_detail_head.js`), metadata editing in
`ui_model_editor.js`; `model_widget_path.js` puts a
model path into a loader's widget. The scan page's model picker is `ui_scan_picker.js`, its settings card `ui_scan_settings.js`; opened afresh, the page shows the last counts at once (`?quick=1`) with "showing the last counts while they are updated", then the fresh ones; before any, a count slower than 400 ms shows "counting your models" instead of a blank page. "Found on Civitai" opens a list of those models with a box to find one by name (`/anomalous/scan_matched`, read when it opens).
- Gallery: generated outputs (pictures and videos; All / Pictures / Videos beside the search
and the hidden formats in Settings, `gallery_filter.js` + `ui_gallery_filter.js`) and the
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). 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
across `ui_combos.js`, `ui_notebooks.js`, `ui_notebook_editor.js`, and
`notebook_canvas.js`; a default combo's `data.loraNode` ('core', 'rgthree', 'easy'; none is 'core')
says which LoRA node it is laid out with (`lora_nodes.js`; one this ComfyUI lacks falls back to
'core' and says so after placing), chosen under New or in its editor (`ui_lora_node_choice.js`,
shown only when there is a choice). A Power Lora Loader in a node structure keeps its rows as
the node's `rows` and each row's LoRA as a slot with `field: 'lora'`; its rows are made again
(`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 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
picked cannot be kept: its input is stored in `structure.open`, and the save dialog, the
editor and the card say it needs wiring. Slots are per box, so a node with
several model drop-downs has several; a box fed by a link is none. Each slot's models folder
is the one whose files hold all the drop-down's options. Putting it down needs every node
type installed (missing ones and their packs are named first), restores the other boxes by
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 → `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
it made itself, and lists each model node's other saved values read-only (on a node with
several models, by the number in the box name); nodes without slots follow under "put back as
saved" with their saved values, read-only. Prompts get a read-only translation panel
(translation_service.js); empty optional slots start unticked in the save dialog.
- Prompts: the rail entry opens Prompt Studio (below); there is no Material Library page.
Saved node values are listed, applied and deleted in Current node.
- Current node (Node Assistant)/Model Check: selected-node actions, saved parameters, and
missing-model recovery. `model_check.js` decides each workflow model's state and
applies fixes; `ui_doctor.js` (the page) and `ui_doctor_banner.js` (the bar shown
after a workflow opens, unless its "Don't show again" or Settings → Workflows turned the
check on opening off) only render it and call it on a press. A missing model with a
certain source gets Download (`ui_model_download.js`; `model_download.js` holds the
logic): the dialog names, per file, the type's folder (a choice when there are several,
remembered per type in `localStorage`) and the subfolder from Settings → Model downloads
— where the workflow says (so the node needs no change) or a set folder, `{base}` there
being the model's base model, matched to an existing folder in any spelling — and the
file keeps the workflow's name. When a download ends, the model lists reload, nodes
still naming that model get the file (one Ctrl+Z step each), and once nothing is
downloading the new files are scanned in one targeted scan.
`ui_node_assistant.js` owns the current-node panel and model history, `ui_node_model_picker.js`
owns the native-widget model replacer, and `ui_node_parameters.js` with
`node_parameter_sets.js` own the panel's one list of saved values for the node type;
`ui_apply_receipt.js` is the receipt with Undo after a panel writes to a node.
`ui_dom.js` owns generic `text` and `jsonResponse` helpers.
`material_inspector.js` holds the image workbench's metadata helpers 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
One shared `openBrowser()` command backs the floating trigger, ComfyUI action-bar
button, and `Extensions -> Anomalous Model Browser` command. Entry presentations
are mutually exclusive and reuse the same browser instance. The Extensions
command remains available as a recovery path in every mode.
A native command owns the default `Shift + B` browser binding (Ctrl + Shift + M before v1.58.2: Chrome and Edge
take it for switching profiles). Prompt Studio has
no shortcut of its own (removed 2026-10-07): it opens from the rail, the 7 key, or by opening
the browser with a text node selected. Shortcut customization delegates
to ComfyUI's command/keybinding panel and recorder; the plugin does not install
a parallel global keyboard listener or maintain a second shortcut preference.
Opening the browser from the trigger, the action-bar button, the menu or its shortcut
(`show({ followSelection: true })`) follows the node selected on the canvas unless Settings
→ Opening and window turns it off (`shell_open_rules.js`, a localStorage preference): a node
with prompt boxes and no model drop-down opens Prompt Studio, which targets that node; any
other node opens Current node; nothing selected reopens the page used last. Other callers
(Prompt Studio closing, the doctor banner, MCP actions) open the browser as it was.
While the browser is visible, the plain keys 1–9 go to the rail's pages from the top
(`ui_shell_nav.js`; ComfyUI binds no plain digits). They are ignored in text fields, while
an input method is composing, and while a dialog is open; the rail's tooltips show each
number. This is in-page navigation, not a second shortcut for opening anything.
Language changes replace the registered Interface setting descriptors with
freshly translated copies. ComfyUI's reactive settings tree therefore updates
the open category heading, labels, tooltips, custom controls, and combo options
immediately without requiring a page refresh.
Hidden reusable dialogs must not advertise an active modal state. In particular,
`aria-modal` is `true` only while the dialog is visible, so ComfyUI's global
modal guard does not suppress unrelated host shortcuts.
## Panel and lifecycle state
Main content surfaces are exclusive. Opening one must hide or clean the surface
it replaces, while preserving only the state required for an explicit return
path. Search from the sidebar exits model detail and shows the grid. Workspace
records the previously visible main-browser panel and restores it on close,
falling back to the grid when the prior state is unavailable.
Recipe-to-model navigation is a special recoverable transition described in
`recipes.md`. It keeps the outer browser open and must not call the browser-wide
`close()` lifecycle.
Grid folder navigation removes old content rather than accumulating hidden
grids. Before removing DOM that contains video or audio, pause the media and
release sources where applicable. Closing the browser aborts active listing,
disconnects observers, pauses media, and releases grid video/audio sources.
Lightweight cards may remain warm for 90 seconds, after which card DOM and model
payloads are released. A new listing cancels the previous request and render
generation.
The full ordered model result remains available, but cards are created in
bounded animation-frame chunks. Images use lazy loading and async decoding.
Grid video sources are attached only near the viewport and then follow the
configured `always` or `hover` behavior with muted, loop, and plays-inline
attributes.
When identifying video media from a served URL, inspect the full URL with a
query-aware extension test such as `/\.(mp4|webm)(?:$|\?|&|#)/i`. Do not use
`new URL(url).pathname`, because the backend may put the actual filename in a
query parameter.
## Model source component links
The Sources view (`ui_model_sources.js`, data in `model_source_links.js`) uses
`inferModelFolderTypes` for workflow component labels and
category-scoped, exact-path local metadata reads. Context requests are batched
to respect the endpoint's 16-item limit. Missing source links and unavailable
native model choices are independent states; dynamic choices are not treated
as evidence of absence. Native extensionless component choices and PTH/GGUF
references remain visible even when the backend cannot locate an individual file.
`model_source_data.js` owns library-result shaping and the Sources view's pure
main/component grouping and filtering. CLIP, text encoder, CLIP Vision, VAE, and
preview-VAE entries live in a session-local, default-collapsed disclosure; the
source-status pills and search affect both main models and components. Collapsed
component rows are not constructed. Main-model completion statistics exclude
optional component links, while the disclosure's missing-local count describes
only the currently filtered component matches.
Legacy Civitai placeholder links with non-positive model IDs are normalized to
an unfilled source before grouping, so offline inference records remain visible
under the source-needed filter.
Workflow save and Note generation always receive the complete workflow model
set; summary copy receives only the visible rows. Such links may be saved to the
workflow, but component sidecar writes require a resolved local target. Opening,
filtering, and expanding the hub do not initiate cloud scans, hash calculation,
or sidecar writes, and do not change graph/model identity recovery rules.
## Localization and DOM safety
`web/modules/locales.js` is the canonical catalog for user-visible runtime
strings. Resolve strings at render time through the shared translator. Chinese
and English dictionaries keep identical key sets, have no duplicate keys, and
cover all statically referenced translation keys.
Dynamic filenames, paths, model names, node types, counts, and user-authored
content remain parameters rather than dictionary entries. Insert untrusted text
with `textContent` or escaping. Only fields intentionally supporting trusted
formatting may pass through `setSafeRichHtml()` in `safe_dom.js`; never assign
metadata directly to `innerHTML`.
Persistent localized nodes that survive a language change carry an i18n key and
are refreshed in place or rebuilt from current state. Missing translation data
must not prevent a panel or action from rendering.
Scope plugin DOM IDs and CSS classes with `anomalous-`. Before introducing a
dynamic ID, search `styles.css` for collisions. Follow the established z-index
tiers and ensure a child modal sits above the parent that would otherwise consume
its clicks.
## Graph and widget mutation rules
Explicit graph mutations are transactional. Validate the entire intended change
before mutation, wrap it in `graph.beforeChange()` / `graph.afterChange()`, and
restore prior nodes, links, and widget values if any operation or callback fails.
A successful change marks graph and canvas dirty and emits the host change event
expected by dependent surfaces.
Partial Recipe Append clones serialized nodes, remaps IDs and links, supports
groups, and rolls back everything it created on failure. It never calls
`loadGraphData` on the live graph and rejects unsupported subgraph definitions
until they have a complete remapping path. Complete recipes use
`loadGraphData` intentionally so ComfyUI owns new-workflow canvas creation.
`graph_splice.js` decides where a LoRA loader goes (`planLoraInsertion`) and wires it
(`spliceLora`), for Current node's insert buttons and the MCP `add_lora` alike. It reads
declared port types, never slot indexes or display names. After a node, every link of its
MODEL output moves behind the LoRA; before one, only that node's MODEL input. The CLIP
line goes through only when the LoRA file trains the text encoder (`api/lora_info.py`
reads its header): from the same node, a UNet workflow's one CLIP source, or, before a
sampler, the source the prompts wired into it share. Otherwise a model-only LoRA loader
is used. A failed connection removes the node and restores every link. Picker candidates come
from the target widget's native combo values; metadata may filter or decorate
that set but may not introduce foreign category values.
Parameter application resolves a serialized match back to a live ComfyUI node
before accessing widgets or callbacks. It validates all target slots first,
skips volatile values, and calls the host's widget hooks with the current
four-argument contract. Serialized workflow records are never treated as live
widget objects.
## Optional capabilities
`web/hash_resolver.js` keeps the local hash cache and may hook a compatible graph
serializer to carry model provenance. If the host API is unavailable, it disables
only that integration with a useful warning. It must not prevent `main.js` from registering or remove
the visible browser entry.
Network-backed enrichment is explicit and recoverable. An unavailable Civitai
or translation service may produce a local error state; it must not block local
browsing, editing, or already stored data.
## Prompt Studio ownership
The studio edits prompts where they are: the prompt boxes of the prompt node last
selected on the canvas, or, with no such node, a positive and a negative draft
(`owner.promptStudioDraft`, kept while the page stays open). It is the browser's Prompts
page (`owner.promptPanel`, `ui_prompt_composer.js`): the rail's Prompts entry, the 7 key,
opening the browser with a text node selected, Current node's "Edit in Prompt Studio" and
a kept prompt all go there. "Beside the canvas" moves it into a drawer docked by the canvas
(side and width remembered) with the browser folded away; "Back to the window" returns it to
the page and Close or Esc closes the drawer. Only one studio is shown at a time; the page's
studio stops when another page is shown or the browser closes (its 400 ms poll with it) and
is made again when the page is shown. There is no assembly board, block draft or plan loading.
The target (`ui_prompt_target.js`) is polled every 400 ms. Selecting another node with
prompt boxes switches to it; empty canvas or a node without boxes keeps the current
one; the ✕ beside the node's name switches to the draft; a node that leaves the open graph drops back to the
draft. Each change is one `applyNodeMaterialValues` write (a Ctrl+Z step). The studio's
Undo takes its own writes back newest first and stops with `materialUndoChanged` when a
box changed elsewhere; switching target clears it. Box editors re-read their widget on
each poll and redraw unless a tag is being edited, a translation is pending or the text
view has focus.
`prompt_tags.js` splits a box on top-level commas and line breaks and keeps each
separator, so editing one tag leaves the rest of the text, line breaks included, as it
was; a weight is `(tag:w)`. Inserting skips tags the box has already (same words, any
weight). Typed or edited text with letters that are not plain English (`needsEnglish`:
Chinese, kana, hangul, Cyrillic, accented Latin) is translated to English before it is
written; a box that holds such tags offers to translate them, a translation the box
has already removes the tag instead, and the result is written only if the box still
holds the text the request was made from. Meanings (`prompt_gloss.js`) are shown in the
language picked in the top bar (one of `GLOSS_LANGUAGES`, none by default, remembered in
local storage with the view; an older "Chinese on" setting reads as Simplified Chinese).
They are asked for in batches, kept for the session per language, and a failed lookup
leaves that box's meanings in that language blank instead of retrying. The backend
(`api/translation_routes.py`) asks DeepL first when Settings → Translation has a key
(`DEEPL_API_KEY` in config.json, sent in the Authorization header, never in a backup),
then Google's free endpoint, then MyMemory; when DeepL failed, the answer carries
`deepl_error` (bad_key, quota, busy or the error) beside the other provider's translation,
which Settings shows after a key is saved. It skips DeepL for a target it does not take and
lets MyMemory detect a source that is neither English nor Chinese.
The cards are three built-in ones and the saved prompts (prompt-kind material files,
`prompt_material_source.js`). The saved list is asked for on open, every 30 seconds
while the page is visible (scheduled after the previous request), on focus/visibility
and after a save; a newer request cancels the older one and a failed one keeps the
current cards. List summaries are not prompt bodies: a prompt's text is read through
`material_prompt_data.js` once per filename and timestamp, since a saved prompt's text
never changes. A clicked card opens as a reference (`ui_prompt_reference.js`, read only,
one at a time) between the cards and the boxes: a click on one of its tags adds that tag,
"Add all" the whole card (as the preview's Add does); the ticks follow the box's text on the
same 400 ms poll. A card's role decides its box: a positive card never goes into a
negative box or the reverse; boxes of unknown or both roles take either. A saved
card's preview renames it (`update_material`, tags kept) or deletes it
(`delete_material`, to the Recycle Bin). A card, or a box's handle with the box's text
at drag start, drags onto the canvas (`prompt_card_drag.js`, one undo step); over the
drawer the drag passes through to the boxes.
| Module | Owned state and responsibilities |
| --- | --- |
| `ui_prompt_composer.js` | Active drawer, docking width/side, Esc, trigger visibility and reopening the browser |
| `ui_prompt_workbench.js` | Top bar, view and meanings switches (remembered), saving a box as a card |
| `ui_prompt_target.js` | Which node or draft is edited, the poll, node/draft writes and the studio's Undo |
| `ui_prompt_box_editor.js` | One box: tags or text, selection, weight, edit, remove, reorder, drops, typing, translation |
| `ui_prompt_source_deck.js` | Built-in and saved cards, search, new-card form, rename/delete and the sync request |
| `ui_prompt_card_popover.js` | Card preview: hover corridor, pin, copy/add/rename/delete actions |
| `prompt_card_drag.js` | A card or box dragged onto the canvas: box fill or new prompt node, passing through the drawer |
| `prompt_tags.js` | Pure tag split/join, weights, insert/remove/replace/move keeping separators |
| `prompt_gloss.js` | Meaning languages, session cache and batched lookup of tags' meanings |
| `prompt_material_source.js` | Saved prompts as cards (paginated list, text read once) and saving a card |
| `prompt_composition.js` | Starter cards (`PROMPT_PRESETS`, also the combo group's negative), composing a saved plan's text, prompt titles and categories; no DOM |
| `ui_lifecycle.js` | View-scoped listeners, AbortSignal, cleanup callbacks and resizing |
Every close route (Close, Esc outside a text field, replacement) disposes the same view
scope: the poll and sync timers, requests, translation and text timers, listeners and
resize handlers. Esc in a studio text field only leaves the field, and Esc with a pinned
card preview leaves the studio open. Closing during resize releases move/up listeners
and restores body cursor and selection styles. A save already sent to the server may
still complete, but must not reopen or repaint a disposed view.
Explicit English translation applies to all input languages, including kana and
Korean. Transport errors and rejected bridge responses remain local errors;
provider-specific validation belongs to the backend translation route.
## Visual styling and theme architecture
`styles.css` is the single external entry and ordered import manifest for current
visual values. Its `web/styles/00-*.css` through `10-*.css` children preserve the
original cascade order; every child import carries the same cache version so an
entry-cache hit cannot leave stale child rules. Shared `--amb-*`
tokens express surfaces, text, borders and control shapes; theme overrides must
be scoped to `.theme-abyssal-scarlet` or `html.amb-theme-light` rather than changing
unrelated surfaces.
Themes (`interface_settings.js`, Settings → Appearance → Theme and the ComfyUI setting
`Anomalous.ModelBrowser.Theme`): follow ComfyUI (default), dark, light, or Abyssal Scarlet.
Following ComfyUI reads `dark-theme` on `<html>`, which ComfyUI sets for a dark palette and
removes for a light one, and repaints when it changes. The light theme is the class
`amb-theme-light` on `<html>`; Abyssal Scarlet keeps `theme-abyssal-scarlet`. An older
Abyssal Scarlet switch (`anomalous_theme_abyssal_scarlet`) reads as the Abyssal choice.
Colours come from the tokens in `00-foundation-models.css`, which every theme defines:
surfaces (`--amb-bg-page`, `-panel`, `-card`, `-card-hover`, `-input`), text
(`--amb-text-main`, `-soft`, `-muted`, `-dim`), borders (`--amb-border`, `-strong`,
`-hover`), the primary button (`--amb-btn-primary-*`) and one accent, `--amb-link`, for
links, selection and focus; its tints are `color-mix(in srgb, var(--amb-link) N%,
transparent)`. CSS injected from JS and inline styles use the same tokens. Literal
colours stay only where the colour is the meaning: status (red, green, amber), prompt
roles, voice emotions, canvas node colours and translucent shadows or overlays.
Tokens added for the light theme keep the dark themes exactly as they were:
`rgba(var(--amb-ink-rgb), a)` for tints over the page (white on dark, black on light),
`--amb-danger/-warn/-ok` and their `-text`/`-soft` shades for status text, `--amb-bg-tooltip`,
`--amb-bg-float`, `--amb-bg-dialog`, `--amb-bg-sunken`, `--amb-scrim` and `--amb-note-*`.
Older rules and inline styles that wrote a dark colour now write
`var(--amb-lt-<role>, <that colour>)`: the `--amb-lt-*` tokens exist only in the light theme,
so every other theme falls back to the original colour. Use them when a dark literal cannot
become a shared token. Inside the plugin's windows ComfyUI's own palette variables
(`--bg-color`, `--fg-color`, `--input-text` …) are mapped to the plugin's tokens whenever the
two themes differ. Surfaces over images (card labels, cover pills) stay dark, and the image
viewer (`.anomalous-workbench-overlay`, `.anomalous-gallery-viewer`) gets the dark tokens back
in the light theme.
Studio drawer rules keep the source deck at the screen edge and the assembly
track next to the canvas. Common geometry is shared between dock directions;
direction-specific rules set column order and separators. The removed embedded
composer's `#anomalous-container.anomalous-docked` overrides must not return.
Before adding an override or `!important`, locate and edit the owning rule.
Older theme overrides in `08-theme-gallery-overrides.css` predate the tokens; many are
now redundant and can go as the rules they override are touched.
## Verification
`tests/prompt_tags.mjs` covers splitting, joining, weights and tag edits keeping
separators. The UI fixture simulates DOM and ComfyUI APIs and does not replace
checking the real host, layout, focus, drag gestures and theme rendering.
+125
View File
@@ -0,0 +1,125 @@
# 踩过的坑(Lessons)
反复出现的坑和严重故障。每条写清:现象、根因、怎么发现的、以后怎么做。
---
## 2026-09-26 · v1.57.2:一条 `:has()` 样式让拖动画布掉帧(严重)
**现象**:B 站用户反馈装了插件后拖动工作流 10% low 只有二十几帧;和 ComfyUI Prompt Assistant 一起装时,大工作流只剩个位数帧甚至卡死。插件界面关着也一样。
**根因**:`web/styles/01-shell-tools.css`(b6acd09,2026-09-19,进了 v1.57 / v1.57.1)
```css
body:has(#anomalous-toolbox-modal[style*="display: flex"]) #anomalous-sidebar-tooltip-bubble, …
```
`:has()` 里放属性选择器(这里是 `[style*=…]`),浏览器在页面上任何元素的该属性变化时,都要重新判断 `body:has(...)`。ComfyUI 平移、缩放画布时每一帧都在改每个 DOM 控件(文本框)的 `style`,于是每帧都要付出和页面元素数量成正比的样式计算。Prompt Assistant 给每个文本框加一组按钮,页面元素多十几倍,开销跟着放大。两个插件本身没有冲突。
**实测**(真 ComfyUI 前端 1.53.6 + Chromium,300 节点工作流平移 120 帧,样式重算合计):
| 情况 | 修复前 | 修复后 |
| --- | --- | --- |
| 不装插件 | 60 ms | — |
| 只装 AMB | 690 ms | 63 ms |
| AMB + Prompt Assistant | 2300 ms(约 19 ms/帧,超过 60 帧的一整帧) | 86 ms |
| 只装 Prompt Assistant | 80 ms | — |
逐条删规则二分:只删掉这 7 条 `:has()` 就回到基线,其中罪魁是带 `[style*=…]` 的那条;`button:has(.x)` 等其他 `:has` 测不出开销。
**修复**:删掉这条规则。它是多余的:工具箱和设置窗口打开前都会调用 `hideTooltipImmediately()`,`showSharedTooltip` 在窗口开着时也会拒绝显示。
**以后怎么做**
- 不要用 `:has()` 或 `[style*="display: …"]` 去判断界面开关状态。要表示“开着”,由 JS 加减一个 class(作用在自己的容器上),或者直接在 JS 里处理。
- 全局生效的昂贵选择器(`body:has`、`html:has`、`*`、未限定作用域的 `button`、`textarea`)会作用在 ComfyUI 的每一帧上。新增前先问:它会不会在画布拖动时被反复判断?
- 性能问题要实测,别只看代码猜:
- 装一个真的 ComfyUI(`--cpu` 即可),用 Playwright 在空白处拖动画布;
- 用 CDP 的 `Performance.getMetrics` 比较 `RecalcStyleDuration`;
- 再在页面里删掉可疑规则做二分(遍历 `document.styleSheets`,对插件自己的样式表 `deleteRule`)。
- 没有显卡时总帧时间不准,但样式计算的倍数可信。
---
## 2026-09-26 · 修复只在本机 main,发布版没带上
**现象**:素材库“选中节点时卡片错位”(应用按钮重复、图标重复成 ⚡ ⚡、标签日期挤在一起)的修复 01fc925、9e761d0 早在 9 月 24 日就做了,但只进了 `feature/audio-engines`(通过“Merge main … into feature”)。GitHub 上的 `main` 并没有,发布版 v1.57.1 的用户一直带着这个问题。
**根因**:修复提交在本机 main 上,只把 main 合进了 feature,本机 main 从未推送。
**以后怎么做**
- 发布或回答“修了没有”之前,用 `git branch -r --contains <提交>` 确认修复在远端的哪个分支,别只看本机。
- 本机 main 有未推送提交时,先推送或说明,再做合并。
- 这次已把两个提交 cherry-pick 进远端 main(e1ac84f、4e8603b),随 v1.57.2 发布。
---
## 2026-09-26 · 发布热修复的检查项
- 改了 CSS 或 JS,要同步改缓存参数:`web/styles.css` 里对应的 `@import …?v=`、`browser_entry.js` 的 `styles.css?v=`、`main.js` 引用 `browser_entry.js` 的 `?v=`。不然用户刷新后浏览器可能还用旧文件。feature 分支已去掉部分 `?v=`,合并时以各分支现状为准。
- `CHANGELOG.md` 是 CRLF、LF 混合换行。用脚本编辑时按原字节读写(`newline=''`),否则整份文件都会显示成改动。
- 云端会话能推分支,推不了标签,GitHub 工具也不能创建 Release。发布要在网页上完成:Draft a new release → 填新标签 → Target 选 `main` 并核对提交号 → 粘贴说明 → Publish。插件的版本面板只列正式 Release,用 ComfyUI Manager 更新的用户则直接拉 main 最新提交。
---
## 2026-09-27 · 拆文件漏了 import,保存工作流在发布版里整个坏掉
**现象**:v1.57 到 v1.57.2,工作流页“保存当前工作流”只要工作流里有模型加载器就提示“保存工作流配方失败”。B 站用户反馈“就没成功过”。素材库走的是另一条路,所以能存。
**根因**:`refactor(recipes): separate schema images and storage`(6ad975c)把函数搬进 `recipe_images.py`,但它调用的 `_resolve_exact_model_reference` 和 `hashlib` 留在了旧文件里。同一轮拆分还让 `media_routes.py` 少了 `time`、`tempfile`:卡片缩略图出错后被 `except Exception` 吞掉,静默退回原图。另外 `model_metadata.py` 少了 `_resolve_paths_to_model_info_sync`。Python 只在那一行真正执行时才报 NameError,导入和其他测试都通过。唯一的保存测试关掉了模型预览快照,正好绕开了出错的那一行。
**以后怎么做**
- 拆模块后跑 `tests/test_backend_names.py`:后端文件读到却从没绑定的名字,它都会列出来。
- 行为测试要走默认路径。`recipe_payload` 默认关着快照,新测试 `test_saving_with_preview_snapshots_captures_the_model_cover` 专门走默认的“开着快照”。
- 真实按钮至少点一次:在隔离的 ComfyUI 里点“保存当前工作流”,一眼就能看出问题。
---
## 2026-10-12 · 页面往下滚后,点“返回”点到了下面的模型(第二次)
**现象**:从画布节点存的搭配(节点搭配),页面往下滚一点后,点“‹ 全部搭配”打开的是被滚到标题下面的模型封面。普通搭配和搭配列表没事。
**根因**:和 2026-10-09 修过的是同一个 Chrome 行为:滚动框里有 `overflow: hidden` 的元素(这里是模型封面的圆角框)或 `content-visibility: auto` 时,滚出可见区域的部分在点击判断里仍算“在那里”,盖住了上面的标题按钮。上次只给当时有问题的两个滚动框加了 `clip-path: inset(0)`,之后新做的节点搭配页面又放进了带圆角封面的行。
**怎么发现的**:在隔离的 ComfyUI 里把节点存成搭配,把编辑区分 12 步滚到底,每一步用 `document.elementFromPoint` 取标题里每个按钮中心点的元素,看是不是按钮自己;滚到 140px 起就变成了封面 `IMG`。
**以后怎么做**
- 标题、标签栏下面的滚动框一律加 `clip-path: inset(0)`(它对点击判断也生效)。`overflow` 本身裁不住这种情况。
- 新页面或新内容放进滚动框后,用上面的办法扫一遍:每个滚动框分步滚到底,检查每个可见按钮都能拿到自己的点击。
---
## 2026-10-08 · ComfyUI-Manager 审核 Anomalous_TTS:三类安全口子
**现象**:Anomalous_TTS 申请收录进 ComfyUI-Manager(Comfy-Org/ComfyUI-Manager#3342),维护者两轮审核退回,先后指出:
1. **下载来的东西被当代码执行**:中文多音字模型从个人 ModelScope 账号的 `master` 下载,不核对指纹,解压后执行包里的 `config.py`;英文词典读 pickle。而网页请求还能改“去哪个文件夹找这些文件”,等于网页能决定运行什么代码。
2. **网页请求决定删改哪里**:`POST /anomalous_tts/storage` 从请求里取新的存放位置,跨盘移动后对旧位置 `rmtree`。只检查“是不是本机请求”不够:本机网页也不该决定在哪里建、删文件夹。
3. **按请求里的路径读任意文件**:导入预览、导入检查会返回请求点名的任何文件内容。
**根因**:这些接口最初是为了方便(在界面里选文件夹、试听本机音频、一键挪角色),没有把“网页请求是不可信的输入”当成前提;“只接受本机请求”被当成了足够的保护。
**怎么改的**(接口版本 13):配置文件当数据解析,词典从文本建,不读写 pickle,测试拦住 `pickle.load` / `exec_module` / `eval(`;下载锁定版本并先核对 SHA-256 再解压;存放位置、角色文件夹、底模来源只从用户手写的设置文件读;导入只收上传的文件,没有接口再接受本机路径。
**以后怎么做**
- 两个仓库的规范都写了安全底线(本仓库 AGENTS.md 第 5 节“安全底线”,Anomalous_TTS 的 ARCHITECTURE.md“不能破的规矩”)。新接口按它写,审查时逐条对照。
- 本仓库的现状(2026-10-09 统计):55 个会写文件的接口,只有 MCP、模型导入、整理三处检查了本机请求;删除模型、改模型信息、开始下载、导入备份等在 `--listen` 时局域网里的其他设备也能调用。没有执行下载内容、pickle、eval 的代码。2026-10-12 的 v1.58.1 一次补齐:`api/local_guard.py` 一个中间件把关 `/anomalous/` 下所有会改东西的请求,只接受本机(见 backend.md“Requests from other devices”)。
---
## 2026-10-13 · v1.58.0–v1.58.2:同一种 `:has()` 又掉帧(第二次)
**现象**:#22(Lindemellis):216 个节点的工作流,开着 Prompt Assistant 时一次画布重绘 465–770 ms,拖动只有 2–3 帧。只删掉一条规则就回到 53 ms。
**根因**:图标栏(46facb9,2026-09-29,随 v1.58.0 发布)在 `web/styles/15-shell-rail.css` 里又写了
```css
#anomalous-container.anomalous-has-rail:has(> .anomalous-nb-modal[style*="display: flex"]) #anomalous-sidebar-wrapper
```
和上面 v1.57.2 那条是同一种写法:`:has()` 里按 `style` 属性匹配。v1.57.2 修好三天后就写回来了,因为经验只写在这份文档里,没有东西拦着;当时的性能实测也只跑了 main 和音频分支,没跑图标栏所在的分支。
**修复**:工作区开没开由 JS 在容器上加减 `anomalous-workspace-open`,CSS 只看这个 class(#22 的修复)。
**以后怎么做**
- `node tools/check_structure.mjs` 现在会拒绝 `:has()` 里出现 `[style…]`,报出文件和行号。
- 同一个坑第二次出现,就把它做成检查,不再只补一段经验。
+209
View File
@@ -0,0 +1,209 @@
# Saved prompts and node values (material files)
Material files hold what the user keeps for reuse that is not a whole workflow or a
combo: saved prompts and saved node values. There is no Material Library page any
more; each kind shows where it is used:
- Prompt kinds (`prompt_plan`, older `prompt_text` / `prompt_note_bundle`, and
selections made only of prompt nodes) are Prompt Studio's library cards
(`prompt_material_source.js`), renamed, deleted and dragged onto the canvas there.
- Value kinds (`node_parameter_selection`, `image_node_selection`,
`recipe_parameter_selection`) are listed per node type in Current node
(`node_parameter_sets.js`, with the recipes' own parameter sets), applied and deleted there.
- `image_workflow_snapshot` records are whole workflows kept before recipes took over;
the Workflows page offers to move them to recipes. Their node blocks still count as
saved values for Current node.
Whole workflows are Workflow Recipes; a main model, LoRAs and a prompt kept together
are combos (`anomalous_notebooks`). Neither is copied into material files.
## Persistence and API ownership
`api/material_schema.py` owns record shaping, prompt-role rules and bounded
normalization; `api/material_assets.py` owns source inspection and private image
copies; `api/material_store.py` is the sole owner of persistence locking, the
summary cache, query/update/delete, and recipe-source resolution. `api/materials.py`
owns HTTP request/response mapping and narrow compatibility entry points;
`api/node_material.py` saves one canvas node's current values. Records live under
`user/<profile>/workflows/anomalous_materials`; source PNG copies and bounded
WebP previews live in a private `.assets/<material-stem>/` directory.
The backend accepts output-image descriptors only after applying the shared
filename and containment checks. It reads bounded embedded metadata, requires a
valid UI workflow, copies at most 64 MiB of source image data, validates the
graph with the Recipe workflow validator, and writes JSON atomically. List
responses omit the full workflow. The image-inspection route returns the validated
workflow plus summary node blocks for the image workbench, which keeps exact metadata
in a small 16-entry in-memory LRU. Asset reads require both a valid material record
and a contained private asset path.
Saving stages the image, preview, and JSON together below a private temporary
directory. Assets are promoted before the record becomes visible. A failed final
record commit removes only the newly promoted assets; staging is cleaned on exit.
Existing records and their images are never overwritten by this path.
Material discovery caches up to 4,096 summaries, keyed by the contained record's
real path, size, mtime, and ctime. It never caches full workflows. A directory
inventory detects additions/deletions and re-parses only changed records. Node-type
lookup filters summaries before opening matching workflows. Callers receive
independent summary copies.
`materials` accepts `q` (name, tags, or node type), `tag`, `kind`, `category`, exact
`node_type`, `page`, and `limit` (at most 100), and returns `total`, `page`, `pages`,
and the available `tags`; callers without page/limit get every summary. `category`
groups sources before pagination: `workflow` contains full snapshots; `prompts`
contains note/text/plan kinds and selections consisting only of prompt nodes;
`params` contains other node/recipe selections; `all` (the default) is every category
but `workflow`. A snapshot moved to a recipe (`mark_material_moved` records
`moved_to_recipe`; the file stays) is listed in no category. Name/tag edits use
`update_material` and atomically replace the record. Tags are trimmed, deduplicated
without case sensitivity, and limited to 20 tags of 60 characters each.
Save, edit, and delete serialize their writes within the server process. An image
save compares the source PNG SHA-256, material kind, and selected node IDs, and a
prompt plan or node values compare their content signature, against existing
summaries. A duplicate returns HTTP 409 with `status: duplicate` and the existing
name/filename, without leaving new assets; `allow_duplicate: true` creates a copy.
Deleting moves the record and its assets to the Recycle Bin.
The route family is:
- `POST /anomalous/inspect_image_material`
- `POST /anomalous/save_image_material`
- `POST /anomalous/save_prompt_plan`
- `POST /anomalous/save_node_material`
- `POST /anomalous/mark_material_moved`
- `GET /anomalous/materials`
- `GET /anomalous/material_full`
- `GET /anomalous/material_asset`
- `GET /anomalous/materials/by_node_type`
- `POST /anomalous/update_material`
- `POST /anomalous/delete_material`
`materials/by_node_type` (Current node's saved values) also says where each record's values
came from: `origin` (the source type, `generated_image` or `canvas_node`), `source_image` (the
kept picture's `filename` and `subfolder`, or null) and `preview` (the saved preview asset's id,
for `material_asset`, or "").
`material_full?include_workflow=0` returns metadata and scoped node blocks (with
`workflow_hashes` restricted to them) and no complete workflow; `include_workflow=1`
returns the original workflow of a complete snapshot. Selected-node records never
return a full workflow and reject explicit workflow requests with HTTP 403.
## Kinds
Records declare `schema_version`, `kind`, and `capabilities`.
- `image_workflow_snapshot`: a generated PNG's complete UI workflow (no longer
created; kept images are recipes).
- `image_node_selection`: some nodes of an image's workflow, saved from the image
workbench (a node card, checked cards, or the generation settings). The workflow
stays as provenance; list, count and lookup expose only the selected blocks.
- `recipe_parameter_selection`: nodes of a recipe or one of its parameter sets, saved
before Current node listed recipe sets itself (no longer created). No image asset;
only `apply_node_parameters`.
- `node_parameter_selection`: one canvas node's values, saved from Current node.
- `prompt_plan`: an image-free prompt with `compose_prompt` (below).
- `prompt_text` / `prompt_note_bundle`: prompts copied from Prompt Notes before combos;
read as prompt cards (`note.promptEn`).
Prompt roles in image selections are inferred from workflow topology first, with
node-title hints as a fallback. The image workbench shows the inferred role beside
every prompt node and allows a positive, negative, shared, unknown, or ignored
override, which travels into the saved record but never writes back to a Recipe.
## Keeping an output image
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`), 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`, 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
/anomalous/kept_images` (`api/kept_images.py`) lists each image's recipe, combo and prompt;
the star is filled when any exists, and a kept row opens it or removes it: what was kept goes to
the Recycle Bin through its own delete route (`delete_recipe`, `delete_notebook`,
`delete_material`) after a confirmation, and the star follows.
## Comparing parameters
`param_compare.js` reads two sides (`readParams`: each node's named widget values, mode, input
wiring and prompt-box role from `prompt_boxes.js`) and compares them (`compareParams`): nodes
pair by id and type, then leftover nodes by type when each side has exactly one of it; values
equal by their text form are counted, not listed; seeds (`isVolatileWidget`) are flagged.
`textDifference` shows a long text as the tags only one side has (each once), or as the
differing span with 40 characters around it; the window gives each side its own line and colour,
since neither side is older than the other. `ui_param_compare.js` is the window: a side is an output image (its
workflow from `inspect_image_material`, laid on a detached graph) or the live canvas. With the
canvas on one side, ticked image values are written through `applyNodeMaterialValues` (seeds,
missing choices and vanished nodes skipped and counted), all as one Ctrl+Z step, with Undo in
the toast. Nothing is stored; the gallery's pick (`owner.comparePick`) lasts until it is used
or cancelled.
An image's workflow is always laid on its graph through `detached_graph.js`, which gives the
copy a fresh id: ComfyUI's frontend keeps widget values by root graph id, node and name, and a
copy under the open canvas's id (any workflow opened from that image) would share and overwrite
the canvas's values.
Key parameters (`key_params.js`) are starred in the window by node type and widget name, so a
star holds in every workflow; they live in the hidden ComfyUI setting
`Anomalous.ModelBrowser.KeyParameters` (at most 40) and show first, equal values included.
A side can also be a saved workflow (`{ workflow, label }`): a recipe's parameter set beside
the recipe (`ui_param_set_keys.js`), whose card in the Parameters tab lists the set's key values
and how many values differ. `GET /anomalous/parameters/signatures` gives each recipe's sets by
their value signature (files over the save limit skipped, at most 5,000 read); a recipe card counts
those unlike the recipe's own, since saving a recipe also keeps a set equal to it.
## Prompt plans
`prompt_plan` has no synthetic workflow. `POST save_prompt_plan` accepts a name, tags,
an optional `source_image`, and `plan`: `parts` is an ordered list of up to 100
records with `name` (up to 120 characters), `category`, `role`, `enabled`, `positive`,
and `negative`; top-level `positive` and `negative` hold the composed text. The plan
is bounded to 2 MiB; unknown fields are discarded. `material_prompt_data.js` reads
the text Prompt Studio shows: plans compose their parts, notes use `note.promptEn`,
and workflow selections use `prompt_groups`.
## Applying saved values
`node_material_actions.js` is the shared, UI-independent mutation owner. It checks
live graph/node identity, indexes, value types and native combo choices before
editing. It skips seed widgets, preserves node identity/links/position, calls the
live widget callback and four-argument node hook, and marks the graph dirty.
Values, serialized widget values and target-scoped hash evidence change in one
before/after transaction; hook failures restore their snapshots. Model paths must
already be available in the native combo. Current node writes only the values that
differ (never seeds, model files or choices this computer lacks) and shows the
receipt with Undo (`ui_apply_receipt.js`); Undo restores values only while the same
live node still holds the applied state. Material storage preserves a workflow's
`extra.anomalous_hashes`, but applying saved values leaves model files and their hash
evidence as they are; Model Check remains the authority on whether a missing model
may be repaired, and a saved name, preview, path or size is never treated as identity.
## Dragging prompts onto the canvas
Prompt Studio's cards drag onto the canvas (`prompt_card_drag.js` through
`material_drag.js`). Only an active, same-page drag is trusted; transfer data is a
marker, not an external mutation command. Over the studio drawer the drag passes
through to the studio's prompt boxes. Live node lookup uses ComfyUI canvas coordinate
conversion, canvas bounds and graph hit-testing; DOM-widget surfaces are accepted,
and the graph/canvas identities captured at drag start are checked again at drop.
While a prompt is dragged (`prompt_drop.js`), every prompt box on the canvas is
outlined in its role's colour (`prompt_boxes.js`: a multiline STRING input by the
node's definition; its role is its own name or the wiring to a sampler). The box
under the pointer is the target (a node with one box needs no aim), the hint names
the node and box, and the text a release would write is laid over the box
(`.anomalous-prompt-preview`, never in the widget's value). A card fills the box with
its text as one undo step; negative text never goes into a positive box. Released on
empty canvas, a card becomes a `CLIPTextEncode` node of its role with its text. Empty
or refusing targets and cancelled drags do not change the graph; nothing queues
generation.
+181
View File
@@ -0,0 +1,181 @@
# Model Identity and Scanning
Read this document for Model Check, workflow provenance, missing-model recovery,
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
Model Check recovers the same physical model referenced by provenance embedded
in a workflow or image. Local renames and path differences are the problem it
solves, so names cannot also be its proof.
Allowed automatic evidence is:
1. plugin-carried cryptographic hash;
2. exact physical byte size only as a disambiguator for that hash;
3. the model category required by the target widget.
Without a hash, one unique in-category size match is only a candidate. Model
Doctor shows it, but it cannot redirect the node until the user takes that
candidate. Confirmation applies to the current node only; it does not create a
persistent binding. If both hash and size are
present and point to conflicting physical files (both match distinct local files),
resolution reports an identity conflict and is rejected. When a requested model hash
simply does not exist in any local file, it returns `{"found": False}` cleanly without
falsely flagging a conflict.
Paths, filenames, source filenames, display/custom names, previews, workflow
fingerprints, and fuzzy/visual similarity never establish identity. They may
be used only after identity is established to return a local dropdown value,
locate presentation media, and verify the value against ComfyUI's native choices.
One exception, for ordinary models only: when the workflow carries no record at
all (a workflow saved without the plugin), the single file of the same name
among the widget's own choices is offered as a candidate (`via: 'name'`); a
size-only candidate whose name also matches is shown as likely (`via:
'name-size'`). Both take a press on their row and never count among the files
put in at once. Several files of that name leave the model ambiguous.
Foundation components—`vae`, `vae_approx`, `clip`, `text_encoders`, and
`clip_vision`—are hash-only automatic-recovery categories. Byte size alone
cannot automatically repair them, and they are never offered by name. When size provenance is available it may be
shown as the same explicit manual candidate, but a supplied hash mismatch never
falls back to a filename or size-only guess.
This is a Model Check confidence boundary, not a scanner-support boundary. The
scanner may traverse any active registered model folder and can still
calculate a local hash when Civitai has no matching record. Sparse or ambiguous
remote metadata is a reason to require cryptographic identity, not to exclude
the category from scanning.
An existing native combo value remains loadable even if a foundation component's
current local hash differs from stored provenance. Model Check shows a
non-blocking identity-change warning instead of declaring the node missing or
replacing it. If the value is absent, redirection requires one exact in-category
hash match. Missing or ambiguous evidence remains unresolved for manual action.
Slash normalization of a value that already has an exact native-option
equivalent is representation normalization, not discovery. The resolver must
never append a foreign or cross-category value to `widget.options.values` merely
to claim success.
## Frontend provenance cache and injection
`window.anomalous_hash_cache` maps model widget values to exact SHA-256 and size
records. It is fetched by the optional resolver from `/anomalous/all_hashes`.
Relative-path and basename aliases exist only when unambiguous; if two files
share an alias but differ in identity, omit that alias. Lookups prefer the full
widget value before basename fallback.
After a scan writes new metadata, `window.anomalous_reload_hashes()` refreshes
the frontend cache.
When provenance injection is enabled, the compatible active graph constructor's
`serialize` path writes
`extraObj.anomalous_hashes[node_id_filename] = {hash, size}` for known model
widgets. The cache covers ordinary models and foundation categories. A
foundation component without a recorded hash receives no size-only provenance.
The same pass writes `extraObj.anomalous_model_sources`: links the workflow keeps
win; a model present here without one gets its own information's link (the
editor's first, else Civitai's), marked `auto` so a newer one replaces it; links
for names no node holds any more are dropped. The live graph is never changed.
The share-code export may leave the links out.
A missing compatible graph API disables only injection and recovery integration,
not the main browser.
Exact-path checks are limited to preflight confirmation that an already resolved
local value exists. Backend discovery constrains candidates to the inferred
category and intersects saved hash/size evidence. One unique in-category
size-only match is returned as a confirmation-required candidate; it is never
an automatic result. Multiple equal-sized candidates remain unresolved. A real
hash/size conflict is rejected.
## Metadata association
A Civitai `.info` record can describe multiple physical files, such as a model,
text encoder, and VAE. `get_metadata()` selects the matching `files[]` entry by
exact physical byte size. An unmatched entry may be used only when it is the sole
hash candidate. Never take the first SHA-256 or select among entries by filename.
Metadata refresh is enrichment, not identity mutation. Offline generated
metadata includes the discovered hash, but consumers still associate the record
with the current physical file through the established size/name rules; array
position is not identity.
## Scanning
A scan (the scan page) runs outside the aiohttp event loop and identifies a model through
the established fallback sequence:
1. use existing valid file SHA-256 metadata when the scan does not request refresh;
2. otherwise calculate the complete file SHA-256, never a ModelSpec header digest;
3. if the remote service has no record, infer a bounded local base-model family
from tensor/header fingerprints and write offline metadata.
`model_identity.py` owns digest validation and the `anomalous_file_identity`
sidecar record: algorithm, file scope, digest, physical size/mtime, and computed
source. ModelSpec hashes may describe tensor content and are never full-file
identity ([specification](https://github.com/Stability-AI/ModelSpec)). Old locally
inferred sidecars without this record keep their display data but need an explicit
scan or successful on-demand SHA-256 check before supplying identity again.
Successful on-demand verification preserves existing notes and remote metadata.
Imported workflow provenance stays intact; it must match valid local file evidence.
Remote metadata requests are part of an explicit user-initiated scan. Local
browsing and offline inference remain usable when the service is unavailable.
Physical rename conflicts are non-destructive. If the generated target filename
already exists, hash both complete files. Deletion is allowed only when their
full SHA-256 values match. Distinct files with the same generated display name
are both preserved.
## Resolution execution
The frontend finds unresolved workflow nodes and sends provenance hash, byte
size, and inferred model category to the batch endpoint, falling back to the
single-item endpoint if batching fails. Requests are grouped by the exact model
category tuple so the backend scans each group once, but every result retains
the same hash, size, category, conflict, and ambiguity rules.
If a size-selected candidate lacks cached hash metadata, the backend may hash it
on demand. Only an exact hash match establishes identity; successful discovery
may write offline metadata for later reuse.
After a hash match—or explicit confirmation of a size-only candidate—the
frontend refreshes ComfyUI's native combo definitions and accepts the returned
path only if it is present in the target widget's choices. Then it updates the
dropdown through the node's widget hooks, which also clears ComfyUI's
missing-model mark.
The check reads every drop-down naming a model file, and rgthree's Power Lora Loader's
switched-on rows, which are no drop-downs (`powerLoraRowBoxes` in `lora_nodes.js` shows each
as one: ComfyUI's LoRA list as its choices, a fix changing only the row's LoRA). The
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
press puts them in: the bar's or the doctor page's for hash matches, the page's
per-row button for a size-only candidate. A scan with "fix the open workflow"
on puts hash matches in when it ends.
A model that is not here can be downloaded from the page when its source names one
exact file: Civitai by the workflow's hash, or a file link the workflow carries. The
download is checked against the SHA-256 before it gets its name, so a wrong file never
lands in a models folder; the file keeps the workflow's name, and nodes are pointed at
it only when it went to another folder than the workflow says.
Provenance-rich workflows skip the redundant full filename-to-hash cache refresh.
Legacy workflows without injected provenance may refresh it for compatibility.
This is an I/O optimization only and does not change identity evidence.
Civitai lookup by hash may resolve a model/version page and route mature content
to the configured domain. Hash-detail UI may compare and copy workflow and local
hash/size values. Neither presentation feature changes recovery rules.
+150
View File
@@ -0,0 +1,150 @@
# Workflow Recipe Model Lifecycle
Read this document before changing Workflow Recipe model cards, local matching,
partial-canvas append, package export, or Model Check integration. UI code may
change freely, but the data ownership and transition rules below are contracts.
## Three representations with different jobs
A saved recipe keeps model information in three related representations:
1. `workflow.nodes[].widgets_values[]` is authoritative for the value ComfyUI
will load into a model widget.
2. `params.model_references[]` is the structured recipe record used by the UI,
package system, history, and matching actions.
3. `workflow.extra.anomalous_hashes` is Model Check provenance keyed to the
serialized node ID and widget value.
Changing only one representation creates a broken recipe. A path change must
update the workflow widget, its model reference, and its node-scoped Hash key in
the same recipe update. A partial append must remap the Hash key alongside the
node ID. Presentation fields must never become identity evidence.
## Schema v7 model reference
`params.model_references[]` contains these persisted fields:
- `node_id`, `node_type`, `node_title`: serialized graph location and display
context;
- `widget_index`, `widget_name`: authoritative widget slot;
- `saved_value`: exact dropdown/path value stored in the workflow;
- `category`, `base_model`: bounded matching and presentation context;
- `identity`: `status`, optional SHA-256, optional byte size, and provenance;
- `origin`: editable official-source metadata, independent of identity;
- `preview`: optional recipe-owned bounded snapshot descriptor;
- `user_note`: optional recipe-scoped personal note, at most 1000 characters.
`user_note` is not global model metadata, is not used for matching, and does not
change Model Check confidence. Enrichment preserves it by the complete model
reference key. Package export explicitly asks whether to include it; excluding
it removes the field only from the exported current/history records.
Runtime-only fields such as `localModel`, `localMatch`,
`currentAvailability`, and `currentPreviewUrl` must not be persisted as schema.
## Hash record shape and keys
The canonical record is compatible with:
```json
{
"hash": "lowercase SHA-256 or an empty string",
"size": 123456
}
```
Node-scoped keys use `<node_id>_<saved_widget_value>`. Slash-normalized aliases
may coexist for Windows compatibility. Readers check node-scoped keys first;
global path aliases are legacy fallback only and must not be relied on when
mutating a recipe.
Graph serialization prefers current verified local-cache data. When a widget
value is still missing—or no current cache record exists—it must preserve the
node-scoped provenance already carried by the graph. Clearing it would make a
successfully appended partial recipe lose its Model Check identity on the next
save.
An empty Hash with a known size represents unverified provenance. It may produce
a manual same-size candidate, but never an automatic repair. A Hash copied from
the original recipe must not be attached to a size-only candidate.
## Lifecycle transitions
### Save and update
The frontend captures the graph once. The backend validates it, derives model
references, preserves compatible identity/origin/preview/user-note fields, and
writes schema v7 atomically. Optional save-time verification may calculate a
full SHA-256. Normal enrichment does not silently replace imported identity.
### Availability and local matching
Availability and previews describe the current computer only. Matching sends
saved Hash/size/category evidence to Model Check:
- exact Hash match: may be offered for explicit recipe application;
- unique size-only match: confirmation-required candidate;
- Hash/size conflict: rejected;
- ambiguity: unresolved.
Finding a candidate is presentation-only. The recipe is not changed until the
user activates its Apply/Confirm action.
### Apply local match
One full-recipe update performs all of these changes together:
1. replace the target `widgets_values[widget_index]` with the local dropdown
value;
2. replace `model_references[].saved_value` and its identity;
3. update `params.baseModel` when it held the same old value;
4. remove old node-scoped Hash aliases and write aliases for the new value;
5. archive the previous recipe through the normal update endpoint.
For a Hash match, the stored local identity retains the proven SHA-256. For a
manually confirmed size candidate, use a valid Hash from the local candidate's
metadata when available; otherwise store size-only unverified provenance. The
author's editable `origin` and the recipe-scoped `user_note` remain intact.
### Apply to canvas
Complete recipes pass their cloned workflow to ComfyUI, so the contained
`extra.anomalous_hashes` opens with the new workflow canvas.
Partial recipes are a merge transaction. New collision-free node IDs are
allocated, links and groups are restored, and node-scoped Hash records are
copied under the new IDs without deleting current-canvas records. If any part
fails, inserted nodes/groups and the previous Hash map are restored together.
### Export and import
Export choices independently control preview snapshots, history, model
identity, and personal model notes. Removing identity or notes sanitizes only
the package clone. Import validates the contained recipe and assets, then uses
the same normalization and enrichment boundaries as local recipes.
## Implementation map for UI refactors
- `api/recipes.py`: schema validation, enrichment, field preservation, history;
- `api/recipe_packages.py`: export privacy and package boundaries;
- `api/models.py`: Model Check evidence and candidate policy;
- `web/modules/recipe_identity.js`: model-reference adapters for presentation:
native loaders plus `ALL_IN_ONE_LOADER_SPECS` (verified serialized layouts),
mirrored by `_model_reference_specs` in `api/recipe_schema.py`. Add a loader
to both tables with its real widget order; never infer a category from a
file name, and skip placeholder values such as `None` / `Baked VAE`. Stack
nodes whose rows are not fixed positions are read by `nodeModelValues` /
`_model_reference_values` (the same two sides): rgthree's Power Lora Loader
(rows are `{on, lora, strength}` objects; off rows are skipped, rows numbered
as rgthree numbers them) and Easy-Use's LoRA stack (`lora_N_name` at
3 + 4·(N−1), up to `num_loras`). Only widely used nodes are read this way;
ComfyUI's own loaders stay the reference;
- `web/modules/recipe_provenance.js`: pure Hash lookup/remap/replace helpers;
- `web/modules/recipe_actions.js`: canvas transactions and append rollback;
- `web/modules/ui_recipe_detail.js`: user actions that invoke the contracts;
- `web/modules/ui_recipes.js`: save/import/export orchestration;
- `web/modules/recipe_diff.js`: history-visible semantic changes.
When restructuring UI, call these data functions rather than recreating Hash
keys, matching confidence, or append behavior inside components. Filenames,
notes, previews, and official names must never be promoted to identity proof.
+254
View File
@@ -0,0 +1,254 @@
# Workflow Recipes and Parameter Notebooks
Read this document for recipe schemas, cards/detail behavior, package handling,
result galleries, Parameter Notebooks, prompt roles, and the recipe-powered Node
Assistant.
## Recipe packages
A recipe package (配方包) gives one recipe to someone else; a backup (`api/backup.py`) is
for the user's own data and a share code (`share_code.js`) for a workflow alone. Reopened
by the user's decision on 2026-10-07 after round-trip and failure tests.
`POST /anomalous/export_recipe_package` (`api/recipe_packages.py`) returns a .zip named
after the recipe: `recipe.json` (its `source_image` cleared), the cover and the models'
thumbnails as WebP assets (`include_snapshots`, on by default), its versions only with
`include_history`, and `manifest.json` listing every entry with size and SHA-256. The card's
export button and the detail's More menu open `ui_recipe_package.js`.
Importing goes through the Workflows page's ⇅ (`ui_share.js`): a dropped .zip is tried as a
recipe package, else as a backup. `POST /anomalous/import_recipe_package_inspect` checks
entry names, count, sizes, compression ratio, the manifest's checksums, the schema and that
every referenced asset is a WebP and nothing unreferenced is there, then keeps it for ten
minutes under a token. `POST /anomalous/import_recipe_package_commit` always writes a new
recipe: a taken name gets " (2)", nothing is replaced. Assets and versions are moved in
first and the recipe file last; a failure removes this import's own new folders and leaves
no half recipe.
## Product and data model
Workspace contains Combos (formerly Prompt Notes) and Workflow Recipes.
Recipe Parameter Notebooks are presented as Parameter Sets (参数方案). Internal
notebook route and property names may remain stable for compatibility even when
the user-facing presentation changes.
A Workflow Recipe stores the authoritative serialized graph plus bounded
metadata for browsing and comparison. Summary adapters expose common model,
LoRA, prompt, and sampling semantics; generic bounded widget summaries cover
other nodes, including third-party nodes. Summaries are presentation only. Edit,
apply, structural validation, and detailed values resolve from the contained
serialized workflow by node ID and widget index.
The card API stays lightweight. Full workflow and history payloads are loaded on
demand. Every update archives the previous full recipe locally, bounded to 20
versions. The structural fingerprint (`sha256-structural-v1`) is an integrity
and version-comparison value, not model identity evidence.
Backend ownership is split by responsibility: `workflow_schema.py` owns graph
validation, fingerprints, signatures and integrity receipts; `recipe_schema.py`
owns recipe/model-reference normalization; `recipe_images.py` owns source images,
covers and output-gallery inspection; `recipe_store.py` owns directory, record and
history I/O. `recipes.py` maps HTTP requests and errors and retains narrow
compatibility exports only. `recipe_packages.py`, `parameters.py`, and materials
import those owners directly rather than reaching through the route facade.
The current persisted recipe schema is v7. Earlier schema steps introduced the
structural fingerprint, explicit model-reference identity records, and optional
recipe-owned preview descriptors; v5 separates model identity from editable
official-origin fields. V6 records whether the saved graph is a partial or
complete recipe. V7 adds bounded recipe-scoped model notes and makes Hash
synchronization across local matching and partial append an explicit invariant.
Normal save/update paths preserve compatible imported
records rather than rebuilding identity from the current machine. An explicit
save-time verification choice may replace missing identity with a freshly
computed SHA-256 for supported model categories.
Recipe model references separate saved identity from current-machine
availability and official origin metadata. Origin refresh is an explicit
enrichment mode. A refresh-only request clones the stored recipe before
enrichment so it cannot be rejected by complete-recipe validation or discard the
workflow. Normal updates preserve imported identity/origin records when the
current machine cannot resolve them.
## Save, edit, and presentation
Save captures the live graph exactly once before opening the dialog; that fixed
snapshot is the graph sent to the backend. The dialog records recipe identity,
notes, tags, and cover/source image. It does not ask users to select presentation
pins or a per-recipe snapshot policy. Existing `params.pinned` values survive an
edit, while new recipes use an empty list.
Before the dialog opens, an advisory check reads only the captured workflow hash
records and cached local metadata. Recognized model references without verified
identity appear as an optional action. The checkbox is off by default; enabling
it computes the exact full-file SHA-256 in a worker thread during persistence.
Foundation components such as VAE and text encoders are included because Model
Doctor requires their hash for automatic recovery and treats size-only evidence
as a manual candidate at most.
Inspection failure never blocks the workflow snapshot from being saved.
The details are built around the recipe's values: a bar (Back, the name, the models'
state, Open, ⋯ edit and export), a left column (the recipe's own values, then its saved sets
whose value signature differs from the recipe's, newest first; all pictures; versions) and the
chosen view on the right. The recipe's own values show its cover, notes and tags first; each set
shows Apply to canvas, ⇄ (with the recipe's values or the canvas), its main values, prompts,
the pictures made with those values, and, folded, the models (own values only) and every node. Compact cards may ellipsize bounded values while preserving their
full copy value. Detail rows provide visible expand/collapse and copy controls
for long values and prompts; they do not silently truncate authoritative data.
`ui_recipe_detail.js` coordinates the detail session (the view: a set, all pictures or versions)
and the bar; `ui_recipe_sets.js` the left column and the set actions (save the canvas, new,
rename, delete); `ui_recipe_set_view.js` one set; `ui_recipe_info.js` the name, notes, tags and
models' state (styles in `styles/29-workflow-detail.css`); `ui_recipe_parameters.js` prompt roles,
raw nodes and the new set's editor; and `ui_recipe_models.js`
lists the models with previews and whether each is here under its saved name. Inline persistence is centralized
in `ui_recipe_metadata.js`, with pure ordering/value helpers in
`ui_recipe_parameter_utils.js`. The catalog shell lives in `ui_recipe_catalog.js`,
cards and card actions in `ui_recipe_cards.js` (styles in `styles/28-workflows.css`; the ⋯
and filter menus are `ui_popup_menu.js`), the save dialog in `ui_recipe_save_dialog.js`, the edit
dialog in `ui_recipe_dialogs.js`, and shared cover helpers in `ui_recipe_media.js`.
`ui_recipe_versions.js` owns version comparison and restore, while
`ui_recipe_gallery.js` owns result rendering and opens `ui_gallery_detail.js`
directly for image inspection. Shared detail DOM/copy primitives live in
`ui_recipe_detail_dom.js`; the subviews return refresh/finish decisions through
callbacks instead of redrawing one another.
Model names in compact recipe presentation use a basename or official model
name, never a full filesystem path; the saved path is a secondary line and the
detail shows no hashes (Model Check shows that evidence). A preview or exact path can locate current-machine presentation
only after the reference is already understood; it cannot establish identity.
The recipe detail does not match or replace models. A model not found under its
saved name is listed as missing, and opening the workflow hands it to Model Check
(model-resolution.md), which finds renamed or moved files on the canvas; saving
the recipe again keeps the change, with the previous version archived.
Model-reference `user_note` is recipe-scoped presentation metadata and never
match evidence.
Recipe-owned model preview snapshots are bounded, content-addressed WebP files
below `.assets/<recipe-stem>/`. They are at most 320 px or 96 KiB each, limited
to 12 images and 1.25 MiB per save/update. Static local preview files are
accepted; original videos are never packaged, though a bound local video may
contribute a bounded first-frame thumbnail. Deleting a recipe removes only its
contained assets after the recipe file is deleted. History may share an asset
ID.
## Canvas actions
Recipe cards and details expose a scope-aware canvas action. A live graph with
an active output node and no unconnected required inputs is saved as a complete
recipe and opens as a new workflow canvas. A graph without an output node or
with required connection boundaries is saved as a partial recipe and appends to
the current canvas. Append clones saved nodes, assigns collision-free IDs,
remaps links, places/selects the inserted content, treats groups as first-class
items, remaps node-scoped model Hash records, and rolls back nodes/groups/Hash
records together on failure. Legacy recipes without
scope metadata are complete recipes because earlier releases only documented
and captured complete workflows.
Structural editing is separate from composition. It may load a recipe into a new
canvas after explicit confirmation and saves back through the full-recipe update
path. It is not presented as an ambiguous “Open to canvas” recipe action.
All list/detail actions await the shared transaction before reporting success or
restoring controls. A failure must leave the button usable and the host graph in
its prior state.
## Result galleries
Recipe result discovery uses `sha256-node-types-v1`: the sorted node class
composition and count. It intentionally ignores seeds, prompts, model values,
and other parameters, so ordinary generation variations can match. This is
separate from the structural integrity fingerprint.
Opening detail scans at most the newest 200 PNG files below the ComfyUI output
directory and reads only bounded embedded `workflow` or API `prompt` metadata.
There is no persistent output index or background polling. A chosen result may
become the recipe-owned compressed cover; its original output path remains a
local convenience reference rather than package content or identity evidence.
The main output Gallery performs its page-one scan when opened and exposes a
manual Refresh action. It does not poll in the background and preserves scroll
position across manual refresh.
## Parameter Notebooks
Parameter Notebooks are immutable generation-value snapshots owned by a recipe.
Saving or updating a recipe creates a new snapshot, equal to the recipe's own values, so
the details leave it out of the list. The details are a two-pane browser: the left pane selects a notebook and the right pane
prominently identifies the active name and timestamp. Selection requests carry a
token so an older gallery response cannot overwrite a newer selection. Parameter
result matching uses the separate `sha256-params-v1` signature.
“New parameter note” clones the selected recipe/snapshot into an editable draft
and saves a new immutable snapshot. Rename uses the dedicated parameter endpoint
and atomic persistence. Delete accepts only the validated notebook filename,
invalidates in-flight requests, reloads the list, and selects the newest remaining
note when necessary. The recipe's current baseline is not a deletable stored row.
Runtime-volatile values such as sampler seeds remain visible by field name but
their values are excluded from summaries, copying, editing, matching, and apply.
The serialized workflow retains the slots for compatibility.
“Read current and create” and “Apply to current workflow” share a skeleton
preflight. Every saved node must match a local node by type and shape, preferring
stable ID/title evidence; extra local nodes are allowed. Read-current refuses to
create a draft if the skeleton is incomplete. Apply validates all widget slots
before mutation, resolves serialized records to live nodes, invokes ComfyUI
callbacks, and rolls values back if a callback fails.
The Node Assistant queries bounded notebook presets by selected node type. Its
cache is invalidated by save/delete and bypassed by an explicit refresh. Applying
a preset is a single-node transaction; it does not apply the full workflow.
## Prompt roles
Automatic prompt roles are conservative. `recipe_parser.js` traces backward
from allowlisted official sampler/Guider conditioning inputs through allowlisted
conditioning pass-through nodes to native `CLIPTextEncode` nodes. Unknown
third-party nodes are opaque; titles, type-name guesses, prompt content, and a
default-positive rule are not semantic evidence.
Saved role metadata joins to assistant records by string-safe node ID. Legacy
recipes may recover a role only from an exact, unambiguous full prompt value in
the saved positive/negative arrays. The same value in both arrays remains
unknown.
Manual overrides live in `params.promptRoleOverrides`, keyed by string-safe node
ID and guarded by saved node type. Values are `positive`, `negative`, `both`,
`ignored`, or `unknown`. An override wins over automatic/legacy metadata; removing
it restores the saved automatic result. Updates retain it only while ID and type
still match.
## Focus values
A workflow's focus values (重点参数) live in `params.focusParams`: at most 12
`{ node, type, widget, note }`, all strings (node ID, saved node type, widget
name, a note of at most 120 characters). `api/recipe_schema.py` refuses any other
shape and drops repeats. A star or a note saves them alone through
`POST /anomalous/recipe_focus` (`api/recipes.py`, from this computer only): the
recipe file is read (up to its size limit), only `focusParams` changes, and no
version is kept, unlike a recipe update. They are shown, compared and kept only while a node of
that ID and type is still in the workflow (`recipe_focus.js focusEntries`); an
update from the canvas keeps the ones still true. Its parameter sets, the canvas
it opens and its pictures share the node IDs, so comparisons among them
(`compareParams`' `isKey(type, widget, bId, aId)`) list the focus values first
with the starred key parameters, which hold for every workflow instead.
## Recipe-to-model navigation
Opening a resolved local model from Recipe Overview keeps the outer browser open
and temporarily replaces all main content surfaces with model detail. A compact
return token retains the recipe payload, active tab, scroll position, and prior
Workspace return state.
Model-detail Back, or reopening Workspace and selecting Workflow Recipes,
consumes that token and reconstructs the exact recipe detail. Navigating directly
to another main panel or closing Workspace abandons the transition and clears
stale media/DOM/return state. Opening Workspace during the transition must not
overwrite the earlier outer-panel return state.
Workflow Recipes and recipe-powered Assistant parameter presets were beta
surfaces until v1.60.0, the first release not marked beta (2026-10-09). Localized
notices identify their data directories and distinguish single-node preset apply
from full-skeleton Parameter Notebook apply.
+47
View File
@@ -0,0 +1,47 @@
# 更新引导维护契约
更新引导只解释明显变化,不承担功能开关、数据迁移或执行用户操作。
修改前阅读根目录 [AGENTS.md](../../AGENTS.md)。
## 内容与实现分工
| 文件 | 责任 |
| --- | --- |
| `web/modules/update_guide_data.js` | 当前引导的稳定 ID、步骤顺序、图标和文案键;也提供配置校验 |
| `web/modules/locales.js` | 每个步骤及按钮的中英文文案,均按纯文本展示 |
| `web/modules/ui_update_guide.js` | 显示、翻页、关闭、焦点、版本记录;一般内容更新不要修改 |
| `web/styles.css` | 引导布局的现有规则 |
| `web/main.js` / `ui_sidebar.js` | 父窗口关闭清理,以及上方按钮和帮助页的手动入口 |
## 后续 AI 更新步骤
1. 先确认功能已实现及真实入口。不要在引导里承诺未上线、暂停或验证失败的功能。
2. 在 `CURRENT_UPDATE_GUIDE.steps` 编写 1–5 个步骤,每项包含唯一 `id`、短 `icon`、`titleKey`、`bodyKey`。
3. 在 `locales.js` 补齐这些键的中文和英文。标题说明变化,正文说明入口和用户该怎么用。禁止 HTML、可执行回调、请求及文件写入。
4. **只有引导内容属于新一轮功能变化时才换 `id`**,例如 `2026-10-library-navigation`。修改错字、翻译、样式时保持 ID。不使用时间戳、随机数、Git 提交号或普通版本号自动生成 ID;换 ID 也不能自动弹窗。
5. 配置校验会拒绝重复步骤、空步骤及缺失双语文案。配置错误只跳过引导,不应阻止插件打开;必须修正错误后再交付。
6. 运行下方相关测试,再在浏览器验证打开插件不弹窗、上方入口、跳过后手动重开、帮助页重看、Esc 和长文案。
7. 修改现有样式规则,不在文件末尾追加覆盖包。没有明确产品决策,不增加强制阅读、自动跳转、网络请求或额外追踪。
## 音频页使用说明
- `AUDIO_USAGE_GUIDE`(同在 `update_guide_data.js`)是音频页的使用说明,不是更新公告。音频域激活时,右上角 **!** 打开它;图像域照旧打开 `CURRENT_UPDATE_GUIDE`。
- 可选字段:`titleKey` 替换对话框标题;`tour: false` 不显示遮罩导览入口,`tour: [...]` 用这份说明自己的导览步骤(音频页用 `AUDIO_TOUR_STEPS`),不写则是图像界面的导览。导览步骤的目标不在屏幕上时自动跳过。
- 内容跟着功能改,ID 保持 `audio-studio-usage`。完整说明在 `docs/guides/audio-studio.md`,两边要一致。
## 生命周期与记录
- 面板右上角常驻感叹号按钮(`#anomalous-update-notice-btn`),提供完整的用途提示和 accessible name。只有用户点击该按钮或帮助页入口才打开引导;初始化、打开插件、版本变化都不能触发弹窗。
- 使用原生 `dialog` 的顶层显示和焦点约束,关闭时尽可能恢复原焦点。
- 完成、跳过、×、Esc、点击空白关闭会记录已读,上方入口仍然保留;关闭父浏览器仅清理,不将未读引导标记为已读。入口不需要全局事件或延时隐藏。
- 持久化键为 `anomalous_update_guide_seen:<id>`,值为 `1`。只保存本地已读状态,不发送到服务端。
- 存储不可用时,当前页面会话仍记住关闭状态;刷新后仍必须手动点击才能显示。
- 同一时刻只有一个引导。重复调用不叠加弹窗;所有关闭路径通过 `closeUpdateGuide` 释放资源。
- 上方按钮和帮助页的“查看更新引导”均使用 `force: true`,允许重看已确认版本。
- 引导不自动点击对应功能,也不修改画布、模型、素材、导入导出限制。
## 验证
更新指南没有单独的自动测试。改动后在浏览器里实际打开 **!** 指南,检查双语、
翻页、重看、关闭和焦点;全部测试的跑法见 `docs/guides/testing.md`。
只有同时修改工坊生命周期或导入导出行为时,才追加对应测试;纯引导样式不运行无关测试。
+194
View File
@@ -0,0 +1,194 @@
# Current Architecture Decisions
This index records concise reasons for enduring product and architecture
boundaries. It is not a changelog. When a decision becomes obsolete, update or
replace the record and rely on Git history for the former implementation.
## AD-001 — One command, exclusive browser entries
The floating trigger, action-bar button, and Extensions command share one
`openBrowser()` command and one browser instance. Presentations are mutually
exclusive, while the Extensions command always remains as a recovery route.
Native ComfyUI command/keybinding infrastructure owns shortcut recording and
modal guards.
## AD-002 — Recipes are templates; notebooks are immutable instances
A Workflow Recipe is the authoritative workflow template. Parameter Notebooks
are immutable generation-value snapshots attached to a recipe and browsed inside
its Parameters tab. The Node Assistant may reuse those snapshots for a selected
same-type node, but this does not replace full-skeleton recipe application.
## AD-003 — Recipe canvas actions follow graph scope
Recipe cards and detail views append partial recipes because their open required
connections make them composition fragments. Complete, independently runnable
recipes load through ComfyUI's workflow path and therefore open as a new canvas
in the current frontend. Structural editing remains a separate, explicitly
confirmed workflow.
## AD-004 — Recipe save hides implementation policy
Save collects identity, notes, tags, and cover/source image. Presentation pins
and snapshot policy are implementation choices, not per-save questions. Legacy
pin data is preserved without exposing a control; new recipes default to none.
## AD-005 — Model identity excludes names and previews
Model Doctor automatically proves identity through provenance hash and required
category; byte size may disambiguate that hash. A unique size-only match is a
manual candidate, never proof or an automatic redirect. Hash/size conflicts are
rejected. Local paths, filenames, display names, previews, and similarity are
presentation or post-resolution routing data. This boundary prevents silent
substitution after local rename or across categories.
## AD-006 — Foundation components are hash-only recovery categories
VAE, VAE Approx, CLIP/Text Encoder, and CLIP Vision files can commonly share
sizes or ambiguous names. Automated redirection therefore requires one exact
in-category hash match. A size-only candidate may still be shown for explicit,
one-time confirmation on the current node. Existing native combo values remain
usable with a visible identity-change warning when provenance differs. This does
not restrict scan coverage: scanning may calculate hashes for any active
registered folder, including categories whose Civitai metadata coverage is weak.
## AD-007 — Recipe origins and local availability are separate
Official origin metadata can be refreshed or edited independently of saved
identity. Exact-path availability checks and previews describe the current
machine; they do not rewrite imported provenance or activate a local match.
## AD-008 — Prompt roles prefer unknown over a guess
Automatic prompt role tracing follows only allowlisted native prompt,
conditioning, and consumer nodes. Unknown third-party nodes are opaque. Legacy
full-value recovery must be exact and unambiguous, and users may store explicit
recipe-scoped overrides.
## AD-009 — Recipe-to-model navigation is recoverable and exclusive
Model detail temporarily replaces all main content surfaces while retaining a
small recipe return token. Back reconstructs recipe detail; unrelated navigation
abandons the token and cleans stale state. The transition never closes the outer
browser or exposes model detail beside Node Assistant.
## AD-010 — Parameter selection must identify both panes
The active Parameter Notebook is visible in the list and in a right-pane banner
with name and timestamp. Requests are selection-tokened so stale gallery results
cannot overwrite the new notebook. Sidebar input/button rows must remain usable
in the narrow docked layout.
## AD-011 — Output discovery remains bounded and user-refreshed
Recipe matching inspects at most the newest bounded set of PNG metadata and has
no persistent index. The main Gallery scans on open and refreshes manually. This
avoids background polling and hidden indexing cost while retaining predictable
freshness.
## AD-012 — Licensing and branding are separate
The standard MIT `LICENSE` applies to project-owned code and documentation.
`TRADEMARKS.md` separately governs the Anomalous Model Browser name and official
branding. Project-specific restrictions are not appended to the MIT license.
## AD-013 — Beta guidance is scoped to experimental workflow features
Workflow Recipes and recipe-powered Assistant presets carry localized backup and
data-safety guidance until an explicit stability decision removes it. Stable
Node Assistant model replacement and LoRA insertion are outside that warning.
Notices distinguish single-node preset application from full-skeleton Parameter
Notebook application.
Status (2026-10-09): ended with v1.60.0, the first release not marked beta. The
in-app beta notices had already been removed.
## AD-014 — Architecture documentation is state, not history
Architecture documents change only when an owner, data flow, interface contract,
persistence format, security boundary, or critical invariant changes. Ordinary
fixes do not append “unchanged” entries. Git is the implementation history;
`CHANGELOG.md` is user-facing release history; `docs/architecture/lessons.md` holds
only durable recurring traps.
## AD-015 — Recipe model mutations update all identity representations
The authoritative workflow widget, structured model reference, and node-scoped
Model Doctor Hash index form one mutation boundary. Local matching updates all
three through the archived full-recipe path. Partial append remaps Hash records
with node IDs and rolls them back with the inserted graph. Personal model notes
are recipe-scoped presentation data and may be excluded from package export.
## AD-016 — The audio page manages one speech engine
The audio page is built around GPT-SoVITS through the separate Anomalous_TTS
node pack: its characters, emotions, pronunciation and sampling defaults live in
the node's own settings, and the Script Director can run a script without the
canvas. The F5-TTS voice library (files under `input/F5-TTS`, upload, romanization,
Load Audio drags) was removed rather than kept as a parallel engine, so the page
teaches one set of rules. A zero-shot voice (one clip, no trained model) belongs
in Anomalous_TTS as a character kind, not as a second engine here.
## AD-017 — The browser is a rail of pages with a home page
Navigation is one narrow icon rail: a home page, the image pages (models, gallery,
workflows, materials), the audio pages (voices, audio gallery), the canvas tools and
settings. The rail replaces the header tabs, the image/audio toggle and the tool bar
under the folder list, so the list column holds only a list and can close without
hiding any command. Each page decides whether it has a list; the open state is
remembered per page and narrow windows start with it closed. The home page offers
tasks rather than a manual: each card opens its page, and a first visit lands there.
AMB stays the chore layer around ComfyUI (AD-016 keeps one engine per page); a new
page earns a rail entry only when it is a place users return to, not a one-off tool.
## AD-018 — The activity log records what Anomalous changed, without undo for now
Users need to see what the plugin did to their canvas and files. File changes are
recorded on the server, after a write request succeeds, from one table of routes,
so a new write route is logged by adding a line there. Canvas changes are recorded
without each action reporting itself: a press inside Anomalous takes a snapshot, and
the difference is logged when the user next presses or types outside it, so the
user's own edits are never attributed to the plugin. Opening another workflow is
logged as that, not as removed nodes. The log is bounded and never fails the action
it records. Undo comes later and only for changes that can be reversed safely
(canvas values, renames); deletions go through their own confirmations instead.
## AD-019 — No Civitai browser inside Anomalous; imported files are put in place
New users' trouble is not finding models but knowing which folder a downloaded file goes
in. A Civitai browser inside the plugin would not reach many users (Civitai is often
unreachable from mainland China, where most models come from LiblibAI, cloud drives and
chat groups), would mirror a large site's search, filters and content rating, and would
show its picture feed inside the plugin. Instead the plugin takes a file from wherever it
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.
+242
View File
@@ -0,0 +1,242 @@
# 音频工作台使用说明
插件里也能看简版:进入音频页后,点右上角的 **!**。
---
## 0. 需要什么
音频页用 GPT-SoVITS 给角色配音,需要节点包 **Anomalous_TTS**。
- **没装时**,音频页中间会写它能做什么、怎么装。页面不会弹窗打扰你。
- **装好后**,重启 ComfyUI,再点“刷新音色”就能看到角色。
- **没装也不影响插件的其他功能。**
### 左边的侧栏
- **分组**:角色按语言分组(中文、日语、英语、未标语言)。点组名可以收起,下次打开还是收着的。只有一组时不分组。
- **展开角色**:点角色名左边的小箭头,下面会列出它的每条声音(主声音、`{开心}` 这样的情绪)。点一条就在右边播放,再点一下停。
- **拖到节点上**:侧栏里的角色可以直接拖到节点上,和拖右边卡片的标题栏一样。
- **小红点**:角色名后面有小红点,说明这个角色有问题(读取失败,或者没有能当主声音的音频)。鼠标停在点上能看到原因。
- **搜索**:角色有 6 个以上时,顶上会出现搜索框,可以搜角色名、别名或情绪名。
- **拖文件**:把音频、模型文件拖到侧栏里的某个角色上,会打开“添加文件”;拖到空白处就是导入新角色。
还没有角色的话,先看第 4 节“导入角色”。
---
## 1. 角色卡片怎么用
每个角色一张卡片:
```
┌───────────────────────────────────────────────┐
│ ⠿ 🎙 Arona 参考音频 读音 添加文件 3 角色音色预设 │ ← 标题栏:代表“整个角色”
├───────────────────────────────────────────────┤
│ ▶ MAIN “それぞれの学園…” [{main}] │ ← 每一行:一条参考音频
│ ▶ ANGRY “相手の雰囲気が…” [{angry}] │
│ ▶ HAPPY “うん、よし!…” [{happy}] │
└───────────────────────────────────────────────┘
```
| 操作 | 结果 |
|---|---|
| 点 ▶ | 试听这一条 |
| 点右边的 **复制 `{标签}`** 按钮(角色设了情绪才有) | 复制标签,粘贴到台词里 |
| **拖标题栏** → 放到 **角色语音 (GPT-SoVITS)** 节点 | 节点的“角色”设为这个角色 |
| **参考音频与情绪** / **读音** / **添加文件** | 见第 2、4 节 |
情绪靠台词里的 `{happy}` 这种标签来切换,所以拖的是整个角色,不是某一条音频。
### 拖动时看提示
拖动时,鼠标旁边有一个提示框:
- **白色**:`松手:GPT-SoVITS 节点的角色设为 Arona`。可以松手。
- **黄色**:拖到了不合适的节点,提示会写原因和正确做法。这时候松手什么也不会改。
目前只接收 **角色语音 (GPT-SoVITS)** 节点,其他节点一律不接收。这是故意的:插件不靠猜,避免写错地方。
## 2. 配音:一段话多种情绪
1. 在 **角色语音** 页顶部从 **角色** 切到 **配音**。先在左边列表里点一个角色,贴进来的台词就由 TA 念。
2. 把整段台词粘贴进去,点 **分成句子**。每句话会变成一张卡片。
3. 每张卡片点一下情绪(主声音 / happy / angry……),旁边的小喇叭能听这个情绪原本的参考声音。角色只有主声音时不显示情绪按钮。也可以直接改字;鼠标移到卡片上会出现 ✂(在光标处拆开)、↧(和下一句合并)、×(删除)。
### 直接生成,不用画布
页面够宽时分成两栏:左边是台词卡片,右边是 **生成语音** 和设置面板。窗口窄或者浏览器停靠在侧边时,这些放在台词下面。两种情况都只有台词这一块会滚动,句子再多,按钮也一直看得见。
**左边选,右边改;设置分三层。** 右边的面板跟着你选了什么变,最上面一条路径告诉你现在改的是哪一层,比如 `整份剧本 › 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. 不满意:
- 某一句不好,点那张卡片上的 **↻**,只把这一句换一版,其他句子不变(直接用缓存,很快)。
- 整段都不好,点 **整段换一版**,换一个种子全部重新生成。
需要 Anomalous_TTS 接口版本 11 以上;旧版本时这里会提示更新,仍然可以推送到节点、在画布上运行。
### 放进自己的工作流
生成按钮下面的 **放进自己的工作流** 一栏,把剧本写进画布上的 **角色语音 (GPT-SoVITS)** 节点:
- 点 **推送到节点**:写进选中的节点。画布上只有一个这种节点时,不用选。
- 按住 **拖到节点**,把整段剧本拖到某个节点上。
- 点 **复制剧本**:复制带标签的台词,例如
```
{main} 老师,早上好!
{happy} 今天也要加油哦!
```
推送和拖动都会同时设置“角色”和“台词”,写错了可以按 Ctrl+Z 撤回。节点里原来有台词时,会先问要不要覆盖。
### 某个词总是读错
角色卡片上的 **读音** 打开这个角色的读音表:左边写剧本里的字,右边写让它实际读的字,例如 `C站 → 西站`、`粘进 → 沾进`。剧本和字幕照常写,只影响念出来的声音。
- 每一行的 **现在** 听这个角色现在怎么读左边的字,**改后** 听右边的字,对比着改。试听不会存进音频库。
- 日语模型说中文时特别有用:它发不好的音(选、全这类 ü,模型、行这类后鼻音)换成好读的同音字,或在前面加个字;英文字母写成读音相近的汉字。
- 长的先匹配,只替换一遍。只对这个角色生效。
## 3. 找回结果
- 生成的音频在图标栏的 **音频库**:
- 每条会显示台词、声音和种子,可以试听、按台词搜索、删除。
- 只在预览里出现、还没保存的临时结果,列在“未保存的预览”里,点 **保存** 就能存到 `output/audio/`。
## 4. 导入角色和设置
### 设置
工具栏上有 **导入角色**。还没有角色时,中间是一块空的导入区,直接把文件拖进去或点 **+ 导入角色**。
存放位置、基础文件这些设置在左边侧栏的最下面:**GPT-SoVITS 设置**,点开是一个窗口。基础文件(GPT-SoVITS 生成时要用的几个公用模型)缺了不会挡路(第一次生成时会自动下载),所以只在这个入口上放一个小黄点:已有角色用到的基础文件还没下载时才出现。窗口里第一行写着缺几个、多大,可以 **现在下载**,也可以点 **不再提醒**;都齐了时写着“一切就绪”。以后导入了要用新基础文件的角色(比如第一个中文角色),小黄点会再出现。缺 Python 包时是小红点,不能关掉。下载的进度也写在这个入口下面。
标题旁边的小标签写着状态(一切就绪 / 缺几个基础文件 / 缺 Python 包)。下面分成几类,一次看一类;哪一类需要你处理,名字旁边有个小黄点,打开窗口时直接停在那一类:
- **语气**:平时用哪种语气(平稳 / 自然 / 生动 / 自建的),可以试听、设为默认;右上角 **简单 / 看数字** 切换要不要显示具体数字。
- **说别的语言**:角色说的不是参考音频的语言(比如日语角色说中文)时用哪种语气。
- **基础文件**:每一项是已就绪、缺少还是下载中,可以单个 **下载** 或 **全部下载**。只说日语的角色不需要中文那几项;标“可选”的缺了也能用。缺 Python 包时这里给出安装命令,复制到命令行运行后重启 ComfyUI。
- **存放位置**:
- **角色存放位置**:导入的角色都放在这里,默认 `ComfyUI/models/gpt_sovits/`。
- **其他位置**:设置文件里写的其他角色文件夹,以及 `extra_model_paths.yaml` 里的文件夹。
- **设置文件**:Anomalous_TTS 的设置文件 `ComfyUI/user/anomalous_tts.json` 的位置,可以复制路径。存放位置、其他角色文件夹和已有的 GPT-SoVITS 整合包(用包里的底模,不用再下一遍)都在这个文件里改,窗口里改不了:节点不接受网页指定的本机路径,这样网页就决定不了它在哪里建、删文件夹或读哪些文件。路径写完整,改完不用重启:
```json
{
"format": 1,
"storage": "D:/voices",
"libraries": ["E:/old voices"],
"pretrained": ["D:/GPT-SoVITS/GPT_SoVITS"]
}
```
要换存放位置,先把角色文件夹自己移过去,再改 `storage`。
导入表单里选了语言后,下面会用小字写出生成时还要用到哪些底模。
这些操作会读写运行 ComfyUI 那台电脑上的文件,所以只能在那台电脑上用;从别的设备打开时按钮是灰的。
### 导入角色(新用户从这里开始)
点 **导入角色**。第一次打开时会有一段遮罩引导,一步步指给你看;之后随时点窗口右上角的 **?** 再看一遍。窗口顶上写着现在在哪一步:**① 选方式 → ② 放入文件 → ③ 导入**。
**① 选方式**:窗口先问“你手上有什么?”
- **一个角色**:这个角色的 GPT 模型、SoVITS 模型和几段参考音频。会一步步带你放好。
- **很多角色 / 整合包**:一次导入好几个角色,或者选一个 GPT-SoVITS 整合包文件夹,会按名字自动分好。
- **给已有角色加声音**:选一个已经导入的角色,往里面加参考音频、台词文件或新模型。
直接把文件拖到音频页或这个窗口上,会跳过这一步,按“很多角色”处理(一个角色的文件也能正确认出来)。选错了可以点 **‹ 换一种方式**(还没放文件时才有)。
**② 放入文件**:每个角色一张卡片,卡片最上面是一份**待办清单**,永远高亮下一步,按钮就在那一行:
1. 放 GPT 模型(`.ckpt`,在整合包的 `GPT_weights` 文件夹里)
2. 放 SoVITS 模型(`.pth`,在 `SoVITS_weights` 文件夹里)
3. 放几段参考音频(3~10 秒、说得清楚的)
4. 选一段当主声音:在音频列表里点 **☆**,变成 **★** 就是主声音(一般会自动选好)
5. 给角色起个名字(一般会从模型文件名自动填好;卡片标题就是名字,可以直接改)
做完一项就打勾;卡片标题旁写着“还差 N 步”,全部完成变成绿色的 **可以导入**。任何时候都可以直接把文件拖进窗口,会自动放到对的地方。
参考音频一段一行:点前面的 **▶** 试听,再点一次(■)停止;点另一段就直接换过去,方便一段段对比着挑。上传中、还没分给角色的音频都能听。填上情绪名(例如“开心”)就成了 `{开心}`。**不在 3~10 秒的音频不会导入**(节点用不了它们),连同同名的台词文件一起收进卡片底部的“不导入”,点开能看到是哪些。一个角色音频很多时先显示前 60 段,点 **显示全部** 看其余的。台词会自动从同名 `.txt` / `.lab` 或标注文件里找;台词框是黄框表示台词取自文件名,请确认;红色虚线框表示没找到,可以粘贴,也可以把 `.txt` / `.lab` / `.list` 拖到框里。卡片底部是台词文件和 **角色说的语言**(一般自动判断就行)。
**很多角色 / 整合包**时:
- **一个文件夹一个角色**:一个文件夹里(包括它的子文件夹)只有一套 GPT + SoVITS 模型,还有音频或台词,它就是一个角色,用文件夹名当角色名。比如 `派蒙/` 里有 `model-e10.ckpt`、`model_e10.pth` 和参考音频,角色就叫“派蒙”;两个角色的模型都叫 `model` 也不会混在一起。文件夹叫 `v2`、`v4` 这类版本名时,前面加上上一级的名字(“派蒙 v2”)。
- 模型放在整合包的 `GPT_weights*` / `SoVITS_weights*` 里,或者好几个角色的模型平铺在一起时,按模型文件名配成角色:`ALuoNa-e15.ckpt` 和 `ALuoNa_e16_s224.pth` 都算 `ALuoNa`。好几轮(e5、e10、e15)时自动用轮数最大的。
- 其他的按所在的文件夹(同一个文件夹里的模型)、文件夹名(比如 `参考音频/ALuoNa/`)或文件名开头分给对应的角色。同一个文件夹里有 `Guang` 和 `Guang_v4` 两套模型时,归 `Guang`。
- 没分给任何角色的 `.list` 标注文件,每个角色都放一份(节点按文件名找台词,不会找错)。
- 同一个角色里有同名的文件(比如 `开心/01.wav` 和 `难过/01.wav`),后来的会在前面加上文件夹名(`难过_01.wav`),同名的台词文件一起改,不会丢;鼠标停在文件名上能看到原来的文件。
- 分不出来的放在最上面一张黄色卡片里,放进角色后才上传。文件多时一个文件夹一行,可以把整个文件夹一次放到某个角色;点开这一行,也能一个一个选。分错的文件,右边也能改。
- 不会读的文件夹:`runtime`、`pretrained_models`、`venv` 这类 Python 环境和底模文件夹;整合包里(旁边有 `runtime`、`GPT_weights*`、`webui.py` 这些)的 `GPT_SoVITS`、`logs`、`output`、`TEMP`、`tools`,里面是程序、训练中间存档和数据集切片。你自己起名叫 `output`、`GPT_SoVITS` 的文件夹照常读;你直接选中或拖进来的那个文件夹一定会读。跳过了哪些,窗口里会写出来。
- 一次最多读 5000 个文件。
- 角色很多时先打开一批(20 个角色,或者大约 800 个文件),其余的在“打开下一批”按钮里;这一批导入完,下一批会自动打开。这样一次拖进很大的文件夹也不会卡。
- 一次要上传很多文件(超过 1500 个或 2 GB)时会先问你要不要继续。文件都是从浏览器上传给节点的,节点不会按路径去读你电脑上的文件。
- 卡片收起时只有一行,点开看详情,一次只展开一张。右上角 **+ 继续添加** 可以再加文件,**+ 新角色** 手动加一个角色。
**③ 导入**:底部按钮写着“导入 N 个角色”,只导入“可以导入”的那些。每个角色单独导入,一个失败不影响其他的;失败的留在窗口里标红,改好再点一次。全部导入完窗口自动关闭,并滚到新角色的卡片。底部还写着会保存到哪里(想换地方,在设置里改存放位置)。
节点会建好 `角色名/GPT_weights/`、`SoVITS_weights/`、`参考音频/` 和设置文件。**一律复制,原文件不动**;中途出错时存放位置保持原样,不会留下半个角色。
给已有角色加音频或情绪:点卡片上的 **添加文件**,或者把文件拖到那张卡片(或侧栏里的角色名)上。这时放进来的文件都归这个角色,模型可以不放。已有的文件不会被覆盖,主声音也不会被换掉(除非你点了“★ 主声音”)。
一条一条加也不会越加越乱:
- 角色里已经有一模一样的文件,那一行是灰色的“角色里已有,跳过”,不会再复制一份。
- 标注文件(`.list` 等)再放一次,只会把新的行追加进角色里已有的那份,写着“合并新台词”。
- 只加一条音频、不带标注文件时,台词会从角色里已有的标注文件里找。
- 只有“同名但内容不同”的文件会标红,不能导入。改个文件名,或者删掉那一行。
### 角色文件夹的规则
- 一个角色就是一个文件夹,里面至少有一个 `.ckpt`(GPT 权重)和一个 `.pth`(SoVITS 权重)。
- 角色文件夹放在存放位置里(默认 `ComfyUI/models/gpt_sovits/`),也可以放在 `extra_model_paths.yaml` 里写的 `gpt_sovits` 目录。自己往存放位置里放整理好的角色文件夹也可以,点 **刷新音色** 就能看到。
- 同一个角色有日配、中配两个版本时,会显示成 `阿罗娜/日配`、`阿罗娜/中配` 两个角色。
### 参考音频与情绪
点角色卡片上的 **参考音频与情绪**,打开一个大窗口:
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`,由节点检查后保存。有问题(比如某个情绪还没选主参考)会提示,什么都不会改。没保存就关窗口会先问一句。
副参考、改台词、音频长度和删除需要 Anomalous_TTS 接口版本 18(1.1.0);旧版本只能选主参考,窗口里会说明。
文件名本身带情绪的音频(例如 `Arona_Talk.开心.wav`)会被自动识别,不用再填一遍。只是扩展名的部分不算情绪,例如 `Arona_Talk.ogg.wav`、`Arona_Talk.ogg (1).ogg`。
角色列表会缓存一会儿,切换页面不会卡。往模型文件夹里加了角色或音频后,点 **刷新音色** 才会重新读取。
### 台词里的其他写法
- `[普拉娜]`:换人说。要写角色名或别名。
- `[pause:1s]`:停顿。
## 5. 常见问题
| 现象 | 原因和解决 |
|---|---|
| 拖到节点上提示“列表里还没有 …” | ComfyUI 还没刷新节点定义。按 **R** 后再拖 |
| 标题栏拖不动 | 这个角色没有主声音 |
| 拖到别的语音节点被拒绝 | 目前只支持“角色语音 (GPT-SoVITS)”节点 |
| 卡片上提示“没有找到合适的主声音” | 角色文件夹里没有 3~10 秒、找得到台词的音频。点“参考音频与情绪”手动选一条 |
| 新放进去的角色或音频没出现 | 点 **刷新音色**。节点那边在 ComfyUI 里按 **R** 刷新下拉框 |
| 侧栏里看不到“GPT-SoVITS 设置”,工具栏上没有“导入角色” | Anomalous_TTS 版本太旧(没有接口版本 3)。更新 Anomalous_TTS 后重启 ComfyUI |
| 同名文件导入时报“有重名的文件” | Anomalous_TTS 早于接口版本 9。更新后重启 ComfyUI |
| “导入角色”是灰的 | 页面是从别的设备打开的。导入会写文件,只能在运行 ComfyUI 的电脑上做 |
+80
View File
@@ -0,0 +1,80 @@
# 在云服务器上使用(AutoDL 等)
> **先说清楚支持到什么程度:**
> - **海外的云服务器**(RunPod 等):完整支持。能连上 Civitai,扫描能拿到名字、触发词和封面。
> - **国内的云服务器**(AutoDL 等):只能部分支持。浏览、离线扫描、整理模型都能用,但服务器多半连不上 Civitai:查模型不稳定,封面基本下不来。
>
> 下面的开关两种服务器都要用。
>
> **还没有完整测过:** 作者只在一台 AutoDL 服务器上实测过,海外服务器还没测过。用的时候请自己留意,遇到问题欢迎反馈。
>
> **想让 AI 帮你弄?** 把这页的链接发给 ChatGPT、Claude 之类的 AI,说“照着这页最后的 For AI assistants 一节带我做”就行。
ComfyUI 装在租来的云服务器上(AutoDL、RunPod 之类),你在自己电脑的浏览器里打开它时,对插件来说页面是从“别的设备”打开的。为了不让别人远程改动你的文件,插件默认只让这种页面浏览:标题旁会显示 **仅浏览**,扫描、下载、移动、删除、保存这类改动会被拒绝。
想在云服务器上照常使用,有两个办法,选一个就行。
## 办法一:用 SSH 隧道打开(推荐,什么都不用改)
SSH 隧道把服务器上 ComfyUI 的端口接到你自己电脑上,浏览器打开的是 `http://127.0.0.1:端口`。请求在服务器上也是从本机发出的,插件就当成本机,所有功能都能用,也不会把页面开放给别人。
AutoDL 控制台里每台实例都有 SSH 登录指令和密码,平台也提供了现成的隧道小工具;按平台说明把 ComfyUI 的端口(常见是 6006 或 8188)映射到本地,再用浏览器打开映射出来的 127.0.0.1 地址即可。
## 办法二:在服务器上放一个开关文件
如果你只能用平台给的网址打开 ComfyUI,最简单的做法:
1. 点标题旁的 **仅浏览**,在弹出的说明里点 **复制**。复制的是一行命令,已经填好了你服务器上插件的真实位置。
2. 打开服务器的终端(AutoDL:JupyterLab → 终端),粘贴,按回车。
3. 重启 ComfyUI。启动日志里会出现一行提示,标题旁的标签变成黄色的 **远程可修改**。
想自己动手也可以:
1. 在服务器上打开插件文件夹 `ComfyUI/custom_nodes/Anomalous_Model_Browser`(AutoDL 可以用 JupyterLab 的文件浏览器,或在终端里操作)。
2. 新建一个空文件,名字是 `allow_remote_changes`(没有扩展名)。终端里可以这样做:
```bash
touch ComfyUI/custom_nodes/Anomalous_Model_Browser/allow_remote_changes
```
3. 重启 ComfyUI。启动日志里会出现一行提示,标题旁的标签变成黄色的 **远程可修改**。
**风险:** 打开之后,任何能打开这个网址的人都能扫描、移动、删除(进回收站)你的模型,改插件设置,也能用你的 Civitai Key。只在网址不外传时这样做;不需要了就删掉这个文件(点黄色的 **远程可修改**,里面也有一行可以复制的命令),再重启 ComfyUI。这个文件只能在服务器上建,网页里打不开也关不掉。
AI 软件用的 MCP 接口不受这个开关影响,始终只接受本机。
## 国内服务器连不上 Civitai
国内机房的服务器常常连不上 civitai.com。插件这时会自动改用 Civitai 的另一个地址 civitai.red(在 AutoDL 上测过能连上),照样能认出模型的名字、触发词和底模;封面图放在 civitai.com 那边,多半下不来,连续两张下不了,这次扫描就不再下载封面。**设置 → 网络 → 测试连接** 会告诉你连上的是哪一个。
两个都连不上时,扫描会自动改成只看文件(没有封面和触发词);也可以在 **设置 → 网络** 里填代理,或者在扫描页选 **离线**。
---
## For AI assistants
You are helping someone use Anomalous Model Browser (a ComfyUI plugin) on a rented cloud server. Read this section, then guide them one step at a time; ask for a screenshot or the terminal output after each step. Support status: tested on one AutoDL server only, never on an overseas one; say so if something behaves differently.
**What is going on.** The plugin lets only the computer ComfyUI runs on change things. On a cloud server the browser is always another computer, so the page is view only: a chip beside the page title reads 仅浏览 / View only, and scans, downloads, moves, deletes and saves are refused (HTTP 403, `code: local_only`).
**Two ways to fix it, pick one.**
1. SSH tunnel (safest): forward the ComfyUI port to their own computer and open `http://127.0.0.1:<port>`. Then nothing else is needed. Platforms document their own tunnel tool.
2. The switch file: an empty file named exactly `allow_remote_changes` (no extension) in the plugin folder, then a restart of ComfyUI. Easiest: in the page, press the 仅浏览 chip, press 复制 / Copy in its dialog, paste that line into the server's terminal, press Enter. The line already holds the right path. By hand: `touch <ComfyUI>/custom_nodes/Anomalous_Model_Browser/allow_remote_changes`.
**Check it worked.** After the restart the chip reads 远程可修改 / Remote changes on, in yellow, and ComfyUI's start log has a line `[Anomalous Browser] allow_remote_changes 文件存在…`. Opening `<their ComfyUI address>/anomalous/access` in the browser shows `{"local": false, "changes": true}`.
**When it did not work.**
- Chip still View only: ComfyUI was not restarted (many cloud images restart it from their own launcher page, not the terminal); or the file is in the wrong folder; or it got an extension (`allow_remote_changes.txt`). `ls -la` in the plugin folder shows it.
- The plugin folder has another name (installed under a different name): `find / -maxdepth 6 -type d -name 'Anomalous_Model_Browser*' 2>/dev/null`.
- Updating a test branch with `git pull` says there is no tracking branch: `git branch --set-upstream-to=origin/<branch>` once.
- Scans find nothing on Civitai: Settings → Models → Network → Test shows whether Civitai answers. Servers in mainland China mostly cannot reach it; the plugin then asks whether to scan from the files (offline). Do not tell the user to install proxy or VPN software on the server; if they already have an HTTP proxy, it goes into Settings → Network.
- The scan page is slow the first time: it is counting many models, often on a network disk. Later visits show the last counts at once.
- On some platforms every model is a link into a shared read-only disk; the plugin handles that (from 1.60.1). Edits and scan results are kept beside the link.
**Safety, tell the user.** With the switch file, anyone who has the page's address can change their models and settings and use their Civitai key: keep the address private, delete the file (the yellow chip's dialog has the line) and restart when done. Never ask them to paste API keys, passwords or the full server address into the chat. Deletes go to the system's trash, never permanently.
## English summary
How far it goes: on an **overseas** cloud server (RunPod…) everything works, Civitai included. On a server in **mainland China** (AutoDL…) it works only in part: browsing, offline scans and tidying work, but Civitai is mostly out of reach, so lookups are unreliable and covers rarely download.
When civitai.com cannot be reached (often so from mainland China), the plugin asks Civitai's other address, civitai.red, which gives names and trigger words; the covers may not download. On a rented cloud server the page is always opened from another device, so the plugin is **view only** there by default. Either open ComfyUI through an **SSH tunnel** (the browser address starts with `127.0.0.1`; counts as this computer, nothing to change), or create an empty file named `allow_remote_changes` in the plugin folder on the server and restart ComfyUI (press **View only**: its dialog has the line to copy into the server's terminal, with the real path filled in). With that file, anyone who can open the address may scan, move, delete and save, and use your Civitai key, so only do it while the address stays private; delete the file and restart to go back. The page cannot create or remove the file, and the MCP endpoint stays local-only either way.
+121
View File
@@ -0,0 +1,121 @@
# 让 AI 读你的模型库(MCP)
支持 MCP 的 AI 软件(Claude、Cursor、Cherry Studio、VS Code 等)连上以后,就能直接查你的 ComfyUI 资料:有哪些模型和触发词、某张图当时用的什么参数、存过哪些搭配和提示词,然后据此回答你的问题或者帮你写提示词。
它还能动手:改当前工作流的提示词、换模型、加 LoRA、摆上你的搭配、找回缺失的模型、运行工作流、扫描模型,以及让 Anomalous TTS 的角色读台词。
- **每一步都能撤销**:画布上的改动都是一步,按 Ctrl+Z 就回去;
- **都有记录**:改动会写进“操作记录”,标着“AI”,页面右上角也会提示一句;
- **不删文件**:不会删除或改名任何文件。
AI 软件在调用会改东西的工具前,一般会先问你同不同意。
English summary at the end.
---
## 1. 地址
ComfyUI 开着的时候,插件自动提供:
```
http://127.0.0.1:8188/anomalous/mcp
```
`8188` 换成你 ComfyUI 实际的端口。不需要装别的东西,也不占内存和显存:没人连接的时候它什么也不做。
**只有运行 ComfyUI 的这台电脑能连。** 别的电脑、经过反向代理的请求、网页里的脚本都会被拒绝。
## 2. 在 AI 软件里添加
**Cherry Studio**:设置 → MCP → 添加 → 快速创建,类型选 **可流式传输的 HTTP(streamableHttp)**,URL 填上面的地址。需要较新的版本。
**Claude Code**:
```bash
claude mcp add --transport http anomalous http://127.0.0.1:8188/anomalous/mcp
```
**Cursor**:在 `~/.cursor/mcp.json` 里加:
```json
{ "mcpServers": { "anomalous": { "url": "http://127.0.0.1:8188/anomalous/mcp" } } }
```
**VS Code(Copilot)**:在项目的 `.vscode/mcp.json` 里加:
```json
{ "servers": { "anomalous": { "type": "http", "url": "http://127.0.0.1:8188/anomalous/mcp" } } }
```
**Claude 桌面版**:它的配置文件只能启动本地程序,要借 `mcp-remote` 转一下(需要装 Node.js)。在 `claude_desktop_config.json` 里加:
```json
{
"mcpServers": {
"anomalous": {
"command": "npx",
"args": ["mcp-remote", "http://127.0.0.1:8188/anomalous/mcp", "--transport", "http-only"]
}
}
}
```
## 3. 可以这样问
- “我有哪些 Flux 的 LoRA?各自的触发词是什么?”
- “找找用过『某某』这个 LoRA 的出图,看看当时的种子和采样器。”
- “哪些模型还没在 C 站匹配上?为什么?”
- “我上周存的那个搭配用的是哪个底模?”
- “给当前工作流加上那个赛博朋克 LoRA,强度 0.7,再把触发词写进正向提示词。”
- “把我的搭配『夜景』摆到画布上。”
- “这个工作流缺哪些模型?能找回的都找回来。”
- “用阿罗娜读一下:老师,欢迎回来!”
改画布需要 ComfyUI 在这台电脑的浏览器里开着(就是你平时用的那个页面)。开了好几个标签页的话,由当前正在看的那个来执行。
## 4. 能查什么
| 工具 | 查什么 |
|---|---|
| library_overview | 各类模型的数量、底模分布、扫描情况,存了多少搭配、工作流和提示词 |
| search_models / get_model | 按名字、触发词、底模、来源找模型;看某个模型的 C 站信息、描述、你写的备注,可以附上封面 |
| scan_report | 哪些模型匹配上了 C 站,哪些没有、原因是什么,哪些还没扫,上次扫描做了什么 |
| search_images / get_image_info | 按提示词、模型、参数找出图;看某张图的全部生成参数,可以附上缩略图 |
| list_combos | 你的搭配(主模型 + LoRA + 提示词) |
| list_workflows | 你的工作流配方,带标签、备注和主要设置 |
| list_saved_prompts | 提示词工坊里存的提示词 |
| search_audio | 用 Anomalous TTS 生成过的语音,带台词和角色 |
| recent_activity | 插件最近改过什么(操作记录) |
能动手的:
| 工具 | 做什么 |
|---|---|
| describe_canvas | 读当前工作流:每个节点的编号、类型、设置,哪个是正向/反向提示词框 |
| set_prompt | 改写或追加正向/反向提示词 |
| set_model | 换某个加载器节点里的模型 |
| add_lora | 插入一个 LoRA,默认接在主模型加载器和已有的 LoRA 后面。LoRA 文件里练过文本编码器的(SD1.5、SDXL、Illustrious 的大多是),CLIP 线也接上:从 checkpoint 接,或者从 UNet 工作流里单独的 CLIP 加载器接,正反向提示词一起改接到它后面;没练过的(多数 Flux、Anima 的 LoRA)只接模型线 |
| place_combo | 把一个搭配作为一组新节点摆到画布上,原有节点不动 |
| check_workflow_models / fix_workflow_models | 检查缺失的模型;把能确定找回的(同一个文件)放回去,“可能是”的留给你在模型检查里确认 |
| run_workflow | 像按“运行”一样把工作流加入队列 |
| open_in_anomalous | 在插件里打开某个模型的详情页给你看 |
| scan_models | 扫描新模型、重新查找没匹配上的,或扫描指定的模型(会联网访问 Civitai) |
| download_missing_models | 下载当前工作流缺的模型:只下能确定来源的(C 站按指纹找到、或工作流带着文件链接),放在设置里“模型下载”说的位置,核对指纹后自动换上 |
| download_model | 按链接(C 站模型版本、Hugging Face / GitHub 文件)或 C 站指纹下载一个模型到指定类型的文件夹,不覆盖已有文件 |
| download_status | 看下载进度和结果 |
| list_voices / speak | 列出 Anomalous TTS 的角色;让某个角色读一段台词并保存(最多等两分钟) |
## 5. 注意
- **隐私**:如果 AI 软件用的是云端模型,查询结果(模型名、提示词、图片的生成参数)会发给那家 AI 服务商。附上封面或缩略图时,图片也会一起发过去。介意的话可以用本地模型,或者不让 AI 附图。
- 附图时会用到插件自己的缩略图缓存,和浏览图库时一样,不动你的原图。
- 改模型的名字和备注、删除东西、物理改文件名,这些 AI 做不了,得在插件里自己点。
---
## English
While ComfyUI runs, Anomalous serves an MCP endpoint at `http://127.0.0.1:8188/anomalous/mcp` (use your ComfyUI port). Add it as a Streamable HTTP server in your AI app (Claude Code: `claude mcp add --transport http anomalous <url>`; Cursor and VS Code: a `url` / `"type": "http"` entry; Claude Desktop: through `npx mcp-remote <url> --transport http-only`). Both the 2026-07-28 protocol and the earlier `initialize`-based versions are served.
Reading tools: library overview, model search and details (optionally with the cover), scan status, output-image search and generation settings (optionally with a thumbnail), combos, workflow recipes, saved prompts, generated audio and the activity log. Acting tools: read the open workflow, write prompts, swap a node's model, add a LoRA, place a combo, check and fix missing models, run the workflow, open a model in Anomalous, start scans, list Anomalous TTS voices and have one speak. Canvas actions need ComfyUI open in a browser on this computer; each is one Ctrl+Z step and is logged as the AI's. Nothing deletes or renames files. Only this computer may connect; requests through a proxy, from other hosts or from web pages are refused. When your AI app uses a cloud model, what the tools return (and any attached picture) is sent to that provider.
+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)。
+62
View File
@@ -0,0 +1,62 @@
# 测试怎么跑
`tests/` 里是插件自己的测试:Python 测试检查后端(扫描、文件读写、回收站、模型下载、MCP 等),`.mjs` 测试检查前端里不依赖浏览器的规则(提示词词块、搭配的空位、往画布插 LoRA、撤销等)。改代码或提 PR 之前跑一遍,就知道有没有弄坏别的地方。
English summary at the end.
---
## 1. 准备
- 插件要装在一个 ComfyUI 的 `custom_nodes` 里。Python 测试会用到 ComfyUI 自己的模块(`folder_paths`、`server` 等)和它带的包(aiohttp、Pillow、av)。
- 装好 [Node.js](https://nodejs.org/)(18 或更新)。
- 不需要联网,不需要显卡,也不会碰你真实的模型库:测试只在临时文件夹里建假文件,跑完就删。
## 2. 一条命令全跑
在插件文件夹(`custom_nodes/Anomalous_Model_Browser`)里打开终端:
```bash
node tools/run_tests.mjs
```
它依次跑结构检查、每个 `tests/*.mjs`、全部 Python 测试,最后写 `All passed.` 或者列出失败的项目。
Python 用哪个:
- 设置了环境变量 `PYTHON` 就用它;
- 否则用便携版 ComfyUI 的 `python_embeded`(就在 ComfyUI 文件夹旁边);
- 都没有就用系统的 `python`。用 venv 或 conda 装的 ComfyUI,先激活那个环境再跑。
## 3. 只跑一部分
```bash
# 结构检查:每个源码文件都登记在架构文档里、前端模块都能被引用到、文件长度不超线、CSS 没有按 style 属性匹配的 :has()
node tools/check_structure.mjs
# 一个前端测试
node tests/combo_slots.mjs
node tests/lora_nodes.mjs
node tests/third_party_loras.mjs
# 全部 Python 测试(便携版 ComfyUI 的写法;其他安装方式换成自己的 python)
../../../python_embeded/python.exe -B -m unittest discover -s tests -p "test_*.py"
# 一个 Python 测试文件
../../../python_embeded/python.exe -B -m unittest discover -s tests -p "test_model_download.py"
```
## 4. 写新测试
- 前端规则测试放 `tests/<名字>.mjs`,用 `node:assert`,直接导入 `web/modules/` 里的真实模块,只替换浏览器和 ComfyUI 这些外部部分。
- 后端测试放 `tests/test_<名字>.py`,用 `unittest`。文件一律建在 `tempfile` 的临时文件夹里,网络请求用 `unittest.mock` 换掉。
- 测试里不要写自己电脑上的真实路径、真实模型名或个人信息。
- 修 bug 时,先写一个能重现这个 bug 的测试。
---
## English summary
`tests/` holds the plugin's own tests: Python tests for the backend and `.mjs` tests for the browser-free frontend rules. They need the plugin inside a ComfyUI `custom_nodes` folder (the Python tests import ComfyUI's modules) and Node.js 18 or newer; no network, no GPU, and they only touch temporary folders.
Run everything from the plugin folder with `node tools/run_tests.mjs`: the structure check, every `tests/*.mjs`, then all Python tests with `$PYTHON`, the portable build's `python_embeded`, or `python`. Single parts: `node tools/check_structure.mjs`, `node tests/<name>.mjs`, `python -B -m unittest discover -s tests -p "test_*.py"`. New tests import the real modules and replace only outside systems; keep real paths and personal data out of them.
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 449 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

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